回應使用者三項需求:
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>
152 lines
5.7 KiB
JavaScript
152 lines
5.7 KiB
JavaScript
// 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.1(stage 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);
|
||
}
|