From a8c9d5fa0d98ba40979f3e390ccdf8ec20d53ead Mon Sep 17 00:00:00 2001 From: Mock Date: Sat, 18 Apr 2026 17:36:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=AD=E8=8B=B1=E6=96=87=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 70 ++- frontend/src/components/ControlFeedback.vue | 59 +- frontend/src/components/ControlPanel.vue | 38 +- frontend/src/components/GpsMapPanel.vue | 152 +++--- frontend/src/components/NetworkPanel.vue | 181 ++++--- frontend/src/components/VideoPanel.vue | 65 +-- .../src/composables/useControlInterface.ts | 55 +- frontend/src/composables/useMonitoringData.ts | 7 +- frontend/src/lib/api.ts | 3 +- frontend/src/lib/locale.ts | 512 ++++++++++++++++++ frontend/src/views/DashboardView.vue | 10 +- frontend/src/views/MapView.vue | 10 +- frontend/src/views/NetworkView.vue | 11 +- frontend/src/views/VideoView.vue | 7 +- 14 files changed, 907 insertions(+), 273 deletions(-) create mode 100644 frontend/src/lib/locale.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1cd0a0c..53f54f1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,12 +1,17 @@ diff --git a/frontend/src/components/ControlPanel.vue b/frontend/src/components/ControlPanel.vue index b15e646..318455e 100644 --- a/frontend/src/components/ControlPanel.vue +++ b/frontend/src/components/ControlPanel.vue @@ -3,14 +3,16 @@ import { computed } from 'vue' import ControlFeedback from '@/components/ControlFeedback.vue' import { useControlInterface } from '@/composables/useControlInterface' +import { useLocale } from '@/lib/locale' const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } = useControlInterface() +const { t } = useLocale() -const inputModes = [ - { id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' }, - { id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' }, -] as const +const inputModes = computed(() => [ + { id: 'keyboard', label: t('common.keyboard'), detail: t('controlPanel.keyboardDetail') }, + { id: 'gamepad', label: t('common.gamepad'), detail: t('controlPanel.gamepadDetail') }, +] as const) const forwardSpeed = computed({ get: () => controlTuning.value.forward, @@ -37,24 +39,24 @@ const turboMultiplier = computed({
-

Control

-

Control Feedback

+

{{ t('controlPanel.eyebrow') }}

+

{{ t('controlPanel.title') }}

-

Input Mode

-

Only one local input mode can control the page at a time.

+

{{ t('controlPanel.inputModeEyebrow') }}

+

{{ t('controlPanel.inputModeCopy') }}

{{ controlInputModeLabel }}
-
+
diff --git a/frontend/src/components/GpsMapPanel.vue b/frontend/src/components/GpsMapPanel.vue index d8d1300..76252e1 100644 --- a/frontend/src/components/GpsMapPanel.vue +++ b/frontend/src/components/GpsMapPanel.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/composables/useControlInterface.ts b/frontend/src/composables/useControlInterface.ts index 67fe2a0..40a1747 100644 --- a/frontend/src/composables/useControlInterface.ts +++ b/frontend/src/composables/useControlInterface.ts @@ -1,6 +1,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { buildControlWebSocketUrl } from '@/lib/api' +import { t } from '@/lib/locale' type SocketState = 'connecting' | 'open' | 'closed' type ControlInputMode = 'keyboard' | 'gamepad' @@ -35,7 +36,7 @@ const KEY_LABELS: Record = { KeyE: 'E', ShiftLeft: 'Shift', ShiftRight: 'Shift', - Space: 'Stop', + Space: 'Space', } const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS'] @@ -57,10 +58,11 @@ const MAX_TURBO_MULTIPLIER = 3 const pressedKeys = ref>(new Set()) const socketState = ref('connecting') -const lastServerMessage = ref('waiting') +const lastServerMessageOverride = ref('') +const lastServerMessagePreset = ref<'waiting' | 'live'>('waiting') const gamepadSupported = ref(false) const gamepadConnected = ref(false) -const gamepadName = ref('No gamepad detected') +const gamepadNameRaw = ref('') const gamepadIndex = ref(null) const gamepadMapping = ref('') const gamepadAxes = ref([0, 0, 0, 0]) @@ -444,7 +446,7 @@ function handleKeyup(event: KeyboardEvent) { function resetGamepadState() { gamepadConnected.value = false - gamepadName.value = 'No gamepad detected' + gamepadNameRaw.value = '' gamepadIndex.value = null gamepadMapping.value = '' gamepadAxes.value = [0, 0, 0, 0] @@ -484,9 +486,9 @@ function pollGamepadState() { lastGamepadSignature = signature gamepadConnected.value = true - gamepadName.value = pad.id || 'Unnamed gamepad' + gamepadNameRaw.value = pad.id || '' gamepadIndex.value = pad.index - gamepadMapping.value = pad.mapping || 'unknown' + gamepadMapping.value = pad.mapping || '' gamepadAxes.value = axes gamepadButtonPressed.value = buttons if (controlInputMode.value === 'gamepad') { @@ -506,18 +508,21 @@ function connectSocket() { socket.onopen = () => { socketState.value = 'open' - lastServerMessage.value = 'control link live' + lastServerMessagePreset.value = 'live' + lastServerMessageOverride.value = '' refreshSendLoop(true, false) } socket.onmessage = (event) => { if (typeof event.data === 'string') { - lastServerMessage.value = event.data + lastServerMessageOverride.value = event.data } } socket.onclose = () => { socketState.value = 'closed' + lastServerMessagePreset.value = 'waiting' + lastServerMessageOverride.value = '' stopSendLoop() socket = null if (manualClose) { @@ -596,20 +601,27 @@ function unmountConsumer() { } const socketLabel = computed(() => { - if (socketState.value === 'open') return 'ws open' - if (socketState.value === 'connecting') return 'connecting' - return 'reconnecting' + if (socketState.value === 'open') return t('control.socket.open') + if (socketState.value === 'connecting') return t('control.socket.connecting') + return t('control.socket.reconnecting') }) const activeSourceLabel = computed(() => { - if (activeSource.value === 'keyboard') return 'Keyboard' - if (activeSource.value === 'gamepad') return 'Gamepad' - return 'Idle' + if (activeSource.value === 'keyboard') return t('common.keyboard') + if (activeSource.value === 'gamepad') return t('common.gamepad') + return t('common.idle') }) const controlInputModeLabel = computed(() => { - if (controlInputMode.value === 'gamepad') return 'Gamepad' - return 'Keyboard' + if (controlInputMode.value === 'gamepad') return t('common.gamepad') + return t('common.keyboard') +}) + +const lastServerMessage = computed(() => { + if (lastServerMessageOverride.value) { + return lastServerMessageOverride.value + } + return lastServerMessagePreset.value === 'live' ? t('control.server.live') : t('control.server.waiting') }) const commandValues = computed(() => { @@ -635,12 +647,12 @@ const commandMagnitude = computed(() => { ) }) -const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none') +const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || t('common.none')) const keyboardKeys = computed(() => TRACKED_KEYS.map((code) => ({ code, - label: KEY_LABELS[code] ?? code, + label: code === 'Space' ? t('control.key.stop') : (KEY_LABELS[code] ?? code), pressed: pressedKeys.value.has(code), })), ) @@ -667,6 +679,13 @@ const gamepadButtons = computed(() => })), ) +const gamepadName = computed(() => { + if (!gamepadConnected.value) { + return t('control.gamepad.none') + } + return gamepadNameRaw.value || t('control.gamepad.unnamed') +}) + const gamepadLeftStick = computed(() => ({ x: gamepadAxes.value[0] ?? 0, y: gamepadAxes.value[1] ?? 0, diff --git a/frontend/src/composables/useMonitoringData.ts b/frontend/src/composables/useMonitoringData.ts index 38c1dc3..054cb26 100644 --- a/frontend/src/composables/useMonitoringData.ts +++ b/frontend/src/composables/useMonitoringData.ts @@ -1,6 +1,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { fetchDashboardSnapshot } from '@/lib/api' +import { t } from '@/lib/locale' import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types' type UseMonitoringDataOptions = { @@ -25,7 +26,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) { video.value = snapshot.video errorMessage.value = '' } catch (error) { - errorMessage.value = error instanceof Error ? error.message : 'Failed to load monitoring data' + errorMessage.value = error instanceof Error ? error.message : t('common.requestFailed', { status: '-', statusText: '' }) } finally { loading.value = false } @@ -36,9 +37,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) { return errorMessage.value } if (loading.value) { - return 'Connecting to the Django backend and loading live monitoring data...' + return t('monitoring.loading') } - return 'Dashboard connected. Video, GPS, and live session telemetry refresh continuously from the unified A-side daemon.' + return t('monitoring.connected') }) onMounted(() => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7dd4d6d..d21b29d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ import type { DashboardSnapshot, VideoStatus } from '@/types' +import { t } from '@/lib/locale' const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined @@ -7,7 +8,7 @@ export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace( async function fetchJson(path: string): Promise { const response = await fetch(`${API_BASE}${path}`) if (!response.ok) { - throw new Error(`请求失败: ${response.status} ${response.statusText}`) + throw new Error(t('common.requestFailed', { status: response.status, statusText: response.statusText })) } return response.json() as Promise } diff --git a/frontend/src/lib/locale.ts b/frontend/src/lib/locale.ts new file mode 100644 index 0000000..89c7398 --- /dev/null +++ b/frontend/src/lib/locale.ts @@ -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 = { + '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')), + } +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index f38dbef..1aad0bb 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -3,6 +3,7 @@ import ControlPanel from '@/components/ControlPanel.vue' import GpsMapPanel from '@/components/GpsMapPanel.vue' import NetworkPanel from '@/components/NetworkPanel.vue' import VideoPanel from '@/components/VideoPanel.vue' +import { t } from '@/lib/locale' import { useMonitoringData } from '@/composables/useMonitoringData' const { gps, network, video, errorMessage, headerStatus } = useMonitoringData() @@ -12,13 +13,10 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
-

Overview

-

Robot Command Center

+

{{ t('dashboard.eyebrow') }}

+

{{ t('dashboard.title') }}

-

- The A-side daemon now owns video receive, control ingress arbitration, and live session - telemetry in one backend process. -

+

{{ t('dashboard.description') }}