依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 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.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>
|
|
);
|
|
}
|