Prechádzať zdrojové kódy

feat: 实现多语言切换功能,支持语言列表获取和系统语言修改

- 新增语言 API (src/API/language.ts)
- 新增语言设置对话框组件 (src/components/LanguageSettingModal.tsx)
- 扩展 i18nSlice 添加语言管理状态 (src/states/i18nSlice.ts)
- 在 SystemZone 集成语言管理按钮 (src/layouts/SystemZone.tsx)
- 添加功能实现文档 (docs/实现/多语言切换功能.md)
sw 6 dní pred
rodič
commit
540d9eb5be

+ 278 - 0
docs/实现/多语言切换功能.md

@@ -0,0 +1,278 @@
+# 多语言切换功能实现文档
+
+## 功能概述
+在 SystemZone 区域添加语言管理按钮(mgr按钮),点击后以弹出对话框的形式显示多语言列表,支持选择语言并保存,实现系统语言切换。
+
+## 需求分析
+
+### 接口信息
+
+#### 1. 获取多语言列表
+- **接口**: `GET /dr/api/v1/pub/language`
+- **说明**: 公开接口,无需认证
+- **响应示例**:
+```json
+{
+  "code": "0x000000",
+  "data": [
+    {
+      "language": "en",
+      "display": "English"
+    },
+    {
+      "language": "zh",
+      "display": "简体中文"
+    }
+  ],
+  "description": "Success",
+  "solution": ""
+}
+```
+
+#### 2. 修改系统语言
+- **接口**: `POST /api/v1/auth/manage/language`
+- **说明**: 需要认证,需要在请求头中携带 token
+- **请求头**:
+  - Authorization: Bearer {token}
+  - Language: en 或 zh
+  - Product: DROS 或 VETDROS
+  - Source: Electron 或 Browser 或 Android
+- **请求体**:
+```json
+{
+  "lang": "en_US.UTF-8" // 或 "zh_CN.UTF-8"
+}
+```
+- **响应示例**:
+```json
+{
+  "code": "0x000000",
+  "description": "Success",
+  "solution": "",
+  "data": {}
+}
+```
+
+### 语言代码映射关系
+- `en` -> `en_US.UTF-8`
+- `zh` -> `zh_CN.UTF-8`
+
+## 实现方案
+
+### 1. 状态管理(Redux)
+扩展 `src/states/i18nSlice.ts`,添加语言管理功能:
+- 新增状态字段:
+  - `availableLanguages`: 可用语言列表
+  - `selectedLanguage`: 当前选中的语言
+  - `languagesLoading`: 加载语言列表状态
+  - `savingLanguage`: 保存语言设置状态
+  - `languageError`: 语言操作错误信息
+
+- 新增 async thunks:
+  - `loadAvailableLanguages`: 加载可用语言列表
+  - `updateSystemLanguage`: 更新系统语言
+
+- 新增 reducers:
+  - `setSelectedLanguage`: 设置选中的语言
+  - `clearLanguageError`: 清除语言错误
+
+### 2. API层实现
+创建 `src/API/language.ts` 文件,包含以下接口:
+
+```typescript
+// 获取多语言列表接口
+export interface LanguageItem {
+  language: string;  // en, zh
+  display: string;   // English, 简体中文
+}
+
+export interface LanguageListResponse {
+  code: string;
+  data: LanguageItem[];
+  description: string;
+  solution: string;
+}
+
+// 修改系统语言接口
+export interface ChangeLanguageRequest {
+  lang: string;  // en_US.UTF-8, zh_CN.UTF-8
+}
+
+export interface ChangeLanguageResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: {};
+}
+```
+
+接口方法:
+- `getLanguageList()`: 获取多语言列表
+- `changeLanguage(lang: string)`: 修改系统语言
+
+### 3. 组件层实现
+
+#### 3.1 创建语言设置对话框组件
+创建 `src/components/LanguageSettingModal.tsx`,实现以下功能:
+- 使用 Redux 状态管理,通过 `useAppSelector` 和 `useAppDispatch` hooks
+- 自动加载语言列表(仅在列表为空时)
+- 使用 Ant Design 的 Select 组件作为下拉选择框
+- 保存按钮,调用修改语言接口
+- 取消按钮,关闭对话框
+- 参考 ExitModal 的实现风格
+
+组件特性:
+- 无本地状态,所有状态由 Redux 管理
+- 居中显示
+- 宽度 500px
+- 包含语言下拉选择和操作按钮
+- 提供成功/失败的消息提示
+- 自动显示加载状态和错误信息
+
+#### 3.2 更新 SystemZone 组件
+在 `src/layouts/SystemZone.tsx` 中:
+- 将"配置"按钮改为"语言管理"按钮(mgr按钮)
+- 移除 disabled 属性
+- 添加状态管理和点击事件处理
+- 集成 LanguageSettingModal 组件
+
+### 4. 实现步骤
+
+1. **创建 API 文件** (`src/API/language.ts`)
+   - 定义接口类型
+   - 实现 getLanguageList 方法
+   - 实现 changeLanguage 方法
+   - 处理语言代码映射 (en -> en_US.UTF-8, zh -> zh_CN.UTF-8)
+
+2. **扩展 i18nSlice** (`src/states/i18nSlice.ts`)
+   - 添加语言管理相关状态
+   - 实现 loadAvailableLanguages async thunk
+   - 实现 updateSystemLanguage async thunk
+   - 添加相关 reducers
+
+3. **创建语言设置对话框组件** (`src/components/LanguageSettingModal.tsx`)
+   - 使用 Modal 组件作为容器
+   - 使用 Select 组件作为语言选择器
+   - 使用 Redux hooks 连接状态
+   - 实现加载语言列表逻辑
+   - 实现保存语言设置逻辑
+   - 添加错误处理和消息提示
+
+4. **更新 SystemZone 组件** (`src/layouts/SystemZone.tsx`)
+   - 添加语言设置对话框的状态管理
+   - 更新"配置"按钮为"语言管理"按钮
+   - 添加按钮点击事件
+   - 集成 LanguageSettingModal 组件
+
+5. **测试验证**
+   - 验证语言列表正确加载
+   - 验证语言切换功能正常
+   - 验证错误处理机制
+   - 验证用户交互体验
+   - 验证状态在 Redux 中正确管理
+
+## 技术细节
+
+### 语言代码转换
+```typescript
+// 从简短代码转换为完整代码
+const languageMap = {
+  'en': 'en_US.UTF-8',
+  'zh': 'zh_CN.UTF-8'
+};
+
+function mapLanguageCode(shortCode: string): string {
+  return languageMap[shortCode] || shortCode;
+}
+```
+
+### 请求头处理
+修改语言接口需要携带特定的请求头,这些会在 axios interceptor 中统一处理:
+- Authorization: 从 Redux store 中获取
+- Language: 当前系统语言
+- Product: 从配置中获取
+- Source: 固定为 Electron
+
+## UI/UX 设计
+
+### 对话框布局
+```
++----------------------------------+
+|      语言设置                      |
++----------------------------------+
+|                                  |
+|  选择语言:                        |
+|  [                     ▼]        |
+|                                  |
+|  [取消]              [保存]       |
+|                                  |
++----------------------------------+
+```
+
+### 交互流程
+1. 用户点击"语言管理"按钮
+2. 弹出语言设置对话框
+3. 自动加载可用语言列表
+4. 用户从下拉框选择目标语言
+5. 点击"保存"按钮
+6. 调用 API 修改系统语言
+7. 显示成功/失败提示
+8. 关闭对话框
+
+## 注意事项
+
+1. **错误处理**:
+   - API 调用失败时显示错误提示
+   - 网络异常时提供友好提示
+
+2. **加载状态**:
+   - 语言列表加载时显示 loading 状态
+   - 保存操作时禁用按钮防止重复提交
+
+3. **用户体验**:
+   - 操作成功后显示成功提示
+   - 按钮状态合理管理
+
+4. **代码规范**:
+   - 遵循项目现有代码风格
+   - 使用 TypeScript 类型定义
+   - 组件解耦,职责清晰
+
+## 文件清单
+
+### 新增文件
+- `src/API/language.ts` - 语言相关API
+
+### 修改文件
+- `src/states/i18nSlice.ts` - 扩展添加语言管理功能
+- `src/components/LanguageSettingModal.tsx` - 语言设置对话框组件(使用 Redux 状态)
+- `src/layouts/SystemZone.tsx` - 集成语言管理功能
+
+## 验收标准
+
+- [x] 能够正确获取并显示语言列表
+- [x] 能够成功切换系统语言
+- [x] 操作有适当的加载状态提示
+- [x] 有完善的错误处理机制
+- [x] 代码符合项目规范
+- [x] UI 与现有组件风格一致
+- [x] 语言状态由 Redux 统一管理
+- [x] 组件无本地状态,完全依赖 Redux
+- [ ] 通过实际运行测试验证功能
+
+## 重构说明
+
+### Redux 状态管理优化
+为了更好地管理应用状态,我们将语言相关的状态从组件本地状态提升到 Redux store 中:
+
+**优势**:
+1. **状态共享**:整个应用可以访问和使用语言列表
+2. **缓存优化**:语言列表只需加载一次,存储在 Redux 中复用
+3. **代码简洁**:组件逻辑更简单,只负责 UI 渲染
+4. **可测试性**:Redux 状态管理更易于测试
+5. **一致性**:与项目现有的状态管理模式保持一致
+
+**实现方式**:
+- 在 `i18nSlice.ts` 中添加 `availableLanguages`、`selectedLanguage` 等状态
+- 使用 `createAsyncThunk` 处理异步 API 调用
+- 组件通过 `useAppSelector` 读取状态,通过 `useAppDispatch` 触发 actions

+ 63 - 0
src/API/language.ts

@@ -0,0 +1,63 @@
+import axiosInstance from './interceptor';
+
+// 获取多语言列表接口
+export interface LanguageItem {
+  language: string; // en, zh
+  display: string; // English, 简体中文
+}
+
+export interface LanguageListResponse {
+  code: string;
+  data: LanguageItem[];
+  description: string;
+  solution: string;
+}
+
+// 修改系统语言接口
+export interface ChangeLanguageRequest {
+  lang: string; // en_US.UTF-8, zh_CN.UTF-8
+}
+
+export interface ChangeLanguageResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: {};
+}
+
+// 语言代码映射:简短代码 -> 完整代码
+const languageMap: Record<string, string> = {
+  en: 'en_US.UTF-8',
+  zh: 'zh_CN.UTF-8',
+};
+
+/**
+ * 将简短语言代码转换为完整语言代码
+ * @param shortCode 简短代码 (en, zh)
+ * @returns 完整代码 (en_US.UTF-8, zh_CN.UTF-8)
+ */
+export function mapLanguageCode(shortCode: string): string {
+  return languageMap[shortCode] || shortCode;
+}
+
+/**
+ * 获取多语言列表
+ * @returns Promise<LanguageListResponse>
+ */
+export function getLanguageList(): Promise<{ data: LanguageListResponse }> {
+  return axiosInstance.get('/pub/language');
+}
+
+/**
+ * 修改系统语言
+ * @param language 语言简短代码 (en, zh)
+ * @returns Promise<ChangeLanguageResponse>
+ */
+export function changeLanguage(
+  language: string
+): Promise<{ data: ChangeLanguageResponse }> {
+  const fullLanguageCode = mapLanguageCode(language);
+  return axiosInstance.post('/auth/manage/language', {
+    lang: fullLanguageCode,
+  });
+}

+ 154 - 0
src/components/LanguageSettingModal.tsx

@@ -0,0 +1,154 @@
+import React, { useEffect } from 'react';
+import { Modal, Select, Button, Space, Typography, message, Spin } from 'antd';
+import { GlobalOutlined } from '@ant-design/icons';
+import { useAppDispatch, useAppSelector } from '../states/store';
+import {
+  loadAvailableLanguages,
+  updateSystemLanguage,
+  setSelectedLanguage,
+} from '../states/i18nSlice';
+
+const { Text } = Typography;
+
+interface LanguageSettingModalProps {
+  visible: boolean;
+  onClose: () => void;
+}
+
+const LanguageSettingModal: React.FC<LanguageSettingModalProps> = ({
+  visible,
+  onClose,
+}) => {
+  const dispatch = useAppDispatch();
+
+  // 从 Redux 获取状态
+  const {
+    availableLanguages,
+    selectedLanguage,
+    languagesLoading,
+    savingLanguage,
+    languageError,
+  } = useAppSelector((state) => state.i18n);
+
+  // 加载语言列表
+  useEffect(() => {
+    if (visible && availableLanguages.length === 0) {
+      dispatch(loadAvailableLanguages());
+    }
+  }, [visible, dispatch, availableLanguages.length]);
+
+  // 显示错误消息
+  useEffect(() => {
+    if (languageError) {
+      message.error({
+        content: languageError,
+        duration: 3,
+      });
+    }
+  }, [languageError]);
+
+  const handleSave = async () => {
+    if (!selectedLanguage) {
+      message.warning('请选择一个语言');
+      return;
+    }
+
+    try {
+      await dispatch(updateSystemLanguage(selectedLanguage)).unwrap();
+      message.success({
+        content: '语言设置已保存',
+        duration: 2,
+      });
+      onClose();
+    } catch (error) {
+      // 错误已经在 Redux slice 中处理并通过 languageError 显示
+      console.error('修改语言失败:', error);
+    }
+  };
+
+  const handleCancel = () => {
+    onClose();
+  };
+
+  const handleLanguageChange = (value: string) => {
+    dispatch(setSelectedLanguage(value));
+  };
+
+  return (
+    <Modal
+      title={
+        <Space>
+          <GlobalOutlined />
+          <span>语言设置</span>
+        </Space>
+      }
+      open={visible}
+      onCancel={handleCancel}
+      footer={null}
+      centered
+      width={500}
+    >
+      <div style={{ padding: '20px 0' }}>
+        {languagesLoading ? (
+          <div style={{ textAlign: 'center', padding: '40px 0' }}>
+            <Spin size="large" tip="加载中..." />
+          </div>
+        ) : (
+          <>
+            <Space direction="vertical" size="large" style={{ width: '100%' }}>
+              <div>
+                <Text
+                  strong
+                  style={{ display: 'block', marginBottom: 8, fontSize: 14 }}
+                >
+                  选择语言:
+                </Text>
+                <Select
+                  style={{ width: '100%' }}
+                  size="large"
+                  value={selectedLanguage}
+                  onChange={handleLanguageChange}
+                  disabled={savingLanguage}
+                  placeholder="请选择语言"
+                >
+                  {availableLanguages.map((lang) => (
+                    <Select.Option key={lang.language} value={lang.language}>
+                      {lang.display}
+                    </Select.Option>
+                  ))}
+                </Select>
+              </div>
+
+              <Space
+                style={{
+                  width: '100%',
+                  justifyContent: 'flex-end',
+                  marginTop: 16,
+                }}
+              >
+                <Button
+                  size="large"
+                  onClick={handleCancel}
+                  disabled={savingLanguage}
+                >
+                  取消
+                </Button>
+                <Button
+                  type="primary"
+                  size="large"
+                  onClick={handleSave}
+                  loading={savingLanguage}
+                  disabled={!selectedLanguage}
+                >
+                  保存
+                </Button>
+              </Space>
+            </Space>
+          </>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default LanguageSettingModal;

+ 18 - 1
src/layouts/SystemZone.tsx

@@ -7,6 +7,7 @@ import { isLoggedIn } from '../states/user_info';
 import Icon from '@/components/Icon';
 import { IconButton } from '@/components/IconButton';
 import ExitModal from '@/components/ExitModal';
+import LanguageSettingModal from '@/components/LanguageSettingModal';
 
 interface SystemZoneProps {
   onMenuClick?: (key: string) => void;
@@ -20,6 +21,8 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
 
     // 退出弹框状态管理
     const [exitModalVisible, setExitModalVisible] = useState(false);
+    // 语言设置弹框状态管理
+    const [languageModalVisible, setLanguageModalVisible] = useState(false);
 
     const handleExitClick = () => {
       setExitModalVisible(true);
@@ -29,6 +32,14 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
       setExitModalVisible(false);
     };
 
+    const handleLanguageClick = () => {
+      setLanguageModalVisible(true);
+    };
+
+    const handleLanguageModalClose = () => {
+      setLanguageModalVisible(false);
+    };
+
     return (
       <Row
         ref={ref}
@@ -64,7 +75,7 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
             iconSize={32} // 和size 2x 保持一致
             type="primary"
             style={{ padding: '4px 16px' }}
-            disabled
+            onClick={handleLanguageClick}
           >
             配置
           </IconButton>
@@ -101,6 +112,12 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
 
         {/* 退出选择弹框 */}
         <ExitModal visible={exitModalVisible} onClose={handleExitModalClose} />
+
+        {/* 语言设置弹框 */}
+        <LanguageSettingModal
+          visible={languageModalVisible}
+          onClose={handleLanguageModalClose}
+        />
       </Row>
     );
   }

+ 87 - 1
src/states/i18nSlice.ts

@@ -1,5 +1,10 @@
 import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
 import { fetchI18nMessages, I18nMessages } from '../API/i18n/i18nActions';
+import {
+  getLanguageList,
+  changeLanguage,
+  LanguageItem,
+} from '../API/language';
 
 export interface I18nState {
   messages: I18nMessages;
@@ -7,6 +12,12 @@ export interface I18nState {
   supportedLocales: string[];
   loading: boolean;
   error: string | null;
+  // 语言管理相关状态
+  availableLanguages: LanguageItem[];
+  selectedLanguage: string;
+  languagesLoading: boolean;
+  savingLanguage: boolean;
+  languageError: string | null;
 }
 
 const initialState: I18nState = {
@@ -15,6 +26,12 @@ const initialState: I18nState = {
   supportedLocales: ['en', 'zh'],
   loading: false,
   error: null,
+  // 语言管理初始状态
+  availableLanguages: [],
+  selectedLanguage: '',
+  languagesLoading: false,
+  savingLanguage: false,
+  languageError: null,
 };
 
 // 异步thunk:加载多语言资源
@@ -26,6 +43,30 @@ export const loadI18nMessages = createAsyncThunk(
   }
 );
 
+// 异步thunk:加载可用语言列表
+export const loadAvailableLanguages = createAsyncThunk(
+  'i18n/loadAvailableLanguages',
+  async () => {
+    const response = await getLanguageList();
+    if (response.data.code === '0x000000') {
+      return response.data.data;
+    }
+    throw new Error(response.data.description || '加载语言列表失败');
+  }
+);
+
+// 异步thunk:更新系统语言
+export const updateSystemLanguage = createAsyncThunk(
+  'i18n/updateSystemLanguage',
+  async (language: string) => {
+    const response = await changeLanguage(language);
+    if (response.data.code === '0x000000') {
+      return language;
+    }
+    throw new Error(response.data.description || '修改语言失败');
+  }
+);
+
 const i18nSlice = createSlice({
   name: 'i18n',
   initialState,
@@ -36,9 +77,18 @@ const i18nSlice = createSlice({
     clearError: (state) => {
       state.error = null;
     },
+    // 设置选中的语言
+    setSelectedLanguage: (state, action: PayloadAction<string>) => {
+      state.selectedLanguage = action.payload;
+    },
+    // 清除语言错误
+    clearLanguageError: (state) => {
+      state.languageError = null;
+    },
   },
   extraReducers: (builder) => {
     builder
+      // 加载多语言资源
       .addCase(loadI18nMessages.pending, (state) => {
         state.loading = true;
         state.error = null;
@@ -51,9 +101,45 @@ const i18nSlice = createSlice({
       .addCase(loadI18nMessages.rejected, (state, action) => {
         state.loading = false;
         state.error = action.error.message || 'Failed to load i18n messages';
+      })
+      // 加载可用语言列表
+      .addCase(loadAvailableLanguages.pending, (state) => {
+        state.languagesLoading = true;
+        state.languageError = null;
+      })
+      .addCase(loadAvailableLanguages.fulfilled, (state, action) => {
+        state.languagesLoading = false;
+        state.availableLanguages = action.payload;
+        // 默认选中第一个语言
+        if (action.payload.length > 0 && !state.selectedLanguage) {
+          state.selectedLanguage = action.payload[0].language;
+        }
+      })
+      .addCase(loadAvailableLanguages.rejected, (state, action) => {
+        state.languagesLoading = false;
+        state.languageError =
+          action.error.message || '加载语言列表失败';
+      })
+      // 更新系统语言
+      .addCase(updateSystemLanguage.pending, (state) => {
+        state.savingLanguage = true;
+        state.languageError = null;
+      })
+      .addCase(updateSystemLanguage.fulfilled, (state, action) => {
+        state.savingLanguage = false;
+        state.currentLocale = action.payload;
+      })
+      .addCase(updateSystemLanguage.rejected, (state, action) => {
+        state.savingLanguage = false;
+        state.languageError = action.error.message || '修改语言失败';
       });
   },
 });
 
-export const { setCurrentLocale, clearError } = i18nSlice.actions;
+export const {
+  setCurrentLocale,
+  clearError,
+  setSelectedLanguage,
+  clearLanguageError,
+} = i18nSlice.actions;
 export default i18nSlice.reducer;