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>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
'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>
|
||
);
|
||
}
|