423 lines
19 KiB
TypeScript
423 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|