# 运行时可配置后端 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/)** ```typescript class ConfigService { - getServerConfig(): Promise - saveServerConfig(config: ServerConfig): Promise - testConnection(url: string): Promise - getDefaultConfig(): ServerConfig - clearConfig(): Promise } ``` **ServerConfigSlice (src/features/serverConfig/state/)** ```typescript - State: { apiBaseUrl, mqttBrokerUrl, isConfigured, testing } - Actions: setServerConfig, testConnection, resetConfig - Thunks: initializeServerConfig, saveAndApplyConfig ``` **ServerConfigModal (src/features/serverConfig/components/)** ```typescript - Props: open, onSave, onCancel, defaultUrl - Methods: handleTest, handleSave, validateUrl - State: inputUrl, testing, testResult ``` **StorageAdapter (src/features/serverConfig/storage/)** ```typescript interface IStorageAdapter { - getItem(key: string): Promise - setItem(key: string, value: string): Promise - removeItem(key: string): Promise } 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 可选增强 - [ ] 二维码扫描功能(移动端) - [ ] 配置导入/导出功能 - [ ] 多环境预设配置 --- ## 🔄 四、交互流程(泳道图) ```mermaid 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 ``` --- ## 📊 五、数据流图 ```mermaid 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 核心类型定义 ```typescript // 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 结构 ```typescript // 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)** ```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 应用启动流程 ```mermaid 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 配置保存流程 ```mermaid 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,后续修改可能不生效 **解决方案**: ```typescript // 使用请求拦截器动态设置(推荐) 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 连接可能在应用启动时已经建立,修改配置后需要重连 **解决方案**: ```typescript // 在 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` **解决方案**: ```bash cordova plugin add cordova-plugin-nativestorage ``` ### 9.3 UI/UX 问题 #### 问题 5:配置对话框阻塞应用启动 **描述**:如果用户不配置,应用无法使用 **解决方案**: - 提供"稍后配置"选项(进入离线模式) - 显示清晰的帮助文档和示例地址 - 添加"使用默认配置"快捷按钮 #### 问题 6:配置修改后的状态同步 **描述**:修改配置后,应用中已加载的数据可能过时 **解决方案**: ```typescript // 保存配置后强制刷新 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:恶意地址注入 **描述**:用户可能输入恶意服务器地址 **解决方案**: ```typescript 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, }; } ``` --- ## 📐 十、类图设计 ```mermaid classDiagram class IStorageAdapter { <> +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 类型定义 ```typescript // 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 存储适配器 ```typescript // src/features/serverConfig/storage/IStorageAdapter.ts /** * 存储适配器接口 */ export interface IStorageAdapter { /** * 获取存储项 */ getItem(key: string): Promise; /** * 设置存储项 */ setItem(key: string, value: string): Promise; /** * 删除存储项 */ removeItem(key: string): Promise; } ``` ```typescript // 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 { 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 { 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 { 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; } } } ``` ```typescript // 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 { 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 { return new Promise((resolve, reject) => { this.NativeStorage.setItem( key, value, () => resolve(), (error: any) => { console.error('保存存储失败:', error); reject(error); } ); }); } async removeItem(key: string): Promise { return new Promise((resolve, reject) => { this.NativeStorage.remove( key, () => resolve(), (error: any) => { console.error('删除存储失败:', error); reject(error); } ); }); } } ``` ```typescript // 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 配置服务 ```typescript // 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 { 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 { 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 { 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 { await this.storage.removeItem(this.CONFIG_KEY); } } export default ConfigService.getInstance(); ``` ### 12.4 Redux Slice ```typescript // 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) => { state.current = action.payload; state.isConfigured = true; }, setTesting: (state, action: PayloadAction) => { state.testing = action.payload; }, setTestResult: ( state, action: PayloadAction ) => { 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 配置 ```typescript // 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 拦截器 ```typescript // 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 修改应用初始化 ```typescript // 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 ( ) || {}} > {/* 配置对话框 */} { // 用户可以选择稍后配置 setShowConfigModal(false); }} /> {/* 其他内容保持不变 */} {/* ... */} ); } ``` ### 13.4 注册 Redux Slice ```typescript // 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; export type AppDispatch = typeof store.dispatch; ``` --- ## 📦 十四、依赖安装 ### 14.1 Cordova 插件 ```bash # 安装 NativeStorage 插件 cordova plugin add cordova-plugin-nativestorage ``` ### 14.2 Electron 依赖 Electron 使用 Node.js 的 `fs` 模块,无需额外安装依赖。 如果使用 `@electron/remote`,需要安装: ```bash 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. ✅ 优化用户体验 --- ## 📚 十六、参考资料 - [Electron App.getPath() 文档](https://www.electronjs.org/docs/latest/api/app#appgetpathname) - [Cordova NativeStorage 插件](https://github.com/TheCocoaProject/cordova-plugin-nativestorage) - [Redux Toolkit 异步逻辑](https://redux-toolkit.js.org/api/createAsyncThunk) - [Axios 拦截器](https://axios-http.com/docs/interceptors) --- **文档版本**: v1.0 **创建日期**: 2025-01-11 **最后更新**: 2025-01-11