jim800121chen ae5dfe1739 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>
2026-04-12 18:57:06 +08:00

290 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useSettingsStore } from '@/stores/settings-store';
import { getApiBaseUrl, getWsBaseUrl, getBackendUrl, setBackendUrl } from '@/lib/constants';
import { showSuccess } from '@/lib/toast';
import { useTranslation } from '@/lib/i18n';
import { ServerLogViewer } from '@/components/server-log-viewer';
import { ServerStatusDashboard } from '@/components/server-status-dashboard';
// 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();
const { theme, language, setLanguage, resetToDefaults } = useSettingsStore();
const [backendUrlInput, setBackendUrlInput] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
setBackendUrlInput(getBackendUrl());
setApiUrl(getApiBaseUrl());
setWsUrl(getWsBaseUrl());
}, []);
function handleSaveBackendUrl() {
setBackendUrl(backendUrlInput);
setApiUrl(getApiBaseUrl());
setWsUrl(getWsBaseUrl());
showSuccess(t('settings.backendUrlSaved'));
}
// Derived port for Advanced tab — parsed from backend URL if possible.
let serverPortDisplay = '3721';
try {
const u = new URL(apiUrl || 'http://localhost:3721');
if (u.port) serverPortDisplay = u.port;
} catch {
/* ignore */
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t('settings.title')}</h1>
<p className="text-muted-foreground">{t('settings.subtitle')}</p>
</div>
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">{t('settings.tabs.general')}</TabsTrigger>
<TabsTrigger value="hardware">{t('settings.tabs.hardware')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.tabs.models')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.tabs.advanced')}</TabsTrigger>
</TabsList>
{/* ─────────── General ─────────── */}
<TabsContent value="general" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.general.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('settings.general.language')}</Label>
<Select value={language} onValueChange={(v) => setLanguage(v as 'zh-TW' | 'en')}>
<SelectTrigger className="w-60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-TW">{t('settings.languageZhTW')}</SelectItem>
<SelectItem value="en">{t('settings.languageEn')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings.general.languageRestartHint')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.general.theme')}</Label>
<Input
value={
theme === 'dark'
? `${t('settings.general.themeFollowSystem')}${t('settings.general.themeCurrentDark')}`
: `${t('settings.general.themeFollowSystem')}${t('settings.general.themeCurrentLight')}`
}
readOnly
className="bg-muted w-80"
/>
<p className="text-xs text-muted-foreground">
{t('settings.general.themeFollowSystemHint')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.general.dataDirectory')}</Label>
<Input value={DATA_DIR_PLACEHOLDER} readOnly className="bg-muted font-mono text-sm" />
<p className="text-xs text-muted-foreground">
{t('settings.general.dataDirectoryHint')}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ─────────── Hardware ─────────── */}
<TabsContent value="hardware" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.hardware.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('settings.hardware.runtimeMode')}</Label>
{/* TODO: 連接 backend GET /api/system/config 讀實際 mode現階段只顯示預設值 real */}
<Select value="real" disabled>
<SelectTrigger className="w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings.hardware.runtimeModeHint')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.hardware.pythonMode')}</Label>
<Input value={BUNDLED_PYTHON_PLACEHOLDER} readOnly className="bg-muted w-80" />
<p className="text-xs text-muted-foreground">
{t('settings.hardware.pythonModeHint')}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ─────────── Models ─────────── */}
<TabsContent value="models" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.models.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('settings.models.presetModels')}</Label>
{/* TODO(M2+): fetch /api/models and list preset models here */}
<p className="text-sm text-muted-foreground">
{t('settings.models.presetModelsEmpty')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.models.uploadPath')}</Label>
<Input
value={MODELS_UPLOAD_PATH_PLACEHOLDER}
readOnly
className="bg-muted font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t('settings.models.uploadPathHint')}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ─────────── Advanced ─────────── */}
<TabsContent value="advanced" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.serverConfig')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('settings.backendUrl')}</Label>
<div className="flex gap-2">
<Input
value={backendUrlInput}
onChange={(e) => setBackendUrlInput(e.target.value)}
placeholder={t('settings.backendUrlPlaceholder')}
className="flex-1"
/>
<Button onClick={handleSaveBackendUrl} size="sm">
{t('settings.saveBackendUrl')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('settings.backendUrlHint')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.hardware.serverPort')}</Label>
<Input value={serverPortDisplay} readOnly className="bg-muted w-32" />
<p className="text-xs text-muted-foreground">
{t('settings.hardware.serverPortHint')}
</p>
</div>
<div className="space-y-2">
<Label>{t('settings.apiUrl')}</Label>
<Input value={apiUrl} readOnly className="bg-muted" />
</div>
<div className="space-y-2">
<Label>{t('settings.wsUrl')}</Label>
<Input value={wsUrl} readOnly className="bg-muted" />
</div>
</CardContent>
</Card>
<ServerStatusDashboard />
<ServerLogViewer />
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.advanced.about')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('settings.versionLabel')}</span>
<span className="font-medium">v0.1.0</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('settings.platform')}</span>
<span className="font-medium">visionA Local</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('settings.advanced.resetAll')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
{t('settings.advanced.resetAllHint')}
</p>
<Button
variant="outline"
onClick={() => {
resetToDefaults();
showSuccess(t('settings.resetSuccess'));
}}
>
{t('settings.resetToDefaults')}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}