简体中文、英文、多语言框架、时区、夏令时、公制/英制和区域配置

This commit is contained in:
mmc
2026-10-09 13:30:55 +08:00
parent 0bc900cb5b
commit 5afaf1de75
14 changed files with 595 additions and 23 deletions

View File

@@ -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
- 温度数据来源是设备上报的摄氏度原始值,转换在前端完成,不涉及后端改动

View File

@@ -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 (

View File

@@ -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 = () => {

View File

@@ -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 = () => {
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item label={t('systemSetting.defaultLanguage')} name="defaultLang" style={{ marginBottom: 12 }}>
<Select size="small" defaultValue="zh">
<Form.Item label={t('systemSetting.defaultLanguage')} style={{ marginBottom: 12 }}>
<Select size="small" value={locale} onChange={(v) => { dispatch(setLocale(v)); i18n.changeLanguage(v); }}>
<Select.Option value="zh">{t('users.chinese')}</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item label={t('systemSetting.timezone')} name="timezone" style={{ marginBottom: 12 }}>
<Select size="small" defaultValue="UTC+08:00">
<Select.Option value="8">UTC+08:00</Select.Option>
<Form.Item label={t('systemSetting.timezoneSelect')} style={{ marginBottom: 12 }}>
<Select size="small" value={timezone} onChange={(v) => dispatch(setTimezone(v))} showSearch optionFilterProp="label">
{tzOptions.map(opt => <Select.Option key={opt.value} value={opt.value}>{opt.label}</Select.Option>)}
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item label={t('systemSetting.daylightSavingTime')} style={{ marginBottom: 12 }}>
<Tag color={dstActive ? 'green' : 'default'}>
{dstActive ? t('systemSetting.dstActive') : t('systemSetting.dstInactive')} ({getTimezoneOffsetLabel(timezone)})
</Tag>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item label={t('systemSetting.unitSystem')} style={{ marginBottom: 12 }}>
<Radio.Group size="small" value={unitSystem} onChange={(e) => dispatch(setUnitSystem(e.target.value))}>
<Radio value="metric" style={{ fontSize: fontSmall }}>{t('systemSetting.metric')}</Radio>
<Radio value="imperial" style={{ fontSize: fontSmall }}>{t('systemSetting.imperial')}</Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8}>
<Form.Item label={t('systemSetting.dateFormat')} style={{ marginBottom: 12 }}>
<Select size="small" value={dateFormat} onChange={(v) => dispatch(setDateFormat(v))}>
<Select.Option value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</Select.Option>
<Select.Option value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</Select.Option>
<Select.Option value="MM/DD/YYYY HH:mm:ss">MM/DD/YYYY HH:mm:ss</Select.Option>
</Select>
</Form.Item>
</Col>

View File

@@ -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 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>{t('home.temperature')}</span>
<span style={{ color: '#faad14' }}>{fix2(realtimeDevice.environment_temperature || 25)}℃</span>
<span style={{ color: '#faad14' }}>{formatTemperature(Number(realtimeDevice.environment_temperature || 25), 1).label}</span>
</div>
)}
</div>
@@ -986,7 +988,7 @@ export default function DeviceOverviewPage() {
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div style={{ display: "flex", border: "1px solid #e8e8e8", borderRadius: 12, overflow: "hidden", flex: 1 }}>
<div style={{ flex: 1, textAlign: 'center', whiteSpace: "nowrap", padding: "12px 4px", borderRight: "1px solid #e8e8e8" }}><div style={{ fontSize: 11, color: '#666', fontWeight: 600 }}>{t('devices.s44')}</div><div style={{ fontSize: 14, fontWeight: 700, color: '#222', marginTop: 2 }}>{fix2(realtimeDevice?.wind_speed)} m/s</div></div>
<div style={{ flex: 1, textAlign: 'center', whiteSpace: "nowrap", padding: "12px 4px", borderRight: "1px solid #e8e8e8" }}><div style={{ fontSize: 11, color: '#666', fontWeight: 600 }}>{t('home.temperature')}</div><div style={{ fontSize: 14, fontWeight: 700, color: '#222', marginTop: 2 }}>{fix2(realtimeDevice?.environment_temperature)}℃</div></div>
<div style={{ flex: 1, textAlign: 'center', whiteSpace: "nowrap", padding: "12px 4px", borderRight: "1px solid #e8e8e8" }}><div style={{ fontSize: 11, color: '#666', fontWeight: 600 }}>{t('home.temperature')}</div><div style={{ fontSize: 14, fontWeight: 700, color: '#222', marginTop: 2 }}>{formatTemperature(Number(realtimeDevice?.environment_temperature || 0), 1).label}</div></div>
<div style={{ flex: 1, textAlign: 'center', whiteSpace: "nowrap", padding: "12px 4px", borderRight: "1px solid #e8e8e8" }}><div style={{ fontSize: 11, color: '#666', fontWeight: 600 }}>{t('devices.rainfall')}</div><div style={{ fontSize: 14, fontWeight: 700, color: '#222', marginTop: 2 }}>{getVal(realtimeDevice?.rainfall)}</div></div>
<div style={{ flex: 1, textAlign: 'center', whiteSpace: "nowrap", padding: "12px 4px" }}><div style={{ fontSize: 11, color: '#666', fontWeight: 600 }}>RTK</div><div style={{ fontSize: 14, fontWeight: 700, color: '#52c41a', marginTop: 2 }}>{realtimeDevice?.position_state?.is_fixed === 'fixing_successful' ? '固定' : '未固定'}</div></div>
</div>

View File

@@ -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() {
<span>{t('devices.rtRightSpeed')}</span>
<span>{runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtLeftTemp')}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? `${Number(runningStatus?.leftMotorTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtRightTemp')}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? `${Number(runningStatus?.rightMotorTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtMainTemp')}</span>
<span>{runningStatus?.chipTemp !== undefined ? `${Number(runningStatus?.chipTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'}</span>
<span>{t('devices.rtHeadingAngle')}</span>
<span>{runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtPitchAngle')}</span>
@@ -1307,11 +1309,11 @@ export default function DeviceStatusPage() {
<span>{t('devices.rtRightSpeed')}</span>
<span>{runningStatus?.rightMeasureSpeed !== undefined ? `${Number(runningStatus?.rightMeasureSpeed).toFixed(1)}rpm` : '-'}</span>
<span>{t('devices.rtLeftTemp')}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? `${Number(runningStatus?.leftMotorTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.leftMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.leftMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtRightTemp')}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? `${Number(runningStatus?.rightMotorTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.rightMotorTemp !== undefined ? formatTemperature(Number(runningStatus?.rightMotorTemp), 1).label : '-'}</span>
<span>{t('devices.rtMainTemp')}</span>
<span>{runningStatus?.chipTemp !== undefined ? `${Number(runningStatus?.chipTemp).toFixed(1)}℃` : '-'}</span>
<span>{runningStatus?.chipTemp !== undefined ? formatTemperature(Number(runningStatus?.chipTemp), 1).label : '-'}</span>
<span>{t('devices.rtHeadingAngle')}</span>
<span>{runningStatus?.yaw !== undefined ? `${Number(runningStatus?.yaw).toFixed(1)}°` : '-'}</span>
<span>{t('devices.rtPitchAngle')}</span>

View File

@@ -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);

112
src/lib/dayjs.ts Normal file
View File

@@ -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)})`,
}));
}

104
src/lib/units.ts Normal file
View File

@@ -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),
};
}

View File

@@ -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',

View File

@@ -2602,6 +2602,17 @@ const zh = {
longitude: '经度',
latitude: '纬度',
timezone: '时区',
regionalSettings: '区域配置',
unitSystem: '单位制',
metric: '公制',
imperial: '英制',
timezoneSelect: '时区选择',
daylightSavingTime: '夏令时',
dstActive: '当前处于夏令时',
dstInactive: '当前非夏令时',
dateFormat: '日期格式',
unitCelsius: '摄氏度',
unitFahrenheit: '华氏度',
// 日志管理
logType: '日志类型',
loginLog: '登录日志',

View File

@@ -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) {

View File

@@ -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,
},
});

View File

@@ -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<PreferencesState>;
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<Locale>) => {
state.locale = action.payload;
persist(state);
},
setTimezone: (state, action: PayloadAction<string>) => {
state.timezone = action.payload;
persist(state);
},
setUnitSystem: (state, action: PayloadAction<UnitSystem>) => {
state.unitSystem = action.payload;
persist(state);
},
setDateFormat: (state, action: PayloadAction<DateFormat>) => {
state.dateFormat = action.payload;
persist(state);
},
setPreferences: (state, action: PayloadAction<Partial<PreferencesState>>) => {
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;