Files
robot-command-center/frontend/src/lib/locale.ts
2026-04-18 17:36:19 +08:00

513 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')),
}
}