Files
Mdeical_Sur_Report/src/pages/SystemSettings.tsx
admin 2cabe7e4fd Preserve frame position order for auto insertion
- 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.
2026-05-02 05:10:39 +08:00

642 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}