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:
parent
9b0d946acd
commit
ae5dfe1739
@ -21,12 +21,17 @@ import { useTranslation } from '@/lib/i18n';
|
|||||||
import { ServerLogViewer } from '@/components/server-log-viewer';
|
import { ServerLogViewer } from '@/components/server-log-viewer';
|
||||||
import { ServerStatusDashboard } from '@/components/server-status-dashboard';
|
import { ServerStatusDashboard } from '@/components/server-status-dashboard';
|
||||||
|
|
||||||
// TODO(M2+): read these values from the backend via /api/system/info once that
|
// TODO(M2+): read actual values from the backend via /api/system/info.
|
||||||
// endpoint exists. For M2 we display placeholders so the Settings UI structure
|
// 暫時用 userAgent 判斷平台顯示對應路徑。
|
||||||
// and i18n bindings can be validated without backend changes.
|
function getPlatformDataDir(): string {
|
||||||
const DATA_DIR_PLACEHOLDER = '~/Library/Application Support/visiona-local';
|
if (typeof navigator === 'undefined') return '(unknown)';
|
||||||
const MODELS_UPLOAD_PATH_PLACEHOLDER = '~/Library/Application Support/visiona-local/models';
|
if (/Windows/i.test(navigator.userAgent)) return '%APPDATA%\\visiona-local';
|
||||||
const BUNDLED_PYTHON_PLACEHOLDER = 'Bundled Python 3.11 (ready)';
|
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() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import { DeviceStatusBadge } from './device-status';
|
import { DeviceStatusBadge } from './device-status';
|
||||||
import { useDeviceStore } from '@/stores/device-store';
|
import { useDeviceStore } from '@/stores/device-store';
|
||||||
import { useDevicePreferencesStore } from '@/stores/device-preferences-store';
|
import { useDevicePreferencesStore } from '@/stores/device-preferences-store';
|
||||||
@ -16,22 +17,25 @@ interface DeviceCardProps {
|
|||||||
|
|
||||||
export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
|
export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { connectDevice, disconnectDevice } = useDeviceStore();
|
const { connectDevice, disconnectDevice, connectingId, disconnectingId } = useDeviceStore();
|
||||||
const prefs = useDevicePreferencesStore((s) => s.getPreferences(device.id));
|
const prefs = useDevicePreferencesStore((s) => s.getPreferences(device.id));
|
||||||
const displayName = prefs.alias || device.name;
|
const displayName = prefs.alias || device.name;
|
||||||
const isConnected = device.status === 'connected' || device.status === 'flashing' || device.status === 'inferencing';
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-base">{displayName}</CardTitle>
|
<CardTitle className="text-base truncate">{displayName}</CardTitle>
|
||||||
{prefs.alias && (
|
{prefs.alias && (
|
||||||
<p className="text-xs text-muted-foreground">{device.name}</p>
|
<p className="text-xs text-muted-foreground">{device.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DeviceStatusBadge status={device.status} />
|
<DeviceStatusBadge status={isConnecting ? 'connecting' : device.status} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@ -55,25 +59,29 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
|
|||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<>
|
<>
|
||||||
<Link href={`/devices/${device.id}`}>
|
<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')}
|
{t('common.manage')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
disabled={isBusy}
|
||||||
onClick={() => disconnectDevice(device.id)}
|
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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={isBusy}
|
||||||
onClick={() => connectDevice(device.id)}
|
onClick={() => connectDevice(device.id)}
|
||||||
{...(isFirstCard ? { 'data-tour-id': 'connect-device-btn' } : {})}
|
{...(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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -67,6 +67,8 @@ export const en: TranslationDict = {
|
|||||||
subtitle: 'Manage your edge AI devices',
|
subtitle: 'Manage your edge AI devices',
|
||||||
scan: 'Scan Devices',
|
scan: 'Scan Devices',
|
||||||
scanning: 'Scanning...',
|
scanning: 'Scanning...',
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
disconnecting: 'Disconnecting...',
|
||||||
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
|
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
firmware: 'Firmware',
|
firmware: 'Firmware',
|
||||||
|
|||||||
@ -67,6 +67,8 @@ export const zhTW: TranslationDict = {
|
|||||||
subtitle: '管理你的 Edge AI 裝置',
|
subtitle: '管理你的 Edge AI 裝置',
|
||||||
scan: '掃描裝置',
|
scan: '掃描裝置',
|
||||||
scanning: '掃描中...',
|
scanning: '掃描中...',
|
||||||
|
connecting: '連線中...',
|
||||||
|
disconnecting: '斷線中...',
|
||||||
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
|
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
|
||||||
type: '類型',
|
type: '類型',
|
||||||
firmware: '韌體',
|
firmware: '韌體',
|
||||||
|
|||||||
@ -10,6 +10,8 @@ interface DeviceState {
|
|||||||
selectedDevice: Device | null;
|
selectedDevice: Device | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
scanning: boolean;
|
scanning: boolean;
|
||||||
|
connectingId: string | null;
|
||||||
|
disconnectingId: string | null;
|
||||||
fetchDevices: () => Promise<void>;
|
fetchDevices: () => Promise<void>;
|
||||||
scanDevices: () => Promise<void>;
|
scanDevices: () => Promise<void>;
|
||||||
fetchDevice: (id: string) => Promise<void>;
|
fetchDevice: (id: string) => Promise<void>;
|
||||||
@ -23,6 +25,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
selectedDevice: null,
|
selectedDevice: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
scanning: false,
|
scanning: false,
|
||||||
|
connectingId: null,
|
||||||
|
disconnectingId: null,
|
||||||
|
|
||||||
scanDevices: async () => {
|
scanDevices: async () => {
|
||||||
set({ scanning: true });
|
set({ scanning: true });
|
||||||
@ -66,6 +70,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
connectDevice: async (id) => {
|
connectDevice: async (id) => {
|
||||||
|
set({ connectingId: id });
|
||||||
|
try {
|
||||||
const res = await api.post(`/devices/${id}/connect`);
|
const res = await api.post(`/devices/${id}/connect`);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showSuccess(getTranslation().t('errors.deviceConnected'));
|
showSuccess(getTranslation().t('errors.deviceConnected'));
|
||||||
@ -73,8 +79,6 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`);
|
useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`);
|
||||||
} else {
|
} else {
|
||||||
const msg = res.error?.message || '';
|
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)) {
|
if (/winusb|error.{0,5}code.{0,5}28|KP_ERROR_CONNECT_FAILED/i.test(msg)) {
|
||||||
showApiError({
|
showApiError({
|
||||||
code: 'DRIVER_NOT_INSTALLED',
|
code: 'DRIVER_NOT_INSTALLED',
|
||||||
@ -86,10 +90,15 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
showApiError(res.error);
|
showApiError(res.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
set({ connectingId: null });
|
||||||
get().fetchDevices();
|
get().fetchDevices();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnectDevice: async (id) => {
|
disconnectDevice: async (id) => {
|
||||||
|
set({ disconnectingId: id });
|
||||||
|
try {
|
||||||
const res = await api.post(`/devices/${id}/disconnect`);
|
const res = await api.post(`/devices/${id}/disconnect`);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showSuccess(getTranslation().t('errors.deviceDisconnected'));
|
showSuccess(getTranslation().t('errors.deviceDisconnected'));
|
||||||
@ -98,7 +107,10 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
showApiError(res.error);
|
showApiError(res.error);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
set({ disconnectingId: null });
|
||||||
get().fetchDevices();
|
get().fetchDevices();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleEvent: (event) => {
|
handleEvent: (event) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user