依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
4.8 KiB
JavaScript
127 lines
4.8 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 定時刷新
|
||
}
|
||
|
||
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);
|
||
}
|