feat(frontend): WP-C serial 路由落地(ADR-018 FE-A 混合模型)
- DeviceSummary 加 serialNumber(serial_number/serialNumber 雙 key 容錯、 缺欄/空字串 → null) - 五個 proxy 操作識別值換 serial:connect/disconnect、flash POST+進度 WS (單一派生點保 room key 一致)、inference WS、camera start/stop+WS、 media 三 upload - serial 為空全面 disable + 提示(workspace banner/media 占位/flash-dialog/ device-card tooltip/device-detail 兩鈕+序號 InfoRow/選擇頁);i18n 兩語系 - UUID 組維持:fetchDevices/fetchDevice/unpair/詳情頁路由 - C5 偏差(路由段維持 UUID):與 FE-A 定案一致、reviewer 獨立驗證成立 (API 層無 GetBySerial、帶 serial deep-link 必 404);文件回填另派 - evidence:tsc 0 / eslint 0 / next build 15 route / 觸及 11 測試檔 99 passed - review:通過 0C/0M/3Mi/2Sug(.autoflow/05-implementation/review/ wp-c-frontend-serial-routing-review.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
12b1fe3bad
commit
12f5bf6c47
@ -19,6 +19,7 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||||
|
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { LocaleProvider } from "@/lib/i18n/context";
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
import { useDeviceStore, type Device } from "@/stores/device-store";
|
import { useDeviceStore, type Device } from "@/stores/device-store";
|
||||||
|
|
||||||
@ -52,12 +53,16 @@ const onlineDevice: Device = {
|
|||||||
remoteStatus: "online",
|
remoteStatus: "online",
|
||||||
lastSeenAt: "2026-04-21T00:00:00Z",
|
lastSeenAt: "2026-04-21T00:00:00Z",
|
||||||
firmwareVersion: "2.3.1",
|
firmwareVersion: "2.3.1",
|
||||||
|
// WP-C(ADR-018):路由類操作(flash / 工作區)需要 serial
|
||||||
|
serialNumber: "KN00123456",
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderDetail(id = "dev-1") {
|
function renderDetail(id = "dev-1") {
|
||||||
return render(
|
return render(
|
||||||
<LocaleProvider>
|
<LocaleProvider>
|
||||||
|
<TooltipProvider>
|
||||||
<DeviceDetailClient id={id} />
|
<DeviceDetailClient id={id} />
|
||||||
|
</TooltipProvider>
|
||||||
</LocaleProvider>,
|
</LocaleProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -170,3 +175,45 @@ describe("DeviceDetailClient 移除裝置按鈕", () => {
|
|||||||
expect(btn).toHaveTextContent("移除中");
|
expect(btn).toHaveTextContent("移除中");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DeviceDetailClient serial 路由 gating(WP-C / ADR-018)", () => {
|
||||||
|
it("serial 有值:flash 觸發鈕正常、序號欄顯示序號", () => {
|
||||||
|
renderDetail();
|
||||||
|
expect(screen.getByTestId("flash-model-trigger")).toBeEnabled();
|
||||||
|
expect(screen.getByText("KN00123456")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 有值 + 已載入模型 + 在線:顯示開啟工作區連結(href 用 UUID 路由段)", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...onlineDevice, flashedModel: "yolo-v5" },
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
const link = screen.getByRole("link", { name: "開啟工作區" });
|
||||||
|
expect(link).toHaveAttribute("href", "/workspace/dev-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 為空:flash 換成 disabled 按鈕、序號欄顯示未回報", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...onlineDevice, serialNumber: null },
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
expect(screen.queryByTestId("flash-model-trigger")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("flash-no-serial-disabled")).toBeDisabled();
|
||||||
|
expect(screen.getByText("尚未回報序號")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 為空 + 已載入模型 + 在線:工作區入口 disabled(不出現連結)", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: {
|
||||||
|
...onlineDevice,
|
||||||
|
serialNumber: null,
|
||||||
|
flashedModel: "yolo-v5",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: "開啟工作區" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -121,6 +121,9 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
const displayName = selectedDevice.alias || selectedDevice.name;
|
const displayName = selectedDevice.alias || selectedDevice.name;
|
||||||
const isOnline = selectedDevice.remoteStatus === "online";
|
const isOnline = selectedDevice.remoteStatus === "online";
|
||||||
const isOffline = selectedDevice.remoteStatus === "offline";
|
const isOffline = selectedDevice.remoteStatus === "offline";
|
||||||
|
// WP-C(ADR-018):serial 為空 → 路由類操作(flash / 工作區)無法路由,disable。
|
||||||
|
// 詳情頁本身(fetchDevice / unpair)維持 UUID,不受 serial 影響。
|
||||||
|
const hasSerial = !!selectedDevice.serialNumber;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
|
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
|
||||||
@ -166,15 +169,41 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。
|
{/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。
|
||||||
|
WP-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。
|
||||||
flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
|
flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
|
||||||
「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
|
「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
|
||||||
|
{hasSerial ? (
|
||||||
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
|
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button disabled data-testid="flash-no-serial-disabled">
|
||||||
|
{t("devices.flash.flashModel")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOnline && selectedDevice.flashedModel && (
|
{isOnline && selectedDevice.flashedModel && hasSerial && (
|
||||||
<Link href={`/workspace/${selectedDevice.id}`}>
|
<Link href={`/workspace/${selectedDevice.id}`}>
|
||||||
<Button>{t("devices.openWorkspace")}</Button>
|
<Button>{t("devices.openWorkspace")}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{isOnline && selectedDevice.flashedModel && !hasSerial && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button disabled data-testid="workspace-no-serial-disabled">
|
||||||
|
{t("devices.openWorkspace")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@ -253,6 +282,21 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<InfoRow label={t("devices.detail.id")} value={<span className="font-mono text-sm">{selectedDevice.id}</span>} />
|
<InfoRow label={t("devices.detail.id")} value={<span className="font-mono text-sm">{selectedDevice.id}</span>} />
|
||||||
|
{/* WP-C(ADR-018):顯示硬體序號(kn_number);未回報時提示需重新配對 */}
|
||||||
|
<InfoRow
|
||||||
|
label={t("devices.serial.label")}
|
||||||
|
value={
|
||||||
|
selectedDevice.serialNumber ? (
|
||||||
|
<span className="font-mono text-sm">
|
||||||
|
{selectedDevice.serialNumber}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("devices.serial.missing")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
|
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label={t("devices.detail.firmware")}
|
label={t("devices.detail.firmware")}
|
||||||
|
|||||||
@ -60,6 +60,22 @@ function seedOnlineDevice() {
|
|||||||
name: "KL520",
|
name: "KL520",
|
||||||
remoteStatus: "online",
|
remoteStatus: "online",
|
||||||
lastSeenAt: null,
|
lastSeenAt: null,
|
||||||
|
// WP-C(ADR-018):camera/media/WS 操作識別值 = serialNumber
|
||||||
|
serialNumber: "KN00123456",
|
||||||
|
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WP-C:seed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → null)。 */
|
||||||
|
function seedOnlineDeviceWithoutSerial() {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
remoteStatus: "online",
|
||||||
|
lastSeenAt: null,
|
||||||
|
serialNumber: null,
|
||||||
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@ -181,8 +197,11 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1
|
|||||||
await switchTab("圖片");
|
await switchTab("圖片");
|
||||||
|
|
||||||
// handleTabChange 應自動停掉 camera(POST /api/camera/stop)
|
// handleTabChange 應自動停掉 camera(POST /api/camera/stop)
|
||||||
|
// WP-C(ADR-018):body 的 deviceId 欄位帶 serialNumber(非雲端 UUID)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "dev-1" });
|
expect(post).toHaveBeenCalledWith("/api/camera/stop", {
|
||||||
|
deviceId: "KN00123456",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// camera 串流已清(isRunning=false)→ camera 的 useInferenceStream enabled 變 false(不殘留連線)
|
// camera 串流已清(isRunning=false)→ camera 的 useInferenceStream enabled 變 false(不殘留連線)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -214,3 +233,50 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1
|
|||||||
expect(lastCameraWsEnabled()).toBe(false);
|
expect(lastCameraWsEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => {
|
||||||
|
it("camera start 的 body deviceId 帶 serialNumber、camera WS 識別值也是 serialNumber", async () => {
|
||||||
|
post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
|
||||||
|
renderClient();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("開始推論"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(post).toHaveBeenCalledWith("/api/camera/start", {
|
||||||
|
deviceId: "KN00123456",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// camera WS 的第一個參數(識別值)= serial,且 enabled=true
|
||||||
|
const wsCall = vi.mocked(useInferenceStream).mock.calls.at(-1);
|
||||||
|
expect(wsCall?.[0]).toBe("KN00123456");
|
||||||
|
expect(wsCall?.[1]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 為空:開始推論 disabled + 顯示 no-serial banner、不打 camera start", () => {
|
||||||
|
seedOnlineDeviceWithoutSerial();
|
||||||
|
renderClient();
|
||||||
|
|
||||||
|
const startBtn = screen.getByText("開始推論").closest("button");
|
||||||
|
expect(startBtn).toBeDisabled();
|
||||||
|
expect(screen.getByTestId("workspace-no-serial-banner")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("開始推論"));
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 為空:media tab 顯示 no-serial 提示、不出現上傳區", async () => {
|
||||||
|
seedOnlineDeviceWithoutSerial();
|
||||||
|
renderClient();
|
||||||
|
|
||||||
|
await switchTab("圖片");
|
||||||
|
expect(screen.getByTestId("media-no-serial")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("media-uploader-image")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serial 有值:media tab 正常顯示上傳區", async () => {
|
||||||
|
renderClient();
|
||||||
|
await switchTab("圖片");
|
||||||
|
expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -20,6 +20,14 @@
|
|||||||
*
|
*
|
||||||
* 重要:雲端版 Workspace 對「裝置離線」極敏感 —
|
* 重要:雲端版 Workspace 對「裝置離線」極敏感 —
|
||||||
* 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。
|
* 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。
|
||||||
|
*
|
||||||
|
* ADR-018 serial 路由(WP-C):
|
||||||
|
* - 路由到 local agent 的操作(camera start/stop、media upload、inference WS)
|
||||||
|
* 識別值一律帶 `device.serialNumber`(kn_number),不是雲端 UUID。
|
||||||
|
* - 路由段 `[deviceId]` 仍是雲端 UUID(fetchDevice 是純 DB 操作、FE-A 混合模型),
|
||||||
|
* serial 從 fetch 回來的 selectedDevice 取。
|
||||||
|
* - serial 為空(舊資料未回報 / 假序號)→ 無法路由:顯示提示 banner、
|
||||||
|
* 「開始推論」disable、media tab 顯示提示不給上傳。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@ -101,6 +109,11 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
// 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前)
|
// 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前)
|
||||||
const isOnline = selectedDevice?.remoteStatus === "online";
|
const isOnline = selectedDevice?.remoteStatus === "online";
|
||||||
|
|
||||||
|
// WP-C(ADR-018):路由到 local agent 的操作一律用 serial 當識別值。
|
||||||
|
// serial 為空(舊資料 / 未回報序號)→ 操作全部 disable(無法路由)。
|
||||||
|
const serialNumber = selectedDevice?.serialNumber ?? null;
|
||||||
|
const hasSerial = !!serialNumber;
|
||||||
|
|
||||||
// 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。
|
// 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@ -112,25 +125,32 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
[isRunning],
|
[isRunning],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 塊 2/3:訂閱 camera WS `inference:<deviceId>`(只在 camera tab + 推論中 + 線上時連線)。
|
// 塊 2/3:訂閱 camera WS `inference:<serial>`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。
|
||||||
// media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。
|
// media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。
|
||||||
useInferenceStream(deviceId, activeTab === "camera" && isRunning && isOnline);
|
// WP-C:WS 識別值用 serial(與 camera start 的 body deviceId 一致,room key 才對得上)。
|
||||||
|
useInferenceStream(
|
||||||
|
serialNumber ?? "",
|
||||||
|
activeTab === "camera" && isRunning && isOnline && hasSerial,
|
||||||
|
);
|
||||||
|
|
||||||
// 塊 2:最新結果的偵測框(供 overlay 繪製)
|
// 塊 2:最新結果的偵測框(供 overlay 繪製)
|
||||||
const liveResult = useInferenceStore((s) => s.result);
|
const liveResult = useInferenceStore((s) => s.result);
|
||||||
const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold);
|
const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold);
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
|
// WP-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。
|
||||||
|
if (!serialNumber) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start
|
// 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start
|
||||||
// body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125)
|
// body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125)
|
||||||
|
// WP-C(ADR-018):deviceId 欄位帶 serial(欄位名不動、值換 serialNumber)。
|
||||||
const data = await api.post<MediaUploadResponse>("/api/camera/start", {
|
const data = await api.post<MediaUploadResponse>("/api/camera/start", {
|
||||||
deviceId,
|
deviceId: serialNumber,
|
||||||
});
|
});
|
||||||
const url = data?.streamUrl ?? "/api/camera/stream";
|
const url = data?.streamUrl ?? "/api/camera/stream";
|
||||||
// cache-bust:每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線
|
// cache-bust:每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線
|
||||||
setStreamUrl(buildStreamUrl(url, `${deviceId}-${Date.now()}`));
|
setStreamUrl(buildStreamUrl(url, `${serialNumber}-${Date.now()}`));
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 後端尚未接上 proxy 時可能 501;不當致命錯誤,僅提示
|
// 後端尚未接上 proxy 時可能 501;不當致命錯誤,僅提示
|
||||||
@ -147,7 +167,11 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/api/camera/stop", { deviceId });
|
// WP-C(ADR-018):與 start 一致,deviceId 欄位帶 serial。
|
||||||
|
// serial 為空時不可能已 start(handleStart 有 guard),直接清本地串流即可。
|
||||||
|
if (serialNumber) {
|
||||||
|
await api.post("/api/camera/stop", { deviceId: serialNumber });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
|
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
@ -179,6 +203,20 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
// 用 render 期衍生而非 effect + setState,避免 cascading render。
|
// 用 render 期衍生而非 effect + setState,避免 cascading render。
|
||||||
const effectiveStreamUrl = isOnline ? streamUrl : "";
|
const effectiveStreamUrl = isOnline ? streamUrl : "";
|
||||||
|
|
||||||
|
// WP-C:media tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial)。
|
||||||
|
const noSerialNotice = (
|
||||||
|
<Card className="min-h-[60vh]">
|
||||||
|
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
|
||||||
|
<div className="max-w-md space-y-2 text-center" data-testid="media-no-serial">
|
||||||
|
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("workspace.noSerial.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
|
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@ -206,13 +244,30 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
{t("workspace.inference.stop")}
|
{t("workspace.inference.stop")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleStart} disabled={busy || !isOnline}>
|
// WP-C:serial 為空無法路由 → disable(原因見下方 no-serial banner)
|
||||||
|
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
|
||||||
{t("workspace.inference.start")}
|
{t("workspace.inference.start")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WP-C(ADR-018):serial 為空 → 無法路由到 local agent,說明原因與解法 */}
|
||||||
|
{device && !hasSerial && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
|
||||||
|
data-testid="workspace-no-serial-banner"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
|
||||||
|
<p className="text-xs opacity-90">
|
||||||
|
{t("workspace.noSerial.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
|
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
|
||||||
@ -266,10 +321,14 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) */}
|
{/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel)
|
||||||
|
WP-C(ADR-018):上傳識別值帶 serial;serial 為空 → 顯示提示、不給上傳。 */}
|
||||||
<TabsContent value="image" className="space-y-4">
|
<TabsContent value="image" className="space-y-4">
|
||||||
|
{!hasSerial ? (
|
||||||
|
noSerialNotice
|
||||||
|
) : (
|
||||||
<MediaTab
|
<MediaTab
|
||||||
deviceId={deviceId}
|
deviceId={serialNumber!}
|
||||||
isOnline={!!isOnline}
|
isOnline={!!isOnline}
|
||||||
sourceType="image"
|
sourceType="image"
|
||||||
accept={IMAGE_ACCEPT}
|
accept={IMAGE_ACCEPT}
|
||||||
@ -277,17 +336,21 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
|
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
|
||||||
}
|
}
|
||||||
upload={async (files, ctx) => {
|
upload={async (files, ctx) => {
|
||||||
return uploadImage(deviceId, files[0]!, {
|
return uploadImage(serialNumber!, files[0]!, {
|
||||||
onProgress: ctx.onProgress,
|
onProgress: ctx.onProgress,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="video" className="space-y-4">
|
<TabsContent value="video" className="space-y-4">
|
||||||
|
{!hasSerial ? (
|
||||||
|
noSerialNotice
|
||||||
|
) : (
|
||||||
<MediaTab
|
<MediaTab
|
||||||
deviceId={deviceId}
|
deviceId={serialNumber!}
|
||||||
isOnline={!!isOnline}
|
isOnline={!!isOnline}
|
||||||
sourceType="video"
|
sourceType="video"
|
||||||
accept={VIDEO_ACCEPT}
|
accept={VIDEO_ACCEPT}
|
||||||
@ -295,30 +358,35 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
|
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
|
||||||
}
|
}
|
||||||
upload={async (files, ctx) => {
|
upload={async (files, ctx) => {
|
||||||
return uploadVideo(deviceId, files[0]!, {
|
return uploadVideo(serialNumber!, files[0]!, {
|
||||||
onProgress: ctx.onProgress,
|
onProgress: ctx.onProgress,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="batch" className="space-y-4">
|
<TabsContent value="batch" className="space-y-4">
|
||||||
|
{!hasSerial ? (
|
||||||
|
noSerialNotice
|
||||||
|
) : (
|
||||||
<MediaTab
|
<MediaTab
|
||||||
deviceId={deviceId}
|
deviceId={serialNumber!}
|
||||||
isOnline={!!isOnline}
|
isOnline={!!isOnline}
|
||||||
sourceType="batch_image"
|
sourceType="batch_image"
|
||||||
accept={IMAGE_ACCEPT}
|
accept={IMAGE_ACCEPT}
|
||||||
multiple
|
multiple
|
||||||
validate={validateBatchFiles}
|
validate={validateBatchFiles}
|
||||||
upload={async (files, ctx) => {
|
upload={async (files, ctx) => {
|
||||||
return uploadBatchImages(deviceId, files, {
|
return uploadBatchImages(serialNumber!, files, {
|
||||||
onProgress: ctx.onProgress,
|
onProgress: ctx.onProgress,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@ -53,9 +53,18 @@ export default function WorkspaceIndexPage() {
|
|||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
data-testid="workspace-device-grid"
|
data-testid="workspace-device-grid"
|
||||||
>
|
>
|
||||||
{online.map((d) => (
|
{online.map((d) => {
|
||||||
<Link key={d.id} href={`/workspace/${d.id}`}>
|
// WP-C(ADR-018):serial 為空的裝置無法路由(推論類操作全不可用),
|
||||||
<Card className="hover:bg-accent cursor-pointer transition-colors">
|
// 卡片不可點、按鈕 disable、顯示原因。
|
||||||
|
const hasSerial = !!d.serialNumber;
|
||||||
|
const card = (
|
||||||
|
<Card
|
||||||
|
className={
|
||||||
|
hasSerial
|
||||||
|
? "hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
: "opacity-75"
|
||||||
|
}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardTitle className="truncate text-base">
|
<CardTitle className="truncate text-base">
|
||||||
@ -76,13 +85,27 @@ export default function WorkspaceIndexPage() {
|
|||||||
{d.flashedModel}
|
{d.flashedModel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" className="mt-3">
|
<Button size="sm" className="mt-3" disabled={!hasSerial}>
|
||||||
{t("workspace.inference.start")}
|
{t("workspace.inference.start")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!hasSerial && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
{t("devices.serial.missingHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
return hasSerial ? (
|
||||||
|
<Link key={d.id} href={`/workspace/${d.id}`}>
|
||||||
|
{card}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
) : (
|
||||||
|
<div key={d.id} data-testid="workspace-card-no-serial">
|
||||||
|
{card}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
65
visionA-frontend/src/components/devices/device-card.test.tsx
Normal file
65
visionA-frontend/src/components/devices/device-card.test.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* DeviceCard 測試 — WP-C(ADR-018)serial 路由 gating
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - online + 已載入模型 + 有 serial → 顯示「開啟工作區」連結(href 用 UUID 路由段)
|
||||||
|
* - online + 已載入模型 + 無 serial → 工作區入口 disabled(不出現連結)+ 可理解提示
|
||||||
|
* - 離線 → 維持既有行為(不顯示工作區入口)
|
||||||
|
*/
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
|
|
||||||
|
import { DeviceCard } from "./device-card";
|
||||||
|
|
||||||
|
const baseDevice: DeviceSummary = {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520 Dev Kit",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remoteStatus: "online",
|
||||||
|
flashedModel: "yolo-v5",
|
||||||
|
serialNumber: "KN00123456",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderCard(device: DeviceSummary) {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<DeviceCard device={device} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => {
|
||||||
|
it("online + 已載入模型 + 有 serial → 顯示工作區連結(路由段用 UUID)", () => {
|
||||||
|
renderCard(baseDevice);
|
||||||
|
const link = screen.getByRole("link", { name: "開啟工作區" });
|
||||||
|
expect(link).toHaveAttribute("href", "/workspace/dev-1");
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("workspace-no-serial-disabled"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online + 已載入模型 + 無 serial → 工作區入口 disabled、不出現連結", () => {
|
||||||
|
renderCard({ ...baseDevice, serialNumber: null });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: "開啟工作區" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("離線 → 不顯示工作區入口(既有行為不變)", () => {
|
||||||
|
renderCard({ ...baseDevice, remoteStatus: "offline" });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: "開啟工作區" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("workspace-no-serial-disabled"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,6 +14,8 @@
|
|||||||
* - 右上角狀態徽章改為 `RemoteDeviceBadge`(雲端版語意)
|
* - 右上角狀態徽章改為 `RemoteDeviceBadge`(雲端版語意)
|
||||||
* - 離線(remoteStatus != online)時卡片 opacity-75 + 操作按鈕 disabled
|
* - 離線(remoteStatus != online)時卡片 opacity-75 + 操作按鈕 disabled
|
||||||
* - 「工作區」按鈕只在 remoteStatus=online 時顯示(flow-offline-handling §4.1)
|
* - 「工作區」按鈕只在 remoteStatus=online 時顯示(flow-offline-handling §4.1)
|
||||||
|
* - WP-C(ADR-018):工作區入口另需 `hasSerial`——serial 為空的裝置無法路由到
|
||||||
|
* local agent(推論 / camera / 上傳全不可用),入口改 disabled + tooltip 說明
|
||||||
* - 移除 local-tool 的 connect/disconnect 按鈕(雲端版這些走 `/devices/[id]` 詳情頁操作)
|
* - 移除 local-tool 的 connect/disconnect 按鈕(雲端版這些走 `/devices/[id]` 詳情頁操作)
|
||||||
* - 卡片本體包成 Link 到 `/devices/[id]`(design-review M3 統一 hover 規格)
|
* - 卡片本體包成 Link 到 `/devices/[id]`(design-review M3 統一 hover 規格)
|
||||||
*/
|
*/
|
||||||
@ -23,6 +25,11 @@ import Link from "next/link";
|
|||||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DeviceSummary } from "@/stores/device-store";
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
@ -35,6 +42,8 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const displayName = device.alias || device.name;
|
const displayName = device.alias || device.name;
|
||||||
const isOnline = device.remoteStatus === "online";
|
const isOnline = device.remoteStatus === "online";
|
||||||
|
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
|
||||||
|
const hasSerial = !!device.serialNumber;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -84,11 +93,23 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
|||||||
{t("common.manage")}
|
{t("common.manage")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isOnline && device.flashedModel && (
|
{isOnline && device.flashedModel && hasSerial && (
|
||||||
<Link href={`/workspace/${device.id}`}>
|
<Link href={`/workspace/${device.id}`}>
|
||||||
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{isOnline && device.flashedModel && !hasSerial && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<Button size="sm" disabled data-testid="workspace-no-serial-disabled">
|
||||||
|
{t("devices.openWorkspace")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -42,6 +42,8 @@ const device: Device = {
|
|||||||
status: "connected",
|
status: "connected",
|
||||||
remoteStatus: "online",
|
remoteStatus: "online",
|
||||||
flashedModel: null,
|
flashedModel: null,
|
||||||
|
// WP-C(ADR-018):flash POST / 進度 WS 的識別值 = serialNumber
|
||||||
|
serialNumber: "KN00123456",
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderDialog(props?: { disabled?: boolean }) {
|
function renderDialog(props?: { disabled?: boolean }) {
|
||||||
@ -106,6 +108,20 @@ describe("FlashDialog", () => {
|
|||||||
expect(mockStop).toHaveBeenCalled();
|
expect(mockStop).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serial 為空(WP-C)→ 開啟 dialog 後開始按鈕 disabled 且顯示未回報序號", () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
selectedDevice: { ...device, serialNumber: null },
|
||||||
|
});
|
||||||
|
renderDialog();
|
||||||
|
fireEvent.click(screen.getByTestId("flash-model-trigger"));
|
||||||
|
|
||||||
|
const startBtn = screen.getByTestId("flash-start-btn");
|
||||||
|
expect(startBtn).toBeDisabled();
|
||||||
|
expect(startBtn).toHaveTextContent("尚未回報序號");
|
||||||
|
fireEvent.click(startBtn);
|
||||||
|
expect(mockBeginFlash).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("flash 失敗 → 顯示錯誤 + 關閉按鈕;點關閉不 fetchDevice", () => {
|
it("flash 失敗 → 顯示錯誤 + 關閉按鈕;點關閉不 fetchDevice", () => {
|
||||||
const fetchDevice = vi.fn();
|
const fetchDevice = vi.fn();
|
||||||
useDeviceStore.setState({ fetchDevice });
|
useDeviceStore.setState({ fetchDevice });
|
||||||
|
|||||||
@ -50,6 +50,11 @@ import { useFlashStore } from "@/stores/flash-store";
|
|||||||
import { useModelStore } from "@/stores/model-store";
|
import { useModelStore } from "@/stores/model-store";
|
||||||
|
|
||||||
interface FlashDialogProps {
|
interface FlashDialogProps {
|
||||||
|
/**
|
||||||
|
* 雲端 device UUID——用於 selectedDevice 比對與 flash 後 fetchDevice(DB 操作、
|
||||||
|
* FE-A 混合模型維持 UUID)。flash POST 與進度 WS 的識別值則用裝置的
|
||||||
|
* serialNumber(ADR-018 serial 路由),由本元件從 selectedDevice 取。
|
||||||
|
*/
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
/** 裝置離線時不可 flash,disable 觸發鈕(呼叫端傳入)。 */
|
/** 裝置離線時不可 flash,disable 觸發鈕(呼叫端傳入)。 */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -72,10 +77,15 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) {
|
|||||||
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
||||||
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
||||||
|
|
||||||
const { beginFlash, stop } = useFlashProgress(deviceId);
|
|
||||||
|
|
||||||
// device 從 selectedDevice 取(detail 頁已 fetch);防呆用 id 比對。
|
// device 從 selectedDevice 取(detail 頁已 fetch);防呆用 id 比對。
|
||||||
const device = selectedDevice?.id === deviceId ? selectedDevice : null;
|
const device = selectedDevice?.id === deviceId ? selectedDevice : null;
|
||||||
|
// WP-C(ADR-018):flash POST + 進度 WS 的識別值用 serial(兩者必須一致,
|
||||||
|
// local agent 端 room key 才對得上)。serial 為空 → 不可 flash(下方按鈕 disable;
|
||||||
|
// 呼叫端 device-detail 也已 disable 觸發鈕,此為雙重防護)。
|
||||||
|
const serialNumber = device?.serialNumber ?? null;
|
||||||
|
|
||||||
|
const { beginFlash, stop } = useFlashProgress(serialNumber ?? "");
|
||||||
|
|
||||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||||
|
|
||||||
const compatible = useMemo(() => {
|
const compatible = useMemo(() => {
|
||||||
@ -102,7 +112,8 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) {
|
|||||||
const done = (progress && progress.percent >= 100) || error !== null;
|
const done = (progress && progress.percent >= 100) || error !== null;
|
||||||
|
|
||||||
const handleFlash = () => {
|
const handleFlash = () => {
|
||||||
if (!selectedModelId || !compatible) return;
|
// WP-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。
|
||||||
|
if (!selectedModelId || !compatible || !serialNumber) return;
|
||||||
// 契約差異 B:只呼叫 beginFlash(設 enabled + onOpen 觸發 POST),不在此直接 POST。
|
// 契約差異 B:只呼叫 beginFlash(設 enabled + onOpen 觸發 POST),不在此直接 POST。
|
||||||
beginFlash(selectedModelId);
|
beginFlash(selectedModelId);
|
||||||
};
|
};
|
||||||
@ -183,11 +194,13 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFlash}
|
onClick={handleFlash}
|
||||||
disabled={!selectedModelId || !compatible}
|
disabled={!selectedModelId || !compatible || !serialNumber}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid="flash-start-btn"
|
data-testid="flash-start-btn"
|
||||||
>
|
>
|
||||||
{!selectedModelId
|
{!serialNumber
|
||||||
|
? t("devices.serial.missing")
|
||||||
|
: !selectedModelId
|
||||||
? t("devices.flash.selectModel")
|
? t("devices.flash.selectModel")
|
||||||
: !compatible
|
: !compatible
|
||||||
? t("devices.flash.incompatibleCannotFlash")
|
? t("devices.flash.incompatibleCannotFlash")
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
* **本 hook 的解法(選項 2,複用最多、無 race)**:
|
* **本 hook 的解法(選項 2,複用最多、無 race)**:
|
||||||
* 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設
|
* 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設
|
||||||
* `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(deviceId, modelId)`,
|
* `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(serialNumber, modelId)`,
|
||||||
* 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。
|
* 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。
|
||||||
*
|
*
|
||||||
* **重連防重複 POST(`hasStartedRef`)**:
|
* **重連防重複 POST(`hasStartedRef`)**:
|
||||||
@ -42,7 +42,15 @@ interface UseFlashProgress {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFlashProgress(deviceId: string): UseFlashProgress {
|
/**
|
||||||
|
* @param serialNumber 目標裝置的硬體序號(kn_number)。
|
||||||
|
*
|
||||||
|
* ⚠️ ADR-018 serial 路由:WS path 與 flash POST 的識別值都必須用 serialNumber,
|
||||||
|
* 兩者要一致——local agent 端 flash 進度是 broadcast 到以「請求帶入的識別值」
|
||||||
|
* 為 key 的 room,POST 帶 serial、WS 帶 UUID 會收不到進度。
|
||||||
|
* serial 為空的裝置不可 beginFlash(呼叫端 FlashDialog disable)。
|
||||||
|
*/
|
||||||
|
export function useFlashProgress(serialNumber: string): UseFlashProgress {
|
||||||
const updateProgress = useFlashStore((s) => s.updateProgress);
|
const updateProgress = useFlashStore((s) => s.updateProgress);
|
||||||
const startFlash = useFlashStore((s) => s.startFlash);
|
const startFlash = useFlashStore((s) => s.startFlash);
|
||||||
|
|
||||||
@ -60,8 +68,8 @@ export function useFlashProgress(deviceId: string): UseFlashProgress {
|
|||||||
const modelId = modelIdRef.current;
|
const modelId = modelIdRef.current;
|
||||||
if (!modelId) return;
|
if (!modelId) return;
|
||||||
hasStartedRef.current = true;
|
hasStartedRef.current = true;
|
||||||
void startFlash(deviceId, modelId);
|
void startFlash(serialNumber, modelId);
|
||||||
}, [deviceId, startFlash]);
|
}, [serialNumber, startFlash]);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
@ -80,7 +88,7 @@ export function useFlashProgress(deviceId: string): UseFlashProgress {
|
|||||||
[updateProgress],
|
[updateProgress],
|
||||||
);
|
);
|
||||||
|
|
||||||
useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/flash-progress`, {
|
useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/flash-progress`, {
|
||||||
enabled,
|
enabled,
|
||||||
onOpen: handleOpen,
|
onOpen: handleOpen,
|
||||||
onMessage: handleMessage,
|
onMessage: handleMessage,
|
||||||
|
|||||||
@ -24,14 +24,17 @@ import { useInferenceStore } from "@/stores/inference-store";
|
|||||||
import type { InferenceResult } from "@/types/inference";
|
import type { InferenceResult } from "@/types/inference";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param deviceId 目標裝置
|
* @param serialNumber 目標裝置的硬體序號(kn_number)。
|
||||||
* @param enabled false 時不連線(例如尚未開始推論 / 裝置離線)
|
* ⚠️ ADR-018 serial 路由:識別值必須與 camera start / media upload 帶的
|
||||||
|
* `deviceId`(= serial)一致——local agent 端 room key 是 `inference:<請求識別值>`,
|
||||||
|
* 兩邊不一致會收不到結果。serial 為空時呼叫端必須傳 enabled=false。
|
||||||
|
* @param enabled false 時不連線(例如尚未開始推論 / 裝置離線 / 無 serial)
|
||||||
*/
|
*/
|
||||||
export function useInferenceStream(deviceId: string, enabled = false): void {
|
export function useInferenceStream(serialNumber: string, enabled = false): void {
|
||||||
const addResult = useInferenceStore((s) => s.addResult);
|
const addResult = useInferenceStore((s) => s.addResult);
|
||||||
const addBatchResult = useInferenceStore((s) => s.addBatchResult);
|
const addBatchResult = useInferenceStore((s) => s.addBatchResult);
|
||||||
|
|
||||||
useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/inference`, {
|
useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/inference`, {
|
||||||
enabled,
|
enabled,
|
||||||
onMessage: (data) => {
|
onMessage: (data) => {
|
||||||
// pipeline 完成事件(batch/video 跑完)— 目前不需特別處理,忽略非結果訊息
|
// pipeline 完成事件(batch/video 跑完)— 目前不需特別處理,忽略非結果訊息
|
||||||
|
|||||||
@ -160,6 +160,12 @@ export const en: Dictionary = {
|
|||||||
"devices.status.error": "Error",
|
"devices.status.error": "Error",
|
||||||
"devices.status.disconnected": "Disconnected",
|
"devices.status.disconnected": "Disconnected",
|
||||||
|
|
||||||
|
// ── Devices: serial number (serial routing, ADR-018 / WP-C) ──
|
||||||
|
"devices.serial.label": "Serial number",
|
||||||
|
"devices.serial.missing": "Serial not reported yet",
|
||||||
|
"devices.serial.missingHint":
|
||||||
|
"This device hasn't reported its serial number, so inference-related actions are unavailable. Re-pair it once from local agent to report the serial.",
|
||||||
|
|
||||||
// ── Devices: remove (unpair) ──
|
// ── Devices: remove (unpair) ──
|
||||||
"devices.remove.action": "Remove device",
|
"devices.remove.action": "Remove device",
|
||||||
"devices.remove.removing": "Removing…",
|
"devices.remove.removing": "Removing…",
|
||||||
@ -317,6 +323,9 @@ export const en: Dictionary = {
|
|||||||
"workspace.offline.description":
|
"workspace.offline.description":
|
||||||
"The connection to {deviceName} was lost; inference has been stopped.",
|
"The connection to {deviceName} was lost; inference has been stopped.",
|
||||||
"workspace.offline.backToList": "Back to devices",
|
"workspace.offline.backToList": "Back to devices",
|
||||||
|
"workspace.noSerial.title": "This device hasn't reported a serial number",
|
||||||
|
"workspace.noSerial.description":
|
||||||
|
"Inference, camera, and media upload need the device serial to route to local agent. Re-pair the device once from local agent; these actions unlock after the serial is reported.",
|
||||||
"workspace.tabs.camera": "Camera",
|
"workspace.tabs.camera": "Camera",
|
||||||
"workspace.tabs.image": "Image",
|
"workspace.tabs.image": "Image",
|
||||||
"workspace.tabs.video": "Video",
|
"workspace.tabs.video": "Video",
|
||||||
|
|||||||
@ -161,6 +161,12 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.status.error": "錯誤",
|
"devices.status.error": "錯誤",
|
||||||
"devices.status.disconnected": "未連接",
|
"devices.status.disconnected": "未連接",
|
||||||
|
|
||||||
|
// ── Devices: 序號(serial 路由,ADR-018 / WP-C) ──
|
||||||
|
"devices.serial.label": "序號",
|
||||||
|
"devices.serial.missing": "尚未回報序號",
|
||||||
|
"devices.serial.missingHint":
|
||||||
|
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。",
|
||||||
|
|
||||||
// ── Devices: 移除裝置(unpair) ──
|
// ── Devices: 移除裝置(unpair) ──
|
||||||
"devices.remove.action": "移除裝置",
|
"devices.remove.action": "移除裝置",
|
||||||
"devices.remove.removing": "移除中…",
|
"devices.remove.removing": "移除中…",
|
||||||
@ -306,6 +312,9 @@ export const zhHant: Dictionary = {
|
|||||||
"workspace.offline.title": "裝置已離線",
|
"workspace.offline.title": "裝置已離線",
|
||||||
"workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止",
|
"workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止",
|
||||||
"workspace.offline.backToList": "返回裝置列表",
|
"workspace.offline.backToList": "返回裝置列表",
|
||||||
|
"workspace.noSerial.title": "此裝置尚未回報序號",
|
||||||
|
"workspace.noSerial.description":
|
||||||
|
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。",
|
||||||
"workspace.tabs.camera": "Camera",
|
"workspace.tabs.camera": "Camera",
|
||||||
"workspace.tabs.image": "圖片",
|
"workspace.tabs.image": "圖片",
|
||||||
"workspace.tabs.video": "影片",
|
"workspace.tabs.video": "影片",
|
||||||
|
|||||||
@ -202,38 +202,46 @@ function postMultipart(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ⚠️ ADR-018 serial 路由:下方三個 upload 函式的裝置識別參數是
|
||||||
|
* `serialNumber`(kn_number),不是雲端 device UUID——local agent 端
|
||||||
|
* `camera_handler.go` 以 body 的 `deviceId` 查 sessions(serial 反查),帶 UUID 會
|
||||||
|
* "device not found"。FormData 欄位名維持 `deviceId`(傳輸結構不動、只換值來源)。
|
||||||
|
* serial 為空的裝置不可上傳(workspace 端 disable)。
|
||||||
|
*/
|
||||||
|
|
||||||
/** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */
|
/** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */
|
||||||
export function uploadImage(
|
export function uploadImage(
|
||||||
deviceId: string,
|
serialNumber: string,
|
||||||
file: File,
|
file: File,
|
||||||
options?: UploadMediaOptions,
|
options?: UploadMediaOptions,
|
||||||
): Promise<MediaUploadResponse> {
|
): Promise<MediaUploadResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("deviceId", deviceId);
|
form.append("deviceId", serialNumber);
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options);
|
return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */
|
/** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */
|
||||||
export function uploadVideo(
|
export function uploadVideo(
|
||||||
deviceId: string,
|
serialNumber: string,
|
||||||
file: File,
|
file: File,
|
||||||
options?: UploadMediaOptions,
|
options?: UploadMediaOptions,
|
||||||
): Promise<MediaUploadResponse> {
|
): Promise<MediaUploadResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("deviceId", deviceId);
|
form.append("deviceId", serialNumber);
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options);
|
return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */
|
/** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */
|
||||||
export function uploadBatchImages(
|
export function uploadBatchImages(
|
||||||
deviceId: string,
|
serialNumber: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
options?: UploadMediaOptions,
|
options?: UploadMediaOptions,
|
||||||
): Promise<MediaUploadResponse> {
|
): Promise<MediaUploadResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("deviceId", deviceId);
|
form.append("deviceId", serialNumber);
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
form.append("files", f);
|
form.append("files", f);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,31 @@ describe("useDeviceStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serial_number 正規化:snake_case / camelCase 都吃、缺欄位與空字串 → null(WP-C)", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
// snake_case(後端實際形狀,omitempty)
|
||||||
|
{ id: "dev-1", name: "A", type: "kl520", status: "connected", serial_number: "KN00123456" },
|
||||||
|
// camelCase 容錯
|
||||||
|
{ id: "dev-2", name: "B", type: "kl720", status: "connected", serialNumber: "KN99887766" },
|
||||||
|
// 缺欄位(舊資料 / 未回報)→ null
|
||||||
|
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
|
||||||
|
// 空字串(防禦性;無法路由)→ null
|
||||||
|
{ id: "dev-4", name: "D", type: "kl520", status: "connected", serial_number: "" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
const { devices } = useDeviceStore.getState();
|
||||||
|
expect(devices[0]?.serialNumber).toBe("KN00123456");
|
||||||
|
expect(devices[1]?.serialNumber).toBe("KN99887766");
|
||||||
|
expect(devices[2]?.serialNumber).toBeNull();
|
||||||
|
expect(devices[3]?.serialNumber).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => {
|
it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
jsonResponse(
|
jsonResponse(
|
||||||
@ -228,6 +253,22 @@ describe("useDeviceStore", () => {
|
|||||||
expect(useDeviceStore.getState().connectingId).toBeNull();
|
expect(useDeviceStore.getState().connectingId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("connect / disconnect 的 path 識別值 = 傳入的 serialNumber(WP-C serial 路由)", async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(jsonResponse({ success: true, data: {} }));
|
||||||
|
|
||||||
|
await useDeviceStore.getState().connectDevice("KN00123456");
|
||||||
|
expect(String(fetchSpy.mock.calls[0]?.[0])).toContain(
|
||||||
|
"/api/devices/KN00123456/connect",
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().disconnectDevice("KN00123456");
|
||||||
|
expect(String(fetchSpy.mock.calls[1]?.[0])).toContain(
|
||||||
|
"/api/devices/KN00123456/disconnect",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("connectDevice 失敗時回傳 false 且寫入 error", async () => {
|
it("connectDevice 失敗時回傳 false 且寫入 error", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
jsonResponse(
|
jsonResponse(
|
||||||
|
|||||||
@ -50,6 +50,14 @@ export interface DeviceSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
/** 使用者自訂別名(後端或本機偏好皆可) */
|
/** 使用者自訂別名(後端或本機偏好皆可) */
|
||||||
alias?: string;
|
alias?: string;
|
||||||
|
/**
|
||||||
|
* Kneron 硬體序號(kn_number)— ADR-018 serial 路由的識別值。
|
||||||
|
* 後端 JSON key 為 `serial_number`(omitempty):舊資料 / 未串通 / 假序號
|
||||||
|
* `0x00000000` 皆為缺省 → null。null 時「路由到 local agent 的操作」
|
||||||
|
* (camera / media / inference WS / flash / connect / disconnect)必須 disable
|
||||||
|
* (FE-A 混合模型;DB 操作如列表 / 詳情 / unpair 仍用 UUID `id`)。
|
||||||
|
*/
|
||||||
|
serialNumber?: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
/** USB 層級狀態;離線時可能來自 cache */
|
/** USB 層級狀態;離線時可能來自 cache */
|
||||||
status: DeviceHardwareStatus;
|
status: DeviceHardwareStatus;
|
||||||
@ -104,10 +112,14 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
const rawRemoteStatus = pick<string>("remote_status", "remoteStatus") as
|
const rawRemoteStatus = pick<string>("remote_status", "remoteStatus") as
|
||||||
| RemoteStatus
|
| RemoteStatus
|
||||||
| undefined;
|
| undefined;
|
||||||
|
// serial_number 為 omitempty:缺欄位 → null;防禦性把空字串也視為 null
|
||||||
|
// (空字串無法路由,語意上等同「未回報序號」)。
|
||||||
|
const rawSerial = pick<string>("serial_number", "serialNumber");
|
||||||
return {
|
return {
|
||||||
id: String(pick<string>("id") ?? ""),
|
id: String(pick<string>("id") ?? ""),
|
||||||
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
|
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
|
||||||
alias: pick<string>("alias") ?? undefined,
|
alias: pick<string>("alias") ?? undefined,
|
||||||
|
serialNumber: rawSerial ? String(rawSerial) : null,
|
||||||
type: String(pick<string>("type", "device_type") ?? ""),
|
type: String(pick<string>("type", "device_type") ?? ""),
|
||||||
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
|
||||||
remoteStatus:
|
remoteStatus:
|
||||||
@ -143,7 +155,7 @@ interface DeviceState {
|
|||||||
devices: DeviceSummary[];
|
devices: DeviceSummary[];
|
||||||
selectedDevice: Device | null;
|
selectedDevice: Device | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
/** 連線中的裝置識別值(serial 路由後為 serialNumber;UI 顯示 button spinner);不使用就是 null */
|
||||||
connectingId: string | null;
|
connectingId: string | null;
|
||||||
disconnectingId: string | null;
|
disconnectingId: string | null;
|
||||||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||||
@ -154,10 +166,17 @@ interface DeviceState {
|
|||||||
fetchDevices: () => Promise<void>;
|
fetchDevices: () => Promise<void>;
|
||||||
/** 呼叫 `GET /api/devices/:id` */
|
/** 呼叫 `GET /api/devices/:id` */
|
||||||
fetchDevice: (id: string) => Promise<void>;
|
fetchDevice: (id: string) => Promise<void>;
|
||||||
/** 呼叫 `POST /api/devices/:id/connect` */
|
/**
|
||||||
connectDevice: (id: string) => Promise<boolean>;
|
* 呼叫 `POST /api/devices/:serialNumber/connect`。
|
||||||
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
*
|
||||||
disconnectDevice: (id: string) => Promise<boolean>;
|
* ⚠️ ADR-018 serial 路由:connect 是純 proxy 操作(雲端零邏輯、透傳 local agent,
|
||||||
|
* 見 wp-c-connect-disconnect-routing-verification.md §1),path 識別值必須帶
|
||||||
|
* `device.serialNumber`(kn_number),不是雲端 UUID。serial 為空的裝置不可呼叫
|
||||||
|
* (呼叫端按鈕 disable)。
|
||||||
|
*/
|
||||||
|
connectDevice: (serialNumber: string) => Promise<boolean>;
|
||||||
|
/** 呼叫 `POST /api/devices/:serialNumber/disconnect`(serial 路由,同 connectDevice)。 */
|
||||||
|
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
||||||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||||
/** 測試 / 雛形用:直接塞 list */
|
/** 測試 / 雛形用:直接塞 list */
|
||||||
@ -208,10 +227,12 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
connectDevice: async (id) => {
|
// serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以
|
||||||
set({ connectingId: id, error: null });
|
// serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。
|
||||||
|
connectDevice: async (serialNumber) => {
|
||||||
|
set({ connectingId: serialNumber, error: null });
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/devices/${encodeURIComponent(id)}/connect`);
|
await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/connect`);
|
||||||
set({ connectingId: null });
|
set({ connectingId: null });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -221,10 +242,10 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnectDevice: async (id) => {
|
disconnectDevice: async (serialNumber) => {
|
||||||
set({ disconnectingId: id, error: null });
|
set({ disconnectingId: serialNumber, error: null });
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/devices/${encodeURIComponent(id)}/disconnect`);
|
await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/disconnect`);
|
||||||
set({ disconnectingId: null });
|
set({ disconnectingId: null });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -47,17 +47,21 @@ describe("flash-store", () => {
|
|||||||
expect(s.error).toBeNull();
|
expect(s.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("startFlash 成功:POST 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => {
|
it("startFlash 成功:POST path 帶 serial、body 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => {
|
||||||
mockPost.mockResolvedValueOnce({ taskId: "t-1" });
|
mockPost.mockResolvedValueOnce({ taskId: "t-1" });
|
||||||
await useFlashStore.getState().startFlash("dev-1", "model-9");
|
// WP-C(ADR-018):識別值是 serialNumber(kn_number),path 形狀不變
|
||||||
|
await useFlashStore.getState().startFlash("KN00123456", "model-9");
|
||||||
|
|
||||||
expect(mockPost).toHaveBeenCalledWith("/api/devices/dev-1/flash", {
|
expect(mockPost).toHaveBeenCalledWith("/api/devices/KN00123456/flash", {
|
||||||
modelId: "model-9",
|
modelId: "model-9",
|
||||||
});
|
});
|
||||||
const s = useFlashStore.getState();
|
const s = useFlashStore.getState();
|
||||||
expect(s.isFlashing).toBe(true); // 觸發成功,等 WS 推完成
|
expect(s.isFlashing).toBe(true); // 觸發成功,等 WS 推完成
|
||||||
expect(s.error).toBeNull();
|
expect(s.error).toBeNull();
|
||||||
expect(s.lastFlashParams).toEqual({ deviceId: "dev-1", modelId: "model-9" });
|
expect(s.lastFlashParams).toEqual({
|
||||||
|
serialNumber: "KN00123456",
|
||||||
|
modelId: "model-9",
|
||||||
|
});
|
||||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_start");
|
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_start");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -73,7 +77,7 @@ describe("flash-store", () => {
|
|||||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error");
|
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("startFlash: deviceId 特殊字元 encode", async () => {
|
it("startFlash: serialNumber 特殊字元 encode", async () => {
|
||||||
mockPost.mockResolvedValueOnce({});
|
mockPost.mockResolvedValueOnce({});
|
||||||
await useFlashStore.getState().startFlash("a/b", "m1");
|
await useFlashStore.getState().startFlash("a/b", "m1");
|
||||||
expect(mockPost).toHaveBeenCalledWith("/api/devices/a%2Fb/flash", {
|
expect(mockPost).toHaveBeenCalledWith("/api/devices/a%2Fb/flash", {
|
||||||
@ -82,7 +86,7 @@ describe("flash-store", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateProgress: error 有值 → 失敗態", () => {
|
it("updateProgress: error 有值 → 失敗態", () => {
|
||||||
useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } });
|
useFlashStore.setState({ isFlashing: true, lastFlashParams: { serialNumber: "d", modelId: "m" } });
|
||||||
useFlashStore.getState().updateProgress({ percent: 40, stage: "loading", error: "boom" });
|
useFlashStore.getState().updateProgress({ percent: 40, stage: "loading", error: "boom" });
|
||||||
const s = useFlashStore.getState();
|
const s = useFlashStore.getState();
|
||||||
expect(s.isFlashing).toBe(false);
|
expect(s.isFlashing).toBe(false);
|
||||||
@ -99,7 +103,7 @@ describe("flash-store", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateProgress: percent>=100 → 完成、加 flash_complete activity", () => {
|
it("updateProgress: percent>=100 → 完成、加 flash_complete activity", () => {
|
||||||
useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } });
|
useFlashStore.setState({ isFlashing: true, lastFlashParams: { serialNumber: "d", modelId: "m" } });
|
||||||
useFlashStore.getState().updateProgress({ percent: 100, stage: "done" });
|
useFlashStore.getState().updateProgress({ percent: 100, stage: "done" });
|
||||||
const s = useFlashStore.getState();
|
const s = useFlashStore.getState();
|
||||||
expect(s.isFlashing).toBe(false);
|
expect(s.isFlashing).toBe(false);
|
||||||
@ -128,7 +132,7 @@ describe("flash-store", () => {
|
|||||||
isFlashing: true,
|
isFlashing: true,
|
||||||
progress: { percent: 50, stage: "x" },
|
progress: { percent: 50, stage: "x" },
|
||||||
error: "e",
|
error: "e",
|
||||||
lastFlashParams: { deviceId: "d", modelId: "m" },
|
lastFlashParams: { serialNumber: "d", modelId: "m" },
|
||||||
});
|
});
|
||||||
useFlashStore.getState().reset();
|
useFlashStore.getState().reset();
|
||||||
const s = useFlashStore.getState();
|
const s = useFlashStore.getState();
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export interface FlashProgress {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /api/devices/:id/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */
|
/** `POST /api/devices/:serialNumber/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */
|
||||||
interface FlashResponse {
|
interface FlashResponse {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
}
|
}
|
||||||
@ -52,10 +52,17 @@ interface FlashState {
|
|||||||
isFlashing: boolean;
|
isFlashing: boolean;
|
||||||
progress: FlashProgress | null;
|
progress: FlashProgress | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
lastFlashParams: { deviceId: string; modelId: string } | null;
|
lastFlashParams: { serialNumber: string; modelId: string } | null;
|
||||||
|
|
||||||
/** 觸發 flash:`POST /api/devices/:id/flash { modelId }`(throw-based)。 */
|
/**
|
||||||
startFlash: (deviceId: string, modelId: string) => Promise<void>;
|
* 觸發 flash:`POST /api/devices/:serialNumber/flash { modelId }`(throw-based)。
|
||||||
|
*
|
||||||
|
* ⚠️ ADR-018 serial 路由:flash 是純 proxy 操作、path 識別值必須帶
|
||||||
|
* `device.serialNumber`(kn_number)而非雲端 UUID——local agent 端以
|
||||||
|
* serialToLocalID 反查 sessions,帶 UUID 會 "device not found"。
|
||||||
|
* serial 為空的裝置不可呼叫(FlashDialog 端 disable)。
|
||||||
|
*/
|
||||||
|
startFlash: (serialNumber: string, modelId: string) => Promise<void>;
|
||||||
/** 消費 WS 推來的進度(error / 完成 / 更新)。 */
|
/** 消費 WS 推來的進度(error / 完成 / 更新)。 */
|
||||||
updateProgress: (progress: FlashProgress) => void;
|
updateProgress: (progress: FlashProgress) => void;
|
||||||
/** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */
|
/** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */
|
||||||
@ -72,23 +79,24 @@ export const useFlashStore = create<FlashState>()((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
lastFlashParams: null,
|
lastFlashParams: null,
|
||||||
|
|
||||||
startFlash: async (deviceId, modelId) => {
|
startFlash: async (serialNumber, modelId) => {
|
||||||
set({
|
set({
|
||||||
isFlashing: true,
|
isFlashing: true,
|
||||||
progress: null,
|
progress: null,
|
||||||
error: null,
|
error: null,
|
||||||
lastFlashParams: { deviceId, modelId },
|
lastFlashParams: { serialNumber, modelId },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await api.post<FlashResponse>(
|
await api.post<FlashResponse>(
|
||||||
`/api/devices/${encodeURIComponent(deviceId)}/flash`,
|
`/api/devices/${encodeURIComponent(serialNumber)}/flash`,
|
||||||
{ modelId },
|
{ modelId },
|
||||||
);
|
);
|
||||||
// POST 成功只代表「觸發成功」,實際進度 / 完成由 WS 推送。
|
// POST 成功只代表「觸發成功」,實際進度 / 完成由 WS 推送。
|
||||||
|
// activity 的 deviceId 欄位是「裝置參考字串」(純顯示 / log),帶 serial 即可。
|
||||||
useActivityStore.getState().addActivity({
|
useActivityStore.getState().addActivity({
|
||||||
type: "flash_start",
|
type: "flash_start",
|
||||||
message: "Flash started",
|
message: "Flash started",
|
||||||
deviceId,
|
deviceId: serialNumber,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -103,7 +111,7 @@ export const useFlashStore = create<FlashState>()((set, get) => ({
|
|||||||
useActivityStore.getState().addActivity({
|
useActivityStore.getState().addActivity({
|
||||||
type: "flash_error",
|
type: "flash_error",
|
||||||
message: `Flash failed: ${message}`,
|
message: `Flash failed: ${message}`,
|
||||||
deviceId,
|
deviceId: serialNumber,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -116,7 +124,7 @@ export const useFlashStore = create<FlashState>()((set, get) => ({
|
|||||||
useActivityStore.getState().addActivity({
|
useActivityStore.getState().addActivity({
|
||||||
type: "flash_error",
|
type: "flash_error",
|
||||||
message: `Flash failed: ${progress.error}`,
|
message: `Flash failed: ${progress.error}`,
|
||||||
deviceId: lastFlashParams?.deviceId,
|
deviceId: lastFlashParams?.serialNumber,
|
||||||
modelId: lastFlashParams?.modelId,
|
modelId: lastFlashParams?.modelId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -128,7 +136,7 @@ export const useFlashStore = create<FlashState>()((set, get) => ({
|
|||||||
useActivityStore.getState().addActivity({
|
useActivityStore.getState().addActivity({
|
||||||
type: "flash_complete",
|
type: "flash_complete",
|
||||||
message: "Flash completed",
|
message: "Flash completed",
|
||||||
deviceId: lastFlashParams?.deviceId,
|
deviceId: lastFlashParams?.serialNumber,
|
||||||
modelId: lastFlashParams?.modelId,
|
modelId: lastFlashParams?.modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -141,7 +149,7 @@ export const useFlashStore = create<FlashState>()((set, get) => ({
|
|||||||
retryFlash: async () => {
|
retryFlash: async () => {
|
||||||
const { lastFlashParams } = get();
|
const { lastFlashParams } = get();
|
||||||
if (!lastFlashParams) return;
|
if (!lastFlashParams) return;
|
||||||
await get().startFlash(lastFlashParams.deviceId, lastFlashParams.modelId);
|
await get().startFlash(lastFlashParams.serialNumber, lastFlashParams.modelId);
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user