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 = { '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> = { '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(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) { if (!params) { return template } return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? '')) } export function t(key: MessageKey, params?: Record) { 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')), } }