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

360 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.

// startup-panel.js — 6 階段啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
import { t } from './i18n.js';
const TOTAL_STAGES = 6;
// 本地狀態stages[1..6] = {status, startedAt, slow, manualHint, detail}
// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位
const stages = {};
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
// 啟動流程旗標stage 5 skipped → stage 6 進入「manual hint」模式
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
let manualMode = false;
// 觀察者:當進入 manual mode 時通知外層app.js加 pulse / enable primary CTA
const manualModeListeners = new Set();
export function onManualModeChange(fn) {
manualModeListeners.add(fn);
return () => manualModeListeners.delete(fn);
}
function emitManualMode(enabled) {
manualModeListeners.forEach((fn) => {
try { fn(enabled); } catch (e) { console.warn('manualMode listener error:', e); }
});
}
// ---------- 渲染 stage 列 skeleton ----------
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
const row = document.createElement('div');
row.className = 'stage-item';
row.dataset.stage = String(i);
row.dataset.state = stages[i].status;
row.innerHTML = `
<span class="stage-icon" aria-hidden="true"></span>
<span class="stage-number">${i} ·</span>
<div class="stage-label">
<div class="stage-label-primary"></div>
<div class="stage-label-secondary"></div>
</div>
<span class="stage-status"></span>
<div class="stage-hint" hidden></div>
`;
container.appendChild(row);
paintStageRow(i);
}
// 更新標題
const titleEl = document.getElementById('startup-title');
if (titleEl) titleEl.textContent = t('startup.panel.title');
paintProgressBar();
}
function paintStageRow(stage) {
const row = document.querySelector(`.stage-item[data-stage="${stage}"]`);
if (!row) return;
const st = stages[stage];
row.dataset.state = st.slow && st.status === 'running' ? 'running-slow' : st.status;
const iconEl = row.querySelector('.stage-icon');
const labelPrimary = row.querySelector('.stage-label-primary');
const labelSecondary = row.querySelector('.stage-label-secondary');
const statusEl = row.querySelector('.stage-status');
const hintEl = row.querySelector('.stage-hint');
// icon
let icon = '○';
switch (st.status) {
case 'pending': icon = '○'; break;
case 'running': icon = '<span class="spinner-sm"></span>'; break;
case 'completed':
case 'done': icon = '✓'; break;
case 'failed': icon = '✕'; break;
case 'skipped': icon = '⏭'; break;
}
iconEl.innerHTML = icon;
// label
labelPrimary.textContent = t(`startup.stage.${stage}.label`);
// Stage 5 skipped → label secondary 顯示 skipped 原因
// Stage 6 manual mode → description 改顯示 manual hint
if (stage === 5 && st.status === 'skipped') {
labelSecondary.textContent = t('startup.stage.5.skipped.label');
} else if (stage === 6 && st.manualHint) {
labelSecondary.textContent = t('startup.stage.6.manualHint');
} else {
labelSecondary.textContent = t(`startup.stage.${stage}.description`);
}
// status text
const statusMap = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
done: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
};
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
// hint line優先順序為 detail細步進度> slow hint > 隱藏
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint等待人為動作
if (st.detail && st.status === 'running') {
hintEl.textContent = st.detail;
hintEl.removeAttribute('hidden');
} else if (st.slow && st.status === 'running' && !st.manualHint) {
hintEl.textContent = t('startup.timeout.message');
hintEl.removeAttribute('hidden');
} else {
hintEl.setAttribute('hidden', '');
}
}
function paintProgressBar() {
const bar = document.getElementById('progress-bar');
const textEl = document.getElementById('progress-text');
if (!bar || !textEl) return;
bar.innerHTML = '';
let completed = 0;
let current = 0;
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
bar.appendChild(cell);
if (stages[i].status === 'completed' || stages[i].status === 'done' || stages[i].status === 'skipped') completed++;
if (stages[i].status === 'running') current = i;
}
const progressNum = current || completed;
// 不顯示已等待秒數 — pause 機制讓 elapsed 計算失準pause 期間 wall clock
// 仍走但 startedAt 沒重設,會顯示明顯比真實還久的數字),且使用者覺得
// 沒必要看秒數。直接顯示 progress N/M 即可slow hint 改由 stage-hint
// 文案傳達。
textEl.textContent = t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
// aria
const panel = document.getElementById('startup-panel');
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
}
// ---------- Panel 顯示 / 收合 / 隱藏 / 重置 ----------
export function showStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.removeAttribute('hidden');
panel.removeAttribute('data-collapsed');
// Error mode 預設隱藏
document.getElementById('startup-error').setAttribute('hidden', '');
}
// collapseStartupPanel 在啟動完成後呼叫:保留 panel 在 DOM 但縮成一行
// summaryStage 都標 ✓,可點擊重新展開)。比 hide 友善——使用者想回顧
// 啟動歷程時點一下就能看到。Restart / Retry 時應呼叫 expandStartupPanel
// 把它打開。
export function collapseStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.removeAttribute('hidden');
panel.setAttribute('data-collapsed', 'true');
panel.setAttribute('data-collapse-hint', t('startup.collapsed.hint'));
// 標題改為「啟動完成」精簡字樣
const titleEl = document.getElementById('startup-title');
if (titleEl) titleEl.textContent = t('startup.collapsed.title');
// 確保 error block 是隱藏的(成功路徑)
const err = document.getElementById('startup-error');
if (err) err.setAttribute('hidden', '');
}
// expandStartupPanel 由「點 collapsed bar」觸發純展開檢視歷史
// 不重置內部狀態 / 不改 title使用者展開後仍是「啟動完成」標題。
export function expandStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.removeAttribute('hidden');
panel.removeAttribute('data-collapsed');
}
// resetStartupPanel重置內部狀態 + DOM render給 Restart 用)
// 注意:不要呼叫 hide因為使用者按 Restart 是想看新一輪啟動進度。
export function resetStartupPanel() {
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
manualMode = false;
emitManualMode(false);
}
renderStages();
expandStartupPanel();
}
// hideStartupPanel 保留為 legacy目前已被 collapseStartupPanel 取代於成功路徑
export function hideStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.setAttribute('hidden', '');
panel.removeAttribute('data-collapsed');
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
manualMode = false;
emitManualMode(false);
}
renderStages();
}
// 階段狀態優先級:愈大愈「前進」。回填 snapshot 時用來避免倒退覆蓋
// 已收到的事件race window 解法的一部分)。
const STAGE_STATUS_RANK = {
pending: 0,
running: 1,
skipped: 2,
failed: 2,
completed: 3,
done: 3,
};
function statusRank(s) {
return STAGE_STATUS_RANK[s] ?? 0;
}
// ---------- 更新階段(收到 startup:progress----------
// monotonic = true 時不會用較舊的狀態覆蓋較新的snapshot 補漏用)
export function updateStage(ev, opts = {}) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (n < 1 || n > TOTAL_STAGES) return;
const newStatus = ev.status || 'pending';
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
// snapshot 帶來的狀態比 event 流的狀態還舊,忽略
return;
}
stages[n].status = newStatus;
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
if (stages[n].status !== 'running') {
stages[n].slow = false;
stages[n].detail = null;
}
// Design Spec §4.1stage 5 skipped → 立即推 stage 6 進入 manual hint mode
// (此時 Go 層也會送 stage 6 running但這裡先行設定 manualHint 旗標,
// 以便收到後續 stage 6 progress 時仍能維持 hint 文案。)
if (n === 5 && stages[5].status === 'skipped') {
enterManualMode();
}
// stage 6 收到 done → 離開 manual mode使用者已成功點擊並建立連線
if (n === 6 && (stages[6].status === 'completed' || stages[6].status === 'done')) {
if (manualMode) {
manualMode = false;
stages[6].manualHint = false;
emitManualMode(false);
}
}
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();
// 無障礙 live region
const live = document.getElementById('startup-live');
if (live) {
const statusKey = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
}[stages[n].status] || 'startup.status.pending';
live.textContent = `${n} · ${t(`startup.stage.${n}.label`)} · ${t(statusKey)}`;
}
}
// ---------- 進入 Manual Hint 模式stage 5 skipped → stage 6 等待人為動作)----------
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
function enterManualMode() {
if (manualMode) return;
manualMode = true;
// 把 stage 6 標成 running + manualHint若 Go 層還沒送 stage 6 running這裡主動 paint
if (stages[6].status === 'pending') {
stages[6].status = 'running';
stages[6].startedAt = Date.now();
}
stages[6].manualHint = true;
// 不套 soft timeout因為是等人為動作
stages[6].slow = false;
paintStageRow(6);
paintProgressBar();
emitManualMode(true);
}
// 外部呼叫:查詢是否處於 manual mode
export function isManualMode() {
return manualMode;
}
// ---------- 階段細步 detail收到 startup:stage-detail----------
// Go 端 EmitStageDetail 送來payload {stage, detailKey, elapsedSeconds}
// 把 i18n key 解析為文案存到 stages[n].detailpaintStageRow 顯示在 stage-hint 欄位。
export function updateStageDetail(ev) {
if (!ev || !ev.stage || !ev.detailKey) return;
const n = ev.stage;
if (!stages[n]) return;
const elapsed = ev.elapsedSeconds || 0;
// 用 i18n 模板({elapsed} 會被 t() 帶入)
stages[n].detail = t(ev.detailKey, { elapsed });
paintStageRow(n);
}
// ---------- 階段 soft timeout ----------
export function markStageTimeout(ev) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (!stages[n]) return;
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint
if (n === 6 && stages[6].manualHint) return;
stages[n].slow = true;
if (!stages[n].startedAt) stages[n].startedAt = Date.now();
paintStageRow(n);
paintProgressBar();
}
// ---------- Error mode ----------
export function showStartupError(ev) {
const errorBlock = document.getElementById('startup-error');
const descEl = document.getElementById('error-desc');
const stageEl = document.getElementById('error-stage');
if (!errorBlock) return;
const cause = ev && ev.cause || 'stage-failure';
const failedStage = ev && ev.stage || 0;
if (cause === 'total-timeout') {
descEl.textContent = t('startup.error.description.timeout');
} else {
const stageLabel = failedStage ? t(`startup.stage.${failedStage}.label`) : '';
descEl.textContent = t('startup.error.description.stageFailed', { stageLabel });
}
if (failedStage) {
stageEl.textContent = t('startup.error.failedStage', {
n: failedStage,
label: t(`startup.stage.${failedStage}.label`),
});
// 標記該階段為 failed
stages[failedStage].status = 'failed';
paintStageRow(failedStage);
paintProgressBar();
}
errorBlock.removeAttribute('hidden');
// 確保 panel 顯示
const panel = document.getElementById('startup-panel');
if (panel) panel.removeAttribute('hidden');
}