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 { DeviceList } from "@/components/devices/device-list";
import { DeviceRescanButton } from "@/components/devices/device-rescan-button";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
@ -54,6 +55,9 @@ export default function DevicesPage() {
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
{/* USB local agent rescan
DBrescan USB */}
<DeviceRescanButton />
<Link href="/devices/pair">
<Button data-testid="devices-pair-cta">
<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.connectedDevices": "Online devices",
"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.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.browseModels": "Browse models",
"dashboard.manageDevices": "Manage devices",
"dashboard.uploadModel": "Upload model",
"dashboard.pairDevice": "Pair device",
"dashboard.pairDevice": "Connect computer",
"dashboard.empty.title": "No devices yet",
"dashboard.empty.description":
"Pair your first Kneron device to start running inference from anywhere.",
"dashboard.empty.action": "Pair a device",
"Connect your computer to start using Kneron devices from anywhere.",
"dashboard.empty.action": "Connect computer",
"dashboard.activity.justNow": "just now",
"dashboard.activity.minutesAgo": "{n} minutes ago",
"dashboard.activity.hoursAgo": "{n} hours ago",
@ -131,13 +131,13 @@ export const en: Dictionary = {
"devices.firmware": "Firmware",
"devices.flashedModel": "Flashed model",
"devices.openWorkspace": "Open workspace",
"devices.addMore": "Pair a new device",
"devices.pairAction": "Pair a new device",
"devices.empty.title": "No devices paired yet",
"devices.addMore": "Connect a new computer",
"devices.pairAction": "Connect a new computer",
"devices.empty.title": "No computers connected yet",
"devices.empty.description":
"Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.",
"devices.empty.action": "Pair your first device",
"devices.empty.secondaryAction": "How pairing works",
"Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.",
"devices.empty.action": "Connect your computer",
"devices.empty.secondaryAction": "How connecting works",
"devices.detail.id": "ID",
"devices.detail.type": "Type",
"devices.detail.firmware": "Firmware",
@ -146,7 +146,7 @@ export const en: Dictionary = {
"devices.detail.modelStatus": "Model status",
"devices.detail.readyForInference": "Ready for inference",
"devices.detail.noModelFlashed": "No model has been flashed",
"devices.detail.pairedAt": "Paired at",
"devices.detail.pairedAt": "Connected at",
"devices.detail.hostName": "Host",
"devices.detail.lastSeen": "Last seen",
"devices.detail.offlineBanner.title": "This device is offline",
@ -165,14 +165,14 @@ export const en: Dictionary = {
"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.",
"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.action": "Remove device",
"devices.remove.removing": "Removing…",
"devices.remove.confirm.title": "Remove this device?",
"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.toast.success": "Device removed",
"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.",
"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.flashModel": "Load model",
"devices.flash.flashToDevice": "Load a model to this device",
@ -279,7 +289,7 @@ export const en: Dictionary = {
"models.filters.all": "All",
"models.empty.title": "No models yet",
"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.section.preset": "Preset models",
"models.section.converted": "Converted by you",
@ -423,7 +433,7 @@ export const en: Dictionary = {
"workspace.subtitle": "Select an online device to start inference",
"workspace.empty.title": "No devices are online",
"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.header.backToDevices": "Back to devices",
"workspace.header.title": "Workspace",
@ -452,7 +462,7 @@ export const en: Dictionary = {
"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.",
"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.image": "Image",
"workspace.tabs.video": "Video",
@ -509,35 +519,35 @@ export const en: Dictionary = {
"settings.advanced.platform": "Platform",
// ── Pairing (F7) ──
"pairing.title": "Pair a new device",
"pairing.title": "Connect this computer",
"pairing.subtitle":
"Connect your Kneron device to the cloud so you can operate it from anywhere.",
"pairing.token.title": "Your pairing token",
"Connect this computer to the cloud so its Kneron devices can be operated from anywhere.",
"pairing.token.title": "Your connection token",
"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.copied": "Copied",
"pairing.regenerate": "Regenerate",
"pairing.timeRemaining": "{time} remaining",
"pairing.generatedAt": "Generated at {time}",
"pairing.token.expired.label": "This token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate token?",
"pairing.token.expired.label": "This connection token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate connection token?",
"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":
"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":
"Tokens are single-use and expire automatically after pairing.",
"pairing.toast.copied": "Token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate token — please retry.",
"Connection tokens are single-use and expire automatically after connecting.",
"pairing.toast.copied": "Connection token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate connection token — please retry.",
"pairing.toast.expiringSoon":
"Token expiring soon — complete pairing or regenerate.",
"pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.",
"Connection token expiring soon — complete the connection or regenerate.",
"pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.",
"pairing.toast.cliCopied": "CLI command copied.",
"pairing.device.unknown": "Unknown device",
"pairing.cli.title": "CLI example",
"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.hint":
"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.hints.running": "Confirm local agent is running",
"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":
"Confirm your network can reach the cloud endpoint",
"pairing.step3.success": "Connected!",

View File

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

View File

@ -24,6 +24,7 @@ beforeEach(() => {
disconnectingId: null,
unpairingId: null,
registeringId: null,
isRescanning: false,
error: null,
});
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -663,3 +664,101 @@ describe("useDeviceStore.unregisterDevice", () => {
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: 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 {
devices: DeviceSummary[];
selectedDevice: Device | null;
@ -214,6 +227,8 @@ interface DeviceState {
unpairingId: string | null;
/** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */
registeringId: string | null;
/** 重新掃描 USB 裝置進行中UI 顯示按鈕 spinner + disable不使用就是 false */
isRescanning: boolean;
error: string | null;
/** 呼叫 `GET /api/devices` */
@ -246,13 +261,23 @@ interface DeviceState {
* unpairunregister registeredAtdevice
*/
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 */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
_setSelected: (device: Device | null) => void;
}
export const useDeviceStore = create<DeviceState>()((set) => ({
export const useDeviceStore = create<DeviceState>()((set, get) => ({
devices: [],
selectedDevice: null,
isLoading: false,
@ -260,6 +285,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
disconnectingId: null,
unpairingId: null,
registeringId: null,
isRescanning: false,
error: null,
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 }),
_setSelected: (selectedDevice) => set({ selectedDevice }),
}));