- Allow system frame position percentages to keep two decimal places without reordering saved values. - Stop frontend and backend settings normalization from sorting framePositions on load or save. - Capture automatic video frames in timeline order while retaining each configured position index. - Insert automatically selected frames into report placeholders according to the configured percentage order. - Add frame position utilities and unit coverage for two-decimal rounding, clamping, order preservation, and timeline capture planning. - Update README, AGENTS, feature, requirement, report editor, system settings, progress, and testing docs for the new frame ordering behavior.
642 lines
30 KiB
TypeScript
642 lines
30 KiB
TypeScript
import React, { useEffect, useRef, 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, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
|
||
import { storage } from '../utils/storage';
|
||
import { listTemplates } from '../api/templates';
|
||
import { getSystemSettings, resetSystemSettings, updateSystemSettings } from '../api/settings';
|
||
import { listAiModels } from '../api/ai';
|
||
import { isLocalFallbackEnabled } from '../config/runtime';
|
||
import { DEFAULT_FRAME_POSITIONS, normalizeFramePositions, roundFramePosition } from '../utils/framePositions';
|
||
|
||
const normalizeSettings = (
|
||
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
|
||
templates: Template[],
|
||
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
|
||
const aiProviders = {
|
||
...DEFAULT_AI_PROVIDERS,
|
||
...(input.aiProviders || {}),
|
||
};
|
||
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
|
||
|
||
return {
|
||
frameCount: framePositions.length,
|
||
framePositions,
|
||
defaultTemplate: input.defaultTemplate || templates[0]?.id || '',
|
||
frameMode: input.frameMode || 'keep',
|
||
activeAiProvider: input.activeAiProvider || 'kimi',
|
||
aiProviders,
|
||
autoInsertFrames: typeof input.autoInsertFrames === 'boolean' ? input.autoInsertFrames : false,
|
||
autoInsertDelay: typeof input.autoInsertDelay === 'number' ? input.autoInsertDelay : 0,
|
||
autoInsertFrameIndices: input.autoInsertFrameIndices || [],
|
||
xfSpeechConfig: input.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' },
|
||
};
|
||
};
|
||
|
||
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: DEFAULT_FRAME_POSITIONS,
|
||
defaultTemplate: '',
|
||
frameMode: 'keep',
|
||
activeAiProvider: 'kimi',
|
||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' }
|
||
});
|
||
const [templates, setTemplates] = useState<Template[]>([]);
|
||
const [isSaved, setIsSaved] = useState(false);
|
||
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
|
||
const [modeModalOpen, setModeModalOpen] = useState(false);
|
||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||
const xfAppIdRef = useRef<HTMLInputElement>(null);
|
||
const xfApiKeyRef = useRef<HTMLInputElement>(null);
|
||
const xfApiSecretRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (apiKeyInputRef.current) {
|
||
const targetValue = settings.aiProviders[settings.activeAiProvider]?.apiKey || '';
|
||
if (apiKeyInputRef.current.value !== targetValue) {
|
||
apiKeyInputRef.current.value = targetValue;
|
||
}
|
||
}
|
||
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
|
||
|
||
useEffect(() => {
|
||
if (xfAppIdRef.current) {
|
||
const target = settings.xfSpeechConfig?.appId || '';
|
||
if (xfAppIdRef.current.value !== target) xfAppIdRef.current.value = target;
|
||
}
|
||
if (xfApiKeyRef.current) {
|
||
const target = settings.xfSpeechConfig?.apiKey || '';
|
||
if (xfApiKeyRef.current.value !== target) xfApiKeyRef.current.value = target;
|
||
}
|
||
if (xfApiSecretRef.current) {
|
||
const target = settings.xfSpeechConfig?.apiSecret || '';
|
||
if (xfApiSecretRef.current.value !== target) xfApiSecretRef.current.value = target;
|
||
}
|
||
}, [settings.xfSpeechConfig]);
|
||
|
||
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' });
|
||
// Migrate old flat fields to new structured format
|
||
if (!savedSettings.aiProviders) {
|
||
const providers = { ...DEFAULT_AI_PROVIDERS };
|
||
if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
|
||
providers.kimi = {
|
||
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
||
apiKey: (savedSettings as any).kimiApiKey || '',
|
||
modelName: 'moonshot-v1-32k-vision-preview'
|
||
};
|
||
}
|
||
savedSettings.aiProviders = providers;
|
||
savedSettings.activeAiProvider = 'kimi';
|
||
storage.set('systemSettings', savedSettings);
|
||
}
|
||
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 = 'keep';
|
||
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
|
||
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
||
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: '', apiKey: '', apiSecret: '' };
|
||
setSettings(savedSettings);
|
||
} else if (savedTemplates.length > 0) {
|
||
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'keep', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
|
||
}
|
||
setTemplates(savedTemplates);
|
||
void listTemplates('use').then((response) => {
|
||
if (response.items.length === 0) return;
|
||
setTemplates(response.items);
|
||
storage.set('templates', response.items);
|
||
if (!savedSettings.defaultTemplate) {
|
||
setSettings(prev => ({ ...prev, defaultTemplate: response.items[0]?.id || '' }));
|
||
}
|
||
}).catch(() => {});
|
||
|
||
void getSystemSettings().then((apiSettings) => {
|
||
const next = normalizeSettings(apiSettings, savedTemplates);
|
||
setSettings(next);
|
||
storage.set('systemSettings', next);
|
||
}).catch(() => {
|
||
if (!isLocalFallbackEnabled()) {
|
||
setSettings(normalizeSettings({}, savedTemplates));
|
||
}
|
||
});
|
||
}, [navigate]);
|
||
|
||
const computeFramePositions = (count: number, mode: 'uniform' | 'keep', currentPositions: number[]) => {
|
||
if (mode === 'uniform') {
|
||
const positions: number[] = [];
|
||
for (let i = 1; i <= count; i++) {
|
||
positions.push(roundFramePosition((100 / (count + 1)) * i));
|
||
}
|
||
return positions;
|
||
}
|
||
const next = normalizeFramePositions(currentPositions);
|
||
if (count <= next.length) {
|
||
return next.slice(0, count);
|
||
}
|
||
const need = count - next.length;
|
||
const last = next[next.length - 1] || 0;
|
||
const range = 100 - last;
|
||
for (let i = 1; i <= need; i++) {
|
||
next.push(roundFramePosition(last + (range / (need + 1)) * i));
|
||
}
|
||
return next;
|
||
};
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const framePositions = normalizeFramePositions(settings.framePositions);
|
||
const finalSettings = { ...settings, framePositions, frameCount: framePositions.length };
|
||
try {
|
||
const savedSettings = await updateSystemSettings(finalSettings);
|
||
const normalized = normalizeSettings(savedSettings, templates);
|
||
storage.set('systemSettings', normalized);
|
||
setSettings(normalized);
|
||
} catch (error) {
|
||
if (!isLocalFallbackEnabled()) {
|
||
alert(`保存失败: ${(error as Error).message}`);
|
||
return;
|
||
}
|
||
console.warn('Save settings API failed, keeping local compatibility path.', error);
|
||
storage.set('systemSettings', finalSettings);
|
||
setSettings(finalSettings);
|
||
}
|
||
setIsSaved(true);
|
||
setTimeout(() => setIsSaved(false), 3000);
|
||
};
|
||
|
||
const testApi = async () => {
|
||
try {
|
||
const savedSettings = await updateSystemSettings(settings);
|
||
const normalized = normalizeSettings(savedSettings, templates);
|
||
setSettings(normalized);
|
||
storage.set('systemSettings', normalized);
|
||
const response = await listAiModels();
|
||
setAvailableModels(response.models);
|
||
if (response.models.length > 0 && !normalized.aiProviders[normalized.activeAiProvider]?.modelName) {
|
||
const next = { ...normalized.aiProviders };
|
||
next[normalized.activeAiProvider] = { ...next[normalized.activeAiProvider], modelName: response.models[0] };
|
||
setSettings({ ...normalized, aiProviders: next });
|
||
}
|
||
alert(`连接成功!可用模型数: ${response.models.length}`);
|
||
} catch (e: any) {
|
||
alert(`连接失败: ${e.message}`);
|
||
setAvailableModels([]);
|
||
}
|
||
};
|
||
|
||
const resetToDefault = async () => {
|
||
const firstConfirm = window.confirm('确定要恢复演示出厂设置吗?这会清空报告、审计日志、自定义模板和非默认用户,并恢复默认演示账号、模板、AI/语音配置。');
|
||
if (!firstConfirm) return;
|
||
|
||
const secondConfirm = window.confirm('请再次确认:该操作会重置当前后端演示数据,且无法从页面撤销。是否继续?');
|
||
if (!secondConfirm) return;
|
||
|
||
try {
|
||
const resetSettings = await resetSystemSettings();
|
||
const normalized = normalizeSettings(resetSettings, templates);
|
||
setSettings(normalized);
|
||
storage.set('systemSettings', normalized);
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
window.location.reload();
|
||
} catch (error) {
|
||
alert(`恢复演示出厂设置失败: ${(error as Error).message}`);
|
||
}
|
||
};
|
||
|
||
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.01"
|
||
value={Number.isFinite(pos) ? pos.toFixed(2) : '0.00'}
|
||
onChange={(e) => {
|
||
const newPos = [...settings.framePositions];
|
||
newPos[idx] = roundFramePosition(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">AI 服务商</label>
|
||
<select
|
||
value={settings.activeAiProvider}
|
||
onChange={(e) => setSettings({ ...settings, activeAiProvider: e.target.value })}
|
||
className="input-minimal bg-white"
|
||
>
|
||
<option value="kimi">Kimi (Moonshot)</option>
|
||
<option value="deepseek">DeepSeek</option>
|
||
<option value="openai">OpenAI</option>
|
||
<option value="custom">自定义</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">接口地址 (Base URL)</label>
|
||
<input
|
||
type="url"
|
||
value={settings.aiProviders[settings.activeAiProvider]?.endpoint || ''}
|
||
onChange={(e) => {
|
||
const next = { ...settings.aiProviders };
|
||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
|
||
setSettings({ ...settings, aiProviders: next });
|
||
}}
|
||
placeholder="https://api.example.com/v1"
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API 密钥</label>
|
||
<input
|
||
ref={apiKeyInputRef}
|
||
type="password"
|
||
onChange={(e) => {
|
||
const next = { ...settings.aiProviders };
|
||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
|
||
setSettings({ ...settings, aiProviders: next });
|
||
}}
|
||
onCopy={(e) => e.preventDefault()}
|
||
onCut={(e) => e.preventDefault()}
|
||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模型名称 (Model Name)</label>
|
||
{availableModels.length > 0 ? (
|
||
<select
|
||
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
||
onChange={(e) => {
|
||
const next = { ...settings.aiProviders };
|
||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
||
setSettings({ ...settings, aiProviders: next });
|
||
}}
|
||
className="input-minimal bg-white"
|
||
>
|
||
{availableModels.map(m => (
|
||
<option key={m} value={m}>{m}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
||
onChange={(e) => {
|
||
const next = { ...settings.aiProviders };
|
||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
||
setSettings({ ...settings, aiProviders: next });
|
||
}}
|
||
placeholder="kimi-k2-5"
|
||
className="input-minimal"
|
||
/>
|
||
)}
|
||
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</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">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
||
讯飞语音听写Websocket接口配置
|
||
</h3>
|
||
</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">APPID</label>
|
||
<input
|
||
ref={xfAppIdRef}
|
||
type="password"
|
||
onChange={(e) => {
|
||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
|
||
setSettings({ ...settings, xfSpeechConfig: next });
|
||
}}
|
||
onCopy={(e) => e.preventDefault()}
|
||
onCut={(e) => e.preventDefault()}
|
||
autoComplete="new-password"
|
||
placeholder="********"
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APIKey</label>
|
||
<input
|
||
ref={xfApiKeyRef}
|
||
type="password"
|
||
onChange={(e) => {
|
||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
|
||
setSettings({ ...settings, xfSpeechConfig: next });
|
||
}}
|
||
onCopy={(e) => e.preventDefault()}
|
||
onCut={(e) => e.preventDefault()}
|
||
autoComplete="new-password"
|
||
placeholder="********************************"
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APISecret</label>
|
||
<input
|
||
ref={xfApiSecretRef}
|
||
type="password"
|
||
onChange={(e) => {
|
||
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
|
||
setSettings({ ...settings, xfSpeechConfig: next });
|
||
}}
|
||
onCopy={(e) => e.preventDefault()}
|
||
onCut={(e) => e.preventDefault()}
|
||
autoComplete="new-password"
|
||
placeholder="********************************"
|
||
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>
|
||
</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>
|
||
);
|
||
}
|