// 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} const stages = {}; for (let i = 1; i <= TOTAL_STAGES; i++) { stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false }; } // 啟動流程旗標: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 = ` ${i} ·
`; 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 = ''; 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'); // slow hint line // Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint(等待人為動作) 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; // 若有 slow 狀態,顯示 elapsed let elapsedText = ''; for (let i = 1; i <= TOTAL_STAGES; i++) { if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) { const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000); elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed }); break; } } textEl.textContent = elapsedText || 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'); // Error mode 預設隱藏 document.getElementById('startup-error').setAttribute('hidden', ''); } export function hideStartupPanel() { const panel = document.getElementById('startup-panel'); if (!panel) return; panel.setAttribute('hidden', ''); // reset for (let i = 1; i <= TOTAL_STAGES; i++) { stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false }; } if (manualMode) { manualMode = false; emitManualMode(false); } renderStages(); } // ---------- 更新階段(收到 startup:progress)---------- export function updateStage(ev) { if (!ev || !ev.stage) return; const n = ev.stage; if (n < 1 || n > TOTAL_STAGES) return; stages[n].status = ev.status || 'pending'; if (ev.startedAt) stages[n].startedAt = ev.startedAt; if (stages[n].status !== 'running') stages[n].slow = false; // Design Spec §4.1:stage 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; } // ---------- 階段 soft timeout ---------- export function markStageTimeout(ev) { if (!ev || !ev.stage) return; const n = ev.stage; if (!stages[n]) return; // Design Spec §4.1:stage 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'); }