From ae5dfe17391d9e18a3b897179b7a07ef0596f6ec Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 12 Apr 2026 18:57:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-tool):=20=E9=80=A3=E7=B7=9A=20loading?= =?UTF-8?q?=20+=20=E8=B3=87=E6=96=99=E7=9B=AE=E9=8C=84=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E8=B7=AF=E5=BE=91=20+=20=E5=8D=A1=E7=89=87=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A. 連線 / 斷線按鈕加 loading state: - device-store.ts 新增 connectingId / disconnectingId state - connectDevice / disconnectDevice 用 try/finally 包裹確保 reset - device-card.tsx 按鈕加 Loader2 spinner + disabled 當任一裝置正在連線/斷線時所有按鈕都 disabled,防止連按 - i18n 新增 connecting / disconnecting 字串 C. 設定 > 一般 > 資料目錄顯示平台正確路徑: - 用 navigator.userAgent 偵測平台 - Windows: %APPDATA%\visiona-local - Linux: ~/.local/share/visiona-local - macOS: ~/Library/Application Support/visiona-local - 修正 Python 版本顯示 3.11 → 3.12 D. 裝置卡片排版修正: - 名稱和狀態 badge 之間加 gap-3 - 名稱區域 min-w-0 + truncate 防止 badge 被擠到換行 - 連線中時 badge 顯示 connecting 狀態 Co-Authored-By: Claude Opus 4.6 (1M context) --- local-tool/frontend/src/app/settings/page.tsx | 17 +++-- .../src/components/devices/device-card.tsx | 24 ++++--- local-tool/frontend/src/lib/i18n/en.ts | 2 + local-tool/frontend/src/lib/i18n/zh-TW.ts | 2 + .../frontend/src/stores/device-store.ts | 64 +++++++++++-------- 5 files changed, 69 insertions(+), 40 deletions(-) diff --git a/local-tool/frontend/src/app/settings/page.tsx b/local-tool/frontend/src/app/settings/page.tsx index 6bc655a..29938b6 100644 --- a/local-tool/frontend/src/app/settings/page.tsx +++ b/local-tool/frontend/src/app/settings/page.tsx @@ -21,12 +21,17 @@ import { useTranslation } from '@/lib/i18n'; import { ServerLogViewer } from '@/components/server-log-viewer'; import { ServerStatusDashboard } from '@/components/server-status-dashboard'; -// TODO(M2+): read these values from the backend via /api/system/info once that -// endpoint exists. For M2 we display placeholders so the Settings UI structure -// and i18n bindings can be validated without backend changes. -const DATA_DIR_PLACEHOLDER = '~/Library/Application Support/visiona-local'; -const MODELS_UPLOAD_PATH_PLACEHOLDER = '~/Library/Application Support/visiona-local/models'; -const BUNDLED_PYTHON_PLACEHOLDER = 'Bundled Python 3.11 (ready)'; +// TODO(M2+): read actual values from the backend via /api/system/info. +// 暫時用 userAgent 判斷平台顯示對應路徑。 +function getPlatformDataDir(): string { + if (typeof navigator === 'undefined') return '(unknown)'; + if (/Windows/i.test(navigator.userAgent)) return '%APPDATA%\\visiona-local'; + if (/Linux/i.test(navigator.userAgent)) return '~/.local/share/visiona-local'; + return '~/Library/Application Support/visiona-local'; +} +const DATA_DIR_PLACEHOLDER = getPlatformDataDir(); +const MODELS_UPLOAD_PATH_PLACEHOLDER = DATA_DIR_PLACEHOLDER + (DATA_DIR_PLACEHOLDER.includes('\\') ? '\\models' : '/models'); +const BUNDLED_PYTHON_PLACEHOLDER = 'Bundled Python 3.12 (ready)'; export default function SettingsPage() { const { t } = useTranslation(); diff --git a/local-tool/frontend/src/components/devices/device-card.tsx b/local-tool/frontend/src/components/devices/device-card.tsx index f423a40..23fa743 100644 --- a/local-tool/frontend/src/components/devices/device-card.tsx +++ b/local-tool/frontend/src/components/devices/device-card.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; import { DeviceStatusBadge } from './device-status'; import { useDeviceStore } from '@/stores/device-store'; import { useDevicePreferencesStore } from '@/stores/device-preferences-store'; @@ -16,22 +17,25 @@ interface DeviceCardProps { export function DeviceCard({ device, isFirstCard }: DeviceCardProps) { const { t } = useTranslation(); - const { connectDevice, disconnectDevice } = useDeviceStore(); + const { connectDevice, disconnectDevice, connectingId, disconnectingId } = useDeviceStore(); const prefs = useDevicePreferencesStore((s) => s.getPreferences(device.id)); const displayName = prefs.alias || device.name; const isConnected = device.status === 'connected' || device.status === 'flashing' || device.status === 'inferencing'; + const isConnecting = connectingId === device.id; + const isDisconnecting = disconnectingId === device.id; + const isBusy = isConnecting || isDisconnecting || connectingId !== null || disconnectingId !== null; return ( -
-
- {displayName} +
+
+ {displayName} {prefs.alias && (

{device.name}

)}
- +
@@ -55,25 +59,29 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) { {isConnected ? ( <> - ) : ( )}
diff --git a/local-tool/frontend/src/lib/i18n/en.ts b/local-tool/frontend/src/lib/i18n/en.ts index 0932601..8ca879a 100644 --- a/local-tool/frontend/src/lib/i18n/en.ts +++ b/local-tool/frontend/src/lib/i18n/en.ts @@ -67,6 +67,8 @@ export const en: TranslationDict = { subtitle: 'Manage your edge AI devices', scan: 'Scan Devices', scanning: 'Scanning...', + connecting: 'Connecting...', + disconnecting: 'Disconnecting...', noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.', type: 'Type', firmware: 'Firmware', diff --git a/local-tool/frontend/src/lib/i18n/zh-TW.ts b/local-tool/frontend/src/lib/i18n/zh-TW.ts index cf19659..f76d596 100644 --- a/local-tool/frontend/src/lib/i18n/zh-TW.ts +++ b/local-tool/frontend/src/lib/i18n/zh-TW.ts @@ -67,6 +67,8 @@ export const zhTW: TranslationDict = { subtitle: '管理你的 Edge AI 裝置', scan: '掃描裝置', scanning: '掃描中...', + connecting: '連線中...', + disconnecting: '斷線中...', noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。', type: '類型', firmware: '韌體', diff --git a/local-tool/frontend/src/stores/device-store.ts b/local-tool/frontend/src/stores/device-store.ts index c931664..4133419 100644 --- a/local-tool/frontend/src/stores/device-store.ts +++ b/local-tool/frontend/src/stores/device-store.ts @@ -10,6 +10,8 @@ interface DeviceState { selectedDevice: Device | null; loading: boolean; scanning: boolean; + connectingId: string | null; + disconnectingId: string | null; fetchDevices: () => Promise; scanDevices: () => Promise; fetchDevice: (id: string) => Promise; @@ -23,6 +25,8 @@ export const useDeviceStore = create((set, get) => ({ selectedDevice: null, loading: false, scanning: false, + connectingId: null, + disconnectingId: null, scanDevices: async () => { set({ scanning: true }); @@ -66,39 +70,47 @@ export const useDeviceStore = create((set, get) => ({ }, connectDevice: async (id) => { - const res = await api.post(`/devices/${id}/connect`); - if (res.success) { - showSuccess(getTranslation().t('errors.deviceConnected')); - const name = get().devices.find((d) => d.id === id)?.name || id; - useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`); - } else { - const msg = res.error?.message || ''; - // 偵測 Kneron WinUSB driver 未安裝的特徵錯誤訊息(error 28 / KP_ERROR_CONNECT_FAILED) - // 若命中,給使用者明確指引去點「安裝 USB Driver」按鈕,而不只是顯示原始錯誤 - if (/winusb|error.{0,5}code.{0,5}28|KP_ERROR_CONNECT_FAILED/i.test(msg)) { - showApiError({ - code: 'DRIVER_NOT_INSTALLED', - message: - '連線失敗:Kneron WinUSB driver 尚未安裝。\n' + - '請點擊右上角「安裝 USB Driver」按鈕(需要系統管理員權限),安裝完成後重新點選連線。', - }); + set({ connectingId: id }); + try { + const res = await api.post(`/devices/${id}/connect`); + if (res.success) { + showSuccess(getTranslation().t('errors.deviceConnected')); + const name = get().devices.find((d) => d.id === id)?.name || id; + useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`); } else { - showApiError(res.error); + const msg = res.error?.message || ''; + if (/winusb|error.{0,5}code.{0,5}28|KP_ERROR_CONNECT_FAILED/i.test(msg)) { + showApiError({ + code: 'DRIVER_NOT_INSTALLED', + message: + '連線失敗:Kneron WinUSB driver 尚未安裝。\n' + + '請點擊右上角「安裝 USB Driver」按鈕(需要系統管理員權限),安裝完成後重新點選連線。', + }); + } else { + showApiError(res.error); + } } + } finally { + set({ connectingId: null }); + get().fetchDevices(); } - get().fetchDevices(); }, disconnectDevice: async (id) => { - const res = await api.post(`/devices/${id}/disconnect`); - if (res.success) { - showSuccess(getTranslation().t('errors.deviceDisconnected')); - const name = get().devices.find((d) => d.id === id)?.name || id; - useActivityStore.getState().addActivity('device_disconnect', `Device disconnected: ${name}`); - } else { - showApiError(res.error); + set({ disconnectingId: id }); + try { + const res = await api.post(`/devices/${id}/disconnect`); + if (res.success) { + showSuccess(getTranslation().t('errors.deviceDisconnected')); + const name = get().devices.find((d) => d.id === id)?.name || id; + useActivityStore.getState().addActivity('device_disconnect', `Device disconnected: ${name}`); + } else { + showApiError(res.error); + } + } finally { + set({ disconnectingId: null }); + get().fetchDevices(); } - get().fetchDevices(); }, handleEvent: (event) => {