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>
205 lines
7.6 KiB
TypeScript
205 lines
7.6 KiB
TypeScript
/**
|
||
* 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` §2(Device 新增 `remoteStatus`)
|
||
* - `.autoflow/04-architecture/TDD.md` §10.1(stores)
|
||
*
|
||
* 職責:
|
||
* - 保存當前使用者可見的裝置列表 + 當前選取裝置詳情
|
||
* - 接 `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;
|
||
/** 連線中的裝置 id(UI 顯示 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 }),
|
||
}));
|