feat(frontend): 裝置頁重新掃描按鈕 + 配對→「連接這台電腦」用詞

1. 重新掃描裝置按鈕:打現成 POST /api/devices/scan(proxy→local-agent
   Rescan)→ fetchDevices refresh。插新 USB 不用重啟 agent。離線 gate +
   loading + 失敗不清空列表。

2. 配對→連接電腦 全站用詞(i18n zh+en 各 41 value):「配對裝置」讓人誤以為
   配對 KL520/KL720 晶片,實際是配對電腦上的 local-agent → 改「連接這台電腦」。
   避撞既有裝置 connect/連線(新詞帶「電腦」主詞)。解除連接/連接碼/連接時間/
   重新連接成套。「配對過的裝置」→「已連接電腦上的裝置」語意校正。key 名/
   pairedAt 欄位/路由 /devices/pair 保留。

reviewer 通過(rescan 0C/0M、用詞 0C/0M 41/41 對齊避撞守住)。tsc/eslint/build
綠 + 39 i18n parity/store test。mapping: docs/autoflow/03-design/pairing-rename-mapping.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-08-03 09:41:18 +08:00
parent e27d8e3bd2
commit 630b8a2d6e
7 changed files with 445 additions and 67 deletions

View File

@ -19,6 +19,7 @@ import Link from "next/link";
import { Link2, RefreshCw } from "lucide-react"; import { Link2, RefreshCw } from "lucide-react";
import { DeviceList } from "@/components/devices/device-list"; import { DeviceList } from "@/components/devices/device-list";
import { DeviceRescanButton } from "@/components/devices/device-rescan-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store"; import { useDeviceStore } from "@/stores/device-store";
@ -54,6 +55,9 @@ export default function DevicesPage() {
className={`size-4 ${isLoading ? "animate-spin" : ""}`} className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/> />
</Button> </Button>
{/* USB local agent rescan
DBrescan USB */}
<DeviceRescanButton />
<Link href="/devices/pair"> <Link href="/devices/pair">
<Button data-testid="devices-pair-cta"> <Button data-testid="devices-pair-cta">
<Link2 aria-hidden="true" className="mr-2 size-4" /> <Link2 aria-hidden="true" className="mr-2 size-4" />

View File

@ -0,0 +1,125 @@
/**
* DeviceRescanButton USB
*
*
* - online disable +
* - success toast error toast code
* - online tunnel disable rescan
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import type { DeviceSummary } from "@/stores/device-store";
import { useDeviceStore } from "@/stores/device-store";
import { DeviceRescanButton } from "./device-rescan-button";
// sonner toast mock — 斷言 success / error 呼叫。
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
const onlineDevice: DeviceSummary = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected",
remoteStatus: "online",
};
const offlineDevice: DeviceSummary = {
...onlineDevice,
id: "dev-off",
remoteStatus: "offline",
};
function resetStore(devices: DeviceSummary[]) {
useDeviceStore.setState({
devices,
selectedDevice: null,
isLoading: false,
connectingId: null,
disconnectingId: null,
unpairingId: null,
registeringId: null,
isRescanning: false,
error: null,
});
}
function renderButton() {
return render(
<LocaleProvider>
<DeviceRescanButton />
</LocaleProvider>,
);
}
beforeEach(() => {
toastSuccess.mockClear();
toastError.mockClear();
resetStore([onlineDevice]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("DeviceRescanButton", () => {
it("有 online 裝置 → 按鈕可點;成功時呼叫 rescanDevices 並跳 success toast", async () => {
const rescan = vi.fn().mockResolvedValue({ ok: true });
useDeviceStore.setState({ rescanDevices: rescan });
renderButton();
const btn = screen.getByTestId("device-rescan-btn");
expect(btn).toBeEnabled();
fireEvent.click(btn);
expect(rescan).toHaveBeenCalledOnce();
await waitFor(() => expect(toastSuccess).toHaveBeenCalledOnce());
expect(toastError).not.toHaveBeenCalled();
});
it("掃描中isRescanning=true→ 按鈕 disable 且顯示「掃描中…」", () => {
useDeviceStore.setState({ isRescanning: true });
renderButton();
const btn = screen.getByTestId("device-rescan-btn");
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent("掃描中…");
});
it("失敗TUNNEL_DISCONNECTED→ 跳 error toast帶離線描述", async () => {
const rescan = vi
.fn()
.mockResolvedValue({ ok: false, code: "TUNNEL_DISCONNECTED", message: "offline" });
useDeviceStore.setState({ rescanDevices: rescan });
renderButton();
fireEvent.click(screen.getByTestId("device-rescan-btn"));
await waitFor(() => expect(toastError).toHaveBeenCalledOnce());
const [, opts] = toastError.mock.calls[0] as [string, { description: string }];
expect(opts.description).toContain("local agent");
expect(toastSuccess).not.toHaveBeenCalled();
});
it("無 online 裝置tunnel 離線)→ 按鈕 disable點了不觸發 rescan", async () => {
const rescan = vi.fn().mockResolvedValue({ ok: true });
resetStore([offlineDevice]);
useDeviceStore.setState({ rescanDevices: rescan });
renderButton();
const btn = screen.getByTestId("device-rescan-btn");
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(rescan).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,82 @@
"use client";
/**
* DeviceRescanButton USB
*
*
* 使 USB local agent rescan
* rescan `POST /api/devices/scan` proxy local agent
* ScanDevices Rescan USB /
* rescanDevices() scan fetchDevices refresh
*
*
* - loading disable + spinner +
* - toast
* - toast tunnel TUNNEL_DISCONNECTED
*
* gate
* scan tunnel proxy online agent 502 TUNNEL_DISCONNECTED
* 使 online disable
* title / aria workspace tunnel
*/
import { toast } from "sonner";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
interface DeviceRescanButtonProps {
size?: "sm" | "default";
}
/** rescan 失敗時把 backend code 映射到 i18n key找不到 → unknown 文案)。 */
function rescanErrorDesc(t: (k: string) => string, code: string): string {
const key = `devices.rescan.error.${code}`;
const resolved = t(key);
return resolved === key ? t("devices.rescan.error.unknown") : resolved;
}
export function DeviceRescanButton({ size = "sm" }: DeviceRescanButtonProps) {
const t = useT();
const rescanDevices = useDeviceStore((s) => s.rescanDevices);
const isRescanning = useDeviceStore((s) => s.isRescanning);
// 是否至少有一台裝置在線(有 online tunnel——scan 只有在有 online agent 時才可能成功。
const hasOnlineAgent = useDeviceStore((s) =>
s.devices.some((d) => d.remoteStatus === "online"),
);
const disabled = isRescanning || !hasOnlineAgent;
async function handleRescan() {
const result = await rescanDevices();
if (result.ok) {
toast.success(t("devices.rescan.toast.success"));
} else {
toast.error(t("devices.rescan.error.title"), {
description: rescanErrorDesc(t, result.code),
});
}
}
return (
<Button
type="button"
variant="outline"
size={size}
onClick={handleRescan}
disabled={disabled}
data-testid="device-rescan-btn"
// 離線時說明為何 disable線上時給一般 tooltip。
title={hasOnlineAgent ? undefined : t("devices.rescan.offlineHint")}
aria-label={t("devices.rescan.action")}
>
<RefreshCw
aria-hidden="true"
className={`mr-2 size-4 ${isRescanning ? "animate-spin" : ""}`}
/>
{isRescanning ? t("devices.rescan.pending") : t("devices.rescan.action")}
</Button>
);
}

View File

@ -106,19 +106,19 @@ export const en: Dictionary = {
"dashboard.flashes": "Flashes", "dashboard.flashes": "Flashes",
"dashboard.connectedDevices": "Online devices", "dashboard.connectedDevices": "Online devices",
"dashboard.noConnectedDevices": "dashboard.noConnectedDevices":
"No devices are online. Pair a Kneron device to start cloud inference.", "No devices are online. Connect your computer to use Kneron devices from the cloud.",
"dashboard.recentActivity": "Recent activity", "dashboard.recentActivity": "Recent activity",
"dashboard.noActivity": "dashboard.noActivity":
"Nothing here yet. Activity appears after pairing, uploads, or inference runs.", "Nothing here yet. Activity appears after connecting a computer, uploads, or inference runs.",
"dashboard.quickActions": "Quick actions", "dashboard.quickActions": "Quick actions",
"dashboard.browseModels": "Browse models", "dashboard.browseModels": "Browse models",
"dashboard.manageDevices": "Manage devices", "dashboard.manageDevices": "Manage devices",
"dashboard.uploadModel": "Upload model", "dashboard.uploadModel": "Upload model",
"dashboard.pairDevice": "Pair device", "dashboard.pairDevice": "Connect computer",
"dashboard.empty.title": "No devices yet", "dashboard.empty.title": "No devices yet",
"dashboard.empty.description": "dashboard.empty.description":
"Pair your first Kneron device to start running inference from anywhere.", "Connect your computer to start using Kneron devices from anywhere.",
"dashboard.empty.action": "Pair a device", "dashboard.empty.action": "Connect computer",
"dashboard.activity.justNow": "just now", "dashboard.activity.justNow": "just now",
"dashboard.activity.minutesAgo": "{n} minutes ago", "dashboard.activity.minutesAgo": "{n} minutes ago",
"dashboard.activity.hoursAgo": "{n} hours ago", "dashboard.activity.hoursAgo": "{n} hours ago",
@ -131,13 +131,13 @@ export const en: Dictionary = {
"devices.firmware": "Firmware", "devices.firmware": "Firmware",
"devices.flashedModel": "Flashed model", "devices.flashedModel": "Flashed model",
"devices.openWorkspace": "Open workspace", "devices.openWorkspace": "Open workspace",
"devices.addMore": "Pair a new device", "devices.addMore": "Connect a new computer",
"devices.pairAction": "Pair a new device", "devices.pairAction": "Connect a new computer",
"devices.empty.title": "No devices paired yet", "devices.empty.title": "No computers connected yet",
"devices.empty.description": "devices.empty.description":
"Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.", "Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.",
"devices.empty.action": "Pair your first device", "devices.empty.action": "Connect your computer",
"devices.empty.secondaryAction": "How pairing works", "devices.empty.secondaryAction": "How connecting works",
"devices.detail.id": "ID", "devices.detail.id": "ID",
"devices.detail.type": "Type", "devices.detail.type": "Type",
"devices.detail.firmware": "Firmware", "devices.detail.firmware": "Firmware",
@ -146,7 +146,7 @@ export const en: Dictionary = {
"devices.detail.modelStatus": "Model status", "devices.detail.modelStatus": "Model status",
"devices.detail.readyForInference": "Ready for inference", "devices.detail.readyForInference": "Ready for inference",
"devices.detail.noModelFlashed": "No model has been flashed", "devices.detail.noModelFlashed": "No model has been flashed",
"devices.detail.pairedAt": "Paired at", "devices.detail.pairedAt": "Connected at",
"devices.detail.hostName": "Host", "devices.detail.hostName": "Host",
"devices.detail.lastSeen": "Last seen", "devices.detail.lastSeen": "Last seen",
"devices.detail.offlineBanner.title": "This device is offline", "devices.detail.offlineBanner.title": "This device is offline",
@ -165,14 +165,14 @@ export const en: Dictionary = {
"devices.serial.label": "Serial number", "devices.serial.label": "Serial number",
"devices.serial.missing": "Serial not reported yet", "devices.serial.missing": "Serial not reported yet",
"devices.serial.missingHint": "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.", "This device hasn't reported its serial number, so inference-related actions are unavailable. Re-connect 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…",
"devices.remove.confirm.title": "Remove this device?", "devices.remove.confirm.title": "Remove this device?",
"devices.remove.confirm.description": "devices.remove.confirm.description":
"This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.", "This disconnects “{name}” from your account and revokes its access. To use it again, you'll need to connect it from local agent. This cannot be undone.",
"devices.remove.confirm.action": "Remove", "devices.remove.confirm.action": "Remove",
"devices.remove.toast.success": "Device removed", "devices.remove.toast.success": "Device removed",
"devices.remove.error.title": "Couldn't remove device", "devices.remove.error.title": "Couldn't remove device",
@ -217,6 +217,16 @@ export const en: Dictionary = {
"Try a different filter, or clear it to see all your devices.", "Try a different filter, or clear it to see all your devices.",
"devices.filter.empty.action": "Clear filter", "devices.filter.empty.action": "Clear filter",
// ── Devices: rescan USB (re-detect newly plugged devices) ──
"devices.rescan.action": "Rescan devices",
"devices.rescan.pending": "Scanning…",
"devices.rescan.toast.success": "Devices rescanned",
"devices.rescan.error.title": "Couldn't rescan devices",
"devices.rescan.error.TUNNEL_DISCONNECTED":
"The local agent is offline. Make sure it's running and connected, then try again.",
"devices.rescan.error.unknown": "Something went wrong. Please try again.",
"devices.rescan.offlineHint": "Connect a local agent to rescan for USB devices.",
// ── Devices: flash (load model to device) ── // ── Devices: flash (load model to device) ──
"devices.flash.flashModel": "Load model", "devices.flash.flashModel": "Load model",
"devices.flash.flashToDevice": "Load a model to this device", "devices.flash.flashToDevice": "Load a model to this device",
@ -279,7 +289,7 @@ export const en: Dictionary = {
"models.filters.all": "All", "models.filters.all": "All",
"models.empty.title": "No models yet", "models.empty.title": "No models yet",
"models.empty.description": "models.empty.description":
"Upload your first .nef model to deploy it to any paired Kneron device.", "Upload your first .nef model to deploy it to any Kneron device on a connected computer.",
"models.empty.action": "Upload your first model", "models.empty.action": "Upload your first model",
"models.section.preset": "Preset models", "models.section.preset": "Preset models",
"models.section.converted": "Converted by you", "models.section.converted": "Converted by you",
@ -423,7 +433,7 @@ export const en: Dictionary = {
"workspace.subtitle": "Select an online device to start inference", "workspace.subtitle": "Select an online device to start inference",
"workspace.empty.title": "No devices are online", "workspace.empty.title": "No devices are online",
"workspace.empty.description": "workspace.empty.description":
"Pair a device and make sure the local agent is connected to the cloud.", "Connect your computer and make sure the local agent is connected to the cloud.",
"workspace.empty.action": "Go to devices", "workspace.empty.action": "Go to devices",
"workspace.header.backToDevices": "Back to devices", "workspace.header.backToDevices": "Back to devices",
"workspace.header.title": "Workspace", "workspace.header.title": "Workspace",
@ -452,7 +462,7 @@ export const en: Dictionary = {
"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.title": "This device hasn't reported a serial number",
"workspace.noSerial.description": "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.", "Inference, camera, and media upload need the device serial to route to local agent. Re-connect 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",
@ -509,35 +519,35 @@ export const en: Dictionary = {
"settings.advanced.platform": "Platform", "settings.advanced.platform": "Platform",
// ── Pairing (F7) ── // ── Pairing (F7) ──
"pairing.title": "Pair a new device", "pairing.title": "Connect this computer",
"pairing.subtitle": "pairing.subtitle":
"Connect your Kneron device to the cloud so you can operate it from anywhere.", "Connect this computer to the cloud so its Kneron devices can be operated from anywhere.",
"pairing.token.title": "Your pairing token", "pairing.token.title": "Your connection token",
"pairing.step1.description": "pairing.step1.description":
"Copy the token below and paste it into your local agent within 15 minutes.", "Copy the connection token below and paste it into your local agent within 15 minutes.",
"pairing.copy": "Copy", "pairing.copy": "Copy",
"pairing.copied": "Copied", "pairing.copied": "Copied",
"pairing.regenerate": "Regenerate", "pairing.regenerate": "Regenerate",
"pairing.timeRemaining": "{time} remaining", "pairing.timeRemaining": "{time} remaining",
"pairing.generatedAt": "Generated at {time}", "pairing.generatedAt": "Generated at {time}",
"pairing.token.expired.label": "This token has expired — please regenerate.", "pairing.token.expired.label": "This connection token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate token?", "pairing.regenerateConfirm.title": "Regenerate connection token?",
"pairing.regenerateConfirm.description": "pairing.regenerateConfirm.description":
"The old token will be invalidated immediately; the new one is valid for 15 minutes.", "The old connection token will be invalidated immediately; the new one is valid for 15 minutes.",
"pairing.security.warning": "pairing.security.warning":
"This token is valid for 15 minutes — complete pairing now.", "This connection token is valid for 15 minutes — complete the connection now.",
"pairing.security.oneTime": "pairing.security.oneTime":
"Tokens are single-use and expire automatically after pairing.", "Connection tokens are single-use and expire automatically after connecting.",
"pairing.toast.copied": "Token copied — valid for 15 minutes.", "pairing.toast.copied": "Connection token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate token — please retry.", "pairing.toast.generateFailed": "Could not generate connection token — please retry.",
"pairing.toast.expiringSoon": "pairing.toast.expiringSoon":
"Token expiring soon — complete pairing or regenerate.", "Connection token expiring soon — complete the connection or regenerate.",
"pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.", "pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.",
"pairing.toast.cliCopied": "CLI command copied.", "pairing.toast.cliCopied": "CLI command copied.",
"pairing.device.unknown": "Unknown device", "pairing.device.unknown": "Unknown device",
"pairing.cli.title": "CLI example", "pairing.cli.title": "CLI example",
"pairing.cli.description": "pairing.cli.description":
"Start local agent on your computer and pass the token to the --relay-token flag.", "Start local agent on your computer and pass the connection token to the --relay-token flag.",
"pairing.cli.copy": "Copy command", "pairing.cli.copy": "Copy command",
"pairing.cli.hint": "pairing.cli.hint":
"Once local agent connects to the cloud, this page detects it and forwards you to the device list.", "Once local agent connects to the cloud, this page detects it and forwards you to the device list.",
@ -545,7 +555,7 @@ export const en: Dictionary = {
"pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)", "pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)",
"pairing.step3.hints.running": "Confirm local agent is running", "pairing.step3.hints.running": "Confirm local agent is running",
"pairing.step3.hints.token": "pairing.step3.hints.token":
"Confirm the token was pasted without missing or extra characters", "Confirm the connection token was pasted without missing or extra characters",
"pairing.step3.hints.network": "pairing.step3.hints.network":
"Confirm your network can reach the cloud endpoint", "Confirm your network can reach the cloud endpoint",
"pairing.step3.success": "Connected!", "pairing.step3.success": "Connected!",

View File

@ -109,17 +109,17 @@ export const zhHant: Dictionary = {
"dashboard.connected": "線上裝置", "dashboard.connected": "線上裝置",
"dashboard.flashes": "已燒錄次數", "dashboard.flashes": "已燒錄次數",
"dashboard.connectedDevices": "線上裝置", "dashboard.connectedDevices": "線上裝置",
"dashboard.noConnectedDevices": "目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。", "dashboard.noConnectedDevices": "目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。",
"dashboard.recentActivity": "近期活動", "dashboard.recentActivity": "近期活動",
"dashboard.noActivity": "還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。", "dashboard.noActivity": "還沒有任何活動。連接電腦、上傳模型或跑一次推論後就會出現。",
"dashboard.quickActions": "快速操作", "dashboard.quickActions": "快速操作",
"dashboard.browseModels": "瀏覽模型", "dashboard.browseModels": "瀏覽模型",
"dashboard.manageDevices": "管理裝置", "dashboard.manageDevices": "管理裝置",
"dashboard.uploadModel": "上傳模型", "dashboard.uploadModel": "上傳模型",
"dashboard.pairDevice": "配對裝置", "dashboard.pairDevice": "連接電腦",
"dashboard.empty.title": "還沒有任何裝置", "dashboard.empty.title": "還沒有任何裝置",
"dashboard.empty.description": "配對你的第一台 Kneron 裝置,開始雲端推論之旅", "dashboard.empty.description": "連接你的電腦,開始從雲端使用 Kneron 裝置",
"dashboard.empty.action": "配對裝置", "dashboard.empty.action": "連接電腦",
"dashboard.activity.justNow": "剛剛", "dashboard.activity.justNow": "剛剛",
"dashboard.activity.minutesAgo": "{n} 分鐘前", "dashboard.activity.minutesAgo": "{n} 分鐘前",
"dashboard.activity.hoursAgo": "{n} 小時前", "dashboard.activity.hoursAgo": "{n} 小時前",
@ -132,13 +132,13 @@ export const zhHant: Dictionary = {
"devices.firmware": "韌體", "devices.firmware": "韌體",
"devices.flashedModel": "已燒錄模型", "devices.flashedModel": "已燒錄模型",
"devices.openWorkspace": "開啟工作區", "devices.openWorkspace": "開啟工作區",
"devices.addMore": "配對新裝置", "devices.addMore": "連接新電腦",
"devices.pairAction": "配對新裝置", "devices.pairAction": "連接新電腦",
"devices.empty.title": "還沒有配對的裝置", "devices.empty.title": "還沒有連接任何電腦",
"devices.empty.description": "devices.empty.description":
"在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置", "在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置",
"devices.empty.action": "配對第一台裝置", "devices.empty.action": "連接你的電腦",
"devices.empty.secondaryAction": "查看配對說明", "devices.empty.secondaryAction": "查看連接說明",
"devices.detail.id": "ID", "devices.detail.id": "ID",
"devices.detail.type": "類型", "devices.detail.type": "類型",
"devices.detail.firmware": "韌體", "devices.detail.firmware": "韌體",
@ -147,7 +147,7 @@ export const zhHant: Dictionary = {
"devices.detail.modelStatus": "模型狀態", "devices.detail.modelStatus": "模型狀態",
"devices.detail.readyForInference": "已就緒,可開始推論", "devices.detail.readyForInference": "已就緒,可開始推論",
"devices.detail.noModelFlashed": "尚未燒錄任何模型", "devices.detail.noModelFlashed": "尚未燒錄任何模型",
"devices.detail.pairedAt": "配對時間", "devices.detail.pairedAt": "連接時間",
"devices.detail.hostName": "所在電腦", "devices.detail.hostName": "所在電腦",
"devices.detail.lastSeen": "最後心跳", "devices.detail.lastSeen": "最後心跳",
"devices.detail.offlineBanner.title": "此裝置目前離線", "devices.detail.offlineBanner.title": "此裝置目前離線",
@ -166,14 +166,14 @@ export const zhHant: Dictionary = {
"devices.serial.label": "序號", "devices.serial.label": "序號",
"devices.serial.missing": "尚未回報序號", "devices.serial.missing": "尚未回報序號",
"devices.serial.missingHint": "devices.serial.missingHint":
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。", "此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。",
// ── Devices: 移除裝置unpair ── // ── Devices: 移除裝置unpair ──
"devices.remove.action": "移除裝置", "devices.remove.action": "移除裝置",
"devices.remove.removing": "移除中…", "devices.remove.removing": "移除中…",
"devices.remove.confirm.title": "確定要移除此裝置?", "devices.remove.confirm.title": "確定要移除此裝置?",
"devices.remove.confirm.description": "devices.remove.confirm.description":
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。", "這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。",
"devices.remove.confirm.action": "移除", "devices.remove.confirm.action": "移除",
"devices.remove.toast.success": "已移除裝置", "devices.remove.toast.success": "已移除裝置",
"devices.remove.error.title": "移除裝置失敗", "devices.remove.error.title": "移除裝置失敗",
@ -216,6 +216,16 @@ export const zhHant: Dictionary = {
"devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。", "devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。",
"devices.filter.empty.action": "清除篩選", "devices.filter.empty.action": "清除篩選",
// ── Devices: 重新掃描 USB偵測新插入的裝置 ──
"devices.rescan.action": "重新掃描裝置",
"devices.rescan.pending": "掃描中…",
"devices.rescan.toast.success": "已重新掃描",
"devices.rescan.error.title": "重新掃描失敗",
"devices.rescan.error.TUNNEL_DISCONNECTED":
"local agent 目前離線。請確認它正在執行並已連線,再試一次。",
"devices.rescan.error.unknown": "發生錯誤,請再試一次。",
"devices.rescan.offlineHint": "請先連接 local agent才能重新掃描 USB 裝置。",
// ── Devices: flash載入模型到裝置 ── // ── Devices: flash載入模型到裝置 ──
"devices.flash.flashModel": "載入模型", "devices.flash.flashModel": "載入模型",
"devices.flash.flashToDevice": "載入模型到此裝置", "devices.flash.flashToDevice": "載入模型到此裝置",
@ -277,7 +287,7 @@ export const zhHant: Dictionary = {
"models.filters.all": "全部", "models.filters.all": "全部",
"models.empty.title": "還沒有任何模型", "models.empty.title": "還沒有任何模型",
"models.empty.description": "models.empty.description":
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置", "上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置",
"models.empty.action": "上傳第一個模型", "models.empty.action": "上傳第一個模型",
"models.section.preset": "預設模型", "models.section.preset": "預設模型",
"models.section.converted": "我轉檔的", "models.section.converted": "我轉檔的",
@ -412,7 +422,7 @@ export const zhHant: Dictionary = {
"workspace.title": "推論工作區", "workspace.title": "推論工作區",
"workspace.subtitle": "選擇已線上的裝置開始推論", "workspace.subtitle": "選擇已線上的裝置開始推論",
"workspace.empty.title": "目前沒有線上裝置", "workspace.empty.title": "目前沒有線上裝置",
"workspace.empty.description": "請先配對並確認 local agent 已連上雲端", "workspace.empty.description": "請先連接你的電腦並確認 local agent 已連上雲端",
"workspace.empty.action": "前往裝置管理", "workspace.empty.action": "前往裝置管理",
"workspace.header.backToDevices": "返回裝置", "workspace.header.backToDevices": "返回裝置",
"workspace.header.title": "工作區", "workspace.header.title": "工作區",
@ -438,7 +448,7 @@ export const zhHant: Dictionary = {
"workspace.offline.backToList": "返回裝置列表", "workspace.offline.backToList": "返回裝置列表",
"workspace.noSerial.title": "此裝置尚未回報序號", "workspace.noSerial.title": "此裝置尚未回報序號",
"workspace.noSerial.description": "workspace.noSerial.description":
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。", "推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。",
"workspace.tabs.camera": "Camera", "workspace.tabs.camera": "Camera",
"workspace.tabs.image": "圖片", "workspace.tabs.image": "圖片",
"workspace.tabs.video": "影片", "workspace.tabs.video": "影片",
@ -494,42 +504,42 @@ export const zhHant: Dictionary = {
"settings.advanced.platform": "平台", "settings.advanced.platform": "平台",
// ── PairingF7 新增)── // ── PairingF7 新增)──
"pairing.title": "配對新裝置", "pairing.title": "連接這台電腦",
"pairing.subtitle": "讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作", "pairing.subtitle": "讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作",
"pairing.token.title": "你的 Pairing Token", "pairing.token.title": "你的連接碼",
"pairing.step1.description": "pairing.step1.description":
"複製下方 token在 15 分鐘內貼到 local agent 完成配對", "複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接",
"pairing.copy": "複製", "pairing.copy": "複製",
"pairing.copied": "已複製", "pairing.copied": "已複製",
"pairing.regenerate": "重新產生", "pairing.regenerate": "重新產生",
"pairing.timeRemaining": "剩餘 {time}", "pairing.timeRemaining": "剩餘 {time}",
"pairing.generatedAt": "產生時間:{time}", "pairing.generatedAt": "產生時間:{time}",
"pairing.token.expired.label": "此 token 已過期,請重新產生", "pairing.token.expired.label": "此連接碼已過期,請重新產生",
"pairing.regenerateConfirm.title": "確定要重新產生", "pairing.regenerateConfirm.title": "確定要重新產生連接碼",
"pairing.regenerateConfirm.description": "pairing.regenerateConfirm.description":
"舊 token 將立即失效,新 token 有效期 15 分鐘", "舊連接碼將立即失效,新連接碼有效期 15 分鐘",
"pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對", "pairing.security.warning": "這組連接碼 15 分鐘內有效,請立刻完成連接",
"pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效", "pairing.security.oneTime": "連接碼是一次性使用,完成連接後自動失效",
"pairing.toast.copied": "Token 已複製到剪貼簿15 分鐘內有效", "pairing.toast.copied": "連接碼已複製到剪貼簿15 分鐘內有效",
"pairing.toast.generateFailed": "無法產生 token,請重試", "pairing.toast.generateFailed": "無法產生連接碼,請重試",
"pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生", "pairing.toast.expiringSoon": "連接碼即將過期,請立刻完成或重新產生",
"pairing.toast.pairedSuccess": "裝置 {deviceName} 已成功配對", "pairing.toast.pairedSuccess": "已成功連接電腦,偵測到裝置 {deviceName}",
"pairing.toast.cliCopied": "指令已複製到剪貼簿", "pairing.toast.cliCopied": "指令已複製到剪貼簿",
"pairing.device.unknown": "未知裝置", "pairing.device.unknown": "未知裝置",
"pairing.cli.title": "CLI 指令範例", "pairing.cli.title": "CLI 指令範例",
"pairing.cli.description": "pairing.cli.description":
"在你的電腦啟動 local agent token 貼到指令的 --relay-token 參數", "在你的電腦啟動 local agent連接碼貼到指令的 --relay-token 參數",
"pairing.cli.copy": "複製指令", "pairing.cli.copy": "複製指令",
"pairing.cli.hint": "pairing.cli.hint":
"local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表", "local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表",
"pairing.step3.waiting": "等待 local agent 連線…", "pairing.step3.waiting": "等待 local agent 連線…",
"pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)", "pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)",
"pairing.step3.hints.running": "確認 local agent 已啟動", "pairing.step3.hints.running": "確認 local agent 已啟動",
"pairing.step3.hints.token": "確認 token 貼上時無缺字或多餘空白", "pairing.step3.hints.token": "確認連接碼貼上時無缺字或多餘空白",
"pairing.step3.hints.network": "確認你的網路可連線到雲端", "pairing.step3.hints.network": "確認你的網路可連線到雲端",
"pairing.step3.success": "已成功連", "pairing.step3.success": "已成功連接電腦",
"pairing.step3.success.detected": "檢測到的裝置", "pairing.step3.success.detected": "檢測到的裝置",
"pairing.step3.failure.timeout": "連超時", "pairing.step3.failure.timeout": "連超時",
"pairing.step3.failure.reason": "pairing.step3.failure.reason":
"超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動", "超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動",
"pairing.step3.failure.retry": "重新檢查", "pairing.step3.failure.retry": "重新檢查",

View File

@ -24,6 +24,7 @@ beforeEach(() => {
disconnectingId: null, disconnectingId: null,
unpairingId: null, unpairingId: null,
registeringId: null, registeringId: null,
isRescanning: false,
error: null, error: null,
}); });
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶) // OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -663,3 +664,101 @@ describe("useDeviceStore.unregisterDevice", () => {
expect(useDeviceStore.getState().registeringId).toBeNull(); expect(useDeviceStore.getState().registeringId).toBeNull();
}); });
}); });
describe("useDeviceStore.rescanDevices", () => {
it("成功時打對 scan endpointPOST、接著 fetchDevices refresh、回 { ok:true }", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
// 1) POST /api/devices/scan
.mockResolvedValueOnce(jsonResponse({ success: true, data: {} }))
// 2) 後續 fetchDevices 的 GET /api/devices回一台新插入的裝置
.mockResolvedValueOnce(
jsonResponse({
success: true,
data: [
{
id: "dev-new",
name: "KL630",
type: "kl630",
status: "detected",
remote_status: "online",
},
],
}),
);
const result = await useDeviceStore.getState().rescanDevices();
expect(result).toEqual({ ok: true });
// 第一發是 scanPOST第二發是 fetchDevicesGET
expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/devices/scan");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
expect(String(fetchSpy.mock.calls[1]?.[0])).toContain("/api/devices");
// 掃描後列表被 refresh新裝置出現。
const state = useDeviceStore.getState();
expect(state.devices.map((d) => d.id)).toEqual(["dev-new"]);
expect(state.isRescanning).toBe(false);
});
it("呼叫期間 isRescanning 設為 trueloading 態),完成後清回 false", async () => {
let seenDuringScan = false;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
// 在 scan 請求進行中觀察 loading 態。
if (String(input).includes("/api/devices/scan")) {
seenDuringScan = useDeviceStore.getState().isRescanning;
}
return jsonResponse({ success: true, data: [] });
});
await useDeviceStore.getState().rescanDevices();
expect(seenDuringScan).toBe(true);
expect(useDeviceStore.getState().isRescanning).toBe(false);
});
it("裝置離線502 TUNNEL_DISCONNECTED→ 回 { ok:false, code:'TUNNEL_DISCONNECTED' },不 refresh、不清空列表", async () => {
useDeviceStore.setState({
devices: [
{
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected",
remoteStatus: "online",
},
],
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{
success: false,
error: { code: "TUNNEL_DISCONNECTED", message: "agent offline" },
},
502,
),
);
const result = await useDeviceStore.getState().rescanDevices();
expect(result).toMatchObject({ ok: false, code: "TUNNEL_DISCONNECTED" });
// 只打了 scan 一發,沒有接著 fetchDevices失敗不 refresh
expect(fetchSpy).toHaveBeenCalledOnce();
// 既有列表保留(不清空)。
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
expect(useDeviceStore.getState().isRescanning).toBe(false);
expect(useDeviceStore.getState().error).toBe("agent offline");
});
it("其他錯誤500→ 回 { ok:false, code:'INTERNAL_ERROR' }isRescanning 清回 false", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "INTERNAL_ERROR", message: "boom" } },
500,
),
);
const result = await useDeviceStore.getState().rescanDevices();
expect(result).toMatchObject({ ok: false, code: "INTERNAL_ERROR" });
expect(useDeviceStore.getState().isRescanning).toBe(false);
});
});

View File

@ -203,6 +203,19 @@ export type RegisterResult =
| { ok: true } | { ok: true }
| { ok: false; code: string; message: string }; | { ok: false; code: string; message: string };
/**
* rescan USB action
*
* code boolean unpair / register UI toast
* - scan tunnel proxy local agent ScanDevices Rescan
* online tunnel 502 `TUNNEL_DISCONNECTED`UI
* local agent code
* fetchDevices() refresh
*/
export type RescanResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface DeviceState { interface DeviceState {
devices: DeviceSummary[]; devices: DeviceSummary[];
selectedDevice: Device | null; selectedDevice: Device | null;
@ -214,6 +227,8 @@ interface DeviceState {
unpairingId: string | null; unpairingId: string | null;
/** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */ /** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */
registeringId: string | null; registeringId: string | null;
/** 重新掃描 USB 裝置進行中UI 顯示按鈕 spinner + disable不使用就是 false */
isRescanning: boolean;
error: string | null; error: string | null;
/** 呼叫 `GET /api/devices` */ /** 呼叫 `GET /api/devices` */
@ -246,13 +261,23 @@ interface DeviceState {
* unpairunregister registeredAtdevice * unpairunregister registeredAtdevice
*/ */
unregisterDevice: (id: string) => Promise<RegisterResult>; unregisterDevice: (id: string) => Promise<RegisterResult>;
/**
* `POST /api/devices/scan` proxy local agent ScanDevices Rescan
* USB / fetchDevices()
* local agent
*
* scan tunnel proxy online tunnel 502
* `TUNNEL_DISCONNECTED` { ok:false, code:"TUNNEL_DISCONNECTED" } UI
* fetchDevices
*/
rescanDevices: () => Promise<RescanResult>;
/** 測試 / 雛形用:直接塞 list */ /** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void; _setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */ /** 測試 / 雛形用:直接塞 selected */
_setSelected: (device: Device | null) => void; _setSelected: (device: Device | null) => void;
} }
export const useDeviceStore = create<DeviceState>()((set) => ({ export const useDeviceStore = create<DeviceState>()((set, get) => ({
devices: [], devices: [],
selectedDevice: null, selectedDevice: null,
isLoading: false, isLoading: false,
@ -260,6 +285,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
disconnectingId: null, disconnectingId: null,
unpairingId: null, unpairingId: null,
registeringId: null, registeringId: null,
isRescanning: false,
error: null, error: null,
fetchDevices: async () => { fetchDevices: async () => {
@ -416,6 +442,28 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
} }
}, },
rescanDevices: async () => {
set({ isRescanning: true, error: null });
try {
// POST /api/devices/scan雲端 proxy 透傳 local agent 的 ScanDevices → Rescan。
// 回傳的 scan 結果本身不需要(新裝置由後續 fetchDevices 從 DB / tunnel 帶出),
// 成功與否由有無 throw 判定(比照 unpair 範式)。
await api.post("/api/devices/scan");
set({ isRescanning: false });
// 掃描完成後 refresh 列表——剛插入的新裝置就會出現。
// 這裡 await 讓 UI 的 loading 能延續到列表更新完fetchDevices 自行管理 isLoading
await get().fetchDevices();
return { ok: true };
} catch (err) {
// ApiError 帶 backend codeTUNNEL_DISCONNECTED / INTERNAL_ERROR / …)給 UI 分流 toast
// 其他例外(網路層)退化成 unknown。失敗時不呼叫 fetchDevices維持既有列表
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ isRescanning: false, error: message });
return { ok: false, code, message };
}
},
_setDevices: (devices) => set({ devices }), _setDevices: (devices) => set({ devices }),
_setSelected: (selectedDevice) => set({ selectedDevice }), _setSelected: (selectedDevice) => set({ selectedDevice }),
})); }));