Compare commits

..

No commits in common. "df082ed308952b9bdf22d256dce978e90ab7b0dc" and "e27d8e3bd2c741aad69bb006e346f13174b44745" have entirely different histories.

8 changed files with 67 additions and 613 deletions

View File

@ -1,168 +0,0 @@
# 「配對」用詞一致化 — 完整 mapping 表frontend 照表落地)
> 狀態:待 frontend 落地 · 產出Design Agent · 語言zh-TW
> 目標檔:`visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts`zh+ `en.ts`en
> **本文件只出 mapping不改任何 code / i18n 檔。frontend 照表逐 key 替換,不需自己判斷。**
---
## 0. 為什麼要改(一句話)
「配對裝置」讓使用者誤以為在配對 KL520/KL720 **晶片**,但實際配對的是**使用者電腦上的 local-agent**(建立雲端帳號 ↔ 這台電腦的信任連結;之後這台電腦上所有 USB 裝置都能從雲端操作)。所以把主詞從「裝置」換成「電腦」。
## 1. 心智模型frontend 理解用,務必記住)
系統裡有**兩層不同的連結**,過去都叫「連接/連線」,這次改詞就是要讓兩層在同一畫面不打架:
| 層 | 原本叫什麼 | 這次改成 | 語意 | 頻率 | 對象 |
|----|-----------|---------|------|------|------|
| **信任連結** | 配對 / pairing | **連接這台電腦 / Connect this computer** | 建立「雲端帳號 ↔ 這台電腦」的信任,一次性 | 一次 | 電腦host / local-agent |
| **驅動連線** | 連線 / connect`devices.connect.*` | **不動**(維持「連線 / 斷線」) | 每次要用某顆 USB 晶片時開啟 driver 連線 | 每次使用 | 裝置USB 晶片) |
**一句話****「連接電腦」是把整台電腦接進你的雲端帳號(原 pairing「連線裝置」是開始使用電腦上的某顆晶片原 device connect不動。**
## 2. 避撞規則frontend 落地時的鐵則)
1. `common.connect="連接"``common.disconnect="中斷連線"``devices.connect.*`(連線/斷線/連線中)**全部保持不動**。
2. **pairing 相關新詞一律帶主詞「電腦 / 這台電腦」**,不可出現裸「連接」。靠主詞讓使用者秒分辨兩層:
- ✅「連接這台電腦」「已連接的電腦」「重新連接電腦」
- ❌「連接」「已連接」(會跟裝置層 `devices.filter.onlineRegistered="已連接"` 撞)
3. 英文同理pairing 用 **connect (this) computer / connected computer**;裝置層維持既有 connect/disconnect 用於 device driver。英文 `Connected!`pairing.step3.success維持不變因為那是「電腦已接上雲端」的成功提示語境清楚。
## 3. 成套用詞決定(正向 / 反向 / 名詞 / 欄位)
| 類別 | 舊詞 | 新詞zh | 新詞en | 理由 |
|------|------|-----------|-----------|------|
| **正向動作** | 配對 / Pair | 連接這台電腦 / **Connect this computer**(按鈕短版「連接電腦 / Connect computer」 | 主詞換成電腦、消除晶片誤解;長句重寫成「連接你的電腦,就能從雲端使用裝置」 |
| **反向動作unpair** | 解除配對 / unpair | **中斷這台電腦的連接 / 解除電腦連接**(動作按鈕沿用既有「移除裝置」不動,內文用「解除電腦連接」) | Disconnect this computer / unlink | unpair 語意=「移除裝置紀錄 + 撤銷存取」;改詞**不改語意**。用「解除…連接」對應正向「連接」,成套。註:現有 `devices.remove.action="移除裝置"` 是刪 device 紀錄的動作,**保持不動**;只有描述 pairing 關係的句子才改。 |
| **名詞Token** | Pairing Token | **連接碼**(口語處)/ **連接 Token**(技術處) | **Pairing token → connection token**CLI 參數 `--relay-token` **不動** | 見 §4「保留 pairing 原文」判定token 名詞面向使用者改「連接碼」較白話;`--relay-token` 是實際 CLI flag屬 code identifier**絕不改**。 |
| **欄位pairedAt** | 配對時間 / Paired at | **連接時間 / Connected at** | i18n label 可改;底層欄位名 `pairedAt`API/DB**不改**,只改顯示字。 |
| **重新配對re-pair** | 重新配對 / re-pair | **重新連接電腦 / re-connect (the computer)** | 成套。凡「請在 local agent 重新配對一次」→「請在 local agent 重新連接一次」。 |
### 3.1 判定「保留 pairing 原文」的技術術語(不改)
| 保留項 | 出現位置 | 為何不改 |
|--------|---------|---------|
| `--relay-token` | `pairing.cli.description` | 實際 CLI flag、code identifier改了指令會壞。 |
| i18n **key 名**`pairing.*``pairedAt``dashboard.pairDevice` 等) | 全部 key | 只改 value不改 key改 key 要動用到處引用的元件,超出本次範圍且無使用者可見效益)。 |
| API/DB 欄位 `pairedAt`、後端 `pairing` endpoint、路由 `/devices/pair` | 非 i18n | 使用者拍板「不改路由」;欄位名屬後端契約,本次只改前端顯示文案。 |
---
## 4. 完整 mapping 表(涵蓋 zh 55 處 + en 57 處)
> 說明:一個 i18n key = 一列,同時給 zh/en 舊→新。frontend 逐列替換 value 即可。
> 「備註」欄標注句子有無重寫、避撞注意。行號為撰文時快照zh-Hant.ts / en.ts實際以 key 為準。
### 4.1 Dashboard
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `dashboard.noConnectedDevices` | 目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。 | 目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。 | 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.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. | pairing→connecting a computer |
| `dashboard.pairDevice` | 配對裝置 | 連接電腦 | Pair device | Connect computer | 按鈕短版 |
| `dashboard.empty.description` | 配對你的第一台 Kneron 裝置,開始雲端推論之旅 | 連接你的電腦,開始從雲端使用 Kneron 裝置 | 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 | Connect computer | 按鈕短版 |
### 4.2 Devices列表 / 空狀態 / 詳情)
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `devices.addMore` | 配對新裝置 | 連接新電腦 | Pair a new device | Connect a new computer | |
| `devices.pairAction` | 配對新裝置 | 連接新電腦 | Pair a new device | Connect a new computer | 與上同值 |
| `devices.empty.title` | 還沒有配對的裝置 | 還沒有連接任何電腦 | No devices paired yet | No computers connected yet | |
| `devices.empty.description` | 在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置 | 在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置 | 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 | Connect your computer | |
| `devices.empty.secondaryAction` | 查看配對說明 | 查看連接說明 | How pairing works | How connecting works | |
| `devices.detail.pairedAt` | 配對時間 | 連接時間 | Paired at | Connected at | 欄位 label底層 `pairedAt` 欄位名不動 |
### 4.3 Devices序號 missingHintre-pair
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `devices.serial.missingHint` | 此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。 | 此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。 | 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. | re-pair→re-connect |
### 4.4 Devices移除裝置unpair 語意reverse action
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `devices.remove.confirm.description` | 這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。 | 這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。 | 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. | **反向成套**unpairs→disconnects「解除…配對」→「解除…連接」。unpair 語意(刪紀錄+撤存取)不變。`devices.remove.action="移除裝置"` 按鈕**不動**。 |
> 註:`devices.remove.action / removing / confirm.title / confirm.action / toast.success / error.*`(移除/移除中/確定要移除此裝置/移除/已移除裝置…)**全部不含「配對」字樣,保持不動**。這些是「移除 device 紀錄」的動作詞,與 pairing 改詞無關。
### 4.5 Models
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `models.empty.description` | 上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置 | 上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置 | 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. | 「配對過的裝置」→「已連接電腦上的裝置」(語意校正:裝置本身不被配對,是它所在電腦被連接) |
### 4.6 Workspace
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `workspace.empty.description` | 請先配對並確認 local agent 已連上雲端 | 請先連接你的電腦並確認 local agent 已連上雲端 | 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. | 「local agent 已連上雲端」中的「連上」屬既有網路語意、不改 |
| `workspace.noSerial.description` | 推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。 | 推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。 | 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. | re-pair→re-connect |
### 4.7 Pairing 頁F7key namespace `pairing.*` 保留、只改 value
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|----------|-------|-------|-------|-------|------|
| `pairing.title` | 配對新裝置 | 連接這台電腦 | Pair a new device | Connect this computer | 頁標題,正向主詞 |
| `pairing.subtitle` | 讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作 | 讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作 | 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` | 你的 Pairing Token | 你的連接碼 | Your pairing token | Your connection token | 名詞成套:連接碼 |
| `pairing.step1.description` | 複製下方 token在 15 分鐘內貼到 local agent 完成配對 | 複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接 | 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. | token→連接碼完成配對→完成連接 |
| `pairing.copy` | 複製 | 複製 | Copy | Copy | 不動 |
| `pairing.copied` | 已複製 | 已複製 | Copied | Copied | 不動 |
| `pairing.regenerate` | 重新產生 | 重新產生 | Regenerate | Regenerate | 不動 |
| `pairing.timeRemaining` | 剩餘 {time} | 剩餘 {time} | {time} remaining | {time} remaining | 不動 |
| `pairing.generatedAt` | 產生時間:{time} | 產生時間:{time} | Generated at {time} | Generated at {time} | 不動 |
| `pairing.token.expired.label` | 此 token 已過期,請重新產生 | 此連接碼已過期,請重新產生 | This token has expired — please regenerate. | This connection token has expired — please regenerate. | token→連接碼 |
| `pairing.regenerateConfirm.title` | 確定要重新產生? | 確定要重新產生? | Regenerate token? | Regenerate token? | 不動token 這裡指連接碼、但短語保留如要一致可改「Regenerate connection token?」,見下方註) |
| `pairing.regenerateConfirm.description` | 舊 token 將立即失效,新 token 有效期 15 分鐘 | 舊連接碼將立即失效,新連接碼有效期 15 分鐘 | 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. | token→連接碼 |
| `pairing.security.warning` | 這組 token 15 分鐘內有效,請立刻完成配對 | 這組連接碼 15 分鐘內有效,請立刻完成連接 | This token is valid for 15 minutes — complete pairing now. | This connection token is valid for 15 minutes — complete the connection now. | token→連接碼complete pairing→complete the connection |
| `pairing.security.oneTime` | token 是一次性使用,完成配對後自動失效 | 連接碼是一次性使用,完成連接後自動失效 | Tokens are single-use and expire automatically after pairing. | Connection tokens are single-use and expire automatically after connecting. | 成套 |
| `pairing.toast.copied` | Token 已複製到剪貼簿15 分鐘內有效 | 連接碼已複製到剪貼簿15 分鐘內有效 | Token copied — valid for 15 minutes. | Connection token copied — valid for 15 minutes. | token→連接碼 |
| `pairing.toast.generateFailed` | 無法產生 token請重試 | 無法產生連接碼,請重試 | Could not generate token — please retry. | Could not generate connection token — please retry. | token→連接碼 |
| `pairing.toast.expiringSoon` | Token 即將過期,請立刻完成或重新產生 | 連接碼即將過期,請立刻完成或重新產生 | Token expiring soon — complete pairing or regenerate. | Connection token expiring soon — complete the connection or regenerate. | 成套 |
| `pairing.toast.pairedSuccess` | 裝置 {deviceName} 已成功配對 | 已成功連接電腦,偵測到裝置 {deviceName} | Device {deviceName} paired successfully. | Computer connected — device {deviceName} detected. | 語意校正:成功的是電腦連接、順帶偵測到裝置 |
| `pairing.toast.cliCopied` | 指令已複製到剪貼簿 | 指令已複製到剪貼簿 | CLI command copied. | CLI command copied. | 不動 |
| `pairing.device.unknown` | 未知裝置 | 未知裝置 | Unknown device | Unknown device | 不動 |
| `pairing.cli.title` | CLI 指令範例 | CLI 指令範例 | CLI example | CLI example | 不動 |
| `pairing.cli.description` | 在你的電腦啟動 local agent將 token 貼到指令的 --relay-token 參數 | 在你的電腦啟動 local agent將連接碼貼到指令的 --relay-token 參數 | 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. | token→連接碼**`--relay-token` 保留不改** |
| `pairing.cli.copy` | 複製指令 | 複製指令 | Copy command | Copy command | 不動 |
| `pairing.cli.hint` | local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表 | local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表 | 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. | 不動(「連上雲端」屬網路語意、非 pairing 動作詞) |
| `pairing.step3.waiting` | 等待 local agent 連線… | 等待 local agent 連線… | Waiting for local agent to connect… | Waiting for local agent to connect… | 不動local agent↔雲端的網路連線非 pairing 動作詞) |
| `pairing.step3.elapsed` | 已等待 {time}(最長 3 分鐘) | 已等待 {time}(最長 3 分鐘) | Elapsed {time} (max 3 minutes) | Elapsed {time} (max 3 minutes) | 不動 |
| `pairing.step3.hints.running` | 確認 local agent 已啟動 | 確認 local agent 已啟動 | Confirm local agent is running | Confirm local agent is running | 不動 |
| `pairing.step3.hints.token` | 確認 token 貼上時無缺字或多餘空白 | 確認連接碼貼上時無缺字或多餘空白 | Confirm the token was pasted without missing or extra characters | Confirm the connection token was pasted without missing or extra characters | token→連接碼 |
| `pairing.step3.hints.network` | 確認你的網路可連線到雲端 | 確認你的網路可連線到雲端 | Confirm your network can reach the cloud endpoint | Confirm your network can reach the cloud endpoint | 不動 |
| `pairing.step3.success` | 已成功連線! | 已成功連接電腦! | Connected! | Connected! | zh 加主詞「電腦」避撞en `Connected!` 語境清楚,保留 |
| `pairing.step3.success.detected` | 檢測到的裝置 | 檢測到的裝置 | Detected device | Detected device | 不動 |
| `pairing.step3.failure.timeout` | 連線超時 | 連接超時 | Connection timeout | Connection timeout | zh「連線超時」→「連接超時」對齊 pairing 語意en 不動 |
| `pairing.step3.failure.reason` | 超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動 | 超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動 | No local agent connection within 3 minutes — agent may not be running. | No local agent connection within 3 minutes — agent may not be running. | 不動(指 agent↔雲端網路連線 |
| `pairing.step3.failure.retry` | 重新檢查 | 重新檢查 | Check again | Check again | 不動 |
> `pairing.regenerateConfirm.title` 註:為求 token 名詞完全一致,可選改 zh「確定要重新產生連接碼」/ en「Regenerate connection token?」。frontend 若採一致版即用此;否則保留現值。此為 nice-to-have不阻擋落地。
---
## 5. 明確「不動」清單frontend 別誤改)
以下 key 含「連接 / 連線 / connect」但屬**裝置驅動層或網路層**,與 pairing 無關,**一律不改**
- `common.connect="連接"``common.disconnect="中斷連線"`
- `devices.connect.*` 全部action 連線 / connecting 連線中 / disconnect 斷線 / driverStatusLabel 連線狀態 / toast.* …)
- `devices.filter.onlineRegistered="已連接"``devices.filter.onlineUnregistered``devices.filter.offline`
- `devices.status.connecting/connected/disconnected``devices.detail.port="連接埠"`
- `devices.rescan.offlineHint`(「請先連接 local agent…」屬 agent 在線語意)
- `tunnel.status.*``remote.status.*`、各處「連線失敗 / 網路連線」等網路語意字串
- `devices.remove.action/removing/confirm.title/confirm.action/toast/error.*`(移除 device 紀錄動作、非 pairing 詞)
---
## 6. 落地後驗證frontend / Design QA 自檢)
1. **同畫面不打架**Devices 列表頁同時出現「連接新電腦」pairing 按鈕)與裝置卡片上「連線 / 斷線」device driver主詞清楚不混淆。
2. **grep 檢查**:改完後 `pairing.*` value 與 dashboard/devices/workspace 的 pairing 文案不再出現裸「配對」en 不再出現 `Pair`/`pairing`/`unpair`(除 `--relay-token`、i18n key 名、`pairedAt` 欄位名外)。
3. **避撞檢查**pairing 新詞不得出現裸「連接/已連接」(無主詞),必帶「電腦」。
4. **語意不變檢查**`devices.remove.confirm.description`unpair改詞後仍表達「移除裝置紀錄 + 撤銷存取 + 不可復原」。

View File

@ -19,7 +19,6 @@ 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";
@ -55,9 +54,6 @@ 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

@ -1,125 +0,0 @@
/**
* 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

@ -1,82 +0,0 @@
"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. Connect your computer to use Kneron devices from the cloud.", "No devices are online. Pair a Kneron device to start cloud inference.",
"dashboard.recentActivity": "Recent activity", "dashboard.recentActivity": "Recent activity",
"dashboard.noActivity": "dashboard.noActivity":
"Nothing here yet. Activity appears after connecting a computer, uploads, or inference runs.", "Nothing here yet. Activity appears after pairing, 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": "Connect computer", "dashboard.pairDevice": "Pair device",
"dashboard.empty.title": "No devices yet", "dashboard.empty.title": "No devices yet",
"dashboard.empty.description": "dashboard.empty.description":
"Connect your computer to start using Kneron devices from anywhere.", "Pair your first Kneron device to start running inference from anywhere.",
"dashboard.empty.action": "Connect computer", "dashboard.empty.action": "Pair a device",
"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": "Connect a new computer", "devices.addMore": "Pair a new device",
"devices.pairAction": "Connect a new computer", "devices.pairAction": "Pair a new device",
"devices.empty.title": "No computers connected yet", "devices.empty.title": "No devices paired yet",
"devices.empty.description": "devices.empty.description":
"Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.", "Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.",
"devices.empty.action": "Connect your computer", "devices.empty.action": "Pair your first device",
"devices.empty.secondaryAction": "How connecting works", "devices.empty.secondaryAction": "How pairing 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": "Connected at", "devices.detail.pairedAt": "Paired 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-connect once from local agent to report the serial.", "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…",
"devices.remove.confirm.title": "Remove this device?", "devices.remove.confirm.title": "Remove this device?",
"devices.remove.confirm.description": "devices.remove.confirm.description":
"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.", "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.",
"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,16 +217,6 @@ 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",
@ -289,7 +279,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 Kneron device on a connected computer.", "Upload your first .nef model to deploy it to any paired Kneron device.",
"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",
@ -433,7 +423,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":
"Connect your computer and make sure the local agent is connected to the cloud.", "Pair a device 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",
@ -462,7 +452,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-connect 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-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",
@ -519,35 +509,35 @@ export const en: Dictionary = {
"settings.advanced.platform": "Platform", "settings.advanced.platform": "Platform",
// ── Pairing (F7) ── // ── Pairing (F7) ──
"pairing.title": "Connect this computer", "pairing.title": "Pair a new device",
"pairing.subtitle": "pairing.subtitle":
"Connect this computer to the cloud so its Kneron devices can be operated from anywhere.", "Connect your Kneron device to the cloud so you can operate it from anywhere.",
"pairing.token.title": "Your connection token", "pairing.token.title": "Your pairing token",
"pairing.step1.description": "pairing.step1.description":
"Copy the connection token below and paste it into your local agent within 15 minutes.", "Copy the 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 connection token has expired — please regenerate.", "pairing.token.expired.label": "This token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate connection token?", "pairing.regenerateConfirm.title": "Regenerate token?",
"pairing.regenerateConfirm.description": "pairing.regenerateConfirm.description":
"The old connection token will be invalidated immediately; the new one is valid for 15 minutes.", "The old token will be invalidated immediately; the new one is valid for 15 minutes.",
"pairing.security.warning": "pairing.security.warning":
"This connection token is valid for 15 minutes — complete the connection now.", "This token is valid for 15 minutes — complete pairing now.",
"pairing.security.oneTime": "pairing.security.oneTime":
"Connection tokens are single-use and expire automatically after connecting.", "Tokens are single-use and expire automatically after pairing.",
"pairing.toast.copied": "Connection token copied — valid for 15 minutes.", "pairing.toast.copied": "Token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate connection token — please retry.", "pairing.toast.generateFailed": "Could not generate token — please retry.",
"pairing.toast.expiringSoon": "pairing.toast.expiringSoon":
"Connection token expiring soon — complete the connection or regenerate.", "Token expiring soon — complete pairing or regenerate.",
"pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.", "pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.",
"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 connection token to the --relay-token flag.", "Start local agent on your computer and pass the 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.",
@ -555,7 +545,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 connection token was pasted without missing or extra characters", "Confirm the 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,16 +216,6 @@ 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": "載入模型到此裝置",
@ -287,7 +277,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": "我轉檔的",
@ -422,7 +412,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": "工作區",
@ -448,7 +438,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": "影片",
@ -504,42 +494,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.title": "你的 Pairing Token",
"pairing.step1.description": "pairing.step1.description":
"複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接", "複製下方 token在 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": "此連接碼已過期,請重新產生", "pairing.token.expired.label": "此 token 已過期,請重新產生",
"pairing.regenerateConfirm.title": "確定要重新產生連接碼", "pairing.regenerateConfirm.title": "確定要重新產生",
"pairing.regenerateConfirm.description": "pairing.regenerateConfirm.description":
"舊連接碼將立即失效,新連接碼有效期 15 分鐘", "舊 token 將立即失效,新 token 有效期 15 分鐘",
"pairing.security.warning": "這組連接碼 15 分鐘內有效,請立刻完成連接", "pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對",
"pairing.security.oneTime": "連接碼是一次性使用,完成連接後自動失效", "pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效",
"pairing.toast.copied": "連接碼已複製到剪貼簿15 分鐘內有效", "pairing.toast.copied": "Token 已複製到剪貼簿15 分鐘內有效",
"pairing.toast.generateFailed": "無法產生連接碼,請重試", "pairing.toast.generateFailed": "無法產生 token,請重試",
"pairing.toast.expiringSoon": "連接碼即將過期,請立刻完成或重新產生", "pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生",
"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連接碼貼到指令的 --relay-token 參數", "在你的電腦啟動 local agent token 貼到指令的 --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": "確認連接碼貼上時無缺字或多餘空白", "pairing.step3.hints.token": "確認 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,7 +24,6 @@ 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 由瀏覽器自動帶)
@ -664,101 +663,3 @@ 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,19 +203,6 @@ 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;
@ -227,8 +214,6 @@ 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` */
@ -261,23 +246,13 @@ 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, get) => ({ export const useDeviceStore = create<DeviceState>()((set) => ({
devices: [], devices: [],
selectedDevice: null, selectedDevice: null,
isLoading: false, isLoading: false,
@ -285,7 +260,6 @@ export const useDeviceStore = create<DeviceState>()((set, get) => ({
disconnectingId: null, disconnectingId: null,
unpairingId: null, unpairingId: null,
registeringId: null, registeringId: null,
isRescanning: false,
error: null, error: null,
fetchDevices: async () => { fetchDevices: async () => {
@ -442,28 +416,6 @@ export const useDeviceStore = create<DeviceState>()((set, get) => ({
} }
}, },
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 }),
})); }));