回應使用者三項需求:
1. 整體 hard timeout 180s → 300s(5 分鐘)
每個 stage 已有 soft timeout 20s 提示機制,整體 budget 不需緊湊。
5 分鐘是「使用者點完一杯咖啡都還沒好」的心理上限。pause 機制
(Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
仍維持作為「一次性 bootstrap 完全不算 budget」的快速通道。
- 同步更新 i18n 紅 banner 文案 180 → 5 分鐘
- 同步更新 unit tests(HardTimeout 用 -305s,SkipBypass 用 -320s,
PreventsHardTimeout 註解 effective<300s)
2. Stage 6「等待 Web UI 連線」從 6 階段面板隱藏到 header 連線指示燈
Go 端 pipeline 仍保持 6 階段(不動),前端 UI 只顯示 5 階段:
- startup-panel.js: TOTAL_STAGES=5 顯示用,PIPELINE_STAGES=6 內部
state 用。renderStages / paintProgressBar / 進度數字都用 5。
- updateStage 仍會收 stage 6 events 更新內部 state(控 collapse 時機)
但 stage 6 不 paint UI(n > TOTAL_STAGES early return)
- 新增 onConnectionStatusChange listener 機制:stage 6 status 變化
時通知外層
- control-panel.js: setWebUIStatus 把連線狀態 (pending/running/
completed/failed) 渲染到 header 的 meta-webui 指示燈:圓點顏色
+ 文字 (等待連線/已連線/未連線)
- index.html: server-meta 新增 <dd id="meta-webui"> 指示燈位置
- i18n: control.meta.webui / control.webui.{connected,waiting,disconnected}
- style.css: .webui-status::before 圓點 + pulse 動畫 + 顏色對應
state (pending=灰 / running=warning+pulse / connected=success / failed=destructive)
- app.js: 註冊 onConnectionStatusChange listener,初始化呼叫
setWebUIStatus('pending')
3. 全屏 spinner splash 取代「啟動中...」三個字
原本 app 啟動最一開始的「啟動中」狀態只有 header 上三個字很不
明顯,使用者體感像沒反應。改為 DOM ready 時就顯示 fullscreen
spinner overlay,收到第一個 startup:progress event 才隱藏。
- index.html: <div id="boot-splash"> 內含 logo + spinner-lg + 文字
- style.css: .boot-splash position:fixed inset:0 z-index:1000,
.boot-splash.hidden { display:none } 用 class 控制(避免和
[hidden]!important 衝突)
- app.js: hideBootSplash() helper,4 個 hide 觸發點:
(a) 收到 startup:progress event
(b) snapshot 補漏發現 pipeline 已啟動
(c) 收到 startup:error event(即使失敗也要看到錯誤)
(d) handleServerStatus 收到非 idle 狀態(restart wails app
server 還活著的情境)
更新 fix marker 為「d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)」
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
15 KiB
JavaScript
285 lines
15 KiB
JavaScript
// visionA-local 控制台 i18n
|
||
// namespace: desktop-control
|
||
// 對齊 Design Spec v2.1 control-panel.md §9 + startup-progress.md §7
|
||
|
||
const dict = {
|
||
'zh-TW': {
|
||
'control.title': 'visionA-local · 伺服器控制台',
|
||
'control.status.idle': '閒置',
|
||
'control.status.starting': '啟動中...',
|
||
'control.status.running': '執行中',
|
||
'control.status.runningBrowserOpened': '執行中 · 已開啟瀏覽器',
|
||
'control.status.stopping': '停止中...',
|
||
'control.status.stopped': '已停止',
|
||
'control.status.error': '錯誤:{reason}',
|
||
'control.meta.port': '連接埠',
|
||
'control.meta.uptime': '執行時間',
|
||
'control.meta.pid': '程序 ID',
|
||
'control.meta.version': '版本',
|
||
'control.meta.webui': 'Web UI',
|
||
'control.webui.connected': '已連線',
|
||
'control.webui.waiting': '等待連線',
|
||
'control.webui.disconnected': '未連線',
|
||
'control.action.openBrowser': '在瀏覽器開啟',
|
||
'control.action.start': '啟動',
|
||
'control.action.stop': '停止',
|
||
'control.action.restart': '重新啟動',
|
||
'control.action.manage': '管理',
|
||
'control.action.stopServer': '停止伺服器',
|
||
'control.action.restartServer': '重新啟動伺服器',
|
||
'control.log.followTail': '自動跟隨最新',
|
||
'control.log.showTimestamps': '顯示時間戳',
|
||
'control.log.filterPlaceholder': '過濾 log...',
|
||
'control.log.jumpToLatest': '跳到最新',
|
||
'control.log.clear': '清空',
|
||
'control.log.clearToast': '已清空 log',
|
||
'control.log.copy': '複製',
|
||
'control.log.copied': '已複製到剪貼簿',
|
||
'control.log.copyPrivacyHint': 'Log 可能包含檔名與裝置資訊,請注意分享對象',
|
||
'control.log.export': '匯出 log',
|
||
'control.log.exported': '已匯出到 {path}',
|
||
'control.log.openFolder': '開啟 log 資料夾',
|
||
'control.log.lines': '行數:{current} / {max}',
|
||
'control.footer.closeWarning': '⚠ 關閉此視窗會停止伺服器',
|
||
'control.error.title': '伺服器無法啟動',
|
||
'control.error.description': '{reason}',
|
||
'control.error.restartButton': '重新啟動伺服器',
|
||
'control.error.viewLogDetails': '檢視 log 詳情',
|
||
'control.error.reportButton': '回報問題...',
|
||
'control.shutdown.stopping': '正在停止伺服器…',
|
||
// startup progress
|
||
'startup.panel.title': '正在啟動 visionA-local',
|
||
'startup.panel.ariaLabel': '啟動進度:階段 {current} / {max}',
|
||
'startup.progressLabel': '進度 {current} / {max}',
|
||
'startup.progressWithElapsed': '進度 {current} / {max} · 已等待 {elapsed} 秒',
|
||
'startup.stage.1.label': '初始化控制台',
|
||
'startup.stage.1.description': '準備 visionA-local 桌面環境',
|
||
'startup.stage.2.label': '檢查 Python 執行環境',
|
||
'startup.stage.2.description': '首次啟動可能需要較長時間',
|
||
'startup.stage.3.label': '啟動本機伺服器',
|
||
'startup.stage.3.description': '在 127.0.0.1:3721 啟動服務',
|
||
'startup.stage.4.label': '偵測 Kneron 裝置',
|
||
'startup.stage.4.description': '掃描已連接的硬體',
|
||
'startup.stage.5.label': '開啟瀏覽器',
|
||
'startup.stage.5.description': '在預設瀏覽器開啟 Web UI',
|
||
'startup.stage.5.skipped.label': '跳過(依偏好設定)',
|
||
'startup.stage.6.label': '等待 Web UI 連線',
|
||
'startup.stage.6.description': '正在與瀏覽器建立即時連線',
|
||
'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕',
|
||
// 各 stage 細步提示(由 Go 的 startup:stage-detail event 觸發)
|
||
// Stage 1 - 初始化控制台
|
||
'startup.stage.1.detail.migrate': '檢查並遷移舊資料目錄...',
|
||
'startup.stage.1.detail.lock': '建立 single-instance lock...',
|
||
'startup.stage.1.detail.ipc': '啟動 Wails IPC server...',
|
||
'startup.stage.1.detail.seed': '正在準備內建模型資料(首次啟動會花幾秒鐘)...',
|
||
'startup.stage.1.detail.seedSlow': '正在準備內建模型資料(Windows Defender 掃描檔案中)...',
|
||
// Stage 2 - 檢查 Python 執行環境
|
||
'startup.stage.2.detail.detect': '偵測系統 Python 執行環境...',
|
||
'startup.stage.2.detail.bootstrap': '正在解壓內建 Python runtime(首次啟動需 1-2 分鐘)...',
|
||
'startup.stage.2.detail.venv': '正在建立 Python 虛擬環境...',
|
||
'startup.stage.2.detail.pip': '正在安裝 Python 套件 numpy / opencv / KneronPLUS(首次啟動需 1-3 分鐘)...',
|
||
'startup.stage.2.detail.driver': '正在安裝 Kneron USB 驅動程式(請點選 UAC 允許)...',
|
||
// Stage 3 - 啟動本機伺服器
|
||
'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...',
|
||
'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過...',
|
||
'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常,Windows Defender 掃描可能需 1-2 分鐘...',
|
||
// Stage 4 - 偵測 Kneron 裝置
|
||
'startup.stage.4.detail.probe': '正在掃描 USB 裝置...',
|
||
// Stage 5 - 開啟瀏覽器
|
||
'startup.stage.5.detail.open': '正在開啟系統預設瀏覽器...',
|
||
// Stage 6 - 等待 Web UI 連線
|
||
'startup.stage.6.detail.wait': '正在等待瀏覽器建立 WebSocket 連線...',
|
||
// 啟動完成後 collapsed 面板的標題與提示
|
||
'startup.collapsed.title': '啟動完成',
|
||
'startup.collapsed.hint': '· 點此展開檢視',
|
||
'startup.collapsed.hintRestart': '· 點此或按重啟可重新展開',
|
||
'startup.status.pending': '等待中',
|
||
'startup.status.running': '進行中',
|
||
'startup.status.done': '完成',
|
||
'startup.status.failed': '失敗',
|
||
'startup.status.skipped': '跳過(依偏好設定)',
|
||
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
|
||
'startup.error.title': '啟動失敗',
|
||
'startup.error.description.timeout': '啟動時間超過 5 分鐘,可能是系統環境異常或網路中斷。',
|
||
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
|
||
'startup.error.failedStage': '失敗階段:{n} · {label}',
|
||
'startup.error.retry': '重試',
|
||
'startup.error.viewLog': '檢視 log',
|
||
'startup.error.report': '回報問題',
|
||
// settings
|
||
'settings.title': '設定',
|
||
'settings.autoOpenBrowser.label': '啟動時自動開啟瀏覽器',
|
||
'settings.autoOpenBrowser.hintLinux': 'Linux 桌面環境差異大,預設關閉',
|
||
'settings.language.label': '語言',
|
||
'settings.about.title': '關於',
|
||
},
|
||
'en': {
|
||
'control.title': 'visionA-local · Server Control',
|
||
'control.status.idle': 'Idle',
|
||
'control.status.starting': 'Starting...',
|
||
'control.status.running': 'Running',
|
||
'control.status.runningBrowserOpened': 'Running · Browser opened',
|
||
'control.status.stopping': 'Stopping...',
|
||
'control.status.stopped': 'Stopped',
|
||
'control.status.error': 'Error: {reason}',
|
||
'control.meta.port': 'Port',
|
||
'control.meta.uptime': 'Uptime',
|
||
'control.meta.pid': 'PID',
|
||
'control.meta.version': 'Version',
|
||
'control.meta.webui': 'Web UI',
|
||
'control.webui.connected': 'Connected',
|
||
'control.webui.waiting': 'Waiting',
|
||
'control.webui.disconnected': 'Disconnected',
|
||
'control.action.openBrowser': 'Open in Browser',
|
||
'control.action.start': 'Start',
|
||
'control.action.stop': 'Stop',
|
||
'control.action.restart': 'Restart',
|
||
'control.action.manage': 'Manage',
|
||
'control.action.stopServer': 'Stop server',
|
||
'control.action.restartServer': 'Restart server',
|
||
'control.log.followTail': 'Follow tail',
|
||
'control.log.showTimestamps': 'Show timestamps',
|
||
'control.log.filterPlaceholder': 'Filter...',
|
||
'control.log.jumpToLatest': 'Jump to latest',
|
||
'control.log.clear': 'Clear',
|
||
'control.log.clearToast': 'Log cleared',
|
||
'control.log.copy': 'Copy',
|
||
'control.log.copied': 'Copied to clipboard',
|
||
'control.log.copyPrivacyHint': 'Log may contain filenames and device info. Share with care.',
|
||
'control.log.export': 'Export log',
|
||
'control.log.exported': 'Exported to {path}',
|
||
'control.log.openFolder': 'Open log folder',
|
||
'control.log.lines': 'Lines: {current} / {max}',
|
||
'control.footer.closeWarning': '⚠ Closing this window will stop the server',
|
||
'control.error.title': 'Server failed to start',
|
||
'control.error.description': '{reason}',
|
||
'control.error.restartButton': 'Restart Server',
|
||
'control.error.viewLogDetails': 'View log details',
|
||
'control.error.reportButton': 'Report...',
|
||
'control.shutdown.stopping': 'Stopping server…',
|
||
// startup progress
|
||
'startup.panel.title': 'Starting visionA-local',
|
||
'startup.panel.ariaLabel': 'Startup progress: stage {current} / {max}',
|
||
'startup.progressLabel': 'Progress {current} / {max}',
|
||
'startup.progressWithElapsed': 'Progress {current} / {max} · {elapsed}s elapsed',
|
||
'startup.stage.1.label': 'Initializing control panel',
|
||
'startup.stage.1.description': 'Preparing visionA-local desktop',
|
||
'startup.stage.2.label': 'Checking Python runtime',
|
||
'startup.stage.2.description': 'First launch may take longer',
|
||
'startup.stage.3.label': 'Starting local server',
|
||
'startup.stage.3.description': 'Starting service on 127.0.0.1:3721',
|
||
'startup.stage.4.label': 'Detecting Kneron devices',
|
||
'startup.stage.4.description': 'Scanning connected hardware',
|
||
'startup.stage.5.label': 'Opening browser',
|
||
'startup.stage.5.description': 'Opening the Web UI in your default browser',
|
||
'startup.stage.5.skipped.label': 'Skipped (per preference)',
|
||
'startup.stage.6.label': 'Waiting for Web UI to connect',
|
||
'startup.stage.6.description': 'Establishing realtime connection with the browser',
|
||
'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel',
|
||
// All stage sub-step hints (triggered by Go startup:stage-detail event)
|
||
// Stage 1
|
||
'startup.stage.1.detail.migrate': 'Checking and migrating legacy data directories...',
|
||
'startup.stage.1.detail.lock': 'Acquiring single-instance lock...',
|
||
'startup.stage.1.detail.ipc': 'Starting Wails IPC server...',
|
||
'startup.stage.1.detail.seed': 'Preparing built-in model data (takes a few seconds on first launch)...',
|
||
'startup.stage.1.detail.seedSlow': 'Preparing built-in model data (Defender scanning files)...',
|
||
// Stage 2
|
||
'startup.stage.2.detail.detect': 'Detecting system Python runtime...',
|
||
'startup.stage.2.detail.bootstrap': 'Extracting bundled Python runtime (takes 1-2 min on first launch)...',
|
||
'startup.stage.2.detail.venv': 'Creating Python virtual environment...',
|
||
'startup.stage.2.detail.pip': 'Installing Python packages numpy / opencv / KneronPLUS (takes 1-3 min on first launch)...',
|
||
'startup.stage.2.detail.driver': 'Installing Kneron USB driver (please allow UAC)...',
|
||
// Stage 3
|
||
'startup.stage.3.detail.spawn': 'Launching server subprocess...',
|
||
'startup.stage.3.detail.waitHealth': 'Waiting for server health check...',
|
||
'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes...',
|
||
// Stage 4
|
||
'startup.stage.4.detail.probe': 'Scanning USB devices...',
|
||
// Stage 5
|
||
'startup.stage.5.detail.open': 'Opening system default browser...',
|
||
// Stage 6
|
||
'startup.stage.6.detail.wait': 'Waiting for browser to establish WebSocket connection...',
|
||
// Collapsed panel after startup ready
|
||
'startup.collapsed.title': 'Startup complete',
|
||
'startup.collapsed.hint': '· click to expand',
|
||
'startup.collapsed.hintRestart': '· click or restart to expand',
|
||
'startup.status.pending': 'Waiting',
|
||
'startup.status.running': 'Running',
|
||
'startup.status.done': 'Done',
|
||
'startup.status.failed': 'Failed',
|
||
'startup.status.skipped': 'Skipped (per preference)',
|
||
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
|
||
'startup.error.title': 'Startup failed',
|
||
'startup.error.description.timeout': 'Startup exceeded 5 minutes. Your environment may have issues or the network is interrupted.',
|
||
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
|
||
'startup.error.failedStage': 'Failed stage: {n} · {label}',
|
||
'startup.error.retry': 'Retry',
|
||
'startup.error.viewLog': 'View Log',
|
||
'startup.error.report': 'Report Issue',
|
||
// settings
|
||
'settings.title': 'Settings',
|
||
'settings.autoOpenBrowser.label': 'Auto-open browser on startup',
|
||
'settings.autoOpenBrowser.hintLinux': 'Linux desktop environments vary, disabled by default',
|
||
'settings.language.label': 'Language',
|
||
'settings.about.title': 'About',
|
||
},
|
||
};
|
||
|
||
let currentLocale = 'zh-TW';
|
||
|
||
// 根據 navigator.language 或儲存值決定 locale
|
||
export function detectLocale(preferredLocale) {
|
||
// 1. 使用者明確指定(preferences)
|
||
if (preferredLocale && dict[preferredLocale]) return preferredLocale;
|
||
// 2. localStorage
|
||
const stored = localStorage.getItem('vl-locale');
|
||
if (stored && dict[stored]) return stored;
|
||
// 3. navigator.language
|
||
const lang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
||
if (lang.startsWith('zh')) return 'zh-TW';
|
||
if (lang.startsWith('en')) return 'en';
|
||
// 4. fallback
|
||
return 'zh-TW';
|
||
}
|
||
|
||
export function setLocale(locale) {
|
||
if (dict[locale]) {
|
||
currentLocale = locale;
|
||
localStorage.setItem('vl-locale', locale);
|
||
}
|
||
}
|
||
|
||
export function getLocale() {
|
||
return currentLocale;
|
||
}
|
||
|
||
// t('key', {param: value}) → 翻譯字串並替換 {param}
|
||
export function t(key, params) {
|
||
let s = (dict[currentLocale] && dict[currentLocale][key]);
|
||
if (s === undefined) {
|
||
// fallback en
|
||
s = (dict.en && dict.en[key]) || key;
|
||
}
|
||
if (params) {
|
||
for (const k in params) {
|
||
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), params[k]);
|
||
}
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// 對 DOM 中所有 [data-i18n] / [data-i18n-placeholder] 執行翻譯
|
||
export function applyI18n(root) {
|
||
const scope = root || document;
|
||
scope.querySelectorAll('[data-i18n]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n');
|
||
el.textContent = t(key);
|
||
});
|
||
scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
const key = el.getAttribute('data-i18n-placeholder');
|
||
el.setAttribute('placeholder', t(key));
|
||
});
|
||
}
|
||
|
||
export const LOCALES = Object.keys(dict);
|