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 = () => { - - { dispatch(setLocale(v)); i18n.changeLanguage(v); }}> {t('users.chinese')} English - - dispatch(setTimezone(v))} showSearch optionFilterProp="label"> + {tzOptions.map(opt => {opt.label})} + + + + + + + {dstActive ? t('systemSetting.dstActive') : t('systemSetting.dstInactive')} ({getTimezoneOffsetLabel(timezone)}) + + + + + + dispatch(setUnitSystem(e.target.value))}> + {t('systemSetting.metric')} + {t('systemSetting.imperial')} + + + + + + diff --git a/src/components/devices/DeviceOverviewPage.tsx b/src/components/devices/DeviceOverviewPage.tsx index 5b30bd0..5bddfdc 100644 --- a/src/components/devices/DeviceOverviewPage.tsx +++ b/src/components/devices/DeviceOverviewPage.tsx @@ -33,6 +33,7 @@ import { useSelector } from 'react-redux'; import { RootState } from '@/store'; import envConfig from '../../../env'; import utils from '@/lib/utils.ts'; +import { useUnitFormatter } from '@/lib/units'; import { useTranslation } from 'react-i18next'; const { Content } = Layout; @@ -44,6 +45,7 @@ export default function DeviceOverviewPage() { const { t } = useTranslation(); const { userInfo } = useSelector((state: RootState) => state.user); const { stationId, currentStation } = useSelector((state: RootState) => state.station); + const { formatTemperature } = useUnitFormatter(); const [zoomedVideo, setZoomedVideo] = useState(null); const [mowerView, setMowerView] = useState('front'); @@ -784,7 +786,7 @@ export default function DeviceOverviewPage() { {realtimeDevice?.environment_temperature && (
{t('home.temperature')} - {fix2(realtimeDevice.environment_temperature || 25)}℃ + {formatTemperature(Number(realtimeDevice.environment_temperature || 25), 1).label}
)} @@ -986,7 +988,7 @@ export default function DeviceOverviewPage() {
{t('devices.s44')}
{fix2(realtimeDevice?.wind_speed)} m/s
-
{t('home.temperature')}
{fix2(realtimeDevice?.environment_temperature)}℃
+
{t('home.temperature')}
{formatTemperature(Number(realtimeDevice?.environment_temperature || 0), 1).label}
{t('devices.rainfall')}
{getVal(realtimeDevice?.rainfall)}
RTK
{realtimeDevice?.position_state?.is_fixed === 'fixing_successful' ? '固定' : '未固定'}
diff --git a/src/components/devices/DeviceStatusPage.tsx b/src/components/devices/DeviceStatusPage.tsx index c74d17c..d040a52 100644 --- a/src/components/devices/DeviceStatusPage.tsx +++ b/src/components/devices/DeviceStatusPage.tsx @@ -61,6 +61,7 @@ import { MowerAllView } from './MowerAllView'; import { useMqtt } from '@/hooks/useMqtt'; import envConfig from '../../../env'; import { dataUrlToFile } from '@/lib/utils.ts'; +import { useUnitFormatter } from '@/lib/units'; import { PauseOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; const { RangePicker } = DatePicker; @@ -72,6 +73,7 @@ export default function DeviceStatusPage() { const { t } = useTranslation(); const { userInfo } = useSelector((state: RootState) => state.user); const { stationId, currentStation } = useSelector((state: RootState) => state.station); + const { formatTemperature } = useUnitFormatter(); const [persistentDroneData, setPersistentDroneData] = useState(null); // 持久化无人机数据 const [persistentHostData, setPersistentHostData] = useState(null); // 持久化机场数据 const [devices, setDevices] = useState([]); @@ -1188,11 +1190,11 @@ export default function DeviceStatusPage() { {t('devices.rtRightSpeed')} {runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'} {t('devices.rtLeftTemp')} - {runningStatus?.leftMotorTemp !== undefined ? `${Number(runningStatus?.leftMotorTemp).toFixed(1)}℃` : '-'} + {runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'} {t('devices.rtRightTemp')} - {runningStatus?.rightMotorTemp !== undefined ? `${Number(runningStatus?.rightMotorTemp).toFixed(1)}℃` : '-'} + {runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'} {t('devices.rtMainTemp')} - {runningStatus?.chipTemp !== undefined ? `${Number(runningStatus?.chipTemp).toFixed(1)}℃` : '-'} + {runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'} {t('devices.rtHeadingAngle')} {runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'} {t('devices.rtPitchAngle')} @@ -1307,11 +1309,11 @@ export default function DeviceStatusPage() { {t('devices.rtRightSpeed')} {runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'} {t('devices.rtLeftTemp')} - {runningStatus?.leftMotorTemp !== undefined ? `${Number(runningStatus?.leftMotorTemp).toFixed(1)}℃` : '-'} + {runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'} {t('devices.rtRightTemp')} - {runningStatus?.rightMotorTemp !== undefined ? `${Number(runningStatus?.rightMotorTemp).toFixed(1)}℃` : '-'} + {runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'} {t('devices.rtMainTemp')} - {runningStatus?.chipTemp !== undefined ? `${Number(runningStatus?.chipTemp).toFixed(1)}℃` : '-'} + {runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'} {t('devices.rtHeadingAngle')} {runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'} {t('devices.rtPitchAngle')} diff --git a/src/components/devices/WayLinePage.tsx b/src/components/devices/WayLinePage.tsx index 7437ac0..76606a8 100644 --- a/src/components/devices/WayLinePage.tsx +++ b/src/components/devices/WayLinePage.tsx @@ -38,6 +38,7 @@ const { RangePicker } = DatePicker; export default function WayLinePage({ onRefresh }) { const { t } = useTranslation(); const { userInfo } = useSelector((state: RootState) => state.user); + const { timezone } = useSelector((state: RootState) => state.preferences); const { stationId } = useSelector((state: RootState) => state.station); const { currentStation } = useSelector((state: RootState) => state.station); @@ -325,7 +326,7 @@ export default function WayLinePage({ onRefresh }) { days_of_month: [], week_of_month: 1, }, - time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + time_zone: timezone, recurring_task_start_time_list: [], }); @@ -389,7 +390,7 @@ export default function WayLinePage({ onRefresh }) { task_type: 'immediate', repeat_type: 'nonrepeating', repeat_option: { interval: 1 }, - time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + time_zone: timezone, begin_at: null, end_at: null, recurring_task_start_time_list: [], @@ -687,7 +688,7 @@ export default function WayLinePage({ onRefresh }) { resumable_status: 'auto', min_battery_capacity: 50, repeat_type: 'nonrepeating', - time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + time_zone: timezone, }; const res: any = await createWaylineTask(payload); diff --git a/src/lib/dayjs.ts b/src/lib/dayjs.ts new file mode 100644 index 0000000..574f3a0 --- /dev/null +++ b/src/lib/dayjs.ts @@ -0,0 +1,112 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import 'dayjs/locale/zh-cn'; +import 'dayjs/locale/en'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export default dayjs; + +/** 在指定时区格式化时间,自动处理 DST */ +export function formatInTz(date: Date | string | number, tz: string, format: string): string { + try { + return dayjs(date).tz(tz).format(format); + } catch { + return dayjs(date).format(format); + } +} + +/** 当前时间在指定时区的格式化结果 */ +export function nowInTz(tz: string, format: string): string { + return formatInTz(new Date(), tz, format); +} + +/** 获取时区偏移标签,如 UTC+08:00 */ +export function getTimezoneOffsetLabel(tz: string): string { + try { + const offset = dayjs().tz(tz).utcOffset(); + const sign = offset >= 0 ? '+' : '-'; + const abs = Math.abs(offset); + const h = Math.floor(abs / 60).toString().padStart(2, '0'); + const m = (abs % 60).toString().padStart(2, '0'); + return `UTC${sign}${h}:${m}`; + } catch { + return 'UTC+00:00'; + } +} + +/** 判断指定时区当前是否处于夏令时 */ +export function isDST(tz: string): boolean { + try { + const now = dayjs().tz(tz); + // 取本年1月(北半球冬季)与本年7月(北半球夏季)的偏移量 + const year = now.year(); + const janOffset = dayjs.tz(dayjs(`${year}-01-15T12:00:00Z`).toISOString(), tz).utcOffset(); + const julOffset = dayjs.tz(dayjs(`${year}-07-15T12:00:00Z`).toISOString(), tz).utcOffset(); + const currentOffset = now.utcOffset(); + // DST 时区夏季偏移量会大于冬季偏移量;当前偏移等于夏季偏移则为 DST + const summerOffset = Math.max(janOffset, julOffset); + const winterOffset = Math.min(janOffset, julOffset); + if (summerOffset === winterOffset) return false; // 无 DST 的时区 + return currentOffset === summerOffset; + } catch { + return false; + } +} + +export interface TimezoneOption { + value: string; + label: string; +} + +/** 常用 IANA 时区列表 */ +export const TIMEZONES: TimezoneOption[] = [ + // 亚洲 + { value: 'Asia/Shanghai', label: '上海 (Shanghai)' }, + { value: 'Asia/Hong_Kong', label: '香港 (Hong Kong)' }, + { value: 'Asia/Tokyo', label: '东京 (Tokyo)' }, + { value: 'Asia/Seoul', label: '首尔 (Seoul)' }, + { value: 'Asia/Singapore', label: '新加坡 (Singapore)' }, + { value: 'Asia/Bangkok', label: '曼谷 (Bangkok)' }, + { value: 'Asia/Kolkata', label: '加尔各答 (Kolkata)' }, + { value: 'Asia/Dubai', label: '迪拜 (Dubai)' }, + { value: 'Asia/Taipei', label: '台北 (Taipei)' }, + // 美洲 + { value: 'America/New_York', label: '纽约 (New York)' }, + { value: 'America/Chicago', label: '芝加哥 (Chicago)' }, + { value: 'America/Denver', label: '丹佛 (Denver)' }, + { value: 'America/Los_Angeles', label: '洛杉矶 (Los Angeles)' }, + { value: 'America/Anchorage', label: '安克雷奇 (Anchorage)' }, + { value: 'America/Toronto', label: '多伦多 (Toronto)' }, + { value: 'America/Vancouver', label: '温哥华 (Vancouver)' }, + { value: 'America/Mexico_City', label: '墨西哥城 (Mexico City)' }, + { value: 'America/Sao_Paulo', label: '圣保罗 (São Paulo)' }, + { value: 'America/Argentina/Buenos_Aires', label: '布宜诺斯艾利斯 (Buenos Aires)' }, + // 欧洲 + { value: 'Europe/London', label: '伦敦 (London)' }, + { value: 'Europe/Paris', label: '巴黎 (Paris)' }, + { value: 'Europe/Berlin', label: '柏林 (Berlin)' }, + { value: 'Europe/Madrid', label: '马德里 (Madrid)' }, + { value: 'Europe/Rome', label: '罗马 (Rome)' }, + { value: 'Europe/Amsterdam', label: '阿姆斯特丹 (Amsterdam)' }, + { value: 'Europe/Moscow', label: '莫斯科 (Moscow)' }, + { value: 'Europe/Istanbul', label: '伊斯坦布尔 (Istanbul)' }, + // 大洋洲 + { value: 'Australia/Sydney', label: '悉尼 (Sydney)' }, + { value: 'Australia/Perth', label: '珀斯 (Perth)' }, + // 非洲 + { value: 'Africa/Cairo', label: '开罗 (Cairo)' }, + { value: 'Africa/Johannesburg', label: '约翰内斯堡 (Johannesburg)' }, + // UTC + { value: 'UTC', label: 'UTC' }, +]; + +/** 获取时区下拉选项,带 UTC 偏移标签 */ +export function getTimezoneOptions(): TimezoneOption[] { + return TIMEZONES.map((tz) => ({ + value: tz.value, + label: `${tz.label} (${getTimezoneOffsetLabel(tz.value)})`, + })); +} diff --git a/src/lib/units.ts b/src/lib/units.ts new file mode 100644 index 0000000..a706d28 --- /dev/null +++ b/src/lib/units.ts @@ -0,0 +1,104 @@ +import { useSelector } from 'react-redux'; +import type { RootState } from '../store'; +import type { UnitSystem } from '../store/preferencesSlice'; + +/** 摄氏度转华氏度 */ +export function celsiusToFahrenheit(c: number): number { + return (c * 9) / 5 + 32; +} + +/** 华氏度转摄氏度 */ +export function fahrenheitToCelsius(f: number): number { + return ((f - 32) * 5) / 9; +} + +/** 公里转英里 */ +export function kmToMile(km: number): number { + return km * 0.621371; +} + +/** 英里转公里 */ +export function mileToKm(mi: number): number { + return mi / 0.621371; +} + +/** km/h 转 mph */ +export function kmhToMph(kmh: number): number { + return kmh * 0.621371; +} + +export interface FormattedValue { + value: number; + unit: string; + label: string; +} + +/** 根据单位制格式化温度(输入为摄氏度原始值) */ +export function formatTemperature( + celsius: number, + unitSystem: UnitSystem, + digits = 1 +): FormattedValue { + if (unitSystem === 'imperial') { + return { + value: Number(celsiusToFahrenheit(celsius).toFixed(digits)), + unit: '°F', + label: `${celsiusToFahrenheit(celsius).toFixed(digits)}°F`, + }; + } + return { + value: Number(celsius.toFixed(digits)), + unit: '℃', + label: `${celsius.toFixed(digits)}℃`, + }; +} + +/** 根据单位制格式化距离(输入为公里原始值) */ +export function formatDistance(km: number, unitSystem: UnitSystem, digits = 1): FormattedValue { + if (unitSystem === 'imperial') { + return { + value: Number(kmToMile(km).toFixed(digits)), + unit: 'mi', + label: `${kmToMile(km).toFixed(digits)} mi`, + }; + } + return { + value: Number(km.toFixed(digits)), + unit: 'km', + label: `${km.toFixed(digits)} km`, + }; +} + +/** 根据单位制格式化速度(输入为 km/h 原始值) */ +export function formatSpeed(kmh: number, unitSystem: UnitSystem, digits = 1): FormattedValue { + if (unitSystem === 'imperial') { + return { + value: Number(kmhToMph(kmh).toFixed(digits)), + unit: 'mph', + label: `${kmhToMph(kmh).toFixed(digits)} mph`, + }; + } + return { + value: Number(kmh.toFixed(digits)), + unit: 'km/h', + label: `${kmh.toFixed(digits)} km/h`, + }; +} + +export interface UnitFormatter { + unitSystem: UnitSystem; + formatTemperature: (celsius: number, digits?: number) => FormattedValue; + formatDistance: (km: number, digits?: number) => FormattedValue; + formatSpeed: (kmh: number, digits?: number) => FormattedValue; +} + +/** React hook: 从 preferencesSlice 读取 unitSystem,返回绑定好的格式化函数 */ +export function useUnitFormatter(): UnitFormatter { + const unitSystem = useSelector((state: RootState) => state.preferences.unitSystem); + return { + unitSystem, + formatTemperature: (celsius: number, digits = 1) => formatTemperature(celsius, unitSystem, digits), + formatDistance: (km: number, digits = 1) => formatDistance(km, unitSystem, digits), + formatSpeed: (kmh: number, digits = 1) => formatSpeed(kmh, unitSystem, digits), + }; +} diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 5cc9e6a..b381f72 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -2559,6 +2559,17 @@ longitude: 'Longitude', latitude: 'Latitude', timezone: 'Timezone', + regionalSettings: 'Regional Settings', + unitSystem: 'Unit System', + metric: 'Metric', + imperial: 'Imperial', + timezoneSelect: 'Timezone', + daylightSavingTime: 'Daylight Saving Time', + dstActive: 'DST Active', + dstInactive: 'DST Inactive', + dateFormat: 'Date Format', + unitCelsius: 'Celsius', + unitFahrenheit: 'Fahrenheit', logType: 'Log Type', loginLog: 'Login Log', operationLog: 'Operation Log', diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts index c4b195a..7b2517f 100644 --- a/src/locales/zh/index.ts +++ b/src/locales/zh/index.ts @@ -2602,6 +2602,17 @@ const zh = { longitude: '经度', latitude: '纬度', timezone: '时区', + regionalSettings: '区域配置', + unitSystem: '单位制', + metric: '公制', + imperial: '英制', + timezoneSelect: '时区选择', + daylightSavingTime: '夏令时', + dstActive: '当前处于夏令时', + dstInactive: '当前非夏令时', + dateFormat: '日期格式', + unitCelsius: '摄氏度', + unitFahrenheit: '华氏度', // 日志管理 logType: '日志类型', loginLog: '登录日志', diff --git a/src/pages/RealTimeMonitor.tsx b/src/pages/RealTimeMonitor.tsx index a8c8adc..75500ff 100644 --- a/src/pages/RealTimeMonitor.tsx +++ b/src/pages/RealTimeMonitor.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { Card, Row, Col, Table, Select, Input, Tag, Typography, Button, Space, Avatar, Pagination, @@ -101,6 +101,7 @@ const RealTimeMonitorPage = () => { const { modal } = App.useApp(); const { stationId, currentStation } = useSelector((state: any) => state.station); const { userInfo } = useSelector((state: any) => state.user); + const { timezone } = useSelector((state: any) => state.preferences); const [filterStatus, setFilterStatus] = useState('all'); const [filterType, setFilterType] = useState('all'); @@ -689,7 +690,7 @@ const RealTimeMonitorPage = () => { resumable_status: 'auto', min_battery_capacity: 50, repeat_type: 'nonrepeating', - time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + time_zone: timezone, }; const res = await createWaylineTask(payload); if (res?.code === 200 || res?.code === 0) { diff --git a/src/store/index.ts b/src/store/index.ts index 42f6d22..1afb030 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,11 +4,13 @@ import { useDispatch } from 'react-redux'; // 👇 保留 import,但是把类型定义放到 store 后面 import userReducer from './userSlice'; import stationReducer from './stationSlice'; // 👈 加这个 +import preferencesReducer from './preferencesSlice'; export const store = configureStore({ reducer: { user: userReducer, station: stationReducer, // 👈 加这个 + preferences: preferencesReducer, }, }); diff --git a/src/store/preferencesSlice.ts b/src/store/preferencesSlice.ts new file mode 100644 index 0000000..35ce3a0 --- /dev/null +++ b/src/store/preferencesSlice.ts @@ -0,0 +1,111 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type Locale = 'zh' | 'en'; +export type UnitSystem = 'metric' | 'imperial'; +export type DateFormat = 'YYYY-MM-DD HH:mm:ss' | 'YYYY-MM-DD HH:mm' | 'MM/DD/YYYY HH:mm:ss'; + +export interface PreferencesState { + locale: Locale; + timezone: string; + unitSystem: UnitSystem; + dateFormat: DateFormat; +} + +const STORAGE_KEY = 'preferences'; + +const detectBrowserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'; + } catch { + return 'Asia/Shanghai'; + } +}; + +const detectLocale = (): Locale => { + const stored = localStorage.getItem('i18nextLng'); + if (stored?.startsWith('en')) return 'en'; + return 'zh'; +}; + +const loadFromStorage = (): PreferencesState => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + locale: parsed.locale === 'en' ? 'en' : 'zh', + timezone: parsed.timezone || detectBrowserTimezone(), + unitSystem: parsed.unitSystem === 'imperial' ? 'imperial' : 'metric', + dateFormat: (parsed.dateFormat as DateFormat) || 'YYYY-MM-DD HH:mm:ss', + }; + } + } catch { + // ignore + } + return { + locale: detectLocale(), + timezone: detectBrowserTimezone(), + unitSystem: 'metric', + dateFormat: 'YYYY-MM-DD HH:mm:ss', + }; +}; + +const persist = (state: PreferencesState) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // ignore + } +}; + +const initialState: PreferencesState = loadFromStorage(); +persist(initialState); + +const preferencesSlice = createSlice({ + name: 'preferences', + initialState, + reducers: { + setLocale: (state, action: PayloadAction) => { + state.locale = action.payload; + persist(state); + }, + setTimezone: (state, action: PayloadAction) => { + state.timezone = action.payload; + persist(state); + }, + setUnitSystem: (state, action: PayloadAction) => { + state.unitSystem = action.payload; + persist(state); + }, + setDateFormat: (state, action: PayloadAction) => { + state.dateFormat = action.payload; + persist(state); + }, + setPreferences: (state, action: PayloadAction>) => { + const { locale, timezone, unitSystem, dateFormat } = action.payload; + if (locale) state.locale = locale; + if (timezone) state.timezone = timezone; + if (unitSystem) state.unitSystem = unitSystem; + if (dateFormat) state.dateFormat = dateFormat; + persist(state); + }, + resetPreferences: (state) => { + state.locale = detectLocale(); + state.timezone = detectBrowserTimezone(); + state.unitSystem = 'metric'; + state.dateFormat = 'YYYY-MM-DD HH:mm:ss'; + persist(state); + }, + }, +}); + +export const { + setLocale, + setTimezone, + setUnitSystem, + setDateFormat, + setPreferences, + resetPreferences, +} = preferencesSlice.actions; + +export default preferencesSlice.reducer;