513 lines
23 KiB
TypeScript
513 lines
23 KiB
TypeScript
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')),
|
||
}
|
||
}
|