jim800121chen 99dea42239 feat(visionA-frontend): Phase 0 → 0.7 雲端前端(Next.js + OIDC redirect 流程)
visionA 雲端版前端 — 沿用 local-tool 既有 UI(原則 4:先抄 local-tool)+
新增雲端特有的登入 / 配對 / 設定流程,含以下整合階段:

- Phase 0:13 頁 + 30+ 元件 + 雛形 banner
  - dashboard / devices / models / workspace / clusters / settings 等頁
  - AppShell + Sidebar + Header + tokens + i18n(中英雙語 96 keys)
  - API client + 5 stores + 3 hooks
- Phase 0.6:OIDC redirect 改造
  - login 頁改為 OIDC redirect(`window.location.href = /api/auth/login`)
  - register 改說明頁、account 改唯讀(user 資料來源是 MC)
  - api client 改 cookie session(credentials: include)+ 完全清掉 localStorage
- Phase 0.7:stage 部署 + nil guard
  - getApiBaseUrl() 修:browser 環境視為 same-origin(與 login 頁一致)
  - login 頁加「已登入 → router.replace('/')」effect
  - User type email/name 改 optional(MC id_token 不一定回 email/name claim)
  - header.tsx UserMenu displayName 4 層 fallback:name → email → id → i18n
  - 雛形 banner 文案更新(已接 Innovedus 帳號中心)+ 版號 Phase 0.7

驗證:pnpm lint / test (125/125) / build 全綠

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

205 lines
7.6 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.

/**
* Device Store — visionA Cloud
*
* 對齊:
* - `.autoflow/04-architecture/api/api-spec.md` §3 Devices`/api/devices` / `/api/devices/:id` / connect / disconnect
* - `.autoflow/03-design/flows/flow-offline-handling.md` §2Device 新增 `remoteStatus`
* - `.autoflow/04-architecture/TDD.md` §10.1stores
*
* 職責:
* - 保存當前使用者可見的裝置列表 + 當前選取裝置詳情
* - 接 `GET /api/devices` / `GET /api/devices/:id`(雛形後端透過 tunnel forward
* - 提供 connect / disconnect 呼叫的 wrapper純 API 呼叫UI 實際狀態變更由 WS 推送)
*
* F6 範圍(雛形):
* - 不做主動 WS 訂閱 — 待 F7/F8 接 `/ws/devices/events`
* - 後端 501 NOT_IMPLEMENTED 時 fallback 為空 list不打擾 UI
* - `remoteStatus` 欄位若後端未提供,默認 `unknown`(避免誤顯示為 offline
*
* 雛形安全/穩定性備註:
* - 所有錯誤以 `error: string` 記錄,不 throw讓 UI 能用 `{ isLoading, error }` 處理
* - unmount 時不主動 abort雛形保持簡單F7/F8 改用 TanStack Query 或 SWR 時再處理)
*/
"use client";
import { create } from "zustand";
import { ApiError, api } from "@/lib/api";
/* -------------------------------------------------------------------------- */
/* Types — 對齊 api-spec.md §3 + flow-offline-handling.md §2 */
/* -------------------------------------------------------------------------- */
/** 既有 USB / 硬體層級狀態(由 local agent 回報) */
export type DeviceHardwareStatus =
| "detected"
| "connecting"
| "connected"
| "flashing"
| "inferencing"
| "error"
| "disconnected";
/** 遠端 tunnel 層級狀態local agent ↔ 雲端) */
export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown";
/** 列表用的精簡裝置資訊 */
export interface DeviceSummary {
id: string;
name: string;
/** 使用者自訂別名(後端或本機偏好皆可) */
alias?: string;
type: string;
/** USB 層級狀態;離線時可能來自 cache */
status: DeviceHardwareStatus;
/** 遠端 tunnel 狀態flow-offline-handling.md §2 新增) */
remoteStatus: RemoteStatus;
/** ISO 8601最後心跳時間 */
lastSeenAt?: string | null;
firmwareVersion?: string | null;
flashedModel?: string | null;
}
/** 裝置詳情(詳細頁用,比 Summary 多欄) */
export interface Device extends DeviceSummary {
port?: string | null;
hostName?: string | null;
pairedAt?: string | null;
errorMessage?: string | null;
}
/* -------------------------------------------------------------------------- */
/* 後端 snake_case ⇄ 前端 camelCase 正規化 */
/* -------------------------------------------------------------------------- */
/**
* 把後端回傳的裝置物件正規化成前端型別。
* - 接受 snake_case / camelCase 兩種形狀
* - 對缺欄位寬容:`remoteStatus` 預設 `unknown`(而非 offline避免誤判
*/
function normalizeDevice(raw: unknown): Device {
const r = (raw ?? {}) as Record<string, unknown>;
const pick = <T = unknown>(...keys: string[]): T | undefined => {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return r[k] as T;
}
return undefined;
};
return {
id: String(pick<string>("id") ?? ""),
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
alias: pick<string>("alias") ?? undefined,
type: String(pick<string>("type", "device_type") ?? ""),
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
remoteStatus:
(pick<string>("remote_status", "remoteStatus") as RemoteStatus) ?? "unknown",
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
firmwareVersion:
pick<string>("firmware_version", "firmwareVersion") ?? null,
flashedModel: pick<string>("flashed_model", "flashedModel") ?? null,
port: pick<string>("port") ?? null,
hostName: pick<string>("host_name", "hostName") ?? null,
pairedAt: pick<string>("paired_at", "pairedAt") ?? null,
errorMessage: pick<string>("error_message", "errorMessage") ?? null,
};
}
/* -------------------------------------------------------------------------- */
/* Store */
/* -------------------------------------------------------------------------- */
interface DeviceState {
devices: DeviceSummary[];
selectedDevice: Device | null;
isLoading: boolean;
/** 連線中的裝置 idUI 顯示 button spinner不使用就是 null */
connectingId: string | null;
disconnectingId: string | null;
error: string | null;
/** 呼叫 `GET /api/devices` */
fetchDevices: () => Promise<void>;
/** 呼叫 `GET /api/devices/:id` */
fetchDevice: (id: string) => Promise<void>;
/** 呼叫 `POST /api/devices/:id/connect` */
connectDevice: (id: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/disconnect` */
disconnectDevice: (id: string) => Promise<boolean>;
/** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
_setSelected: (device: Device | null) => void;
}
export const useDeviceStore = create<DeviceState>()((set) => ({
devices: [],
selectedDevice: null,
isLoading: false,
connectingId: null,
disconnectingId: null,
error: null,
fetchDevices: async () => {
set({ isLoading: true, error: null });
try {
const raw = await api.get<unknown[]>("/api/devices");
const devices = Array.isArray(raw) ? raw.map(normalizeDevice) : [];
set({ devices, isLoading: false });
} catch (err) {
// 雛形後端 501 / 無 tunnel 時不當嚴重錯誤,給空 list
if (err instanceof ApiError && (err.code === "NOT_IMPLEMENTED" || err.code === "TUNNEL_DISCONNECTED")) {
set({ devices: [], isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
}
},
fetchDevice: async (id) => {
set({ isLoading: true, error: null });
try {
const raw = await api.get<unknown>(`/api/devices/${encodeURIComponent(id)}`);
set({ selectedDevice: normalizeDevice(raw), isLoading: false });
} catch (err) {
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
// 雛形後端未實作 — UI 退回 list 上的最後一筆或顯示 "離線 cache"
set({ isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
}
},
connectDevice: async (id) => {
set({ connectingId: id, error: null });
try {
await api.post(`/api/devices/${encodeURIComponent(id)}/connect`);
set({ connectingId: null });
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
set({ connectingId: null, error: message });
return false;
}
},
disconnectDevice: async (id) => {
set({ disconnectingId: id, error: null });
try {
await api.post(`/api/devices/${encodeURIComponent(id)}/disconnect`);
set({ disconnectingId: null });
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
set({ disconnectingId: null, error: message });
return false;
}
},
_setDevices: (devices) => set({ devices }),
_setSelected: (selectedDevice) => set({ selectedDevice }),
}));