From 5afaf1de75d8c910c01b2da9a32dfbfc0c5faa49 Mon Sep 17 00:00:00 2001 From: mmc <1556375442@qq.com> Date: Fri, 9 Oct 2026 13:30:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E4=BD=93=E4=B8=AD=E6=96=87=E3=80=81?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E3=80=81=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E3=80=81=E6=97=B6=E5=8C=BA=E3=80=81=E5=A4=8F=E4=BB=A4?= =?UTF-8?q?=E6=97=B6=E3=80=81=E5=85=AC=E5=88=B6/=E8=8B=B1=E5=88=B6?= =?UTF-8?q?=E5=92=8C=E5=8C=BA=E5=9F=9F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../完善多语言与区域配置功能.md | 171 ++++++++++++++++++ src/components/LanguageSwitcher.tsx | 6 +- src/components/Layout.tsx | 12 +- .../SystemSetting/BasicSettings.tsx | 46 ++++- src/components/devices/DeviceOverviewPage.tsx | 6 +- src/components/devices/DeviceStatusPage.tsx | 14 +- src/components/devices/WayLinePage.tsx | 7 +- src/lib/dayjs.ts | 112 ++++++++++++ src/lib/units.ts | 104 +++++++++++ src/locales/en/index.ts | 11 ++ src/locales/zh/index.ts | 11 ++ src/pages/RealTimeMonitor.tsx | 5 +- src/store/index.ts | 2 + src/store/preferencesSlice.ts | 111 ++++++++++++ 14 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 .trae/documents/完善多语言与区域配置功能.md create mode 100644 src/lib/dayjs.ts create mode 100644 src/lib/units.ts create mode 100644 src/store/preferencesSlice.ts diff --git a/.trae/documents/完善多语言与区域配置功能.md b/.trae/documents/完善多语言与区域配置功能.md new file mode 100644 index 0000000..d15ff63 --- /dev/null +++ b/.trae/documents/完善多语言与区域配置功能.md @@ -0,0 +1,171 @@ +# 完善多语言与区域配置功能 + +## Context(背景) + +本项目(机器人智慧服务系统)已具备基础 i18n 框架(i18next + react-i18next),支持简体中文(zh)和英文(en)切换,并有 `LanguageSwitcher` 组件和 antd ConfigProvider 的 locale 切换。但以下功能存在缺口: + +- **时区**:`BasicSettings.tsx` 的时区选择器只有固定的 `UTC+08:00` 一个选项,无法选择其他时区;`Layout.tsx` 用 `new Date().toLocaleString()` 显示时间,依赖浏览器本地时区;`WayLinePage`/`RealTimeMonitor` 用 `Intl.DateTimeFormat().resolvedOptions().timeZone` 获取浏览器时区写入接口字段,用户无法手动配置。 +- **夏令时(DST)**:固定偏移无法处理 DST,项目无任何 DST 逻辑。 +- **公制/英制单位**:温度在 `DeviceOverviewPage.tsx`(L787,L989) 和 `DeviceStatusPage.tsx`(L1191-1195,L1310-1314) 硬编码为 `℃`,无英制(°F)转换;无距离/速度单位切换。 +- **区域配置**:`userSlice` 只管理 token/userInfo/auth,无集中化的区域偏好(语言、时区、单位制)状态。 + +本次目标:搭建集中化的区域配置框架(preferencesSlice + dayjs 时区插件 + 单位换算工具 + 设置 UI),并接入关键展示点(顶部时钟、设备温度),让时区/DST/单位制可由用户配置并全局生效。**不包含**补全约 1070 个缺失翻译 key 和清理 69 个文件硬编码中文的工作。 + +## 实现方案 + +### 1. 新建 preferencesSlice — 区域偏好状态管理 + +**新文件**:`src/store/preferencesSlice.ts` + +State 结构: +```ts +interface PreferencesState { + locale: 'zh' | 'en'; // 界面语言 + timezone: string; // IANA 时区名,如 'Asia/Shanghai' + unitSystem: 'metric' | 'imperial'; // 单位制 + dateFormat: 'YYYY-MM-DD HH:mm:ss' | 'YYYY-MM-DD HH:mm' | 'MM/DD/YYYY HH:mm:ss'; // 日期格式 +} +``` + +- 初始化:从 `localStorage.preferences` 读取;无则用浏览器默认:locale 取 `i18nextLng`、timezone 取 `Intl.DateTimeFormat().resolvedOptions().timeZone`、unitSystem 按 locale 默认(zh→metric,en→metric,用户可手动切 imperial) +- Actions:`setLocale`、`setTimezone`、`setUnitSystem`、`setDateFormat`、`setPreferences`(批量)、`resetPreferences` +- 每次更新同步写回 `localStorage.preferences` +- 导出 selector hooks:`usePreferences()`、`useTimezone()`、`useUnitSystem()` + +**修改文件**:`src/store/index.ts` — 注册 `preferences: preferencesReducer`,更新 `RootState` 类型。 + +### 2. dayjs 时区插件配置 — DST 自动处理 + +**新文件**:`src/lib/dayjs.ts` + +- 导入并扩展 `dayjs/plugin/utc` 和 `dayjs/plugin/timezone` +- 导出配置好的 `dayjs` 实例 +- 导出辅助函数: + - `formatInTz(date: Date|string, tz: string, format: string): string` — 在指定时区格式化时间,自动处理 DST + - `nowInTz(tz: string, format: string): string` — 当前时间在指定时区的格式化结果 + - `getTimezoneOffsetLabel(tz: string): string` — 获取时区偏移标签如 `UTC+08:00`(用于显示) + - `isDST(tz: string): boolean` — 判断指定时区当前是否处于夏令时 +- 导出常用 IANA 时区列表 `TIMEZONES`(覆盖主要地区:Asia/Shanghai、Asia/Tokyo、America/New_York、America/Los_Angeles、Europe/London、Europe/Paris、Australia/Sydney 等,约 30-40 个) + +### 3. 单位换算工具 — 公制/英制转换 + +**新文件**:`src/lib/units.ts` + +- `celsiusToFahrenheit(c: number): number` — ℃→°F +- `fahrenheitToCelsius(f: number): number` — °F→℃ +- `kmToMile(km: number): number` — km→mile +- `mileToKm(mi: number): number` +- `kmhToMph(kmh: number): number` +- `formatTemperature(celsius: number, unitSystem: 'metric'|'imperial', digits=1): { value: number; unit: '℃'|'°F'; label: string }` — 根据单位制格式化温度,返回数值+单位 +- `formatDistance(km: number, unitSystem): { value: number; unit: 'km'|'mi' }` +- `formatSpeed(kmh: number, unitSystem): { value: number; unit: 'km/h'|'mph' }` +- React hook `useUnitFormatter()` — 从 preferencesSlice 读取 unitSystem,返回绑定好的格式化函数 + +### 4. i18n 集成 — 翻译 key 补充 + 语言联动 + +**修改文件**:`src/locales/zh/index.ts` 和 `src/locales/en/index.ts` + +在 `systemSetting` 命名空间下新增翻译 key(zh/en 各一份): +``` +unitSystem: '单位制' / 'Unit System' +metric: '公制' / 'Metric' +imperial: '英制' / 'Imperial' +timezoneSelect: '时区选择' / 'Timezone' +daylightSavingTime: '夏令时' / 'Daylight Saving Time' +dstActive: '当前处于夏令时' / 'DST Active' +dstInactive: '当前非夏令时' / 'DST Inactive' +dateFormat: '日期格式' / 'Date Format' +regionalSettings: '区域配置' / 'Regional Settings' +unitCelsius: '摄氏度' / 'Celsius' +unitFahrenheit: '华氏度' / 'Fahrenheit' +``` + +**修改文件**:`src/i18n/index.ts` — 不改动核心配置,但在 App 层监听 preferencesSlice.locale 变化时调用 `i18n.changeLanguage`。 + +### 5. 设置 UI — 扩展 BasicSettings 区域配置面板 + +**修改文件**:`src/components/SystemSetting/BasicSettings.tsx` + +在现有"默认语言""时区""默认首页"区域(L234-245)进行改造: +- **时区选择器**:从单一 `UTC+08:00` 替换为完整 IANA 时区下拉列表(使用 `TIMEZONES`),选项显示 `时区名 (UTC偏移)`,选中后写入 `preferencesSlice.timezone` +- **单位制切换**:新增 `Radio.Group`(公制/英制),写入 `preferencesSlice.unitSystem` +- **DST 指示器**:在时区选择旁显示当前时区是否处于夏令时(用 `isDST()` 判断,显示绿/灰标签) +- **日期格式选择**:新增日期格式下拉 +- 所有变更通过 `dispatch(setXxx(...))` 写入 store,立即生效并持久化 + +### 6. 接入展示点 — 让区域配置全局生效 + +#### 6.1 顶部时钟(Layout.tsx) +**修改文件**:`src/components/Layout.tsx` +- `useState(new Date().toLocaleString())` → 改为读取 `preferencesSlice.timezone` + `dateFormat`,用 `nowInTz(tz, fmt)` 格式化 +- `setInterval` 回调改为调用 `nowInTz` +- 时区变化时自动重格式化(useEffect 依赖 timezone) + +#### 6.2 设备温度展示(DeviceOverviewPage.tsx + DeviceStatusPage.tsx) +**修改文件**: +- `src/components/devices/DeviceOverviewPage.tsx`(L787, L989) +- `src/components/devices/DeviceStatusPage.tsx`(L1191-1195, L1310-1314) + +将硬编码 `℃` 替换为 `useUnitFormatter().formatTemperature(celsius)` 返回的 `{value}{unit}`,根据 unitSystem 自动切换 ℃/°F。 + +#### 6.3 接口时区字段(WayLinePage.tsx + RealTimeMonitor.tsx) +**修改文件**: +- `src/components/devices/WayLinePage.tsx`(L328, L392, L690) +- `src/pages/RealTimeMonitor.tsx`(L692) + +将 `Intl.DateTimeFormat().resolvedOptions().timeZone` 替换为读取 `preferencesSlice.timezone`,优先用用户配置的时区。 + +#### 6.4 语言联动 +**修改文件**:`src/components/LanguageSwitcher.tsx`(或新建 hook) +- 切换语言时同步 `dispatch(setLocale(key))`,确保 preferencesSlice 与 i18n 一致 +- App 启动时从 preferencesSlice 初始化 i18n 语言 + +### 7. 类型定义 + +**修改文件**:`src/store/types.ts`(如存在)或 `preferencesSlice.ts` 内联 +- 导出 `PreferencesState`、`UnitSystem`、`Locale` 类型 + +## 关键文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/store/preferencesSlice.ts` | 新建 | 区域偏好状态管理 | +| `src/store/index.ts` | 修改 | 注册 preferencesReducer | +| `src/lib/dayjs.ts` | 新建 | dayjs 时区插件 + 辅助函数 | +| `src/lib/units.ts` | 新建 | 公制/英制单位换算 | +| `src/locales/zh/index.ts` | 修改 | 新增区域配置翻译 key | +| `src/locales/en/index.ts` | 修改 | 新增区域配置翻译 key | +| `src/components/SystemSetting/BasicSettings.tsx` | 修改 | 时区/单位/DST 设置 UI | +| `src/components/Layout.tsx` | 修改 | 顶部时钟时区感知 | +| `src/components/devices/DeviceOverviewPage.tsx` | 修改 | 温度单位制适配 | +| `src/components/devices/DeviceStatusPage.tsx` | 修改 | 温度单位制适配 | +| `src/components/devices/WayLinePage.tsx` | 修改 | 接口时区字段用配置值 | +| `src/pages/RealTimeMonitor.tsx` | 修改 | 接口时区字段用配置值 | +| `src/components/LanguageSwitcher.tsx` | 修改 | 语言切换同步 preferencesSlice | + +## 复用现有代码 + +- 复用 `src/i18n/index.ts` 的 i18next 实例,不重建 +- 复用 `src/store/index.ts` 的 `configureStore` 模式(参考 userSlice/stationSlice 的 localStorage 持久化模式) +- 复用 `src/lib/utils.ts` 中已有的 `fix2` 数字格式化函数(温度展示中已使用) +- 复用 antd 的 `Select`、`Radio`、`Tag` 组件(BasicSettings 已大量使用) +- dayjs 已作为 antd 的传递依赖可用,仅需在项目中显式 `import dayjs from 'dayjs'` 并扩展插件(UTC/Timezone 插件随 dayjs 包内置,无需额外安装) + +## 验证方案 + +1. **启动开发服务器**:`npm run dev`(vite --port=3001) +2. **语言切换**:点击右上角 LanguageSwitcher 切换中/英文,验证所有新 UI 文案正确切换 +3. **时区配置**:进入系统设置 → 区域配置,选择不同时区(如 America/New_York),验证: + - 顶部时钟立即切换为纽约时间 + - 切换到 Europe/Paris 并验证夏令时标签正确显示(10 月欧洲仍在 DST 内) +4. **单位制切换**:在设置中切换英制,进入设备概览/设备状态页,验证温度从 `℃` 变为 `°F` 且数值正确(如 25℃ → 77.0°F) +5. **持久化**:刷新页面,验证语言/时区/单位制设置从 localStorage 恢复 +6. **类型检查**:`npm run lint`(tsc --noEmit)无类型错误 +7. **DST 验证**:选择 America/New_York(EST/EDT),验证当前(10 月仍在 EDT)显示 DST Active;选择 Asia/Shanghai 验证显示 DST Inactive + +## 注意事项 + +- dayjs 的 UTC/Timezone 插件随 dayjs npm 包内置(`dayjs/plugin/utc`、`dayjs/plugin/timezone`),无需额外安装依赖 +- dayjs 目前是 antd 的传递依赖,项目中已有多处直接 `import dayjs from 'dayjs'`,可安全使用 +- 不改动 i18n 核心配置文件 `src/i18n/index.ts` 的 resources 结构,仅新增翻译 key +- 温度数据来源是设备上报的摄氏度原始值,转换在前端完成,不涉及后端改动 diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 293ee4f..d6d28a0 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -3,9 +3,12 @@ import { Button, Dropdown } from 'antd'; import { GlobalOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import type { MenuProps } from 'antd'; +import { useAppDispatch } from '../store'; +import { setLocale } from '../store/preferencesSlice'; export default function LanguageSwitcher() { - const { i18n ,t} = useTranslation(); + const { i18n, t } = useTranslation(); + const dispatch = useAppDispatch(); const currentLng = i18n.language?.startsWith('en') ? 'en' : 'zh'; const items: MenuProps['items'] = [ @@ -28,6 +31,7 @@ export default function LanguageSwitcher() { const handleChange: MenuProps['onClick'] = ({ key }) => { i18n.changeLanguage(key); localStorage.setItem('i18nextLng', key); + dispatch(setLocale(key as 'zh' | 'en')); }; return ( diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index d4f769e..920126a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Layout, Menu, Button, Space, Select, Avatar, Card, Tag @@ -19,6 +19,7 @@ import { setCurrentStation, clearStation, setStationList as setStoreStationList import { getMenuList } from '../api/mune'; import { getStationByUserId } from '../api/stationManage'; import LanguageSwitcher from './LanguageSwitcher'; +import { nowInTz } from '../lib/dayjs'; import envConfig from '../../env'; const { Header, Sider, Content } = Layout; @@ -27,6 +28,7 @@ export default function AppLayout() { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState(false); const [time, setTime] = useState(new Date().toLocaleString()); + const { timezone, dateFormat } = useSelector((state: RootState) => state.preferences); const [menuList, setMenuList] = useState([]); const [stationList, setStationList] = useState([]); const [isFullScreen, setIsFullScreen] = useState(false); @@ -163,11 +165,15 @@ export default function AppLayout() { }; useEffect(() => { - const timer = setInterval(() => setTime(new Date().toLocaleString()), 1000); + const timer = setInterval(() => setTime(nowInTz(timezone, dateFormat)), 1000); getMenu(); getStationData(); return () => clearInterval(timer); - }, [userInfo]); + }, [userInfo, timezone, dateFormat]); + + useEffect(() => { + setTime(nowInTz(timezone, dateFormat)); + }, [timezone, dateFormat]); useEffect(() => { const fullscreenChange = () => { diff --git a/src/components/SystemSetting/BasicSettings.tsx b/src/components/SystemSetting/BasicSettings.tsx index c262e43..5ff9322 100644 --- a/src/components/SystemSetting/BasicSettings.tsx +++ b/src/components/SystemSetting/BasicSettings.tsx @@ -15,6 +15,7 @@ import { Space, TimePicker, Divider, + Radio, } from 'antd'; import { CloudOutlined, @@ -58,12 +59,21 @@ import { } from 'lucide-react'; import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { useAppDispatch } from '../../store'; +import { setLocale, setTimezone, setUnitSystem, setDateFormat } from '../../store/preferencesSlice'; +import { getTimezoneOptions, isDST, getTimezoneOffsetLabel } from '../../lib/dayjs'; const { Text, Title } = Typography; const BasicSettings = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [form] = Form.useForm(); + const dispatch = useAppDispatch(); + const { locale, timezone, unitSystem, dateFormat } = useSelector((state: any) => state.preferences); + const tzOptions = getTimezoneOptions(); + const dstActive = isDST(timezone); // 响应式字体大小 const fontNormal = 'clamp(12px, 0.75vw, 13px)'; @@ -224,17 +234,41 @@ const BasicSettings = () => {