简体中文、英文、多语言框架、时区、夏令时、公制/英制和区域配置
This commit is contained in:
171
.trae/documents/完善多语言与区域配置功能.md
Normal file
171
.trae/documents/完善多语言与区域配置功能.md
Normal 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
|
||||
- 温度数据来源是设备上报的摄氏度原始值,转换在前端完成,不涉及后端改动
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
112
src/lib/dayjs.ts
Normal 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
104
src/lib/units.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -2602,6 +2602,17 @@ const zh = {
|
||||
longitude: '经度',
|
||||
latitude: '纬度',
|
||||
timezone: '时区',
|
||||
regionalSettings: '区域配置',
|
||||
unitSystem: '单位制',
|
||||
metric: '公制',
|
||||
imperial: '英制',
|
||||
timezoneSelect: '时区选择',
|
||||
daylightSavingTime: '夏令时',
|
||||
dstActive: '当前处于夏令时',
|
||||
dstInactive: '当前非夏令时',
|
||||
dateFormat: '日期格式',
|
||||
unitCelsius: '摄氏度',
|
||||
unitFahrenheit: '华氏度',
|
||||
// 日志管理
|
||||
logType: '日志类型',
|
||||
loginLog: '登录日志',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
111
src/store/preferencesSlice.ts
Normal file
111
src/store/preferencesSlice.ts
Normal 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;
|
||||
Reference in New Issue
Block a user