jim800121chen 9c9e005d33 feat(local-tool): Stage 3 sub-step 進度 + 啟動完成後面板可收合
回應使用者三項需求:
1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境)
2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次
   啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了
3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷
   史紀錄;Restart / Retry 會重置並展開新一輪

實作:

Go 端
- healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲)
- waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds
- StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API
- startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback
  emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s)

前端
- 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為
  文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint)
- collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新
  API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸
  發 reset+expand
- collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ +
  「點此展開檢視」hint),整個 panel 可點擊;hover 加亮
- i18n 加 6 個 keys(zh-TW + en)

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
- 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary
  + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start
  正常

Windows 首次安裝預期行為(修復後):
- Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage
  3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N
  秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟

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

408 lines
12 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,
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();
// 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();
}