jim800121chen d946561362 fix(local-tool): Stage 順序亂跳修復 + 移除秒數顯示
兩個問題一次修:

1. Stage 順序亂跳 — 「Stage 1 等待中、Stage 2 完成、Stage 3 進行中」
   根因:Wails Webview JS load 需 1-3 秒(Windows 乾淨環境更慢),這段
   期間 Go 的 Pipeline.Start 已經 emit Stage 1 running event 甚至跑完
   Stage 1 / Stage 2,但前端 EventsOn 還沒掛上去,events 全被丟掉。前端
   接到的第一個 event 可能是 Stage 2 completed 或 Stage 3 running,
   stages[1].status 仍是初始 pending 值,UI 顯示亂序。

   修法:
   - 新增 Go binding GetStartupSnapshot() 回傳 pipeline 當前所有 stages
     狀態(含 current / startedAt / status)。
   - 前端 init 流程在 subscribeEvents 後立即拉一次 snapshot,補上漏掉
     的 stage 狀態。
   - updateStage 加 monotonic 模式:snapshot 補漏時不會用較舊狀態覆蓋
     已收到的較新狀態(避免 race condition 倒退)。
   - status 優先級 STAGE_STATUS_RANK = pending<running<{skipped,failed}<completed

2. 進度條已等待秒數顯示錯誤 — 「進度 3 / 6 · 已等待 20 秒」
   根因:pause 機制讓 elapsed 計算失準(pause 期間 wall clock 仍走但
   stages[i].startedAt 沒重設,會顯示明顯比真實還久的數字)。使用者
   覺得不需要顯示秒數。
   修法:
   - paintProgressBar 移除 elapsedText 邏輯,永遠顯示 progressLabel
   - i18n 文案移除 {elapsed} placeholder(zh-TW + en):
     stage.1.detail.seedSlow / stage.3.detail.waitHealth /
     stage.3.detail.waitHealthSlow 都改為固定文案
   - Go 端 emit 仍會傳 elapsed(waitProgress callback 不變),但前端
     i18n template 不再用該變數,自然就不顯示

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
- Wails bindings 自動 regen 含 GetStartupSnapshot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:14:21 +08:00

438 lines
14 KiB
JavaScript
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.

// visionA-local 控制台 main entry
// - 負責 bindings / events 註冊、初始化 UI
// - M8-5 Wails 控制台 UI對齊 Design Spec v2.1
import {
StartServer,
StopServer,
RestartServer,
ForceKillServer,
GetServerStatusV2,
GetStartupSnapshot,
GetRecentLogs,
ClearLogs,
GetSystemInfo,
OpenInBrowser,
RevealLogsFolder,
ExportLog,
GetPreferences,
SetPreferences,
RestartStartupSequence,
} from './wailsjs/go/main/App.js';
import { EventsOn } from './wailsjs/runtime/runtime.js';
import { t, applyI18n, setLocale, getLocale, detectLocale, LOCALES } from './i18n.js';
import {
setServerState,
updateServerMeta,
updatePrimaryControls,
showErrorBanner,
hideErrorBanner,
showToast,
initHeaderClock,
setPrimaryCTAPulse,
STATE_ERROR,
} from './control-panel.js';
import {
showStartupPanel,
hideStartupPanel,
collapseStartupPanel,
expandStartupPanel,
resetStartupPanel,
updateStage,
updateStageDetail,
markStageTimeout,
showStartupError,
renderStages,
onManualModeChange,
} from './startup-panel.js';
import {
initLogPanel,
appendLogs,
clearLog,
flashLastError,
applyLogFilter,
} from './log-panel.js';
import { initSettingsPanel, openSettings } from './settings-panel.js';
// ---------- 全域狀態 ----------
const state = {
server: null, // ServerStatusV2
prefs: null, // Preferences
sysInfo: null, // SystemInfo
starting: false, // 啟動進度面板是否顯示
};
// ---------- 初始化 ----------
async function init() {
// 1. 讀 preferences → 決定 locale
try {
state.prefs = await GetPreferences();
} catch (e) {
console.warn('GetPreferences failed:', e);
state.prefs = { autoOpenBrowser: true, locale: '' };
}
const locale = detectLocale(state.prefs && state.prefs.locale);
setLocale(locale);
// 2. 讀系統資訊
try {
state.sysInfo = await GetSystemInfo();
} catch (e) {
console.warn('GetSystemInfo failed:', e);
}
// 3. 套 i18n 到 DOM
applyI18n();
document.title = t('control.title');
if (state.sysInfo && state.sysInfo.appVersion) {
document.getElementById('app-version').textContent = state.sysInfo.appVersion;
}
// 4. render 6 階段 UI skeleton
renderStages();
// 5. 初始化 log panel拉既有 buffer
try {
const existingLogs = await GetRecentLogs(2000);
initLogPanel(existingLogs || []);
} catch (e) {
console.warn('GetRecentLogs failed:', e);
initLogPanel([]);
}
// 6. 綁按鈕 handlers
bindHandlers();
// 7. 訂閱 Wails events
subscribeEvents();
// 7.5. 補上前端 init 完成前 Go 端已 emit 但被丟掉的 startup events。
// Wails Webview JS load 完成需 1-3 秒Windows 乾淨環境更慢),這段
// 期間 Go 的 Pipeline.Start 已經跑完 emit Stage 1 running、可能也
// 跑完 Stage 1 complete + Stage 2 running 等多個 events前端 EventsOn
// 還沒掛上去就被丟掉。拉一次 snapshot 把這些 stage 狀態補回來,
// 避免 UI 顯示「Stage 1 等待中、Stage 2 完成」這種亂序畫面。
try {
const snapshot = await GetStartupSnapshot();
if (snapshot && snapshot.current > 0) {
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
state.starting = true;
showStartupPanel();
for (const s of snapshot.stages || []) {
updateStage(
{ stage: s.stage, status: s.status, startedAt: s.startedAt },
{ monotonic: true },
);
}
// 已 ready (current=7) → 直接 collapse 面板
if (snapshot.current > snapshot.totalStages) {
state.starting = false;
collapseStartupPanel();
}
}
} catch (e) {
console.warn('GetStartupSnapshot failed:', e);
}
// 8. 初始 state query
try {
const status = await GetServerStatusV2();
handleServerStatus(status);
} catch (e) {
console.warn('GetServerStatusV2 failed:', e);
}
// 9. 初始化 settings panel
initSettingsPanel({
prefs: state.prefs,
sysInfo: state.sysInfo,
getPreferences: GetPreferences,
setPreferences: SetPreferences,
onLocaleChange: (newLoc) => {
setLocale(newLoc || detectLocale(''));
applyI18n();
document.title = t('control.title');
renderStages();
// re-render current state text
if (state.server) setServerState(state.server);
},
});
// 10. header uptime 時鐘
initHeaderClock(() => state.server);
// 11. Manual mode 監聽stage 5 skipped 時引導使用者點 Open in Browser
// 對齊 Design Spec §4.1
onManualModeChange((enabled) => {
setPrimaryCTAPulse(enabled);
});
}
// ---------- 處理 server status ----------
function handleServerStatus(status) {
if (!status) return;
state.server = status;
setServerState(status);
updateServerMeta(status);
updatePrimaryControls(status);
// Error state runtime banner非 startup error
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
showErrorBanner(status.lastError);
} else if (status.state !== STATE_ERROR) {
hideErrorBanner();
}
}
// ---------- 按鈕 handlers ----------
function bindHandlers() {
const $ = (id) => document.getElementById(id);
// Open in Browser
$('btn-open-browser').addEventListener('click', async () => {
try {
await OpenInBrowser('');
} catch (e) {
showToast('Failed to open browser: ' + e);
}
});
// Start
$('btn-start').addEventListener('click', async () => {
try {
await StartServer();
} catch (e) {
showToast('Start failed: ' + e);
}
});
// Manage menu
const manageBtn = $('btn-manage');
const manageMenu = $('manage-menu');
manageBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const hidden = manageMenu.hasAttribute('hidden');
if (hidden) {
manageMenu.removeAttribute('hidden');
manageBtn.setAttribute('aria-expanded', 'true');
} else {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', () => {
if (!manageMenu.hasAttribute('hidden')) {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
$('mi-stop').addEventListener('click', async () => {
try {
await StopServer();
} catch (e) {
showToast('Stop failed: ' + e);
}
});
$('mi-restart').addEventListener('click', async () => {
try {
resetStartupPanel();
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('mi-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Log actions
$('btn-clear-log').addEventListener('click', async () => {
try {
await ClearLogs();
clearLog();
showToast(t('control.log.clearToast'));
} catch (e) {
showToast('Clear failed: ' + e);
}
});
$('btn-copy-log').addEventListener('click', async () => {
const output = document.getElementById('log-output');
try {
await navigator.clipboard.writeText(output.innerText || '');
showToast(t('control.log.copied'));
} catch (e) {
showToast('Copy failed: ' + e);
}
});
$('btn-export-log').addEventListener('click', async () => {
try {
const path = await ExportLog();
showToast(t('control.log.exported', { path }));
} catch (e) {
showToast('Export failed: ' + e);
}
});
$('btn-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Filter
$('filter-input').addEventListener('input', (e) => applyLogFilter({ text: e.target.value }));
$('level-filter').addEventListener('change', (e) => applyLogFilter({ level: e.target.value }));
// Settings
$('btn-settings').addEventListener('click', openSettings);
// Startup panel collapsed bar點任何位置都展開看歷史紀錄
const startupPanelEl = $('startup-panel');
if (startupPanelEl) {
startupPanelEl.addEventListener('click', () => {
if (startupPanelEl.getAttribute('data-collapsed') === 'true') {
expandStartupPanel();
}
});
}
// Startup error actions — Retry 會把面板重置展開
$('btn-retry').addEventListener('click', async () => {
try {
resetStartupPanel();
await RestartStartupSequence();
} catch (e) {
showToast('Retry failed: ' + e);
}
});
$('btn-view-log').addEventListener('click', () => {
hideStartupPanel();
flashLastError();
});
// Report 按鈕 disabledcoming soon
// Error banner — Restart Server 會重置展開(讓使用者看新一輪 6 階段)
$('banner-restart').addEventListener('click', async () => {
try {
resetStartupPanel();
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('banner-view').addEventListener('click', () => {
flashLastError();
});
// 鍵盤快捷鍵⌘F / Ctrl+F focus filter
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
$('filter-input').focus();
}
});
}
// ---------- 訂閱 Wails events ----------
function subscribeEvents() {
// server state / error / recovered
EventsOn('server:state-change', (payload) => handleServerStatus(payload));
EventsOn('server:error', (payload) => {
// Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error"
if (payload && payload.reason) showErrorBanner(payload.reason);
});
EventsOn('server:recovered', () => {
hideErrorBanner();
showToast('Server recovered');
});
// log stream
EventsOn('log:append', (entries) => {
if (Array.isArray(entries)) appendLogs(entries);
else if (entries) appendLogs([entries]);
});
EventsOn('log:clear', () => clearLog());
// startup pipeline
EventsOn('startup:progress', (ev) => {
if (!state.starting) {
state.starting = true;
showStartupPanel();
}
updateStage(ev);
});
EventsOn('startup:stage-timeout', (ev) => {
markStageTimeout(ev);
});
// Stage 細步 detail例如 Stage 3 的 spawn / waitHealth / waitHealthSlow
EventsOn('startup:stage-detail', (ev) => {
updateStageDetail(ev);
});
EventsOn('startup:error', (ev) => {
showStartupError(ev);
});
EventsOn('startup:ready', () => {
state.starting = false;
collapseStartupPanel();
});
// shutdown modalM8-4 1 秒後顯示)
// 用 watchdog 做 safety netpopup show 後最多顯示 15 秒,即使 Go 端
// 沒 emit hidestopGraceful 卡死、Process.Wait 阻塞等邊界情境)前端也
// 會自己 hide避免使用者卡在無法關閉的 popup。
let shutdownModalWatchdog = null;
const hideShutdownModal = () => {
const m = document.getElementById('shutdown-modal');
if (m) m.setAttribute('hidden', '');
if (shutdownModalWatchdog) {
clearTimeout(shutdownModalWatchdog);
shutdownModalWatchdog = null;
}
};
EventsOn('shutdown:modal-show', () => {
const m = document.getElementById('shutdown-modal');
if (m) m.removeAttribute('hidden');
if (shutdownModalWatchdog) clearTimeout(shutdownModalWatchdog);
shutdownModalWatchdog = setTimeout(() => {
hideShutdownModal();
showToast('伺服器停止耗時過久popup 已自動關閉。請用工作管理員確認 server 是否已結束。');
}, 15000);
});
// stopGraceful 結束時對稱 hide。
EventsOn('shutdown:modal-hide', hideShutdownModal);
// 點 backdrop 空白處可手動關閉 popupEscape hatch
const shutdownBackdrop = document.getElementById('shutdown-modal');
if (shutdownBackdrop) {
shutdownBackdrop.addEventListener('click', (e) => {
if (e.target === shutdownBackdrop) hideShutdownModal();
});
}
// Esc 鍵也可以關
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const m = document.getElementById('shutdown-modal');
if (m && !m.hasAttribute('hidden')) hideShutdownModal();
}
});
// app level fatal保留相容
EventsOn('app:error', (msg) => {
showErrorBanner(typeof msg === 'string' ? msg : JSON.stringify(msg));
});
}
// ---------- 啟動 ----------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}