fix(local-tool): 連線 loading + 資料目錄平台路徑 + 卡片排版

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 18:57:06 +08:00
parent 9b0d946acd
commit ae5dfe1739
5 changed files with 69 additions and 40 deletions

View File

@ -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();

View File

@ -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 (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-base">{displayName}</CardTitle>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base truncate">{displayName}</CardTitle>
{prefs.alias && (
<p className="text-xs text-muted-foreground">{device.name}</p>
)}
</div>
<DeviceStatusBadge status={device.status} />
<DeviceStatusBadge status={isConnecting ? 'connecting' : device.status} />
</div>
</CardHeader>
<CardContent className="space-y-3">
@ -55,25 +59,29 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
{isConnected ? (
<>
<Link href={`/devices/${device.id}`}>
<Button size="sm" variant="outline" {...(isFirstCard ? { 'data-tour-id': 'manage-device-btn' } : {})}>
<Button size="sm" variant="outline" disabled={isBusy} {...(isFirstCard ? { 'data-tour-id': 'manage-device-btn' } : {})}>
{t('common.manage')}
</Button>
</Link>
<Button
size="sm"
variant="ghost"
disabled={isBusy}
onClick={() => disconnectDevice(device.id)}
>
{t('common.disconnect')}
{isDisconnecting && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{isDisconnecting ? t('devices.disconnecting') : t('common.disconnect')}
</Button>
</>
) : (
<Button
size="sm"
disabled={isBusy}
onClick={() => connectDevice(device.id)}
{...(isFirstCard ? { 'data-tour-id': 'connect-device-btn' } : {})}
>
{t('common.connect')}
{isConnecting && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{isConnecting ? t('devices.connecting') : t('common.connect')}
</Button>
)}
</div>

View File

@ -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',

View File

@ -67,6 +67,8 @@ export const zhTW: TranslationDict = {
subtitle: '管理你的 Edge AI 裝置',
scan: '掃描裝置',
scanning: '掃描中...',
connecting: '連線中...',
disconnecting: '斷線中...',
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
type: '類型',
firmware: '韌體',

View File

@ -10,6 +10,8 @@ interface DeviceState {
selectedDevice: Device | null;
loading: boolean;
scanning: boolean;
connectingId: string | null;
disconnectingId: string | null;
fetchDevices: () => Promise<void>;
scanDevices: () => Promise<void>;
fetchDevice: (id: string) => Promise<void>;
@ -23,6 +25,8 @@ export const useDeviceStore = create<DeviceState>((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<DeviceState>((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) => {