feat: 中英文切换

This commit is contained in:
2026-04-18 17:36:19 +08:00
parent 9c0e879aec
commit a8c9d5fa0d
14 changed files with 907 additions and 273 deletions

512
frontend/src/lib/locale.ts Normal file
View File

@@ -0,0 +1,512 @@
import { computed, readonly, ref } from 'vue'
export type Locale = 'zh-CN' | 'en-US'
const LOCALE_STORAGE_KEY = 'robot-command-center.locale'
const DEFAULT_LOCALE: Locale = 'zh-CN'
const zhCNMessages = {
'common.loading': '加载中',
'common.waiting': '等待中',
'common.unavailable': '不可用',
'common.unknown': '未知',
'common.none': '无',
'common.na': 'n/a',
'common.yes': '是',
'common.no': '否',
'common.keyboard': '键盘',
'common.gamepad': '手柄',
'common.idle': '空闲',
'common.control': '控制',
'common.video': '视频',
'common.online': '在线',
'common.offline': '离线',
'common.fresh': '新鲜',
'common.stale': '过期',
'common.stable': '稳定',
'common.rising': '上升',
'common.falling': '下降',
'common.selected': '已选中',
'common.standby': '待命',
'common.turbo': '加速',
'common.ackLoop': 'ACK 闭环',
'common.srttFallback': 'SRTT 回退',
'common.requestFailed': '请求失败: {status} {statusText}',
'app.brandTitle': '机器人指挥中心',
'app.brandSubtitle': '远程机器人控制台',
'app.nav.overview': '概览',
'app.nav.video': '视频',
'app.nav.map': '地图定位',
'app.nav.network': '网络状态',
'app.localeToggle': 'English',
'dashboard.eyebrow': '概览',
'dashboard.title': '机器人指挥中心',
'dashboard.description': 'A 端统一后台进程持续刷新视频、控制仲裁和链路遥测。',
'networkView.eyebrow': '网络',
'networkView.title': '网络遥测',
'networkView.description': '查看 A <-> D 与 D <-> B 两段链路的实时队列、重传、窗口压力和延迟估计。',
'videoView.eyebrow': '视频',
'videoView.title': '视频监控',
'videoView.description': '查看机器人实时 JPEG 视频流、画面新鲜度和端到端延迟估计。',
'mapView.eyebrow': '地图',
'mapView.title': '地图定位',
'mapView.description': '查看机器人最新 GPS 数据,并按需使用高德地图做坐标转换和展示。',
'monitoring.loading': '正在连接 Django 后端并加载实时监控数据...',
'monitoring.connected': '仪表盘已连接。视频、GPS 和会话遥测正在持续刷新。',
'control.socket.open': 'WebSocket 已连接',
'control.socket.connecting': '连接中',
'control.socket.reconnecting': '重连中',
'control.server.waiting': '等待控制链路就绪',
'control.server.live': '控制链路已建立',
'control.gamepad.none': '未检测到手柄',
'control.gamepad.unnamed': '未命名手柄',
'control.gamepad.unknownMapping': '未知映射',
'control.key.stop': '停止',
'controlPanel.eyebrow': '控制',
'controlPanel.title': '控制反馈',
'controlPanel.resetDefaults': '恢复默认',
'controlPanel.inputModeEyebrow': '输入模式',
'controlPanel.inputModeCopy': '同一时刻只能有一种本地输入模式控制页面。',
'controlPanel.keyboardDetail': '使用 W/S、A/D、Q/E、Shift 和 Space。',
'controlPanel.gamepadDetail': '仅使用浏览器识别到的手柄。',
'controlPanel.forward': '前进',
'controlPanel.strafe': '横移',
'controlPanel.turn': '转向',
'controlPanel.turbo': '加速',
'controlPanel.keyboardHint': '键盘映射: W/S 前后, A/D 横移, Q/E 转向, Shift 加速, Space 停止。',
'controlPanel.tuningHint': '速度调节由两种本地输入模式共享,并保存在当前浏览器中。',
'controlPanel.gamepadHint': '手柄模式下左摇杆控制移动右摇杆控制转向RB 加速A 发送停止。',
'controlFeedback.modeChip': '{mode} 模式',
'controlFeedback.forward': '前进',
'controlFeedback.strafe': '横移',
'controlFeedback.turn': '转向',
'controlFeedback.tuningSummary': '调参: 前进 {forward} m/s, 横移 {strafe} m/s, 转向 {turn} rad/s, 加速 x{turbo}',
'controlFeedback.keyboard': '键盘',
'controlFeedback.gamepad': '手柄',
'controlFeedback.waitingForController': '等待手柄接入',
'controlFeedback.gamepadMeta': '#{index} / 映射={mapping}',
'controlFeedback.gamepadHint': '左摇杆控制移动右摇杆控制转向RB 加速A 停止。',
'controlFeedback.leftStick': '左摇杆',
'controlFeedback.rightStick': '右摇杆',
'controlFeedback.outgoingCommand': '当前发出命令: {command}',
'videoPanel.eyebrow': '视频',
'videoPanel.title': '实时视频',
'videoPanel.frameAlt': '机器人实时画面',
'videoPanel.waitingFrames': '等待实时视频帧',
'videoPanel.mode.loading': '加载中',
'videoPanel.mode.live': '{fps} FPS 实时',
'videoPanel.stats.frames': '帧数',
'videoPanel.stats.latestSeq': '最新序号',
'videoPanel.stats.videoE2E': '视频端到端估计',
'videoPanel.stats.paintDelay': '绘制延迟',
'videoPanel.section.pipeline': '流水线估计',
'videoPanel.section.freshness': '新鲜度',
'videoPanel.section.operator': '操作员闭环',
'videoPanel.captureToSend': '采集到发送',
'videoPanel.networkOneWay': '网络单程',
'videoPanel.partialEstimate': '部分估计',
'videoPanel.endToEndEstimate': '端到端估计',
'videoPanel.interFrameAvg': '帧间平均',
'videoPanel.interFrameP95': '帧间 p95',
'videoPanel.repeatedRatio': '重复比例',
'videoPanel.skipRatio': '跳帧比例',
'videoPanel.longestFreeze': '最长卡顿',
'videoPanel.lagFrames': '落后帧数',
'videoPanel.inputToNextSeq': '输入到下一新序号',
'videoPanel.inputToChangedFrame': '输入到下一变化帧',
'videoPanel.inputToPaint': '输入到下一次绘制',
'videoPanel.displayProbeRequestToPaint': '显示探针请求到绘制',
'videoPanel.senderClockDelta': '发送端时钟差',
'videoPanel.timing.waiting': '等待中',
'videoPanel.timing.noTrailer': '正在等待第一帧带有效 trailer 的视频数据',
'videoPanel.timing.rawHint': '这里只显示发送端原始时钟差,设备时钟未同步',
'videoPanel.noSourceDetail': '暂无实时视频详情',
'networkPanel.eyebrow': '网络',
'networkPanel.title': '双段链路遥测',
'networkPanel.controlLoopRtt': '控制闭环 RTT',
'networkPanel.controlToPersist': '控制到持久化',
'networkPanel.controlSrttOneWay': '控制单程 SRTT',
'networkPanel.videoOneWayEst': '视频单程估计',
'networkPanel.txRate': '发送速率',
'networkPanel.rxRate': '接收速率',
'networkPanel.robotFault': '机器人故障',
'networkPanel.recoveryState': '恢复状态',
'networkPanel.healthConfidence': '健康置信度',
'networkPanel.healthUpdated': '健康更新时间',
'networkPanel.transport': '传输',
'networkPanel.activeControl': '当前控制源',
'networkPanel.lease': '租约',
'networkPanel.ackMode': 'ACK 模式',
'networkPanel.ackUpdated': 'ACK 更新时间',
'networkPanel.telemetryPeer': '遥测 Peer',
'networkPanel.telemetryRegistered': '遥测已注册',
'networkPanel.hubFreshness': 'Hub 新鲜度',
'networkPanel.hubState': 'Hub 状态',
'networkPanel.telemetryReconnects': '遥测重连次数',
'networkPanel.hubError': 'Hub 错误',
'networkPanel.telemetrySessionError': '遥测会话错误',
'networkPanel.online': '在线',
'networkPanel.maxPressure': '最大压力',
'networkPanel.queued': '排队量',
'networkPanel.inFlightBuffer': '在途缓冲',
'networkPanel.retransDelta': '重传增量',
'networkPanel.repairRate': '修复率',
'networkPanel.updated': '更新时间',
'networkPanel.srtt': 'SRTT',
'networkPanel.rttvar': 'RTTVAR',
'networkPanel.rto': 'RTO',
'networkPanel.sndWnd': '发送窗口',
'networkPanel.rmtWnd': '远端窗口',
'networkPanel.inflight': '在途',
'networkPanel.windowLimit': '窗口上限',
'networkPanel.pressure': '压力',
'networkPanel.sndQueue': '发送队列',
'networkPanel.sndBuffer': '发送缓冲',
'networkPanel.queueDelta': '队列增量',
'networkPanel.bufferDelta': '缓冲增量',
'networkPanel.retrans': '重传',
'networkPanel.fastRetrans': '快速重传',
'networkPanel.lost': '丢失',
'networkPanel.repeat': '重复',
'networkPanel.appBytes': '应用字节',
'networkPanel.registered': '已注册',
'networkPanel.serverError': '服务端错误',
'networkPanel.combined': '总计',
'networkPanel.videoE2E': '视频端到端估计',
'networkPanel.controlEstimateConfidence': '控制估计置信度',
'networkPanel.videoFreshness': '视频新鲜度',
'networkPanel.videoFreshnessRepeat': '重复',
'networkPanel.videoFreshnessSkip': '跳帧',
'networkPanel.videoFreshnessFreeze': '卡顿',
'networkPanel.nativeUdp': '原生 UDP',
'networkPanel.controlSender': '控制发送端',
'networkPanel.ackReceiver': 'ACK 接收端',
'networkPanel.controlReconnects': '控制重连次数',
'networkPanel.controlSessionError': '控制会话错误',
'networkPanel.loadingPeer': '加载中',
'networkPanel.unassigned': '未分配',
'gpsMap.eyebrow': 'GPS',
'gpsMap.title': '地图定位',
'gpsMap.intro': '这里展示机器人最新的 GPS 定位,并在需要时调用高德地图做坐标转换。',
'gpsMap.keyPlaceholder': '高德 Web 端 Key',
'gpsMap.jscodePlaceholder': '安全密钥 jscode',
'gpsMap.loadMap': '加载地图',
'gpsMap.stopMap': '停止加载',
'gpsMap.status.waitingInit': '等待加载高德地图。',
'gpsMap.status.fillCredentials': '请先填写高德 Key 和安全密钥 jscode。',
'gpsMap.status.loading': '正在加载高德地图...',
'gpsMap.status.loaded': '地图已加载。',
'gpsMap.status.stopped': '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。',
'gpsMap.status.waitingGps': '等待 GPS 数据。',
'gpsMap.status.noFix': 'GPS 在线,但当前还没有有效定位。',
'gpsMap.status.convertFailed': 'GPS 坐标转换失败。',
'gpsMap.status.refreshedSource': '地图已刷新,数据源: {source}',
'gpsMap.status.restoredConfig': '已恢复高德配置。地图不会自动加载,按需点击“加载地图”。',
'gpsMap.status.loadFailed': '地图加载失败。',
'gpsMap.mapPlaceholder': '高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。',
'gpsMap.wgs84': 'WGS84 坐标',
'gpsMap.gcj02': '高德 GCJ-02',
'gpsMap.rawLatHex': '纬度原始 8 字节',
'gpsMap.rawLonHex': '经度原始 8 字节',
'gpsMap.utcTime': 'UTC 时间',
'gpsMap.satAltitude': '卫星 / 海拔',
'gpsMap.coordMeta': '坐标系 / 格式',
'gpsMap.lastUpdated': '最近刷新',
'gpsMap.noValue': '暂无',
'gpsMap.noValidFix': '暂无有效定位',
'gpsMap.infoTitle': '机器人 GPS 定位',
'gpsMap.infoSatellites': '卫星数',
'gpsMap.infoAltitude': '海拔',
} as const
export type MessageKey = keyof typeof zhCNMessages
const enUSMessages: Record<MessageKey, string> = {
'common.loading': 'Loading',
'common.waiting': 'Waiting',
'common.unavailable': 'Unavailable',
'common.unknown': 'Unknown',
'common.none': 'None',
'common.na': 'n/a',
'common.yes': 'Yes',
'common.no': 'No',
'common.keyboard': 'Keyboard',
'common.gamepad': 'Gamepad',
'common.idle': 'Idle',
'common.control': 'Control',
'common.video': 'Video',
'common.online': 'Online',
'common.offline': 'Offline',
'common.fresh': 'Fresh',
'common.stale': 'Stale',
'common.stable': 'Stable',
'common.rising': 'Rising',
'common.falling': 'Falling',
'common.selected': 'Selected',
'common.standby': 'Standby',
'common.turbo': 'Turbo',
'common.ackLoop': 'ACK loop',
'common.srttFallback': 'SRTT fallback',
'common.requestFailed': 'Request failed: {status} {statusText}',
'app.brandTitle': 'Robot Command Center',
'app.brandSubtitle': 'Remote robot command console',
'app.nav.overview': 'Overview',
'app.nav.video': 'Video',
'app.nav.map': 'Map',
'app.nav.network': 'Network',
'app.localeToggle': '中文',
'dashboard.eyebrow': 'Overview',
'dashboard.title': 'Robot Command Center',
'dashboard.description': 'The A-side unified backend keeps video, control arbitration, and live transport telemetry refreshed.',
'networkView.eyebrow': 'Network',
'networkView.title': 'Network Telemetry',
'networkView.description': 'Inspect queueing, retransmissions, window pressure, and latency estimates for the A <-> D and D <-> B legs.',
'videoView.eyebrow': 'Video',
'videoView.title': 'Video Monitor',
'videoView.description': 'Inspect the live robot JPEG stream, freshness metrics, and end-to-end latency estimates.',
'mapView.eyebrow': 'Map',
'mapView.title': 'Map Positioning',
'mapView.description': 'Inspect the latest robot GPS fix and use AMap for coordinate conversion when needed.',
'monitoring.loading': 'Connecting to the Django backend and loading live monitoring data...',
'monitoring.connected': 'Dashboard connected. Video, GPS, and session telemetry are refreshing continuously.',
'control.socket.open': 'WebSocket open',
'control.socket.connecting': 'Connecting',
'control.socket.reconnecting': 'Reconnecting',
'control.server.waiting': 'Waiting for control link',
'control.server.live': 'Control link live',
'control.gamepad.none': 'No gamepad detected',
'control.gamepad.unnamed': 'Unnamed gamepad',
'control.gamepad.unknownMapping': 'unknown',
'control.key.stop': 'Stop',
'controlPanel.eyebrow': 'Control',
'controlPanel.title': 'Control Feedback',
'controlPanel.resetDefaults': 'Reset Defaults',
'controlPanel.inputModeEyebrow': 'Input Mode',
'controlPanel.inputModeCopy': 'Only one local input mode can control the page at a time.',
'controlPanel.keyboardDetail': 'Use W/S, A/D, Q/E, Shift, and Space.',
'controlPanel.gamepadDetail': 'Use the browser-detected controller only.',
'controlPanel.forward': 'Forward',
'controlPanel.strafe': 'Strafe',
'controlPanel.turn': 'Turn',
'controlPanel.turbo': 'Turbo',
'controlPanel.keyboardHint': 'Keyboard mapping: W/S forward-back, A/D strafe, Q/E turn, Shift turbo, Space stop.',
'controlPanel.tuningHint': 'Speed tuning is shared by both local input modes and saved in this browser.',
'controlPanel.gamepadHint': 'Gamepad mode uses the left stick to drive, the right stick to turn, RB to boost, and A to stop.',
'controlFeedback.modeChip': '{mode} mode',
'controlFeedback.forward': 'Forward',
'controlFeedback.strafe': 'Strafe',
'controlFeedback.turn': 'Turn',
'controlFeedback.tuningSummary': 'Tuning: fwd {forward} m/s, strafe {strafe} m/s, turn {turn} rad/s, turbo x{turbo}',
'controlFeedback.keyboard': 'Keyboard',
'controlFeedback.gamepad': 'Gamepad',
'controlFeedback.waitingForController': 'Waiting for controller',
'controlFeedback.gamepadMeta': '#{index} / mapping={mapping}',
'controlFeedback.gamepadHint': 'Left stick drives, right stick turns, RB boosts, A stops.',
'controlFeedback.leftStick': 'Left stick',
'controlFeedback.rightStick': 'Right stick',
'controlFeedback.outgoingCommand': 'Outgoing command: {command}',
'videoPanel.eyebrow': 'Video',
'videoPanel.title': 'Live Video',
'videoPanel.frameAlt': 'Robot live frame',
'videoPanel.waitingFrames': 'waiting for live video frames',
'videoPanel.mode.loading': 'loading',
'videoPanel.mode.live': '{fps} FPS live',
'videoPanel.stats.frames': 'Frames',
'videoPanel.stats.latestSeq': 'Latest Seq',
'videoPanel.stats.videoE2E': 'Video E2E Est.',
'videoPanel.stats.paintDelay': 'Paint Delay',
'videoPanel.section.pipeline': 'Pipeline Estimate',
'videoPanel.section.freshness': 'Freshness',
'videoPanel.section.operator': 'Operator Loop',
'videoPanel.captureToSend': 'Capture to send',
'videoPanel.networkOneWay': 'Network one-way',
'videoPanel.partialEstimate': 'Partial estimate',
'videoPanel.endToEndEstimate': 'End-to-end estimate',
'videoPanel.interFrameAvg': 'Inter-frame avg',
'videoPanel.interFrameP95': 'Inter-frame p95',
'videoPanel.repeatedRatio': 'Repeated ratio',
'videoPanel.skipRatio': 'Skip ratio',
'videoPanel.longestFreeze': 'Longest freeze',
'videoPanel.lagFrames': 'Lag frames',
'videoPanel.inputToNextSeq': 'Input to next seq',
'videoPanel.inputToChangedFrame': 'Input to changed frame',
'videoPanel.inputToPaint': 'Input to paint',
'videoPanel.displayProbeRequestToPaint': 'Display probe request-to-paint',
'videoPanel.senderClockDelta': 'Sender Clock Delta',
'videoPanel.timing.waiting': 'waiting',
'videoPanel.timing.noTrailer': 'waiting for the first valid video trailer',
'videoPanel.timing.rawHint': 'raw sender clock delta only, unsynced clocks',
'videoPanel.noSourceDetail': 'no live video detail available',
'networkPanel.eyebrow': 'Network',
'networkPanel.title': 'Dual-Leg Telemetry',
'networkPanel.controlLoopRtt': 'Control Loop RTT',
'networkPanel.controlToPersist': 'Control to Persist',
'networkPanel.controlSrttOneWay': 'Control SRTT One-way',
'networkPanel.videoOneWayEst': 'Video One-way Est.',
'networkPanel.txRate': 'TX Rate',
'networkPanel.rxRate': 'RX Rate',
'networkPanel.robotFault': 'Robot Fault',
'networkPanel.recoveryState': 'Recovery State',
'networkPanel.healthConfidence': 'Health Confidence',
'networkPanel.healthUpdated': 'Health Updated',
'networkPanel.transport': 'Transport',
'networkPanel.activeControl': 'Active Control',
'networkPanel.lease': 'Lease',
'networkPanel.ackMode': 'ACK Mode',
'networkPanel.ackUpdated': 'ACK Updated',
'networkPanel.telemetryPeer': 'Telemetry Peer',
'networkPanel.telemetryRegistered': 'Telemetry Registered',
'networkPanel.hubFreshness': 'Hub Freshness',
'networkPanel.hubState': 'Hub State',
'networkPanel.telemetryReconnects': 'Telemetry Reconnects',
'networkPanel.hubError': 'Hub Error',
'networkPanel.telemetrySessionError': 'Telemetry Session Error',
'networkPanel.online': 'Online',
'networkPanel.maxPressure': 'Max Pressure',
'networkPanel.queued': 'Queued',
'networkPanel.inFlightBuffer': 'In Flight Buffer',
'networkPanel.retransDelta': 'Retrans Delta',
'networkPanel.repairRate': 'Repair Rate',
'networkPanel.updated': 'Updated',
'networkPanel.srtt': 'SRTT',
'networkPanel.rttvar': 'RTTVAR',
'networkPanel.rto': 'RTO',
'networkPanel.sndWnd': 'SND WND',
'networkPanel.rmtWnd': 'RMT WND',
'networkPanel.inflight': 'Inflight',
'networkPanel.windowLimit': 'Window Limit',
'networkPanel.pressure': 'Pressure',
'networkPanel.sndQueue': 'SND Queue',
'networkPanel.sndBuffer': 'SND Buffer',
'networkPanel.queueDelta': 'Queue Delta',
'networkPanel.bufferDelta': 'Buffer Delta',
'networkPanel.retrans': 'Retrans',
'networkPanel.fastRetrans': 'Fast Retrans',
'networkPanel.lost': 'Lost',
'networkPanel.repeat': 'Repeat',
'networkPanel.appBytes': 'App Bytes',
'networkPanel.registered': 'Registered',
'networkPanel.serverError': 'Server Error',
'networkPanel.combined': 'Combined',
'networkPanel.videoE2E': 'Video E2E Est.',
'networkPanel.controlEstimateConfidence': 'Control Estimate Confidence',
'networkPanel.videoFreshness': 'Video Freshness',
'networkPanel.videoFreshnessRepeat': 'repeat',
'networkPanel.videoFreshnessSkip': 'skip',
'networkPanel.videoFreshnessFreeze': 'freeze',
'networkPanel.nativeUdp': 'Native UDP',
'networkPanel.controlSender': 'Control Sender',
'networkPanel.ackReceiver': 'ACK Receiver',
'networkPanel.controlReconnects': 'Control Reconnects',
'networkPanel.controlSessionError': 'Control Session Error',
'networkPanel.loadingPeer': 'loading',
'networkPanel.unassigned': 'unassigned',
'gpsMap.eyebrow': 'GPS',
'gpsMap.title': 'Map Positioning',
'gpsMap.intro': 'This panel displays the latest robot GPS fix and uses AMap for coordinate conversion when needed.',
'gpsMap.keyPlaceholder': 'AMap Web Key',
'gpsMap.jscodePlaceholder': 'Security jscode',
'gpsMap.loadMap': 'Load Map',
'gpsMap.stopMap': 'Stop Loading',
'gpsMap.status.waitingInit': 'Waiting to load AMap.',
'gpsMap.status.fillCredentials': 'Please enter the AMap key and security jscode first.',
'gpsMap.status.loading': 'Loading AMap...',
'gpsMap.status.loaded': 'Map loaded.',
'gpsMap.status.stopped': 'Stopped AMap loading and coordinate conversion. Click "Load Map" again when needed.',
'gpsMap.status.waitingGps': 'Waiting for GPS data.',
'gpsMap.status.noFix': 'GPS is online, but there is no valid fix yet.',
'gpsMap.status.convertFailed': 'GPS coordinate conversion failed.',
'gpsMap.status.refreshedSource': 'Map refreshed, source: {source}',
'gpsMap.status.restoredConfig': 'Recovered saved AMap config. The map will not auto-load; click "Load Map" when needed.',
'gpsMap.status.loadFailed': 'Map loading failed.',
'gpsMap.mapPlaceholder': 'AMap is not loaded right now. Click "Load Map" above before requesting map and coordinate conversion services.',
'gpsMap.wgs84': 'WGS84 Coordinates',
'gpsMap.gcj02': 'AMap GCJ-02',
'gpsMap.rawLatHex': 'Raw Latitude 8 Bytes',
'gpsMap.rawLonHex': 'Raw Longitude 8 Bytes',
'gpsMap.utcTime': 'UTC Time',
'gpsMap.satAltitude': 'Satellites / Altitude',
'gpsMap.coordMeta': 'Coordinate System / Format',
'gpsMap.lastUpdated': 'Last Updated',
'gpsMap.noValue': 'Unavailable',
'gpsMap.noValidFix': 'No valid fix',
'gpsMap.infoTitle': 'Robot GPS Position',
'gpsMap.infoSatellites': 'Satellites',
'gpsMap.infoAltitude': 'Altitude',
}
const messages: Record<Locale, Record<MessageKey, string>> = {
'zh-CN': zhCNMessages,
'en-US': enUSMessages,
}
function normalizeLocale(raw: unknown): Locale {
return raw === 'en-US' ? 'en-US' : DEFAULT_LOCALE
}
function loadStoredLocale(): Locale {
if (typeof window === 'undefined') {
return DEFAULT_LOCALE
}
try {
return normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY))
} catch {
return DEFAULT_LOCALE
}
}
const localeState = ref<Locale>(loadStoredLocale())
function storeLocale(locale: Locale) {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
} catch {
// Ignore storage failures; locale still works for current session.
}
}
function interpolate(template: string, params?: Record<string, string | number | null | undefined>) {
if (!params) {
return template
}
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? ''))
}
export function t(key: MessageKey, params?: Record<string, string | number | null | undefined>) {
const template = messages[localeState.value][key] ?? key
return interpolate(template, params)
}
export function formatDateTime(value?: string | null) {
if (!value) {
return t('common.unavailable')
}
return new Date(value).toLocaleString(localeState.value, { hour12: false })
}
export function setLocale(locale: Locale) {
const next = normalizeLocale(locale)
if (localeState.value === next) {
return
}
localeState.value = next
storeLocale(next)
}
export function toggleLocale() {
setLocale(localeState.value === 'zh-CN' ? 'en-US' : 'zh-CN')
}
export function useLocale() {
return {
locale: readonly(localeState),
setLocale,
toggleLocale,
t,
formatDateTime,
nextLocaleLabel: computed(() => t('app.localeToggle')),
}
}