v1.3.0 release - Docker-deployable production build

This commit is contained in:
2026-04-19 01:28:21 +08:00
commit 36cfcb116e
29 changed files with 11558 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
import { User, SystemSettings as ISystemSettings, Template } from '../types';
import { storage } from '../utils/storage';
export default function SystemSettings() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
defaultTemplate: '',
frameMode: 'uniform'
});
const [templates, setTemplates] = useState<Template[]>([]);
const [isSaved, setIsSaved] = useState(false);
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
const [modeModalOpen, setModeModalOpen] = useState(false);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user) {
navigate('/');
return;
}
setCurrentUser(user);
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedSettings.frameCount) {
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
savedSettings.defaultTemplate = savedTemplates[0].id;
}
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
setSettings(savedSettings);
} else if (savedTemplates.length > 0) {
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
}
setTemplates(savedTemplates);
}, [navigate]);
const round1 = (n: number) => Math.round(n * 10) / 10;
const computeFramePositions = (count: number, mode: 'uniform' | 'keep', currentPositions: number[]) => {
if (mode === 'uniform') {
const positions: number[] = [];
for (let i = 1; i <= count; i++) {
positions.push(round1((100 / (count + 1)) * i));
}
return positions;
}
const sorted = [...currentPositions].sort((a, b) => a - b);
if (count <= sorted.length) {
return sorted.slice(0, count);
}
const need = count - sorted.length;
const last = sorted[sorted.length - 1] || 0;
const range = 100 - last;
for (let i = 1; i <= need; i++) {
sorted.push(round1(last + (range / (need + 1)) * i));
}
return sorted;
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
const sortedPositions = [...settings.framePositions].sort((a, b) => a - b);
const finalSettings = { ...settings, framePositions: sortedPositions, frameCount: sortedPositions.length };
storage.set('systemSettings', finalSettings);
setSettings(finalSettings);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
};
const testApi = async () => {
if (!settings.apiEndpoint) {
alert('请先输入 API 接口地址');
return;
}
alert(`正在测试连接到: ${settings.apiEndpoint}\n(模拟测试: 连接成功)`);
};
const resetToDefault = () => {
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
defaultTemplate: templates[0]?.id || '',
frameMode: 'uniform',
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
};
setSettings(defaultSettings);
storage.set('systemSettings', defaultSettings);
}
};
const resetAllData = () => {
if (window.confirm('确定要重置全部数据吗?这将清除所有报告、模板和用户设置。')) {
localStorage.clear();
window.location.reload();
}
};
if (!currentUser) return null;
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="flex-1 p-10 overflow-y-auto">
<header className="flex justify-between items-center mb-10">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-main"></h1>
<p className="text-text-muted text-sm mt-1">
{currentUser.role === 'super' ? '配置全局参数,包括视频抽帧策略与外部 AI API 对接。' : '设置您的默认报告模板。'}
</p>
</div>
</header>
<form onSubmit={handleSave} className="max-w-[800px] space-y-8 pb-20">
{currentUser.role === 'super' && (
<div className="card-minimal">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
<Video size={20} className="text-accent" />
</h3>
<span className="text-[10px] font-bold bg-slate-100 text-text-muted px-2 py-1 rounded-full uppercase tracking-wider">
{settings.framePositions.length}
</span>
</div>
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
<div className="flex gap-2">
<input
type="number"
min={1}
max={100}
value={settings.frameCount}
onChange={(e) => {
const count = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
setSettings({ ...settings, frameCount: count });
}}
className="input-minimal bg-white"
/>
<button
type="button"
onClick={() => {
setPendingFrameCount(settings.frameCount);
setModeModalOpen(true);
}}
className="px-4 py-2 bg-accent text-white rounded-lg text-xs font-semibold hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
<div className="flex items-center h-[42px]">
<span className="text-sm text-text-main">
{settings.frameMode === 'uniform' ? '整体均匀抽取' : '保持当前抽帧'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoInsertFrames"
checked={settings.autoInsertFrames || false}
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
className="w-4 h-4 accent-accent cursor-pointer"
/>
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer"></label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (s)</label>
<input
type="number"
min={0}
step={0.1}
value={settings.autoInsertDelay || 0}
onChange={(e) => setSettings({ ...settings, autoInsertDelay: Math.max(0, parseFloat(e.target.value) || 0) })}
className="input-minimal bg-white w-full"
/>
</div>
</div>
<p className="text-[11px] text-text-muted"></p>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (%)</label>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
{settings.framePositions.map((pos, idx) => (
<div key={idx} className="relative group">
<input
type="number"
min="0"
max="100"
step="0.1"
value={pos}
onChange={(e) => {
const newPos = [...settings.framePositions];
newPos[idx] = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0));
setSettings({ ...settings, framePositions: newPos });
}}
className="input-minimal w-full pr-6 text-center"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
{settings.autoInsertFrames && (
<span
onClick={() => {
const current = settings.autoInsertFrameIndices || [];
const next = current.includes(idx)
? current.filter(i => i !== idx)
: [...current, idx].sort((a, b) => a - b);
setSettings({ ...settings, autoInsertFrameIndices: next });
}}
className={`absolute top-1 left-1 cursor-pointer transition-colors ${
(settings.autoInsertFrameIndices || []).includes(idx) ? 'text-green-500' : 'text-slate-300'
}`}
>
<Check size={12} />
</span>
)}
<button
type="button"
onClick={() => {
const newPos = settings.framePositions.filter((_, i) => i !== idx);
const newIndices = (settings.autoInsertFrameIndices || [])
.filter(i => i !== idx)
.map(i => i > idx ? i - 1 : i);
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length, autoInsertFrameIndices: newIndices });
}}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
>
<X size={10} />
</button>
</div>
))}
<button
type="button"
onClick={() => {
const newPos = [...settings.framePositions, 50];
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length });
}}
className="w-full h-10 rounded-xl border-2 border-dashed border-border flex items-center justify-center text-text-muted hover:border-accent hover:text-accent hover:bg-slate-50 transition-all"
>
<Plus size={18} />
</button>
</div>
<p className="text-[11px] text-text-muted mt-2"> AI </p>
</div>
</div>
</div>
)}
{currentUser.role === 'super' && (
<div className="card-minimal">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
<Globe size={20} className="text-accent" />
AI
</h3>
<button
type="button"
onClick={testApi}
className="text-[10px] font-bold text-accent uppercase tracking-wider hover:underline"
>
</button>
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> API (Endpoint)</label>
<input
type="url"
value={settings.apiEndpoint}
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
placeholder="https://api.example.com/v1/generate"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API (Secret Key)</label>
<input
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
placeholder="sk-xxxxxxxxxxxxxxxx"
className="input-minimal"
/>
</div>
</div>
</div>
)}
<div className="card-minimal">
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center gap-2">
<Layout size={20} className="text-accent" />
</h3>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
<select
value={settings.defaultTemplate}
onChange={(e) => setSettings({ ...settings, defaultTemplate: e.target.value })}
className="input-minimal bg-white"
>
<option value=""> ()</option>
{templates.map(tpl => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
<p className="text-[11px] text-text-muted"></p>
</div>
</div>
<div className="flex items-center justify-between pt-6 border-t border-border">
{currentUser.role === 'super' && (
<div className="flex flex-col items-start gap-2">
<button
type="button"
onClick={resetToDefault}
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
>
</button>
<button
type="button"
onClick={resetAllData}
className="text-xs font-bold text-red-500 hover:text-red-600 transition-colors uppercase tracking-wider"
>
</button>
</div>
)}
<div className="flex items-center gap-4">
{isSaved && (
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
<Check size={14} />
</span>
)}
<button
type="submit"
className="btn-accent px-12"
>
</button>
</div>
</div>
</form>
{modeModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-8 w-full max-w-[420px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-2"></h3>
<p className="text-sm text-text-muted mb-6">
<strong className="text-accent">{pendingFrameCount}</strong>
</p>
<div className="grid grid-cols-2 gap-3 mb-6">
<button
type="button"
onClick={() => {
if (pendingFrameCount !== null) {
const newPositions = computeFramePositions(pendingFrameCount, 'uniform', settings.framePositions);
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'uniform', framePositions: newPositions });
}
setModeModalOpen(false);
setPendingFrameCount(null);
}}
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
>
</button>
<button
type="button"
onClick={() => {
if (pendingFrameCount !== null) {
const newPositions = computeFramePositions(pendingFrameCount, 'keep', settings.framePositions);
setSettings({ ...settings, frameCount: pendingFrameCount, frameMode: 'keep', framePositions: newPositions });
}
setModeModalOpen(false);
setPendingFrameCount(null);
}}
className="px-4 py-3 border border-border rounded-xl text-sm font-semibold text-text-main hover:border-accent hover:bg-slate-50 transition-colors"
>
</button>
</div>
<button
type="button"
onClick={() => { setModeModalOpen(false); setPendingFrameCount(null); }}
className="w-full px-4 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
>
</button>
</div>
</div>
)}
</main>
</div>
);
}