jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 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>
2026-04-15 17:57:54 +08:00

684 lines
18 KiB
CSS
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.

/* visionA-local 控制台 — Design Spec v2.1 對齊
* 設計 tokens 參考 shadcn oklch tokens近似 */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* ---------- Design Tokens ---------- */
:root {
--bg: #ffffff;
--surface-1: #fafafa;
--surface-2: #f4f4f5;
--fg: #111827;
--fg-muted: #6b7280;
--border: #e5e7eb;
--border-strong: #d1d5db;
--primary: #2563eb;
--primary-fg: #ffffff;
--primary-hover: #1d4ed8;
--success: #16a34a;
--warning: #b45309;
--destructive: #b91c1c;
--destructive-soft:#fef2f2;
--focus-ring: rgba(37, 99, 235, 0.35);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Consolas', 'Roboto Mono', monospace;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--surface-1: #17181a;
--surface-2: #1f2022;
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--border: #2a2b2e;
--border-strong: #3a3b3e;
--primary: #3b82f6;
--primary-fg: #ffffff;
--primary-hover: #2563eb;
--success: #22c55e;
--warning: #fbbf24;
--destructive: #f87171;
--destructive-soft:#2a1414;
--focus-ring: rgba(59, 130, 246, 0.45);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.55);
}
}
html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* ---------- Layout ---------- */
.control-panel {
display: flex;
flex-direction: column;
height: 100vh;
min-width: 560px;
}
/* ---------- Header ---------- */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.brand-logo {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.brand-info { flex: 1; min-width: 0; }
.brand-name {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 14px;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--fg-muted);
transition: background 300ms ease-out;
}
.status-dot.state-starting { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-running { background: var(--success); }
.status-dot.state-stopping { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-stopped { background: var(--fg-muted); }
.status-dot.state-idle { background: var(--fg-muted); }
.status-dot.state-error { background: var(--destructive); }
.status-text {
font-weight: 500;
color: var(--fg);
}
.server-meta {
display: flex;
gap: 16px;
margin: 6px 0 0;
padding: 0;
font-size: 12px;
color: var(--fg-muted);
}
.server-meta .meta-item { display: flex; gap: 4px; }
.server-meta dt { display: inline; }
.server-meta dt::after { content: ':'; margin-right: 2px; }
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
.brand-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
font-size: 12px;
color: var(--fg-muted);
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--fg-muted);
font-size: 16px;
}
.icon-btn:hover { background: var(--surface-2); color: var(--fg); }
/* ---------- Primary controls ---------- */
.primary-controls {
display: flex;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
line-height: 1;
min-height: 36px;
transition: background 120ms, border-color 120ms, color 120ms;
}
.btn:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.btn[disabled] { cursor: not-allowed; opacity: 0.45; }
.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; }
.btn-primary {
background: var(--primary);
color: var(--primary-fg);
border-color: var(--primary);
}
.btn-primary:hover:not([disabled]) { background: var(--primary-hover); border-color: var(--primary-hover); }
/* Stage 6 manual hint → 引導使用者點擊 Open in Browser
* 對齊 Design Spec v2.1 startup-progress.md §4.1 */
.btn.pulse-cta:not([disabled]) {
animation: ctaPulse 1.8s ease-in-out infinite;
box-shadow: 0 0 0 0 var(--focus-ring);
}
@keyframes ctaPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.55);
}
50% {
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
}
}
@media (prefers-color-scheme: dark) {
@keyframes ctaPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.65); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}
}
/* Reduced motion改為靜態高亮外框不做動畫 */
@media (prefers-reduced-motion: reduce) {
.btn.pulse-cta:not([disabled]) {
animation: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
}
.btn-outline {
background: transparent;
color: var(--fg);
border-color: var(--border-strong);
}
.btn-outline:hover:not([disabled]) { background: var(--surface-2); }
.btn-ghost {
background: transparent;
color: var(--fg);
border-color: transparent;
}
.btn-ghost:hover:not([disabled]) { background: var(--surface-2); }
/* Manage dropdown */
.manage-wrapper { position: relative; }
.manage-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 10;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--fg);
cursor: pointer;
}
.menu-item:hover:not([disabled]) { background: var(--surface-2); }
.menu-item-danger { color: var(--destructive); }
.menu-item[disabled] { opacity: 0.4; cursor: not-allowed; }
.menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* ---------- Log controls ---------- */
.log-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--fg-muted);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.filter-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
}
.filter-wrapper input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
#level-filter {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
/* ---------- Startup panel ---------- */
.startup-panel {
margin: 12px 16px 0;
padding: 16px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.startup-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.stages {
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-item {
display: grid;
grid-template-columns: 24px 24px 1fr auto;
column-gap: 8px;
row-gap: 2px;
align-items: center;
padding: 4px 0;
transition: opacity 200ms;
}
.stage-item[data-state="pending"] { opacity: 0.6; }
.stage-item[data-state="completed"] { opacity: 0.75; }
.stage-item[data-state="failed"] { background: rgba(185, 28, 28, 0.06); border-radius: var(--radius-sm); padding: 6px 8px; }
.stage-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 14px;
color: var(--fg-muted);
}
.stage-item[data-state="running"] .stage-icon,
.stage-item[data-state="running-slow"] .stage-icon { color: var(--primary); }
.stage-item[data-state="completed"] .stage-icon { color: var(--success); }
.stage-item[data-state="failed"] .stage-icon { color: var(--destructive); }
.stage-number {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.stage-label-primary {
font-size: 13px;
font-weight: 500;
color: var(--fg);
}
.stage-item[data-state="completed"] .stage-label-primary { color: var(--fg-muted); }
.stage-item[data-state="failed"] .stage-label-primary { color: var(--destructive); font-weight: 600; }
.stage-label-secondary {
font-size: 11px;
color: var(--fg-muted);
}
.stage-status {
font-size: 12px;
color: var(--fg-muted);
justify-self: end;
}
.stage-item[data-state="running"] .stage-status { color: var(--primary); }
.stage-item[data-state="completed"] .stage-status { color: var(--success); }
.stage-item[data-state="failed"] .stage-status { color: var(--destructive); }
.stage-hint {
grid-column: 3 / 5;
font-size: 11px;
color: var(--warning);
margin-top: 2px;
}
.stage-hint::before { content: '⚠ '; }
/* Spinner */
.spinner-sm {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
.spinner-lg {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@media (prefers-color-scheme: dark) {
.spinner-sm, .spinner-lg { border-color: rgba(255,255,255,0.12); border-top-color: var(--primary); }
}
.progress-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.progress-bar {
flex: 1;
display: flex;
gap: 2px;
height: 6px;
}
.progress-cell {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 2px;
transition: background 250ms;
}
.progress-cell.state-completed, .progress-cell.state-done, .progress-cell.state-skipped {
background: var(--success);
}
.progress-cell.state-running {
background: var(--primary);
animation: pulse 1.5s ease-in-out infinite;
}
.progress-cell.state-failed { background: var(--destructive); }
.progress-text {
font-size: 11px;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Startup error mode */
.startup-error {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.error-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--destructive);
}
.error-desc, .error-stage {
margin: 6px 0;
font-size: 12px;
color: var(--fg);
}
.error-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
/* ---------- Runtime Error banner ---------- */
.error-banner {
display: flex;
gap: 12px;
margin: 12px 16px 0;
padding: 14px 16px;
background: var(--destructive-soft);
border: 1px solid var(--destructive);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.banner-icon { font-size: 20px; color: var(--destructive); }
.banner-content { flex: 1; }
.banner-title { display: block; font-size: 14px; color: var(--destructive); }
.banner-desc { margin: 4px 0 8px; font-size: 13px; color: var(--fg); }
.banner-actions { display: flex; gap: 8px; }
/* ---------- Log panel ---------- */
.log-panel {
flex: 1;
min-height: 100px;
margin: 12px 16px 0;
position: relative;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
#log-output {
display: block;
height: 100%;
overflow-y: auto;
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--fg);
white-space: pre;
}
.log-line {
display: block;
white-space: pre-wrap;
word-break: break-all;
padding: 1px 0;
border-radius: 2px;
transition: background 200ms;
}
.log-line.flash { background: rgba(185, 28, 28, 0.2); }
.log-ts {
color: var(--fg-muted);
margin-right: 4px;
}
.log-level {
display: inline-block;
font-weight: 600;
width: 48px;
}
.log-line.level-debug .log-level, .log-line.level-debug .log-msg { color: #60a5fa; }
.log-line.level-info .log-level { color: var(--fg-muted); }
.log-line.level-warn .log-level, .log-line.level-warn .log-msg { color: var(--warning); }
.log-line.level-error .log-level, .log-line.level-error .log-msg { color: var(--destructive); }
.log-line.level-plain .log-level { display: none; }
.jump-latest {
position: absolute;
bottom: 12px;
right: 12px;
padding: 6px 12px;
font-size: 11px;
background: var(--primary);
color: var(--primary-fg);
border: none;
border-radius: 16px;
cursor: pointer;
box-shadow: var(--shadow-md);
}
/* ---------- Log actions ---------- */
.log-actions {
display: flex;
gap: 4px;
padding: 8px 16px;
}
/* ---------- Footer ---------- */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 16px 8px;
font-size: 11px;
color: var(--fg-muted);
border-top: 1px solid var(--border);
}
.footer-right { color: var(--warning); }
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 150ms ease-out;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
min-width: 380px;
max-width: 560px;
box-shadow: var(--shadow-md);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
.modal-body { padding: 16px; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.setting-row:last-of-type { border-bottom: none; }
.setting-label { font-size: 13px; font-weight: 500; }
.setting-hint { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.setting-row input[type="checkbox"], .setting-row select {
font-family: inherit;
}
.about-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.about-section h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
.about-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 11px;
margin: 0;
}
.about-list dt { color: var(--fg-muted); }
.about-list dd { margin: 0; color: var(--fg); word-break: break-all; }
/* Shutdown modal */
.shutdown-modal .modal.shutdown-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 24px 40px;
min-width: 280px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 56px;
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
background: rgba(17, 24, 39, 0.92);
color: #fff;
border-radius: var(--radius-md);
font-size: 12px;
box-shadow: var(--shadow-md);
z-index: 200;
max-width: 80%;
word-break: break-all;
animation: fadeIn 150ms ease-out;
}
@media (prefers-color-scheme: dark) {
.toast { background: rgba(240, 240, 240, 0.95); color: #111; }
}
/* ---------- Animations ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.spinner-sm, .spinner-lg { border: 2px solid var(--primary); border-radius: 50%; }
}