运行时配置后端地址功能.md 43 KB

运行时可配置后端 API 地址 - 完整实现方案

📋 一、需求整理

1.1 核心需求

主需求:实现运行时可配置的后端 API 地址,支持 Electron、Cordova 两种环境(不包括 Web 端)

具体要求

  1. ✅ 应用启动时自动尝试连接默认后端地址
  2. ✅ 连接失败时弹出配置对话框,让用户输入后端地址
  3. ✅ 支持测试连接功能,验证地址有效性
  4. ✅ 将配置保存到本地持久化存储
  5. ✅ 后续启动优先使用已保存的配置
  6. ✅ 支持在设置页面修改后端地址
  7. ✅ 支持多环境切换(开发/测试/生产)

1.2 当前项目状态分析

现有架构

  • API 配置:src/API/config.ts 使用 webpack 注入的常量
  • 网络层:src/API/interceptor.ts 中的 axiosInstance
  • 应用初始化:src/app.tsx 启动时调用 initializeProductState()
  • 首个 API 请求:fetchSoftwareInfo() 获取 /pub/software_info

问题

  • API_BASE_URL 和 MQTT_BROKER_URL 在编译时写死
  • 不支持运行时修改
  • 私有化部署需要重新打包

不包括 Web 端的原因

  • Web 端通过 Nginx 部署,后端地址由 Nginx 配置的代理决定
  • Web 端的后端地址是固定的,不需要运行时配置

🏗️ 二、架构设计

2.1 文件结构设计(Feature-based)

src/features/serverConfig/
├── index.ts                          # 导出文件
├── types/
│   └── index.ts                      # 类型定义
├── storage/
│   ├── IStorageAdapter.ts            # 存储适配器接口
│   ├── ElectronStorageAdapter.ts     # Electron 存储实现
│   ├── CordovaStorageAdapter.ts      # Cordova 存储实现
│   └── index.ts                      # 存储工厂
├── services/
│   └── ConfigService.ts              # 配置服务
├── state/
│   └── serverConfigSlice.ts          # Redux slice
└── components/
    ├── ServerConfigModal.tsx         # 配置对话框
    ├── ServerConfigModal.module.css  # 对话框样式
    └── ServerSettingsPanel.tsx       # 设置面板

2.2 涉及的参与者(从粗到细)

层级 1:核心模块

  1. 配置管理模块 (ConfigService)

    • 负责配置的读取、保存、验证
  2. 网络层改造 (axiosInstance)

    • 动态设置 baseURL
  3. UI 组件

    • 配置对话框 (ServerConfigModal)
    • 设置页面配置项 (ServerSettingsPanel)

层级 2:状态管理

  1. Redux Slice (serverConfigSlice)
    • 管理服务器配置状态
    • Actions: setServerConfig, testConnection, resetConfig

层级 3:存储层

  1. 跨平台存储适配器 (StorageAdapter)
    • Electron: electron-store 或文件系统
    • Cordova: cordova-plugin-nativestorage

层级 4:具体类和方法

ConfigService (src/features/serverConfig/services/)

class ConfigService {
  - getServerConfig(): Promise<ServerConfig>
  - saveServerConfig(config: ServerConfig): Promise<void>
  - testConnection(url: string): Promise<ConnectionTestResult>
  - getDefaultConfig(): ServerConfig
  - clearConfig(): Promise<void>
}

ServerConfigSlice (src/features/serverConfig/state/)

- State: { apiBaseUrl, mqttBrokerUrl, isConfigured, testing }
- Actions: setServerConfig, testConnection, resetConfig
- Thunks: initializeServerConfig, saveAndApplyConfig

ServerConfigModal (src/features/serverConfig/components/)

- Props: open, onSave, onCancel, defaultUrl
- Methods: handleTest, handleSave, validateUrl
- State: inputUrl, testing, testResult

StorageAdapter (src/features/serverConfig/storage/)

interface IStorageAdapter {
  - getItem(key: string): Promise<string | null>
  - setItem(key: string, value: string): Promise<void>
  - removeItem(key: string): Promise<void>
}

class ElectronStorageAdapter implements IStorageAdapter
class CordovaStorageAdapter implements IStorageAdapter

✅ 三、实现 TodoList

3.1 创建新文件

  • src/features/serverConfig/types/index.ts - 类型定义
  • src/features/serverConfig/storage/IStorageAdapter.ts - 存储接口
  • src/features/serverConfig/storage/ElectronStorageAdapter.ts - Electron 存储
  • src/features/serverConfig/storage/CordovaStorageAdapter.ts - Cordova 存储
  • src/features/serverConfig/storage/index.ts - 存储工厂
  • src/features/serverConfig/services/ConfigService.ts - 配置服务
  • src/features/serverConfig/state/serverConfigSlice.ts - Redux slice
  • src/features/serverConfig/components/ServerConfigModal.tsx - 配置对话框
  • src/features/serverConfig/components/ServerConfigModal.module.css - 对话框样式
  • src/features/serverConfig/components/ServerSettingsPanel.tsx - 设置面板
  • src/features/serverConfig/index.ts - 导出文件

3.2 修改现有文件

  • src/API/config.ts - 改为从配置服务获取地址
  • src/API/interceptor.ts - 支持动态修改 baseURL
  • src/app.tsx - 添加配置初始化和错误处理逻辑
  • src/states/store.ts - 注册新的 slice
  • package.json - 添加依赖(如需要)

3.3 可选增强

  • 二维码扫描功能(移动端)
  • 配置导入/导出功能
  • 多环境预设配置

🔄 四、交互流程(泳道图)

sequenceDiagram
    participant User as 用户
    participant App as App.tsx
    participant Config as ConfigService
    participant Storage as StorageAdapter
    participant Modal as ServerConfigModal
    participant API as axiosInstance
    participant Backend as 后端服务器

    User->>App: 启动应用
    App->>Config: 初始化配置
    Config->>Storage: 读取本地配置
    Storage-->>Config: 返回配置/null
    
    alt 配置存在
        Config-->>App: 返回已保存配置
        App->>API: 设置 baseURL
        App->>Backend: 请求 /software_info
        
        alt 请求成功
            Backend-->>App: 返回软件信息
            App->>User: 正常显示应用
        else 请求失败
            Backend-->>App: 连接失败
            App->>Modal: 显示配置对话框
            Modal->>User: 请求输入服务器地址
        end
    else 配置不存在
        Config-->>App: 返回默认配置
        App->>Backend: 尝试默认地址
        
        alt 默认地址可用
            Backend-->>App: 连接成功
            App->>User: 正常显示应用
        else 默认地址不可用
            Backend-->>App: 连接失败
            App->>Modal: 显示配置对话框
            Modal->>User: 请求输入服务器地址
        end
    end

    User->>Modal: 输入服务器地址
    User->>Modal: 点击"测试连接"
    Modal->>API: 临时设置 baseURL
    Modal->>Backend: 测试请求 /software_info
    
    alt 测试成功
        Backend-->>Modal: 返回成功
        Modal->>User: 显示"连接成功"
        User->>Modal: 点击"保存"
        Modal->>Config: 保存配置
        Config->>Storage: 持久化配置
        Storage-->>Config: 保存成功
        Modal->>App: 应用新配置
        App->>API: 更新 baseURL
        App->>User: 重新初始化应用
    else 测试失败
        Backend-->>Modal: 连接失败
        Modal->>User: 显示错误信息
        User->>Modal: 修正地址后重试
    end

📊 五、数据流图

flowchart TD
    Start([应用启动]) --> LoadConfig[加载本地配置]
    LoadConfig --> HasConfig{配置存在?}
    
    HasConfig -->|是| UseConfig[使用已保存配置]
    HasConfig -->|否| UseDefault[使用默认配置]
    
    UseConfig --> TestAPI[测试 API 连接]
    UseDefault --> TestAPI
    
    TestAPI --> APISuccess{连接成功?}
    
    APISuccess -->|是| InitApp[初始化应用]
    APISuccess -->|否| ShowModal[显示配置对话框]
    
    ShowModal --> UserInput[用户输入地址]
    UserInput --> TestConnection[测试连接]
    
    TestConnection --> TestResult{测试结果}
    TestResult -->|成功| SaveConfig[保存配置]
    TestResult -->|失败| ShowError[显示错误]
    ShowError --> UserInput
    
    SaveConfig --> UpdateAxios[更新 axios baseURL]
    UpdateAxios --> InitApp
    
    InitApp --> AppRunning[应用正常运行]
    
    AppRunning --> SettingsChange{用户修改配置?}
    SettingsChange -->|是| ShowModal
    SettingsChange -->|否| AppRunning

🗂️ 六、数据结构设计

6.1 核心类型定义

// src/features/serverConfig/types/index.ts

/**
 * 服务器配置接口
 */
export interface ServerConfig {
  /** API 基础地址 */
  apiBaseUrl: string;
  
  /** MQTT Broker 地址 */
  mqttBrokerUrl: string;
  
  /** 配置创建时间 */
  createdAt?: string;
  
  /** 配置更新时间 */
  updatedAt?: string;
  
  /** 配置名称(用于多环境) */
  name?: string;
}

/**
 * 连接测试结果
 */
export interface ConnectionTestResult {
  success: boolean;
  message: string;
  serverInfo?: {
    version: string;
    product: string;
  };
}

/**
 * 配置验证结果
 */
export interface ConfigValidation {
  valid: boolean;
  errors: string[];
}

/**
 * 环境预设
 */
export interface EnvironmentPreset {
  name: string;
  displayName: string;
  apiBaseUrl: string;
  mqttBrokerUrl: string;
}

/**
 * 平台类型
 */
export type PlatformType = 'Electron' | 'Cordova';

6.2 Redux State 结构

// src/features/serverConfig/state/serverConfigSlice.ts

interface ServerConfigState {
  /** 当前配置 */
  current: ServerConfig | null;
  
  /** 是否已配置 */
  isConfigured: boolean;
  
  /** 是否正在测试连接 */
  testing: boolean;
  
  /** 测试结果 */
  testResult: ConnectionTestResult | null;
  
  /** 加载状态 */
  loading: boolean;
  
  /** 错误信息 */
  error: string | null;
  
  /** 环境预设列表 */
  presets: EnvironmentPreset[];
}

6.3 本地存储格式

Electron (userData/config.json)

{
  "serverConfig": {
    "apiBaseUrl": "http://192.168.1.100:8080",
    "mqttBrokerUrl": "ws://192.168.1.100:8083/mqtt",
    "createdAt": "2025-01-11T09:00:00.000Z",
    "updatedAt": "2025-01-11T10:30:00.000Z",
    "name": "生产环境"
  }
}

Cordova (SharedPreferences/NSUserDefaults)

Key: serverConfig
Value: {"apiBaseUrl":"http://192.168.1.100:8080","mqttBrokerUrl":"ws://192.168.1.100:8083/mqtt"}

🚀 七、执行流程详解

7.1 应用启动流程

flowchart TD
    A[应用启动] --> B[App.tsx useEffect]
    B --> C[dispatch initializeServerConfig]
    C --> D[ConfigService.getServerConfig]
    D --> E{本地有配置?}
    
    E -->|有| F[返回本地配置]
    E -->|无| G[返回默认配置]
    
    F --> H[更新 Redux State]
    G --> H
    
    H --> I[更新 axios baseURL]
    I --> J[dispatch initializeProductState]
    J --> K[调用 fetchSoftwareInfo]
    K --> L{API 请求成功?}
    
    L -->|成功| M[加载应用内容]
    L -->|失败| N[显示 ServerConfigModal]
    
    N --> O[用户配置后重试]
    O --> K

7.2 配置保存流程

flowchart TD
    A[用户输入地址] --> B[点击测试连接]
    B --> C[临时创建 axios 实例]
    C --> D[请求 /pub/software_info]
    D --> E{请求成功?}
    
    E -->|成功| F[显示成功提示]
    E -->|失败| G[显示错误信息]
    
    F --> H[用户点击保存]
    H --> I[dispatch saveAndApplyConfig]
    I --> J[ConfigService.saveServerConfig]
    J --> K[StorageAdapter.setItem]
    K --> L[更新 Redux State]
    L --> M[更新全局 axios baseURL]
    M --> N[关闭对话框]
    N --> O[重新初始化应用]

7.3 用户操作触发点

  1. 自动触发

    • 应用首次启动且无配置
    • 应用启动时连接失败
  2. 手动触发

    • 用户在设置页面点击"服务器配置"
    • 用户在设置中切换环境

🧪 八、测试方案

8.1 功能测试场景

场景 1:首次启动(无配置)

测试步骤

  1. 清除所有本地存储
    • Electron: 删除 userData/config.json
    • Cordova: 清除应用数据
  2. 启动应用
  3. 预期:自动弹出配置对话框
  4. 输入有效的服务器地址(如 http://192.168.1.100:8080
  5. 点击"测试连接"
  6. 预期:显示"连接成功"
  7. 点击"保存"
  8. 预期:对话框关闭,应用正常加载

场景 2:已有配置(连接成功)

测试步骤

  1. 确保本地有有效配置
  2. 启动应用
  3. 预期:不显示配置对话框,直接加载应用

场景 3:已有配置(连接失败)

测试步骤

  1. 本地配置指向一个不可用的地址
  2. 启动应用
  3. 预期:显示配置对话框,提示连接失败
  4. 修改为正确地址
  5. 测试并保存
  6. 预期:应用正常加载

场景 4:输入无效地址

测试步骤

  1. 在配置对话框中输入无效地址(如 invalid-url
  2. 点击"测试连接"
  3. 预期:显示"地址格式不正确"错误

场景 5:服务器不可达

测试步骤

  1. 输入格式正确但服务器不存在的地址(如 http://192.168.999.999:8080
  2. 点击"测试连接"
  3. 预期:显示"无法连接到服务器"错误

场景 6:在设置中修改配置

测试步骤

  1. 应用正常运行
  2. 进入"设置"页面
  3. 点击"服务器配置"
  4. 修改服务器地址
  5. 测试并保存
  6. 预期:应用使用新配置重新加载

8.2 跨平台测试

平台 存储方式 测试要点
Electron (Windows) electron-store 检查 %APPDATA%/app-name/config.json
Electron (macOS) electron-store 检查 ~/Library/Application Support/app-name/config.json
Cordova (Android) SharedPreferences 检查应用数据目录
Cordova (iOS) NSUserDefaults 检查应用沙盒

8.3 边界测试

  1. 空输入:测试不输入任何内容直接保存
  2. 超长 URL:测试超过 2048 字符的 URL
  3. 特殊字符:测试包含中文、emoji 的 URL
  4. 端口号边界:测试端口 0、65535、65536
  5. 网络切换:测试从有网切换到无网的情况
  6. MQTT 地址格式:测试 ws:// 和 wss:// 协议

🐛 九、潜在问题分析

9.1 网络层问题

问题 1:axios 实例 baseURL 动态更新

描述:axios 实例在创建时设置了 baseURL,后续修改可能不生效

解决方案

// 使用请求拦截器动态设置(推荐)
axiosInstance.interceptors.request.use((config) => {
  const serverConfig = store.getState().serverConfig.current;
  if (serverConfig) {
    config.baseURL = serverConfig.apiBaseUrl + '/dr/api/v1/';
  }
  return config;
});

问题 2:MQTT 连接地址更新

描述:MQTT 连接可能在应用启动时已经建立,修改配置后需要重连

解决方案

// 在 ConfigService.saveServerConfig 中
async saveServerConfig(config: ServerConfig) {
  await this.storage.setItem('serverConfig', JSON.stringify(config));
  
  // 触发 MQTT 重连
  if (config.mqttBrokerUrl !== this.currentConfig?.mqttBrokerUrl) {
    emitter.emit('mqtt:reconnect', config.mqttBrokerUrl);
  }
}

9.2 存储层问题

问题 3:Electron 中的文件权限

描述:某些系统中可能无法写入配置文件

解决方案

  • 添加错误处理和降级方案
  • 提供导出配置文件功能,让用户手动保存
  • 使用 app.getPath('userData') 确保有写入权限

问题 4:Cordova 插件依赖

描述:需要安装 cordova-plugin-nativestorage

解决方案

cordova plugin add cordova-plugin-nativestorage

9.3 UI/UX 问题

问题 5:配置对话框阻塞应用启动

描述:如果用户不配置,应用无法使用

解决方案

  • 提供"稍后配置"选项(进入离线模式)
  • 显示清晰的帮助文档和示例地址
  • 添加"使用默认配置"快捷按钮

问题 6:配置修改后的状态同步

描述:修改配置后,应用中已加载的数据可能过时

解决方案

// 保存配置后强制刷新
async function applyNewConfig(config: ServerConfig) {
  // 1. 保存配置
  await ConfigService.save(config);
  
  // 2. 清除所有缓存状态
  store.dispatch(resetAllData());
  
  // 3. 重新初始化
  await store.dispatch(initializeProductState());
  
  // 4. 显示成功提示
  message.success('配置已更新,应用已重新加载');
}

9.4 安全问题

问题 7:配置信息泄露

描述:配置中可能包含内网地址等敏感信息

解决方案

  • 不在日志中打印完整配置
  • 考虑对存储的配置进行简单加密

问题 8:恶意地址注入

描述:用户可能输入恶意服务器地址

解决方案

function validateServerUrl(url: string): ConfigValidation {
  const errors: string[] = [];
  
  try {
    const parsed = new URL(url);
    
    // 只允许 http 和 https
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      errors.push('只支持 HTTP 和 HTTPS 协议');
    }
    
    // 检查端口范围
    const port = parseInt(parsed.port);
    if (port && (port < 1 || port > 65535)) {
      errors.push('端口号必须在 1-65535 之间');
    }
  } catch (e) {
    errors.push('URL 格式不正确');
  }
  
  return {
    valid: errors.length === 0,
    errors,
  };
}

📐 十、类图设计

classDiagram
    class IStorageAdapter {
        <<interface>>
        +getItem(key: string) Promise~string~
        +setItem(key: string, value: string) Promise~void~
        +removeItem(key: string) Promise~void~
    }
    
    class ElectronStorageAdapter {
        -configPath: string
        +getItem(key: string) Promise~string~
        +setItem(key: string, value: string) Promise~void~
        +removeItem(key: string) Promise~void~
    }
    
    class CordovaStorageAdapter {
        +getItem(key: string) Promise~string~
        +setItem(key: string, value: string) Promise~void~
        +removeItem(key: string) Promise~void~
    }
    
    class ConfigService {
        -storage: IStorageAdapter
        -CONFIG_KEY: string
        +getServerConfig() Promise~ServerConfig~
        +saveServerConfig(config) Promise~void~
        +testConnection(url) Promise~ConnectionTestResult~
        +getDefaultConfig() ServerConfig
        +clearConfig() Promise~void~
    }
    
    class ServerConfigSlice {
        +state: ServerConfigState
        +setServerConfig(config)
        +setTesting(testing)
        +setTestResult(result)
        +initializeServerConfig()
        +saveAndApplyConfig(config)
        +testConnectionThunk(url)
    }
    
    class ServerConfigModal {
        -props: ServerConfigModalProps
        -state: ModalState
        +handleTest()
        +handleSave()
        +validateUrl()
        +render()
    }
    
    IStorageAdapter <|.. ElectronStorageAdapter
    IStorageAdapter <|.. CordovaStorageAdapter
    ConfigService --> IStorageAdapter
    ServerConfigSlice --> ConfigService
    ServerConfigModal --> ServerConfigSlice

🎨 十一、UI 设计

11.1 配置对话框 (ServerConfigModal)

┌─────────────────────────────────────────────┐
│  ⚙️ 服务器配置                        ✕     │
├─────────────────────────────────────────────┤
│                                             │
│  ⚠️ 无法连接到服务器,请配置后端地址          │
│                                             │
│  服务器地址:                                 │
│  ┌───────────────────────────────────────┐ │
│  │ http://192.168.1.100:8080            │ │
│  └───────────────────────────────────────┘ │
│  示例: http://192.168.1.100:8080           │
│                                             │
│  MQTT 地址:                                 │
│  ┌───────────────────────────────────────┐ │
│  │ ws://192.168.1.100:8083/mqtt         │ │
│  └───────────────────────────────────────┘ │
│  示例: ws://192.168.1.100:8083/mqtt        │
│                                             │
│  ┌─────────────────────────────────────┐   │
│  │ 🧪 测试连接                          │   │
│  └─────────────────────────────────────┘   │
│                                             │
│  ✅ 连接成功!服务器版本: v2.1.0             │
│                                             │
│  ┌──────────┐  ┌──────────┐                │
│  │   取消   │  │   保存   │                │
│  └──────────┘  └──────────┘                │
└─────────────────────────────────────────────┘

11.2 设置页面配置项

设置
├── 通用设置
├── 语言设置
└── 🌐 服务器配置
    ├── 当前服务器: http://192.168.1.100:8080
    ├── 连接状态: ✅ 已连接
    ├── [修改配置] 按钮
    └── 环境快捷切换:
        ○ 开发环境
        ○ 测试环境
        ● 生产环境

📝 十二、实现代码示例

12.1 类型定义

// src/features/serverConfig/types/index.ts

/**
 * 服务器配置接口
 */
export interface ServerConfig {
  /** API 基础地址 */
  apiBaseUrl: string;
  
  /** MQTT Broker 地址 */
  mqttBrokerUrl: string;
  
  /** 配置创建时间 */
  createdAt?: string;
  
  /** 配置更新时间 */
  updatedAt?: string;
  
  /** 配置名称(用于多环境) */
  name?: string;
}

/**
 * 连接测试结果
 */
export interface ConnectionTestResult {
  success: boolean;
  message: string;
  serverInfo?: {
    version: string;
    product: string;
  };
}

/**
 * 配置验证结果
 */
export interface ConfigValidation {
  valid: boolean;
  errors: string[];
}

/**
 * 环境预设
 */
export interface EnvironmentPreset {
  name: string;
  displayName: string;
  apiBaseUrl: string;
  mqttBrokerUrl: string;
}

/**
 * 平台类型
 */
export type PlatformType = 'Electron' | 'Cordova';

12.2 存储适配器

// src/features/serverConfig/storage/IStorageAdapter.ts

/**
 * 存储适配器接口
 */
export interface IStorageAdapter {
  /**
   * 获取存储项
   */
  getItem(key: string): Promise<string | null>;
  
  /**
   * 设置存储项
   */
  setItem(key: string, value: string): Promise<void>;
  
  /**
   * 删除存储项
   */
  removeItem(key: string): Promise<void>;
}
// src/features/serverConfig/storage/ElectronStorageAdapter.ts

import { IStorageAdapter } from './IStorageAdapter';
import fs from 'fs';
import path from 'path';

/**
 * Electron 存储适配器
 * 使用文件系统存储配置
 */
export class ElectronStorageAdapter implements IStorageAdapter {
  private configPath: string;

  constructor() {
    // 获取 userData 路径
    const { app } = require('electron').remote || require('@electron/remote');
    const userDataPath = app.getPath('userData');
    this.configPath = path.join(userDataPath, 'config.json');
    
    // 确保配置文件所在目录存在
    const dir = path.dirname(this.configPath);
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }
  }

  async getItem(key: string): Promise<string | null> {
    try {
      if (!fs.existsSync(this.configPath)) {
        return null;
      }
      
      const content = fs.readFileSync(this.configPath, 'utf-8');
      const data = JSON.parse(content);
      return data[key] || null;
    } catch (error) {
      console.error('读取配置文件失败:', error);
      return null;
    }
  }

  async setItem(key: string, value: string): Promise<void> {
    try {
      let data = {};
      
      // 读取现有配置
      if (fs.existsSync(this.configPath)) {
        const content = fs.readFileSync(this.configPath, 'utf-8');
        data = JSON.parse(content);
      }
      
      // 更新配置
      data[key] = value;
      
      // 写入文件
      fs.writeFileSync(this.configPath, JSON.stringify(data, null, 2), 'utf-8');
    } catch (error) {
      console.error('保存配置文件失败:', error);
      throw error;
    }
  }

  async removeItem(key: string): Promise<void> {
    try {
      if (!fs.existsSync(this.configPath)) {
        return;
      }
      
      const content = fs.readFileSync(this.configPath, 'utf-8');
      const data = JSON.parse(content);
      
      delete data[key];
      
      fs.writeFileSync(this.configPath, JSON.stringify(data, null, 2), 'utf-8');
    } catch (error) {
      console.error('删除配置项失败:', error);
      throw error;
    }
  }
}
// src/features/serverConfig/storage/CordovaStorageAdapter.ts

import { IStorageAdapter } from './IStorageAdapter';

/**
 * Cordova 存储适配器
 * 使用 cordova-plugin-nativestorage
 */
export class CordovaStorageAdapter implements IStorageAdapter {
  private NativeStorage: any;

  constructor() {
    // @ts-ignore
    this.NativeStorage = window.NativeStorage;
    
    if (!this.NativeStorage) {
      throw new Error('NativeStorage plugin not available');
    }
  }

  async getItem(key: string): Promise<string | null> {
    return new Promise((resolve) => {
      this.NativeStorage.getItem(
        key,
        (value: string) => resolve(value),
        (error: any) => {
          console.error('读取存储失败:', error);
          resolve(null);
        }
      );
    });
  }

  async setItem(key: string, value: string): Promise<void> {
    return new Promise((resolve, reject) => {
      this.NativeStorage.setItem(
        key,
        value,
        () => resolve(),
        (error: any) => {
          console.error('保存存储失败:', error);
          reject(error);
        }
      );
    });
  }

  async removeItem(key: string): Promise<void> {
    return new Promise((resolve, reject) => {
      this.NativeStorage.remove(
        key,
        () => resolve(),
        (error: any) => {
          console.error('删除存储失败:', error);
          reject(error);
        }
      );
    });
  }
}
// src/features/serverConfig/storage/index.ts

import { IStorageAdapter } from './IStorageAdapter';
import { ElectronStorageAdapter } from './ElectronStorageAdapter';
import { CordovaStorageAdapter } from './CordovaStorageAdapter';

/**
 * 检测是否在 Electron 环境
 */
function isElectron(): boolean {
  return (
    typeof window !== 'undefined' &&
    typeof window.process === 'object' &&
    (window.process as any).type === 'renderer'
  );
}

/**
 * 检测是否在 Cordova 环境
 */
function isCordova(): boolean {
  return typeof window !== 'undefined' && !!(window as any).cordova;
}

/**
 * 创建存储适配器
 */
export function createStorageAdapter(): IStorageAdapter {
  if (isElectron()) {
    console.log('使用 Electron 存储适配器');
    return new ElectronStorageAdapter();
  } else if (isCordova()) {
    console.log('使用 Cordova 存储适配器');
    return new CordovaStorageAdapter();
  } else {
    throw new Error('不支持的平台');
  }
}

12.3 配置服务

// src/features/serverConfig/services/ConfigService.ts

import { ServerConfig, ConnectionTestResult, ConfigValidation } from '../types';
import { createStorageAdapter } from '../storage';
import axios from 'axios';

export class ConfigService {
  private static instance: ConfigService;
  private storage = createStorageAdapter();
  private readonly CONFIG_KEY = 'serverConfig';

  private constructor() {}

  static getInstance(): ConfigService {
    if (!ConfigService.instance) {
      ConfigService.instance = new ConfigService();
    }
    return ConfigService.instance;
  }

  /**
   * 获取默认配置
   */
  getDefaultConfig(): ServerConfig {
    return {
      apiBaseUrl: 'http://127.0.0.1:8080',
      mqttBrokerUrl: 'ws://127.0.0.1:8083/mqtt',
      name: '默认配置',
    };
  }

  /**
   * 获取服务器配置
   */
  async getServerConfig(): Promise<ServerConfig> {
    try {
      const configJson = await this.storage.getItem(this.CONFIG_KEY);
      if (configJson) {
        return JSON.parse(configJson);
      }
    } catch (error) {
      console.error('读取配置失败:', error);
    }
    return this.getDefaultConfig();
  }

  /**
   * 保存服务器配置
   */
  async saveServerConfig(config: ServerConfig): Promise<void> {
    const configWithTimestamp: ServerConfig = {
      ...config,
      updatedAt: new Date().toISOString(),
      createdAt: config.createdAt || new Date().toISOString(),
    };
    
    await this.storage.setItem(
      this.CONFIG_KEY,
      JSON.stringify(configWithTimestamp)
    );
  }

  /**
   * 验证 URL 格式
   */
  validateUrl(url: string): ConfigValidation {
    const errors: string[] = [];
    
    if (!url || url.trim() === '') {
      errors.push('地址不能为空');
      return { valid: false, errors };
    }
    
    try {
      const parsed = new URL(url);
      
      // 只允许 http 和 https
      if (!['http:', 'https:'].includes(parsed.protocol)) {
        errors.push('只支持 HTTP 和 HTTPS 协议');
      }
      
      // 检查端口范围
      if (parsed.port) {
        const port = parseInt(parsed.port);
        if (port < 1 || port > 65535) {
          errors.push('端口号必须在 1-65535 之间');
        }
      }
    } catch (e) {
      errors.push('URL 格式不正确');
    }
    
    return {
      valid: errors.length === 0,
      errors,
    };
  }

  /**
   * 验证 MQTT URL 格式
   */
  validateMqttUrl(url: string): ConfigValidation {
    const errors: string[] = [];
    
    if (!url || url.trim() === '') {
      errors.push('MQTT 地址不能为空');
      return { valid: false, errors };
    }
    
    try {
      const parsed = new URL(url);
      
      // 只允许 ws 和 wss
      if (!['ws:', 'wss:'].includes(parsed.protocol)) {
        errors.push('只支持 WS 和 WSS 协议');
      }
    } catch (e) {
      errors.push('MQTT URL 格式不正确');
    }
    
    return {
      valid: errors.length === 0,
      errors,
    };
  }

  /**
   * 测试连接
   */
  async testConnection(apiBaseUrl: string): Promise<ConnectionTestResult> {
    try {
      const testInstance = axios.create({
        baseURL: apiBaseUrl + '/dr/api/v1',
        timeout: 10000,
      });

      const response = await testInstance.get('/pub/software_info');
      
      if (response.data?.code === '0x000000') {
        return {
          success: true,
          message: '连接成功',
          serverInfo: {
            version: response.data.data.server?.version || 'unknown',
            product: response.data.data.product || 'unknown',
          },
        };
      } else {
        return {
          success: false,
          message: '服务器响应异常: ' + response.data?.description,
        };
      }
    } catch (error: any) {
      let message = '连接失败';
      
      if (error.code === 'ECONNABORTED') {
        message = '连接超时,请检查服务器地址';
      } else if (error.code === 'ERR_NETWORK') {
        message = '网络错误,请检查网络连接';
      } else if (error.message) {
        message = error.message;
      }
      
      return {
        success: false,
        message,
      };
    }
  }

  /**
   * 清除配置
   */
  async clearConfig(): Promise<void> {
    await this.storage.removeItem(this.CONFIG_KEY);
  }
}

export default ConfigService.getInstance();

12.4 Redux Slice

// src/features/serverConfig/state/serverConfigSlice.ts

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import {
  ServerConfig,
  ConnectionTestResult,
  EnvironmentPreset,
} from '../types';
import ConfigService from '../services/ConfigService';

interface ServerConfigState {
  current: ServerConfig | null;
  isConfigured: boolean;
  testing: boolean;
  testResult: ConnectionTestResult | null;
  loading: boolean;
  error: string | null;
  presets: EnvironmentPreset[];
}

const initialState: ServerConfigState = {
  current: null,
  isConfigured: false,
  testing: false,
  testResult: null,
  loading: false,
  error: null,
  presets: [
    {
      name: 'dev',
      displayName: '开发环境',
      apiBaseUrl: 'http://192.168.110.245:6001',
      mqttBrokerUrl: 'ws://192.168.110.245:8083/mqtt',
    },
    {
      name: 'test',
      displayName: '测试环境',
      apiBaseUrl: 'http://192.168.110.100:6001',
      mqttBrokerUrl: 'ws://192.168.110.100:8083/mqtt',
    },
    {
      name: 'prod',
      displayName: '生产环境',
      apiBaseUrl: 'http://127.0.0.1:8080',
      mqttBrokerUrl: 'ws://127.0.0.1:8083/mqtt',
    },
  ],
};

/**
 * 初始化服务器配置
 */
export const initializeServerConfig = createAsyncThunk(
  'serverConfig/initialize',
  async () => {
    const config = await ConfigService.getServerConfig();
    return config;
  }
);

/**
 * 测试连接
 */
export const testConnectionThunk = createAsyncThunk(
  'serverConfig/testConnection',
  async (apiBaseUrl: string) => {
    const result = await ConfigService.testConnection(apiBaseUrl);
    return result;
  }
);

/**
 * 保存并应用配置
 */
export const saveAndApplyConfig = createAsyncThunk(
  'serverConfig/saveAndApply',
  async (config: ServerConfig) => {
    await ConfigService.saveServerConfig(config);
    return config;
  }
);

const serverConfigSlice = createSlice({
  name: 'serverConfig',
  initialState,
  reducers: {
    setServerConfig: (state, action: PayloadAction<ServerConfig>) => {
      state.current = action.payload;
      state.isConfigured = true;
    },
    setTesting: (state, action: PayloadAction<boolean>) => {
      state.testing = action.payload;
    },
    setTestResult: (
      state,
      action: PayloadAction<ConnectionTestResult | null>
    ) => {
      state.testResult = action.payload;
    },
    clearTestResult: (state) => {
      state.testResult = null;
    },
    resetConfig: (state) => {
      state.current = null;
      state.isConfigured = false;
      state.testResult = null;
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      // 初始化配置
      .addCase(initializeServerConfig.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(initializeServerConfig.fulfilled, (state, action) => {
        state.loading = false;
        state.current = action.payload;
        state.isConfigured = true;
      })
      .addCase(initializeServerConfig.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || '加载配置失败';
      })
      
      // 测试连接
      .addCase(testConnectionThunk.pending, (state) => {
        state.testing = true;
        state.testResult = null;
      })
      .addCase(testConnectionThunk.fulfilled, (state, action) => {
        state.testing = false;
        state.testResult = action.payload;
      })
      .addCase(testConnectionThunk.rejected, (state, action) => {
        state.testing = false;
        state.testResult = {
          success: false,
          message: action.error.message || '测试失败',
        };
      })
      
      // 保存并应用配置
      .addCase(saveAndApplyConfig.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(saveAndApplyConfig.fulfilled, (state, action) => {
        state.loading = false;
        state.current = action.payload;
        state.isConfigured = true;
      })
      .addCase(saveAndApplyConfig.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || '保存配置失败';
      });
  },
});

export const {
  setServerConfig,
  setTesting,
  setTestResult,
  clearTestResult,
  resetConfig,
} = serverConfigSlice.actions;

export default serverConfigSlice.reducer;

🔧 十三、集成步骤

13.1 修改 API 配置

// src/API/config.ts

import store from '../states/store';

/**
 * 动态获取 API 基础地址
 */
export function getApiBaseUrl(): string {
  const serverConfig = store.getState().serverConfig?.current;
  if (serverConfig) {
    return serverConfig.apiBaseUrl + '/dr/api/v1/';
  }
  // 降级到编译时配置
  return `${API_BASE_URL_FROM_WEBPACK}/dr/api/v1/`;
}

/**
 * 动态获取 MQTT Broker 地址
 */
export function getMqttBrokerUrl(): string {
  const serverConfig = store.getState().serverConfig?.current;
  if (serverConfig) {
    return serverConfig.mqttBrokerUrl;
  }
  // 降级到编译时配置
  return `${MQTT_BROKER_URL_FROM_WEBPACK}`;
}

// 保留原有导出以兼容现有代码,但标记为废弃
/** @deprecated 使用 getApiBaseUrl() 代替 */
export const API_BASE_URL = getApiBaseUrl();

/** @deprecated 使用 getMqttBrokerUrl() 代替 */
export const MQTT_BROKER_URL = getMqttBrokerUrl();

export const IP_PORT = `${API_BASE_URL_FROM_WEBPACK}`;

13.2 修改 axios 拦截器

// src/API/interceptor.ts

import axios from 'axios';
import store from '../states/store';
import { getApiBaseUrl } from './config';
// ... 其他导入

const axiosInstance = axios.create({
  // 初始 baseURL,会被拦截器动态更新
  baseURL: getApiBaseUrl(),
  timeout: 30000,
});

// 添加请求拦截器,动态设置 baseURL
axiosInstance.interceptors.request.use(
  (config) => {
    // 动态获取当前配置的 API 地址
    config.baseURL = getApiBaseUrl();
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// ... 其他拦截器保持不变

13.3 修改应用初始化

// src/app.tsx

import { useState, useEffect, ReactNode } from 'react';
// ... 其他导入
import { initializeServerConfig } from './features/serverConfig/state/serverConfigSlice';
import ServerConfigModal from './features/serverConfig/components/ServerConfigModal';

function AppContent({ children }: { children: ReactNode }): JSX.Element {
  const dispatch = useAppDispatch();
  // ... 其他状态
  const [isI18nReady, setIsI18nReady] = useState(false);
  const [showConfigModal, setShowConfigModal] = useState(false);
  const serverConfig = useAppSelector((state) => state.serverConfig);

  useEffect(() => {
    // 1. 先初始化服务器配置
    dispatch(initializeServerConfig())
      .unwrap()
      .then((config) => {
        console.log('服务器配置已加载:', config);
        
        // 2. 尝试加载软件信息
        return dispatch(initializeProductState()).unwrap();
      })
      .then((productState) => {
        console.log(`初始化,拉取到产品信息:${JSON.stringify(productState)}`);
        const languageCode = productState.language;
        return dispatch(loadI18nMessages(languageCode)).unwrap();
      })
      .then(() => {
        setIsI18nReady(true);
      })
      .catch((error) => {
        console.error('初始化失败:', error);
        // 显示配置对话框
        setShowConfigModal(true);
      });
  }, [dispatch]);

  // 配置保存成功后重新初始化
  const handleConfigSaved = () => {
    setShowConfigModal(false);
    // 重新初始化应用
    dispatch(initializeProductState())
      .unwrap()
      .then((productState) => {
        const languageCode = productState.language;
        return dispatch(loadI18nMessages(languageCode)).unwrap();
      })
      .then(() => {
        setIsI18nReady(true);
      })
      .catch((error) => {
        console.error('重新初始化失败:', error);
        setShowConfigModal(true);
      });
  };

  return (
    <ConfigProvider theme={themeWithAlgorithm}>
      <IntlProvider
        locale={currentLocale ? currentLocale.split('_')[0] : 'en'}
        messages={(messages as Record<string, string>) || {}}
      >
        {/* 配置对话框 */}
        <ServerConfigModal
          open={showConfigModal}
          onSave={handleConfigSaved}
          onCancel={() => {
            // 用户可以选择稍后配置
            setShowConfigModal(false);
          }}
        />
        
        {/* 其他内容保持不变 */}
        {/* ... */}
      </IntlProvider>
    </ConfigProvider>
  );
}

13.4 注册 Redux Slice

// src/states/store.ts

import { configureStore } from '@reduxjs/toolkit';
// ... 其他导入
import serverConfigReducer from '../features/serverConfig/state/serverConfigSlice';

const store = configureStore({
  reducer: {
    // ... 其他 reducers
    serverConfig: serverConfigReducer,
  },
});

export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

📦 十四、依赖安装

14.1 Cordova 插件

# 安装 NativeStorage 插件
cordova plugin add cordova-plugin-nativestorage

14.2 Electron 依赖

Electron 使用 Node.js 的 fs 模块,无需额外安装依赖。

如果使用 @electron/remote,需要安装:

npm install @electron/remote

🎯 十五、总结

15.1 方案优势

  1. 运行时可配置:无需重新打包即可修改后端地址
  2. 用户友好:首次启动或连接失败时自动引导配置
  3. 跨平台支持:统一的接口,适配 Electron 和 Cordova
  4. 可维护性强:Feature-based 文件组织,集中管理相关代码
  5. 健壮性:完善的错误处理和降级方案
  6. 可扩展性:支持环境预设、配置导入导出等增强功能

15.2 关键设计决策

  1. Feature-based 文件组织:将所有相关代码集中到 src/features/serverConfig/
  2. 适配器模式:统一不同平台的存储接口
  3. Redux 状态管理:集中管理配置状态,便于全局访问
  4. 动态 baseURL:通过请求拦截器实现运行时更新
  5. 降级方案:保留编译时配置作为 fallback

15.3 实施顺序建议

  1. ✅ 创建类型定义和接口
  2. ✅ 实现存储适配器
  3. ✅ 实现配置服务
  4. ✅ 实现 Redux Slice
  5. ✅ 实现 UI 组件
  6. ✅ 集成到现有代码
  7. ✅ 测试各种场景
  8. ✅ 优化用户体验

📚 十六、参考资料


文档版本: v1.0
创建日期: 2025-01-11
最后更新: 2025-01-11