visionA/local-tool/frontend/src/stores/firmware-store.ts
jim800121chen 06ff2fe987 feat(local-tool): M9-4 — Frontend FW badge + 升級 modal + WS hot-fix
A 階段第四個 milestone、完整 Frontend FW UI(badge / modal / 8 種 reason 復原)+ backend WS hot-fix(補對稱於 flash 的 firmware WS endpoint)。

Frontend(13 修改 / 7 新檔):
- 新 firmware/ component group (badge / upgrade-button / upgrade-dialog 4-phase / progress-view / error-view 8-reason / index)
- Zustand store (firmware-store.ts) + WS hook (use-firmware-progress.ts) 對齊既有 useFlashProgress pattern
- DeviceCard 整合 FirmwareBadge + FirmwareUpgradeButton
- i18n: settings.firmware.* namespace (對齊 Design Spec §9 SoT) + devices.card.fwBadge.* (zh-TW + en, 57 leaf keys × 2 lang = 114 strings)
- toast.ts ToastOptions interface (duration param)
- types/device.ts: FW 衍生欄位 + FirmwareStage/Reason/ProgressEvent/ActiveTask types

Backend WS hot-fix (3 檔):
- ws/firmware_ws.go (50 行、純對稱 flash_ws.go)
- ws/firmware_ws_test.go (165 行、2 smoke tests: broadcast + room isolation)
- router.go: GET /ws/devices/:id/firmware-progress

關鍵設計:
- R-FW-11 緩解: upgrading phase modal 不可關 (onInteractOutside/onEscapeKeyDown preventDefault + 隱藏 X)
- 多裝置隔離 defense in depth: store handleEvent activeDeviceId mismatch 直接 return
- 8 種 reason → 4 種 UX (recoverable/destructive/brick 警告/contactSupport)
- ContactSupport mailto handler (RFC 6068 + encodeURIComponent)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 3 Major / 8 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 2 Suggestion(接受方案 A、不需 frontend 第 3 輪)
- MJ1 i18n namespace 採方案 A (settings.firmware.*)、Design SoT 優先、Reviewer 同意

測試:
- pnpm test --run: 60 tests pass (32 firmware: 22 store + 10 badge + 新 9 error-view + 19 既有)
- npx tsc --noEmit: 0 error
- pnpm build: production build 成功
- go test ./internal/api/ws/... -race: 1.964s 全綠
- pnpm lint firmware/: 0 hit (17 既有 lint 問題不屬 M9-4、follow-up)

未做(範圍外):
- Settings 韌體面板 (M9-12 B 階段)
- 手動降版 UI (M9-12)
- 版本切換 dropdown (B 階段)
- Wails 控制台 force-quit modal (M9-4.5)

A 階段 MVP 後端 + 前端開發全部完成、剩 M9-4.5 (SIGTERM + Wails OnBeforeClose) + M9-5 (三平台實機驗證)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:57:21 +08:00

274 lines
9.8 KiB
TypeScript
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.

import { create } from 'zustand';
import { api } from '@/lib/api';
import { showApiError } from '@/lib/toast';
import { useActivityStore } from './activity-store';
import type {
FirmwareProgressEvent,
FirmwareStage,
FirmwareReason,
FirmwareActiveTask,
} from '@/types/device';
// FirmwareUpgradeState 描述目前螢幕上 firmware modal 的狀態。
// per-device 隔離activeDeviceId 機制、對齊 flash-store M4 fix
// 避免多 dongle 升級時、Frontend WS 訊息互相蓋掉。
//
// 設計參考design-spec v2.2 §8 狀態機。本期 M9-4 A 階段只實作
// upgrade directiondowngrade B2 階段 M9-12 才開)。
interface FirmwareState {
// 當前正在升級的 device。null = 沒有升級在進行中。
activeDeviceId: string | null;
// last task — 升級啟動時 backend 回的 taskId。診斷用。
activeTaskId: string | null;
// confirming升級確認 modal 顯示中、尚未送 API。
// upgradingAPI 已送出 / WS 正在推進度。
// success完成、toast 已出。
// error失敗、error modal 顯示中。
// idlemodal 關閉、無工作中。
phase: 'idle' | 'confirming' | 'upgrading' | 'success' | 'error';
// 當前最新的 progress event。
progress: FirmwareProgressEvent | null;
// 升級開始時的版本字串(讓 success toast 顯示 from→to
beforeVersion: string | null;
// 升級開始時的目標版本bundled字串。
targetVersion: string | null;
// 升級開始的 wall clockms— 用來算 toast duration。
startedAt: number | null;
// ─── Actions ───
startConfirm: (deviceId: string, beforeVersion: string, targetVersion: string) => void;
cancelConfirm: () => void;
startUpgrade: (deviceId: string) => Promise<{ ok: boolean; taskId?: string; error?: string }>;
handleEvent: (ev: FirmwareProgressEvent) => void;
/** 把 store 重設到 idlemodal 關閉、可開新升級)。 */
reset: () => void;
}
export const useFirmwareStore = create<FirmwareState>((set, get) => ({
activeDeviceId: null,
activeTaskId: null,
phase: 'idle',
progress: null,
beforeVersion: null,
targetVersion: null,
startedAt: null,
startConfirm: (deviceId, beforeVersion, targetVersion) => {
set({
activeDeviceId: deviceId,
phase: 'confirming',
progress: null,
beforeVersion,
targetVersion,
startedAt: null,
activeTaskId: null,
});
},
cancelConfirm: () => {
// 只允許在 confirming 階段取消、進度中不可取消R-FW-11 緩解)。
if (get().phase !== 'confirming') return;
set({
activeDeviceId: null,
phase: 'idle',
progress: null,
beforeVersion: null,
targetVersion: null,
startedAt: null,
activeTaskId: null,
});
},
startUpgrade: async (deviceId) => {
set({ phase: 'upgrading', startedAt: Date.now(), progress: null });
type StartResp = { taskId: string };
const res = await api.post<StartResp>(`/devices/${deviceId}/firmware/upgrade`);
if (!res.success || !res.data) {
const msg = res.error?.message || 'Firmware upgrade failed to start';
showApiError(res.error);
// 啟動失敗:直接進 error phase、給 modal 顯示
set({
phase: 'error',
progress: {
type: 'firmware_progress',
deviceId,
stage: 'error',
percent: -1,
elapsedMs: 0,
error: msg,
errorCode: res.error?.code,
},
});
useActivityStore.getState().addActivity('flash_error', `Firmware upgrade start failed: ${msg}`);
return { ok: false, error: msg };
}
set({ activeTaskId: res.data.taskId });
useActivityStore.getState().addActivity('flash_start', `Firmware upgrade started: ${deviceId}`);
return { ok: true, taskId: res.data.taskId };
},
handleEvent: (ev) => {
// 只處理當前 active device 的 event多裝置同時升級時也只更新對應的
const active = get().activeDeviceId;
if (active !== null && active !== ev.deviceId) {
return;
}
// 記錄事件本身
set({ progress: ev });
if (ev.stage === 'done') {
set({ phase: 'success' });
useActivityStore.getState().addActivity('flash_complete', `Firmware upgraded: ${ev.deviceId}`);
return;
}
if (ev.stage === 'error') {
set({ phase: 'error' });
useActivityStore
.getState()
.addActivity('flash_error', `Firmware upgrade failed: ${ev.error || ev.reason || 'unknown'}`);
return;
}
// preparing / loading / flashing / verifying — phase 維持 upgrading
},
reset: () => {
set({
activeDeviceId: null,
activeTaskId: null,
phase: 'idle',
progress: null,
beforeVersion: null,
targetVersion: null,
startedAt: null,
});
},
}));
// ─── Pure helpers無 React 依賴、可直接 import───
/** 從 stage + reason 推回顯示的 friendly message i18n key對齊 Design §7.1 + §9.8 namespace。 */
export function errorMessageKeyFor(stage: FirmwareStage, reason?: FirmwareReason): string {
if (reason === 'timeout') return 'settings.firmware.error.message.timeout';
if (reason === 'disconnect_during_op') return 'settings.firmware.error.message.disconnect';
if (reason === 'scan_not_found') return 'settings.firmware.error.message.scanNotFound';
if (reason === 'connect_failed') return 'settings.firmware.error.message.connectFailed';
if (reason === 'loader_write_failed') return 'settings.firmware.error.message.loaderWriteFailed';
if (reason === 'upgrade_mid_failed') return 'settings.firmware.error.message.upgradeMidFailed';
if (reason === 'verify_mismatch') return 'settings.firmware.error.message.verifyMismatch';
if (reason === 'verify_not_found') return 'settings.firmware.error.message.verifyNotFound';
// fallback by stage
switch (stage) {
case 'preparing':
return 'settings.firmware.error.message.connectFailed';
case 'loading':
return 'settings.firmware.error.message.loaderWriteFailed';
case 'flashing':
return 'settings.firmware.error.message.upgradeMidFailed';
case 'verifying':
return 'settings.firmware.error.message.verifyMismatch';
default:
return 'settings.firmware.error.message.upgradeMidFailed';
}
}
/**
* canRetry 判定某個 reason 是否可重試Design §7、安全考量
* disconnect / verify_mismatch / verify_not_found 三種屬於 destructive 失敗
* (可能 brick、不提供 Retry 按鈕、只能 ContactSupport。
*/
export function canRetryReason(reason?: FirmwareReason): boolean {
if (!reason) return true;
return (
reason !== 'disconnect_during_op' &&
reason !== 'verify_mismatch' &&
reason !== 'verify_not_found'
);
}
/** 從 reason 決定主要建議動作的 i18n key對齊 Design §9.8 namespace。 */
export function primaryActionKeyFor(reason?: FirmwareReason): string {
if (reason === 'scan_not_found' || reason === 'disconnect_during_op') {
return 'settings.firmware.error.action.replugRetry';
}
if (reason === 'verify_mismatch' || reason === 'verify_not_found') {
return 'settings.firmware.error.action.rescan';
}
if (reason === 'timeout') {
return 'settings.firmware.error.action.rescan';
}
return 'settings.firmware.error.action.retry';
}
/**
* 從 chip type 推估升級時間AC-FW-1.7)。
* KL520 ~ 30s實測、KL720 ~ 180s實測
*
* Fallback 邏輯M9-4 v2 對齊 Reviewer M3 釐清):
* - `undefined` deviceTypecaller 沒拿到 chip 資訊)→ 保守值 60s介於 KL520 與 KL720 之間、避免過度樂觀)
* - 有 deviceType 字串但 toLowerCase 後不含 kl720 → 預設走 KL520 路徑30s
*
* 兩種 fallback 數值不同是刻意的:「不知道有沒有 type」和「有 type 但認不出」資訊量不同。
*/
export function estimatedDurationSeconds(deviceType?: string): number {
if (!deviceType) return 60;
const low = deviceType.toLowerCase();
if (low.includes('kl720')) return 180;
return 30;
}
/** 從後端 stage 算「階段 n / total」— KDP1→KDP2 = 4 階段、KDP2→KDP2 = 3 階段。 */
export function stageOrdinal(
stage: FirmwareStage,
isLegacyUpgrade: boolean,
): { n: number; total: number } {
const total = isLegacyUpgrade ? 4 : 3;
if (isLegacyUpgrade) {
switch (stage) {
case 'preparing':
return { n: 1, total };
case 'loading':
return { n: 2, total };
case 'flashing':
return { n: 3, total };
case 'verifying':
return { n: 4, total };
default:
return { n: total, total };
}
}
switch (stage) {
case 'preparing':
return { n: 1, total };
case 'flashing':
return { n: 2, total };
case 'verifying':
return { n: 3, total };
default:
return { n: total, total };
}
}
// ─── Active tasks API給 M9-4.5 Wails OnBeforeClose 銜接用)───
/**
* 查詢 backend 目前有沒有進行中的 firmware task。
* 主要呼叫時機Wails 控制台 OnBeforeClose拒絕關閉時帶 task 清單給 UI
* Frontend 一般 UI 流程不需要 polling 這個 endpoint、用 WS 即時更新就夠。
*
* 刻意設計為 module-level helper、不放 store action
* - 此函數無 reactive state不更新 zustand store、純 fetch + return
* - 呼叫端是 Wails callback / 一次性查詢、不需 subscribe
* - 放 store 會造成 hook + action 介面雜訊store 該專注 modal state machine
*/
export async function fetchActiveFirmwareTasks(): Promise<{
hasActive: boolean;
tasks: FirmwareActiveTask[];
}> {
type Resp = { hasActive: boolean; tasks: FirmwareActiveTask[] };
const res = await api.get<Resp>('/firmware/active-tasks');
if (res.success && res.data) {
return { hasActive: !!res.data.hasActive, tasks: res.data.tasks || [] };
}
return { hasActive: false, tasks: [] };
}