Compare commits
No commits in common. "df082ed308952b9bdf22d256dce978e90ab7b0dc" and "e27d8e3bd2c741aad69bb006e346f13174b44745" have entirely different histories.
df082ed308
...
e27d8e3bd2
@ -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:序號 missingHint(re-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 頁(F7,key 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)改詞後仍表達「移除裝置紀錄 + 撤銷存取 + 不可復原」。
|
|
||||||
@ -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)— 與上方「刷新列表」不同:
|
|
||||||
刷新只重讀雲端 DB,rescan 讓剛插入的新 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" />
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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!",
|
||||||
|
|||||||
@ -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": "平台",
|
||||||
|
|
||||||
// ── Pairing(F7 新增)──
|
// ── Pairing(F7 新增)──
|
||||||
"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": "重新檢查",
|
||||||
|
|||||||
@ -24,7 +24,6 @@ beforeEach(() => {
|
|||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
unpairingId: null,
|
unpairingId: null,
|
||||||
registeringId: null,
|
registeringId: null,
|
||||||
isRescanning: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||||
@ -664,101 +663,3 @@ describe("useDeviceStore.unregisterDevice", () => {
|
|||||||
expect(useDeviceStore.getState().registeringId).toBeNull();
|
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useDeviceStore.rescanDevices", () => {
|
|
||||||
it("成功時打對 scan endpoint(POST)、接著 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 });
|
|
||||||
// 第一發是 scan(POST),第二發是 fetchDevices(GET)。
|
|
||||||
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 設為 true(loading 態),完成後清回 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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;
|
||||||
/** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
/** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 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 {
|
|||||||
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。
|
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。
|
||||||
*/
|
*/
|
||||||
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 code(TUNNEL_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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user