jim800121chen f5655e38b1 feat(local-tool): hard timeout 5min + Stage 6 隱藏到 header + 全屏 splash
回應使用者三項需求:

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>
2026-04-16 01:23:55 +08:00

152 lines
5.7 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.

// control-panel.js — Header / Status / Primary controls / Error banner / Toast
import { t } from './i18n.js';
// ---------- Server state 常數 ----------
// 必須與 Go 端 server_control.go §ServerState 常數對齊(全部 lowercase
// 千萬不要改成 PascalCase會讓 state 比對全面失敗。
export const STATE_IDLE = 'idle';
export const STATE_STARTING = 'starting';
export const STATE_RUNNING = 'running';
export const STATE_STOPPING = 'stopping';
export const STATE_STOPPED = 'stopped';
export const STATE_ERROR = 'error';
// ---------- Server state ----------
export function setServerState(status) {
const root = document.getElementById('app');
const dot = document.getElementById('status-dot');
const textEl = document.getElementById('status-text');
if (!status) return;
const s = status.state || STATE_IDLE;
root.setAttribute('data-state', s);
dot.className = 'status-dot state-' + s;
let text;
switch (s) {
case STATE_IDLE: text = t('control.status.idle'); break;
case STATE_STARTING: text = t('control.status.starting'); break;
case STATE_RUNNING:
text = t('control.status.running');
if (status.port) text += ' · :' + status.port;
break;
case STATE_STOPPING: text = t('control.status.stopping'); break;
case STATE_STOPPED: text = t('control.status.stopped'); break;
case STATE_ERROR: text = t('control.status.error', { reason: (status.lastError || '').slice(0, 80) }); break;
default: text = s;
}
textEl.textContent = text;
}
// ---------- Server meta ----------
let headerClockTimer = null;
export function updateServerMeta(status) {
const portEl = document.getElementById('meta-port');
const pidEl = document.getElementById('meta-pid');
portEl.textContent = status && status.port ? String(status.port) : '—';
pidEl.textContent = status && status.pid ? String(status.pid) : '—';
// uptime 由 initHeaderClock 定時刷新
}
// ---------- Web UI 連線指示燈(取代 stage 6 的 UI 顯示)----------
// 由 startup-panel 的 connection listener 觸發stage 6 status 變化時呼叫。
// status 取值:'pending' | 'running' | 'completed' | 'done' | 'failed' | 'skipped'
export function setWebUIStatus(status) {
const el = document.getElementById('meta-webui');
if (!el) return;
el.setAttribute('data-state', status || 'pending');
let textKey;
switch (status) {
case 'completed':
case 'done':
textKey = 'control.webui.connected';
break;
case 'running':
textKey = 'control.webui.waiting';
break;
case 'failed':
textKey = 'control.webui.disconnected';
break;
default:
textKey = 'control.webui.waiting';
}
el.textContent = t(textKey);
}
export function initHeaderClock(getServer) {
if (headerClockTimer) clearInterval(headerClockTimer);
const uptimeEl = document.getElementById('meta-uptime');
headerClockTimer = setInterval(() => {
const s = getServer();
if (!s || !s.startedAt || s.state !== STATE_RUNNING) {
uptimeEl.textContent = '—';
return;
}
const ms = Date.now() - s.startedAt;
uptimeEl.textContent = formatUptime(ms);
}, 1000);
}
function formatUptime(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// ---------- Primary controls enable/disable ----------
export function updatePrimaryControls(status) {
const s = status && status.state;
const openBtn = document.getElementById('btn-open-browser');
const startBtn = document.getElementById('btn-start');
const manageBtn = document.getElementById('btn-manage');
const miStop = document.getElementById('mi-stop');
const miRestart = document.getElementById('mi-restart');
openBtn.disabled = s !== STATE_RUNNING;
startBtn.disabled = !(s === STATE_STOPPED || s === STATE_IDLE || s === STATE_ERROR);
manageBtn.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
miStop.disabled = s !== STATE_RUNNING;
miRestart.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
}
// ---------- Error banner ----------
export function showErrorBanner(errorMsg) {
const banner = document.getElementById('error-banner');
const desc = document.getElementById('banner-desc');
desc.textContent = errorMsg || '';
banner.removeAttribute('hidden');
}
export function hideErrorBanner() {
const banner = document.getElementById('error-banner');
banner.setAttribute('hidden', '');
}
// ---------- Primary CTA pulse引導使用者點擊 Open in Browser ----------
// 對齊 Design Spec v2.1 startup-progress.md §4.1stage 6 manual hint mode
// 於 stage 5 skipped 時由 app.js 主動開啟,成功建立 WS 連線或 panel 關閉時取消
export function setPrimaryCTAPulse(enabled) {
const openBtn = document.getElementById('btn-open-browser');
if (!openBtn) return;
if (enabled) {
openBtn.classList.add('pulse-cta');
} else {
openBtn.classList.remove('pulse-cta');
}
}
// ---------- Toast ----------
let toastTimer = null;
export function showToast(message, duration = 3000) {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = message;
el.removeAttribute('hidden');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.setAttribute('hidden', '');
}, duration);
}