jim800121chen 9c9e005d33 feat(local-tool): Stage 3 sub-step 進度 + 啟動完成後面板可收合
回應使用者三項需求:
1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境)
2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次
   啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了
3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷
   史紀錄;Restart / Retry 會重置並展開新一輪

實作:

Go 端
- healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲)
- waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds
- StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API
- startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback
  emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s)

前端
- 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為
  文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint)
- collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新
  API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸
  發 reset+expand
- collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ +
  「點此展開檢視」hint),整個 panel 可點擊;hover 加亮
- i18n 加 6 個 keys(zh-TW + en)

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
- 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary
  + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start
  正常

Windows 首次安裝預期行為(修復後):
- Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage
  3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N
  秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:17:37 +08:00

741 lines
20 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;
}
/* Collapsed mode啟動完成後保留面板但縮成 1 行 summary
* 使用者點擊整個 panel 可以展開回完整 6 階段視圖。
* Restart / Retry 時會 expandStartupPanel() 移除 data-collapsed。
*/
.startup-panel[data-collapsed="true"] {
padding: 8px 16px;
cursor: pointer;
}
.startup-panel[data-collapsed="true"] .startup-title {
margin: 0;
font-size: 13px;
color: var(--fg-muted);
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.startup-panel[data-collapsed="true"] .startup-title::before {
content: '✓';
color: var(--success);
font-weight: 700;
}
.startup-panel[data-collapsed="true"] .startup-title::after {
content: attr(data-collapse-hint);
color: var(--fg-muted);
font-size: 11px;
margin-left: auto;
opacity: 0.7;
}
.startup-panel[data-collapsed="true"] .stages,
.startup-panel[data-collapsed="true"] .progress-row,
.startup-panel[data-collapsed="true"] .startup-error,
.startup-panel[data-collapsed="true"] .sr-only {
display: none;
}
.startup-panel[data-collapsed="true"]:hover {
background: var(--surface-2);
}
.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;
}
/* CSS specificity 修補:`.modal-backdrop` 的 `display: flex` 規則
* specificity = (0,1,0),和 user agent stylesheet 的 `[hidden] { display: none }`
* 相同,但因為寫在後面 cascade 勝出 → 結果是即使 div 有 `hidden` 屬性,
* modal 依然可見。這裡用 (0,2,0) 的規則強制 hidden 生效。
*
* 症狀M8 一開始就踩到但 M7 splash 沒 modal 所以沒人抓到):
* Wails 控制台一打開就同時看到 Settings modal + shutdown-modal 疊在畫面上,
* 即使 DOM 裡兩個都有 `hidden` 屬性。
*
* 同樣問題也影響 `.error-banner`display: flex 覆蓋 hidden所以紅 banner
* 「伺服器無法啟動」也會一開 app 就可見。下面的通用 `[hidden]` 全域規則處理
* 這兩個和未來任何 `.class { display: X }` 被 `hidden` 屬性覆蓋的情況。
*
* 用 !important 是必要的——attribute selector specificity `(0,1,0)` 和 class
* selector 相同,光靠 `.class[hidden]` 要重複寫每個 class。全域 `[hidden]!important`
* 最省事,且符合 HTML 規範「hidden 屬性代表該元素不應被顯示」的語意。
*/
[hidden] { display: none !important; }
.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%; }
}