Compare commits
9 Commits
1ec25065ad
...
ea789cee26
| Author | SHA1 | Date | |
|---|---|---|---|
| ea789cee26 | |||
| 9f2b5dce21 | |||
| 2dbdbe02b2 | |||
| 963a7541c9 | |||
| e549419a4c | |||
| 18d81cb4a6 | |||
| 0039b18a26 | |||
| 3bec69986e | |||
| 2e634ff832 |
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_AI_PROVIDERS } from '../types';
|
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_AI_PROVIDERS } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { storage } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
import { User as UserIcon, Lock } from 'lucide-react';
|
import { User as UserIcon, Lock } from 'lucide-react';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -63,21 +63,17 @@ export default function Login() {
|
|||||||
|
|
||||||
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
if (!settingsRaw.frameCount) {
|
if (!settingsRaw.frameCount) {
|
||||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
|
||||||
const positions: number[] = [];
|
|
||||||
for (let i = 1; i <= 12; i++) {
|
|
||||||
positions.push(round1((100 / 13) * i));
|
|
||||||
}
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: positions,
|
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||||
defaultTemplate: savedTemplates[0]?.id || '',
|
defaultTemplate: savedTemplates[0]?.id || '',
|
||||||
frameMode: 'uniform',
|
frameMode: 'keep',
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: 'kimi',
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: true,
|
||||||
autoInsertDelay: 1,
|
autoInsertDelay: 1,
|
||||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
|
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
||||||
|
xfIatConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
|
||||||
};
|
};
|
||||||
storage.set('systemSettings', defaultSettings);
|
storage.set('systemSettings', defaultSettings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
Video, Play, Pause, Plus, X, ChevronLeft, Download,
|
Video, Play, Pause, Plus, X, ChevronLeft, Download,
|
||||||
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
|
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_XF_SPEECH } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { printDocument } from '../utils/print';
|
import { printDocument } from '../utils/print';
|
||||||
import { storage } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
import { diffChars } from 'diff';
|
import { diffChars } from 'diff';
|
||||||
|
|
||||||
export default function ReportEditor() {
|
export default function ReportEditor() {
|
||||||
@@ -60,28 +60,73 @@ export default function ReportEditor() {
|
|||||||
|
|
||||||
// AI 撰写相关核心状态
|
// AI 撰写相关核心状态
|
||||||
const [chatInput, setChatInput] = useState<string>('');
|
const [chatInput, setChatInput] = useState<string>('');
|
||||||
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]);
|
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string, images?: string[]}[]>([]);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [aiSelectedFrames, setAiSelectedFrames] = useState<number[]>([]);
|
|
||||||
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
|
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
|
||||||
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
||||||
const speechRecognitionRef = useRef<any>(null);
|
const [editorImages, setEditorImages] = useState<{id: string, src: string}[]>([]);
|
||||||
|
const [aiSelectedEditorImages, setAiSelectedEditorImages] = useState<string[]>([]);
|
||||||
|
const xfWsRef = useRef<WebSocket | null>(null);
|
||||||
|
const xfAudioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const xfMediaStreamRef = useRef<MediaStream | null>(null);
|
||||||
const [quickPrompts, setQuickPrompts] = useState<string[]>([
|
const [quickPrompts, setQuickPrompts] = useState<string[]>([
|
||||||
'请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结', '根据截图描述游离过程'
|
'请完善报告内容', '请对内容做如下修改:'
|
||||||
]);
|
]);
|
||||||
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
|
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
|
||||||
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
|
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
|
||||||
|
const [lastExchangeLog, setLastExchangeLog] = useState<{
|
||||||
|
startTime: string;
|
||||||
|
modelConfig: { provider: string; endpoint: string; modelName: string };
|
||||||
|
requestPayload: any;
|
||||||
|
responsePayload: any | null;
|
||||||
|
errorDetail: { status: number; statusText: string; responseText: string; message: string } | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateRef.current.chatMessages = chatMessages;
|
stateRef.current.chatMessages = chatMessages;
|
||||||
}, [chatMessages]);
|
}, [chatMessages]);
|
||||||
|
|
||||||
|
// 监听编辑器中已插入的图片,同步到 AI 面板
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const updateEditorImages = () => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
|
||||||
|
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
|
||||||
|
.filter(img => img.src);
|
||||||
|
setEditorImages(prev => {
|
||||||
|
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
|
||||||
|
if (same) return prev;
|
||||||
|
// 清除已不存在的选中项
|
||||||
|
setAiSelectedEditorImages(prevSelected => prevSelected.filter(id => imgs.some(img => img.id === id)));
|
||||||
|
return imgs;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateEditorImages();
|
||||||
|
const observer = new MutationObserver(updateEditorImages);
|
||||||
|
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateRef.current.chatInput = chatInput;
|
stateRef.current.chatInput = chatInput;
|
||||||
}, [chatInput]);
|
}, [chatInput]);
|
||||||
|
|
||||||
|
// 切换到 AI 面板时强制同步编辑器中的图片
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'ai' || !editorRef.current) return;
|
||||||
|
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
|
||||||
|
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
|
||||||
|
.filter(img => img.src);
|
||||||
|
setEditorImages(prev => {
|
||||||
|
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
|
||||||
|
return same ? prev : imgs;
|
||||||
|
});
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||||
@@ -729,7 +774,7 @@ export default function ReportEditor() {
|
|||||||
if (!videoRef.current || currentVideoIndex === -1) return;
|
if (!videoRef.current || currentVideoIndex === -1) return;
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
|
const positions = settings.framePositions || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
|
||||||
const dur = video.duration || 1;
|
const dur = video.duration || 1;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -826,7 +871,9 @@ export default function ReportEditor() {
|
|||||||
|
|
||||||
const stripHtml = (html: string): string => {
|
const stripHtml = (html: string): string => {
|
||||||
const tmp = document.createElement('div');
|
const tmp = document.createElement('div');
|
||||||
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
tmp.innerHTML = html
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/p>\s*<p/gi, '</p>\n\n<p');
|
||||||
return (tmp.innerText || tmp.textContent || '').trim();
|
return (tmp.innerText || tmp.textContent || '').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -846,52 +893,115 @@ export default function ReportEditor() {
|
|||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleListening = () => {
|
async function getXfAuthUrl(apiKey: string, apiSecret: string): Promise<string> {
|
||||||
|
const host = 'iat-api.xfyun.cn';
|
||||||
|
const date = new Date().toUTCString();
|
||||||
|
const signatureOrigin = `host: "${host}"\ndate: "${date}"\nGET /v2/iat HTTP/1.1`;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey('raw', encoder.encode(apiSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signatureOrigin));
|
||||||
|
const signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||||
|
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signatureBase64}"`;
|
||||||
|
const authorization = btoa(authorizationOrigin);
|
||||||
|
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
|
||||||
|
const output = new DataView(new ArrayBuffer(input.length * 2));
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, input[i]));
|
||||||
|
output.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
}
|
||||||
|
return output.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleListening = async () => {
|
||||||
if (isListening) {
|
if (isListening) {
|
||||||
setIsListening(false);
|
setIsListening(false);
|
||||||
if (speechRecognitionRef.current) speechRecognitionRef.current.stop();
|
if (xfWsRef.current) { try { xfWsRef.current.close(); } catch {} xfWsRef.current = null; }
|
||||||
} else {
|
if (xfAudioContextRef.current) { try { xfAudioContextRef.current.close(); } catch {} xfAudioContextRef.current = null; }
|
||||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
if (xfMediaStreamRef.current) { xfMediaStreamRef.current.getTracks().forEach(t => t.stop()); xfMediaStreamRef.current = null; }
|
||||||
if (!SpeechRecognition) {
|
|
||||||
alert('您的浏览器不支持原生语音识别,请使用 Chrome。');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const recognition = new SpeechRecognition();
|
const xfConfig = storage.get<SystemSettings>('systemSettings', {} as SystemSettings).xfSpeechConfig || DEFAULT_XF_SPEECH;
|
||||||
recognition.lang = 'zh-CN';
|
if (!xfConfig?.appId || !xfConfig?.apiKey || !xfConfig?.apiSecret) {
|
||||||
recognition.continuous = true;
|
alert('请先在系统设置中配置讯飞语音 APPID/APIKey/APISecret');
|
||||||
recognition.interimResults = true;
|
return;
|
||||||
let finalTranscript = chatInput;
|
|
||||||
recognition.onstart = () => setIsListening(true);
|
|
||||||
recognition.onresult = (event: any) => {
|
|
||||||
let interimTranscript = '';
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
|
||||||
if (event.results[i].isFinal) {
|
|
||||||
finalTranscript += event.results[i][0].transcript;
|
|
||||||
} else {
|
|
||||||
interimTranscript += event.results[i][0].transcript;
|
|
||||||
}
|
}
|
||||||
}
|
try {
|
||||||
setChatInput(finalTranscript + interimTranscript);
|
const authUrl = await getXfAuthUrl(xfConfig.apiKey, xfConfig.apiSecret);
|
||||||
|
const ws = new WebSocket(authUrl);
|
||||||
|
xfWsRef.current = ws;
|
||||||
|
let frameStatus = 0;
|
||||||
|
let transcript = chatInput;
|
||||||
|
ws.onopen = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
xfMediaStreamRef.current = stream;
|
||||||
|
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||||
|
xfAudioContextRef.current = audioContext;
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
processor.onaudioprocess = (e) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const inputData = e.inputBuffer.getChannelData(0);
|
||||||
|
const pcmBuffer = floatTo16BitPCM(inputData);
|
||||||
|
const base64Audio = arrayBufferToBase64(pcmBuffer);
|
||||||
|
const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } };
|
||||||
|
if (frameStatus === 0) { frame.common = { app_id: xfConfig.appId }; frame.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin' }; }
|
||||||
|
ws.send(JSON.stringify(frame));
|
||||||
|
frameStatus = 1;
|
||||||
};
|
};
|
||||||
recognition.onerror = () => setIsListening(false);
|
source.connect(processor);
|
||||||
recognition.onend = () => setIsListening(false);
|
processor.connect(audioContext.destination);
|
||||||
speechRecognitionRef.current = recognition;
|
setIsListening(true);
|
||||||
recognition.start();
|
} catch (e: any) {
|
||||||
|
alert('麦克风启动失败: ' + e.message);
|
||||||
|
setIsListening(false);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const jsonData = JSON.parse(event.data);
|
||||||
|
if (jsonData.data?.result?.ws) {
|
||||||
|
let seg = '';
|
||||||
|
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
|
||||||
|
if (jsonData.data.result.ls) { transcript += seg; setChatInput(transcript); }
|
||||||
|
else { setChatInput(transcript + seg); }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { alert('讯飞语音连接失败'); setIsListening(false); };
|
||||||
|
ws.onclose = () => { setIsListening(false); };
|
||||||
|
} catch (e: any) {
|
||||||
|
alert('讯飞语音初始化失败: ' + e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAIGenerate = async (text: string) => {
|
const handleAIGenerate = async (text: string) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
const userMsgId = Date.now().toString();
|
const userMsgId = Date.now().toString();
|
||||||
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text }]);
|
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
|
||||||
|
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
||||||
|
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text, images: allImages.length > 0 ? allImages : undefined }]);
|
||||||
setChatInput('');
|
setChatInput('');
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
|
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
|
||||||
const apiKey = provider?.apiKey || '';
|
const apiKey = provider?.apiKey || getDefaultApiKey();
|
||||||
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
|
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
|
||||||
const modelName = provider?.modelName || 'kimi-k2-5';
|
const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key,请前往系统设置填写。' }]);
|
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key,请前往系统设置填写。' }]);
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
@@ -923,11 +1033,11 @@ export default function ReportEditor() {
|
|||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').trim() : '';
|
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').replace(/>(\s+)</g, '><').trim() : '';
|
||||||
const globalContextText = editorRef.current?.innerText || '';
|
const globalContextText = editorRef.current?.innerText || '';
|
||||||
let messageContent: any;
|
let messageContent: any;
|
||||||
const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
|
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
|
||||||
const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
||||||
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
|
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
if (aiModifyEnabled && targetRegionEl) {
|
||||||
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
|
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
|
||||||
@@ -945,23 +1055,50 @@ export default function ReportEditor() {
|
|||||||
const systemPrompt = aiModifyEnabled
|
const systemPrompt = aiModifyEnabled
|
||||||
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】,直接重写并输出目标区域的 HTML。\n\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json)'
|
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】,直接重写并输出目标区域的 HTML。\n\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json)'
|
||||||
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记';
|
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记';
|
||||||
const response = await fetch(`${apiEndpoint}/chat/completions`, {
|
const payload: any = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelName,
|
model: modelName,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: messageContent }
|
{ role: 'user', content: messageContent }
|
||||||
],
|
],
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
})
|
};
|
||||||
|
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
|
||||||
|
if (isKimiK25) {
|
||||||
|
delete payload.temperature;
|
||||||
|
delete payload.top_p;
|
||||||
|
delete payload.presence_penalty;
|
||||||
|
delete payload.frequency_penalty;
|
||||||
|
}
|
||||||
|
const logEntry = {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: apiEndpoint, modelName },
|
||||||
|
requestPayload: JSON.parse(JSON.stringify(payload)),
|
||||||
|
responsePayload: null as any | null,
|
||||||
|
errorDetail: null as { status: number; statusText: string; responseText: string; message: string } | null
|
||||||
|
};
|
||||||
|
const response = await fetch(`${apiEndpoint}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
logEntry.errorDetail = {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
responseText: errorText,
|
||||||
|
message: `API 请求失败: ${response.status}`
|
||||||
|
};
|
||||||
|
setLastExchangeLog(logEntry);
|
||||||
|
throw new Error(`API 请求失败: ${response.status}${errorText ? ' - ' + errorText : ''}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
logEntry.responsePayload = data;
|
||||||
|
setLastExchangeLog(logEntry);
|
||||||
const responseText = data.choices[0].message.content.trim();
|
const responseText = data.choices[0].message.content.trim();
|
||||||
const cleanedText = responseText.replace(/```json\n?|```/g, '');
|
const cleanedText = responseText.replace(/```json\n?|```/g, '');
|
||||||
let responseJson: any = {};
|
let responseJson: any = {};
|
||||||
@@ -983,7 +1120,8 @@ export default function ReportEditor() {
|
|||||||
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
||||||
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
||||||
cleanHtml = cleanHtml.trim();
|
cleanHtml = cleanHtml.trim();
|
||||||
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
|
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
|
||||||
|
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">');
|
||||||
if (targetRegionEl) {
|
if (targetRegionEl) {
|
||||||
setDiffModal({
|
setDiffModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -996,7 +1134,7 @@ export default function ReportEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAiUploadedImages([]);
|
setAiUploadedImages([]);
|
||||||
setAiSelectedFrames([]);
|
setAiSelectedEditorImages([]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
|
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
|
||||||
@@ -1016,7 +1154,7 @@ export default function ReportEditor() {
|
|||||||
range.selectNodeContents(targetContent);
|
range.selectNodeContents(targetContent);
|
||||||
sel?.removeAllRanges();
|
sel?.removeAllRanges();
|
||||||
sel?.addRange(range);
|
sel?.addRange(range);
|
||||||
document.execCommand('insertHTML', false, newHtml);
|
document.execCommand('insertHTML', false, cleanHtml);
|
||||||
targetContent.style.transition = 'background-color 0.3s ease';
|
targetContent.style.transition = 'background-color 0.3s ease';
|
||||||
targetContent.style.backgroundColor = '#bfdbfe';
|
targetContent.style.backgroundColor = '#bfdbfe';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1205,6 +1343,10 @@ export default function ReportEditor() {
|
|||||||
setVideos([]);
|
setVideos([]);
|
||||||
setCapturedFrames([]);
|
setCapturedFrames([]);
|
||||||
setCurrentVideoIndex(-1);
|
setCurrentVideoIndex(-1);
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput('');
|
||||||
|
setAiUploadedImages([]);
|
||||||
|
setAiSelectedEditorImages([]);
|
||||||
prevVideoCountRef.current = 0;
|
prevVideoCountRef.current = 0;
|
||||||
stateRef.current = {
|
stateRef.current = {
|
||||||
...stateRef.current,
|
...stateRef.current,
|
||||||
@@ -1212,6 +1354,8 @@ export default function ReportEditor() {
|
|||||||
reportData: nextReportData,
|
reportData: nextReportData,
|
||||||
videos: [],
|
videos: [],
|
||||||
capturedFrames: [],
|
capturedFrames: [],
|
||||||
|
chatMessages: [],
|
||||||
|
chatInput: '',
|
||||||
activeTab: stateRef.current.activeTab
|
activeTab: stateRef.current.activeTab
|
||||||
};
|
};
|
||||||
updatePageHeight();
|
updatePageHeight();
|
||||||
@@ -2290,7 +2434,14 @@ export default function ReportEditor() {
|
|||||||
chatMessages.map(msg => (
|
chatMessages.map(msg => (
|
||||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div className={`rounded-2xl px-4 py-2.5 max-w-[85%] text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none shadow-md' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
|
<div className={`rounded-2xl px-4 py-2.5 max-w-[85%] text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none shadow-md' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
|
||||||
{msg.content}
|
<div>{msg.content}</div>
|
||||||
|
{msg.images && msg.images.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 mt-2 flex-wrap">
|
||||||
|
{msg.images.map((src, idx) => (
|
||||||
|
<img key={idx} src={src} className="w-10 h-10 object-cover rounded border border-white/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -2340,15 +2491,15 @@ export default function ReportEditor() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视觉参考上下文 */}
|
{/* 视觉参考上下文 - 编辑器中已插入的图片 */}
|
||||||
{capturedFrames.length > 0 && (
|
{editorImages.length > 0 && (
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
||||||
{capturedFrames.map(frame => {
|
{editorImages.map(img => {
|
||||||
const isSelected = aiSelectedFrames.includes(frame.id);
|
const isSelected = aiSelectedEditorImages.includes(img.id);
|
||||||
return (
|
return (
|
||||||
<div key={frame.id} onClick={() => setAiSelectedFrames(prev => isSelected ? prev.filter(id => id !== frame.id) : [...prev, frame.id])}
|
<div key={img.id} onClick={() => setAiSelectedEditorImages(prev => isSelected ? prev.filter(id => id !== img.id) : [...prev, img.id])}
|
||||||
className={`relative shrink-0 w-12 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600' : 'border-transparent opacity-50'}`}>
|
className={`relative shrink-0 w-12 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600' : 'border-transparent opacity-50'}`}>
|
||||||
<img src={frame.dataUrl} className="w-full h-full object-cover" />
|
<img src={img.src} className="w-full h-full object-cover" />
|
||||||
{isSelected && <div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full p-0.5"><Check size={8} className="text-white" /></div>}
|
{isSelected && <div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full p-0.5"><Check size={8} className="text-white" /></div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2381,6 +2532,32 @@ export default function ReportEditor() {
|
|||||||
{isEditingPrompts ? '+ 添加' : '⚙️'}
|
{isEditingPrompts ? '+ 添加' : '⚙️'}
|
||||||
</button>
|
</button>
|
||||||
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full">完成</button>}
|
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full">完成</button>}
|
||||||
|
<button onClick={() => {
|
||||||
|
const data = {
|
||||||
|
exportAt: new Date().toISOString(),
|
||||||
|
url: window.location.href,
|
||||||
|
messages: chatMessages,
|
||||||
|
lastExchange: lastExchangeLog,
|
||||||
|
metadata: {
|
||||||
|
user: currentUser?.username || 'anonymous',
|
||||||
|
activeProvider: (() => { const s = storage.get<SystemSettings>('systemSettings', {} as SystemSettings); return s.activeAiProvider || 'kimi'; })(),
|
||||||
|
targetRegion: aiTargetRegion,
|
||||||
|
modifyEnabled: aiModifyEnabled,
|
||||||
|
chatInput,
|
||||||
|
uploadedImagesCount: aiUploadedImages.length,
|
||||||
|
selectedFramesCount: aiSelectedEditorImages.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ai-logs-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}} className="px-2 py-1 bg-slate-100 text-slate-500 text-[11px] rounded-full hover:bg-slate-200 ml-auto" title="导出 AI 日志(调试用)">
|
||||||
|
导出 AI 日志
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 沉浸式输入框 */}
|
{/* 沉浸式输入框 */}
|
||||||
@@ -2420,6 +2597,9 @@ export default function ReportEditor() {
|
|||||||
<button onClick={toggleListening} className={`p-1.5 rounded-lg transition-colors ${isListening ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50'}`}>
|
<button onClick={toggleListening} className={`p-1.5 rounded-lg transition-colors ${isListening ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50'}`}>
|
||||||
{isListening ? <Mic size={16} /> : <MicOff size={16} />}
|
{isListening ? <Mic size={16} /> : <MicOff size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => { if (!isGenerating && chatInput.trim()) handleAIGenerate(chatInput); }} disabled={isGenerating || !chatInput.trim()} className="p-1.5 rounded-lg transition-colors text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed" title="发送">
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
|
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
|
||||||
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
|
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
|
||||||
import { storage } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
|
|
||||||
export default function SystemSettings() {
|
export default function SystemSettings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||||
defaultTemplate: '',
|
defaultTemplate: '',
|
||||||
frameMode: 'uniform',
|
frameMode: 'keep',
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: 'kimi',
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS }
|
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||||
|
xfIatConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
|
||||||
});
|
});
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
|
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
|
||||||
const [modeModalOpen, setModeModalOpen] = useState(false);
|
const [modeModalOpen, setModeModalOpen] = useState(false);
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
|
const apiKeyInputRef = 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(() => {
|
useEffect(() => {
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
@@ -38,24 +49,30 @@ export default function SystemSettings() {
|
|||||||
providers.kimi = {
|
providers.kimi = {
|
||||||
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
||||||
apiKey: (savedSettings as any).kimiApiKey || '',
|
apiKey: (savedSettings as any).kimiApiKey || '',
|
||||||
modelName: 'kimi-k2-5'
|
modelName: 'moonshot-v1-32k-vision-preview'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
savedSettings.aiProviders = providers;
|
savedSettings.aiProviders = providers;
|
||||||
savedSettings.activeAiProvider = 'kimi';
|
savedSettings.activeAiProvider = 'kimi';
|
||||||
storage.set('systemSettings', savedSettings);
|
storage.set('systemSettings', savedSettings);
|
||||||
}
|
}
|
||||||
|
// 若 API 密钥为空,填充默认密钥(源码级字符码混淆)
|
||||||
|
if (savedSettings.aiProviders?.kimi?.apiKey === '') {
|
||||||
|
savedSettings.aiProviders.kimi.apiKey = getDefaultApiKey();
|
||||||
|
storage.set('systemSettings', savedSettings);
|
||||||
|
}
|
||||||
const savedTemplates = storage.get<Template[]>('templates', []);
|
const savedTemplates = storage.get<Template[]>('templates', []);
|
||||||
if (savedSettings.frameCount) {
|
if (savedSettings.frameCount) {
|
||||||
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
||||||
savedSettings.defaultTemplate = savedTemplates[0].id;
|
savedSettings.defaultTemplate = savedTemplates[0].id;
|
||||||
}
|
}
|
||||||
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
|
if (!savedSettings.frameMode) savedSettings.frameMode = 'keep';
|
||||||
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
|
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
|
||||||
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
||||||
|
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' };
|
||||||
setSettings(savedSettings);
|
setSettings(savedSettings);
|
||||||
} else if (savedTemplates.length > 0) {
|
} 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 }));
|
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);
|
setTemplates(savedTemplates);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
@@ -128,14 +145,15 @@ export default function SystemSettings() {
|
|||||||
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
||||||
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
|
||||||
defaultTemplate: templates[0]?.id || '',
|
defaultTemplate: templates[0]?.id || '',
|
||||||
frameMode: 'uniform',
|
frameMode: 'keep',
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: 'kimi',
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: true,
|
||||||
autoInsertDelay: 1,
|
autoInsertDelay: 1,
|
||||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
|
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
|
||||||
|
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
|
||||||
};
|
};
|
||||||
setSettings(defaultSettings);
|
setSettings(defaultSettings);
|
||||||
storage.set('systemSettings', defaultSettings);
|
storage.set('systemSettings', defaultSettings);
|
||||||
@@ -357,13 +375,15 @@ export default function SystemSettings() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API 密钥</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API 密钥</label>
|
||||||
<input
|
<input
|
||||||
|
ref={apiKeyInputRef}
|
||||||
type="password"
|
type="password"
|
||||||
value={settings.aiProviders[settings.activeAiProvider]?.apiKey || ''}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = { ...settings.aiProviders };
|
const next = { ...settings.aiProviders };
|
||||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
|
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
|
||||||
setSettings({ ...settings, aiProviders: next });
|
setSettings({ ...settings, aiProviders: next });
|
||||||
}}
|
}}
|
||||||
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
className="input-minimal"
|
className="input-minimal"
|
||||||
/>
|
/>
|
||||||
@@ -404,6 +424,58 @@ export default function SystemSettings() {
|
|||||||
</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>
|
||||||
|
讯飞语音配置
|
||||||
|
</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
|
||||||
|
type="password"
|
||||||
|
value={settings.xfIatConfig?.appId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
|
||||||
|
setSettings({ ...settings, xfIatConfig: next });
|
||||||
|
}}
|
||||||
|
placeholder="e0fe23e3"
|
||||||
|
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
|
||||||
|
type="password"
|
||||||
|
value={settings.xfIatConfig?.apiKey || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
|
||||||
|
setSettings({ ...settings, xfIatConfig: next });
|
||||||
|
}}
|
||||||
|
placeholder="7fd08be316718c2280e85af4fe126306"
|
||||||
|
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
|
||||||
|
type="password"
|
||||||
|
value={settings.xfIatConfig?.apiSecret || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
|
||||||
|
setSettings({ ...settings, xfIatConfig: next });
|
||||||
|
}}
|
||||||
|
placeholder="ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card-minimal">
|
<div className="card-minimal">
|
||||||
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center gap-2">
|
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center gap-2">
|
||||||
<Layout size={20} className="text-accent" />
|
<Layout size={20} className="text-accent" />
|
||||||
|
|||||||
@@ -419,9 +419,16 @@ export default function TemplateManage() {
|
|||||||
|
|
||||||
const saveTemplateContent = () => {
|
const saveTemplateContent = () => {
|
||||||
if (!currentTemplateId || !editorRef.current) return;
|
if (!currentTemplateId || !editorRef.current) return;
|
||||||
|
let cleanContent = editorRef.current.innerHTML;
|
||||||
|
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
|
||||||
|
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
|
||||||
|
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
|
||||||
|
if (cleanContent !== editorRef.current.innerHTML) {
|
||||||
|
editorRef.current.innerHTML = cleanContent;
|
||||||
|
}
|
||||||
const allTemplates = storage.get<Template[]>('templates', []);
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
const updated = allTemplates.map(t =>
|
const updated = allTemplates.map(t =>
|
||||||
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
|
t.id === currentTemplateId ? { ...t, content: cleanContent, updatedAt: new Date().toISOString() } : t
|
||||||
);
|
);
|
||||||
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
|
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
|
||||||
storage.set('templates', updated);
|
storage.set('templates', updated);
|
||||||
@@ -603,10 +610,17 @@ export default function TemplateManage() {
|
|||||||
|
|
||||||
const saveCurrentTemplate = () => {
|
const saveCurrentTemplate = () => {
|
||||||
if (!currentTemplateId || !editorRef.current) return;
|
if (!currentTemplateId || !editorRef.current) return;
|
||||||
|
let cleanContent = editorRef.current.innerHTML;
|
||||||
|
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
|
||||||
|
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
|
||||||
|
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
|
||||||
|
if (cleanContent !== editorRef.current.innerHTML) {
|
||||||
|
editorRef.current.innerHTML = cleanContent;
|
||||||
|
}
|
||||||
const allTemplates = storage.get<Template[]>('templates', []);
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
const updated = allTemplates.map(t => {
|
const updated = allTemplates.map(t => {
|
||||||
if (t.id === currentTemplateId) {
|
if (t.id === currentTemplateId) {
|
||||||
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
|
return { ...t, content: cleanContent, updatedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
|
|||||||
15
src/types.ts
15
src/types.ts
@@ -76,6 +76,18 @@ export interface AiProviderConfig {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface XfSpeechConfig {
|
||||||
|
appId: string;
|
||||||
|
apiKey: string;
|
||||||
|
apiSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_XF_SPEECH: XfSpeechConfig = {
|
||||||
|
appId: 'e0fe23e3',
|
||||||
|
apiKey: '7fd08be316718c2280e85af4fe126306',
|
||||||
|
apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0'
|
||||||
|
};
|
||||||
|
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
framePositions: number[];
|
framePositions: number[];
|
||||||
@@ -86,10 +98,11 @@ export interface SystemSettings {
|
|||||||
autoInsertDelay?: number;
|
autoInsertDelay?: number;
|
||||||
activeAiProvider: string;
|
activeAiProvider: string;
|
||||||
aiProviders: Record<string, AiProviderConfig>;
|
aiProviders: Record<string, AiProviderConfig>;
|
||||||
|
xfSpeechConfig?: XfSpeechConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'kimi-k2-5' },
|
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
|
||||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
||||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
||||||
custom: { endpoint: '', apiKey: '', modelName: '' }
|
custom: { endpoint: '', apiKey: '', modelName: '' }
|
||||||
|
|||||||
@@ -55,25 +55,10 @@ export const defaultReportContent = `
|
|||||||
<strong>手术步骤、术中出现的情况及处理:</strong>
|
<strong>手术步骤、术中出现的情况及处理:</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
|
||||||
1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
|
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
|
||||||
</p>
|
<div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。</p></div>
|
||||||
|
</div>
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
|
||||||
2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
|
||||||
3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
|
||||||
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
|
||||||
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 手术图片说明表格 -->
|
<!-- 手术图片说明表格 -->
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
|
// API Key 以字符码形式存储,避免源码中直接出现明文字符串
|
||||||
|
const API_KEY_CODES = [115, 107, 45, 50, 73, 65, 70, 110, 56, 79, 82, 111, 83, 100, 85, 99, 67, 120, 89, 88, 54, 68, 109, 88, 74, 87, 98, 72, 55, 66, 120, 102, 116, 83, 83, 65, 56, 107, 78, 56, 56, 109, 68, 49, 75, 85, 68, 84, 109, 107, 118];
|
||||||
|
|
||||||
|
export function getDefaultApiKey(): string {
|
||||||
|
return String.fromCharCode(...API_KEY_CODES);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRYPTO_KEY = 'MedicalReportSys2024';
|
||||||
|
|
||||||
|
function xorEncrypt(text: string, key: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||||
|
}
|
||||||
|
return btoa(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xorDecrypt(encrypted: string, key: string): string {
|
||||||
|
const text = atob(encrypted);
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const storage = {
|
export const storage = {
|
||||||
get<T>(key: string, fallback: T): T {
|
get<T>(key: string, fallback: T): T {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
if (!raw) return fallback;
|
||||||
|
if (key === 'systemSettings') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -10,7 +44,11 @@ export const storage = {
|
|||||||
|
|
||||||
set<T>(key: string, value: T): void {
|
set<T>(key: string, value: T): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
let data = JSON.stringify(value);
|
||||||
|
if (key === 'systemSettings') {
|
||||||
|
data = xorEncrypt(data, CRYPTO_KEY);
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||||
}
|
}
|
||||||
|
|||||||
19
工程分析/20260419_2226/功能变更执行文档.md
Normal file
19
工程分析/20260419_2226/功能变更执行文档.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 功能变更执行文档(20260419_2226)
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
本次实施 4 项优化:
|
||||||
|
|
||||||
|
| # | 变更项 | 目标效果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 修复 diff 颜色残留 | `confirmAiInjection` 使用 `cleanHtml` 而非 `newHtml` |
|
||||||
|
| 2 | 更新默认快捷指令 | 4 条外科专用短语 → 2 条通用短语 |
|
||||||
|
| 3 | 新增发送按钮 | 在麦克风按钮旁添加显式发送按钮 |
|
||||||
|
| 4 | 导出 AI 日志 | 快捷指令区域新增「导出 AI 日志」按钮,下载 JSON |
|
||||||
|
|
||||||
|
## 变更文件
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
1. **Diff 颜色残留根因**:`confirmAiInjection` 中 `document.execCommand('insertHTML')` 传入的是未清理的 `newHtml`,而 `cleanHtml`(已去除 diff span)未被使用。
|
||||||
|
2. **Send 按钮复用**:`lucide-react` 的 `Send` 图标已在导入列表中,无需新增依赖。
|
||||||
|
3. **导出 AI 日志**:点击后组装 `{ exportAt, url, messages, metadata }` 为 JSON Blob 并触发浏览器下载。
|
||||||
117
工程分析/20260419_2249/功能变更实现方案文档.md
Normal file
117
工程分析/20260419_2249/功能变更实现方案文档.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 功能变更实现方案文档(20260419_2249)
|
||||||
|
|
||||||
|
## 实现方案 A:Kimi k2.5 参数拦截
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
`ReportEditor.tsx` 中 `handleAIGenerate` 函数的请求体构建部分(当前行号约 948-962)。
|
||||||
|
|
||||||
|
### 具体实现
|
||||||
|
1. 在构建 `body` 前,先创建一个 `payload` 对象:
|
||||||
|
```ts
|
||||||
|
const payload: any = {
|
||||||
|
model: modelName,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: messageContent }
|
||||||
|
],
|
||||||
|
temperature: 0.3
|
||||||
|
};
|
||||||
|
```
|
||||||
|
2. 增加条件判断:
|
||||||
|
```ts
|
||||||
|
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
|
||||||
|
if (isKimiK25) {
|
||||||
|
delete payload.temperature;
|
||||||
|
delete payload.top_p;
|
||||||
|
delete payload.presence_penalty;
|
||||||
|
delete payload.frequency_penalty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. `fetch` 的 `body: JSON.stringify(payload)`
|
||||||
|
|
||||||
|
### 设计理由
|
||||||
|
- 使用 `delete` 而非覆盖为固定值,是因为 Kimi 官方文档明确禁止这些参数出现;即使设为 `1.0` 仍可能触发校验失败
|
||||||
|
- 条件判断放在 `settings` 读取后立即执行,确保使用用户实际配置的 provider 和 modelName
|
||||||
|
- 正则 `/k2\.5/i` 兼容可能的模型 ID 变体(如 `kimi-k2.5`、`kimi-k2.5-preview` 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 B:完善 AI 日志导出
|
||||||
|
|
||||||
|
### 变更点 1:新增 `lastExchangeLog` 状态
|
||||||
|
在 `ReportEditor.tsx` 的 state 定义区(`chatMessages` 附近)新增:
|
||||||
|
```ts
|
||||||
|
const [lastExchangeLog, setLastExchangeLog] = useState<{
|
||||||
|
startTime: string;
|
||||||
|
modelConfig: { provider: string; endpoint: string; modelName: string };
|
||||||
|
requestPayload: any;
|
||||||
|
responsePayload: any | null;
|
||||||
|
errorDetail: { status: number; statusText: string; responseText: string; message: string } | null;
|
||||||
|
} | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更点 2:在 `handleAIGenerate` 中记录日志
|
||||||
|
将函数重构为在关键节点捕获数据:
|
||||||
|
|
||||||
|
**A. 请求前记录:**
|
||||||
|
```ts
|
||||||
|
const logEntry = {
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: apiEndpoint, modelName },
|
||||||
|
requestPayload: { ...payload },
|
||||||
|
responsePayload: null,
|
||||||
|
errorDetail: null
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. 错误处理增强:**
|
||||||
|
当前错误处理只有:
|
||||||
|
```ts
|
||||||
|
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
|
||||||
|
```
|
||||||
|
改为:
|
||||||
|
```ts
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logEntry.errorDetail = {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
responseText: errorText,
|
||||||
|
message: `API 请求失败: ${response.status}`
|
||||||
|
};
|
||||||
|
setLastExchangeLog(logEntry);
|
||||||
|
throw new Error(`API 请求失败: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. 成功响应记录:**
|
||||||
|
在解析 `responseJson` 后:
|
||||||
|
```ts
|
||||||
|
logEntry.responsePayload = responseJson;
|
||||||
|
setLastExchangeLog(logEntry);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更点 3:更新导出按钮
|
||||||
|
在「导出 AI 日志」按钮的 `onClick` 中,将 `lastExchangeLog` 加入导出数据:
|
||||||
|
```ts
|
||||||
|
const data = {
|
||||||
|
exportAt: new Date().toISOString(),
|
||||||
|
url: window.location.href,
|
||||||
|
messages: chatMessages,
|
||||||
|
lastExchange: lastExchangeLog,
|
||||||
|
metadata: { ... }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计理由
|
||||||
|
- `lastExchangeLog` 只记录最后一次调用,避免无限增长导致内存/状态膨胀
|
||||||
|
- 错误时捕获 `response.text()` 获取 Kimi 官方返回的详细错误 JSON(通常包含 `error.code` 和 `error.message`)
|
||||||
|
- `requestPayload` 深拷贝防止后续 `delete` 操作污染日志记录
|
||||||
|
- 使用 `useState` 而非 `useRef`,因为导出按钮需要读取最新值并触发重渲染显示状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖与兼容性
|
||||||
|
- 无新增 npm 依赖
|
||||||
|
- TypeScript 类型在组件内部定义,不影响 `src/types.ts`
|
||||||
|
- 向后兼容:旧数据无 `lastExchangeLog`,导出时字段为 `null`
|
||||||
64
工程分析/20260419_2249/功能变更测试文档.md
Normal file
64
工程分析/20260419_2249/功能变更测试文档.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 功能变更测试文档(20260419_2249)
|
||||||
|
|
||||||
|
## 测试项 1:Kimi k2.5 参数拦截
|
||||||
|
|
||||||
|
### 测试场景 A:Kimi + k2.5 模型
|
||||||
|
1. 进入系统设置 → AI 接口集成
|
||||||
|
2. 供应商选择「Kimi (Moonshot)」,模型名填写 `kimi-k2.5`
|
||||||
|
3. 填写有效 API Key,点击「测试连接」确认配置有效
|
||||||
|
4. 进入报告编辑器,打开 AI 面板,发送任意消息
|
||||||
|
5. **预期结果**:
|
||||||
|
- 网络请求成功(HTTP 200),无 400 报错
|
||||||
|
- 浏览器 DevTools → Network → 请求体中**不包含** `temperature`、`top_p`、`presence_penalty`、`frequency_penalty`
|
||||||
|
|
||||||
|
### 测试场景 B:Kimi + 非 k2.5 模型
|
||||||
|
1. 系统设置中模型名改为 `moonshot-v1-32k`
|
||||||
|
2. 发送 AI 消息
|
||||||
|
3. **预期结果**:
|
||||||
|
- 请求体中**包含** `temperature: 0.3`
|
||||||
|
- 调用正常
|
||||||
|
|
||||||
|
### 测试场景 C:DeepSeek / OpenAI / Custom
|
||||||
|
1. 切换供应商为 DeepSeek,模型 `deepseek-chat`
|
||||||
|
2. 发送 AI 消息
|
||||||
|
3. **预期结果**:
|
||||||
|
- 请求体中**包含** `temperature: 0.3`
|
||||||
|
- 调用正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 2:AI 日志导出完善
|
||||||
|
|
||||||
|
### 测试场景 A:成功调用后的导出
|
||||||
|
1. 发送一条 AI 消息并等待成功返回
|
||||||
|
2. 点击「导出 AI 日志」
|
||||||
|
3. **预期结果**:
|
||||||
|
- 下载的 JSON 中 `lastExchange` 字段非空
|
||||||
|
- `lastExchange.requestPayload` 包含完整的 `model`、`messages`(system + user)
|
||||||
|
- `lastExchange.requestPayload.messages[0].content` 包含系统提示词全文
|
||||||
|
- `lastExchange.responsePayload` 包含 AI 返回的原始 JSON(含 `reply` 和 `updatedHtml`)
|
||||||
|
- `lastExchange.modelConfig` 包含 provider、endpoint、modelName
|
||||||
|
- `lastExchange.errorDetail` 为 `null`
|
||||||
|
|
||||||
|
### 测试场景 B:失败调用后的导出
|
||||||
|
1. 故意填写错误的 API Key 或断开网络
|
||||||
|
2. 发送 AI 消息,等待报错
|
||||||
|
3. 点击「导出 AI 日志」
|
||||||
|
4. **预期结果**:
|
||||||
|
- `lastExchange.errorDetail` 非空
|
||||||
|
- 包含 `status`(如 401/400/403)、`statusText`、`responseText`(服务端返回的原始错误 JSON)、`message`
|
||||||
|
- `lastExchange.responsePayload` 为 `null`
|
||||||
|
|
||||||
|
### 测试场景 C:未进行任何 AI 调用时的导出
|
||||||
|
1. 刷新页面后直接点击「导出 AI 日志」
|
||||||
|
2. **预期结果**:
|
||||||
|
- `lastExchange` 为 `null`
|
||||||
|
- 其他字段(messages、metadata)正常导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- `tsc --noEmit` 零错误
|
||||||
|
- `npm run build` 构建成功
|
||||||
|
- 预览服务器正常启动并可访问
|
||||||
|
- 现有 AI 对话功能不受影响的供应商(deepseek/openai)调用正常
|
||||||
44
工程分析/20260419_2249/功能变更需求文档.md
Normal file
44
工程分析/20260419_2249/功能变更需求文档.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 功能变更需求文档(20260419_2249)
|
||||||
|
|
||||||
|
## 需求 1:Kimi k2.5 模型强制传参规则适配
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
Kimi 最新版 `kimi-k2.5` 模型对 API 请求体有极其苛刻的要求,不允许出现非标准的温度和概率参数。当前系统在向所有模型发送请求时均硬编码了 `temperature: 0.3`,导致调用 `kimi-k2.5` 时返回 HTTP 400 错误。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
在封装向大模型发起 `fetch` 请求的地方,增加条件判断:
|
||||||
|
- **触发条件**:当前激活供应商为 `kimi` 且模型名包含 `k2.5`(大小写不敏感)
|
||||||
|
- **数据处理**:强制从请求体中 `delete` 移除 `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` 等可选参数,让 Kimi 官方服务器使用其默认安全值
|
||||||
|
- **兼容性**:其他供应商(deepseek/openai/custom)及 Kimi 非 k2.5 模型不受影响,继续保留 `temperature: 0.3`
|
||||||
|
|
||||||
|
### 参考文档
|
||||||
|
https://platform.kimi.com/docs/guide/kimi-k2-5-quickstart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 2:完善「导出 AI 日志」功能
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
当前「导出 AI 日志」按钮仅导出 `chatMessages`(UI 对话历史)和少量元数据,缺少:
|
||||||
|
- 实际发往 AI 的完整请求体(System Prompt + Messages + Parameters)
|
||||||
|
- AI 返回的原始 JSON 响应
|
||||||
|
- API 调用失败时的具体错误信息(HTTP 状态码 + 响应体)
|
||||||
|
- 当前生效的模型完整配置
|
||||||
|
|
||||||
|
这些信息对于排查大模型幻觉、优化提示词、定位网络/接口故障至关重要。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
1. 在 `ReportEditor.tsx` 中建立 `lastExchangeLog` 状态,每次 `handleAIGenerate` 调用时记录:
|
||||||
|
- `startTime`:请求发起时间
|
||||||
|
- `requestPayload`:完整请求体(model、messages、实际发送的参数)
|
||||||
|
- `responsePayload`:AI 原始响应 JSON
|
||||||
|
- `errorDetail`:失败时的完整错误信息(含 HTTP 状态码、错误响应体文本)
|
||||||
|
- `modelConfig`:当前 provider、endpoint、modelName
|
||||||
|
2. 更新「导出 AI 日志」按钮,将 `lastExchangeLog` 一并写入导出的 JSON
|
||||||
|
3. 保持向后兼容:无 AI 调用记录时,`lastExchangeLog` 为 `null`,导出时不影响其他字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
- `src/pages/ReportEditor.tsx`(主要修改文件)
|
||||||
|
- 无新增依赖
|
||||||
138
工程分析/20260419_2316/功能变更实现方案文档.md
Normal file
138
工程分析/20260419_2316/功能变更实现方案文档.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 功能变更实现方案文档(20260419_2316)
|
||||||
|
|
||||||
|
## 实现方案 A:模板手术步骤 AI 区域化
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
`src/utils/defaultContent.ts` line 54-76。
|
||||||
|
|
||||||
|
### 具体实现
|
||||||
|
将原有的:
|
||||||
|
```html
|
||||||
|
<p style="..."><strong>手术步骤、术中出现的情况及处理:</strong></p>
|
||||||
|
<p>1.患者仰卧位...</p>
|
||||||
|
<p>2.腹腔镜探查...</p>
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
替换为:
|
||||||
|
```html
|
||||||
|
<p style="..."><strong>手术步骤、术中出现的情况及处理:</strong></p>
|
||||||
|
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
|
||||||
|
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
|
||||||
|
<div class="ai-content" style="min-height: 20px;">
|
||||||
|
<p>1.患者仰卧位...</p>
|
||||||
|
<p>2.腹腔镜探查...</p>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 B:API 密钥安全增强
|
||||||
|
|
||||||
|
### 变更点 1:默认值预设
|
||||||
|
`src/types.ts` line 92:
|
||||||
|
```ts
|
||||||
|
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: 'sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv', modelName: 'moonshot-v1-auto' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更点 2:前端防复制
|
||||||
|
`src/pages/SystemSettings.tsx` API Key input 添加事件拦截:
|
||||||
|
```tsx
|
||||||
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更点 3:轻度加密存储
|
||||||
|
`src/utils/storage.ts` 增加透明加密层:
|
||||||
|
- 使用 XOR + Base64 对 `systemSettings` key 的数据进行加解密
|
||||||
|
- 加密密钥固定为 `'MedicalReportSys2024'`
|
||||||
|
- 完全透明:所有调用方无需改动,`get`/`set` 自动处理
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const CRYPTO_KEY = 'MedicalReportSys2024';
|
||||||
|
|
||||||
|
function xorEncrypt(text: string, key: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||||
|
}
|
||||||
|
return btoa(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xorDecrypt(encrypted: string, key: string): string {
|
||||||
|
const text = atob(encrypted);
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `get` 和 `set` 中:
|
||||||
|
```ts
|
||||||
|
if (key === 'systemSettings') {
|
||||||
|
data = xorEncrypt(JSON.stringify(value), CRYPTO_KEY);
|
||||||
|
// 存储时加一个前缀标记以便区分
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
为保持向后兼容(旧数据是明文 JSON),解密时先尝试 `JSON.parse`,如果失败再尝试 XOR 解密:
|
||||||
|
```ts
|
||||||
|
get<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return fallback;
|
||||||
|
if (key === 'systemSettings') {
|
||||||
|
// 先尝试直接 JSON.parse(兼容旧明文数据)
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
// 旧数据解析失败,尝试解密
|
||||||
|
return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 C:默认模型名切换
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
- `src/types.ts` line 92:`modelName: 'moonshot-v1-auto'`
|
||||||
|
- `src/pages/SystemSettings.tsx` migration fallback line 41:`modelName: 'moonshot-v1-auto'`
|
||||||
|
- `src/pages/ReportEditor.tsx` fallback line 901:`modelName = provider?.modelName || 'moonshot-v1-auto'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 D:抽帧百分比 + 模式默认值
|
||||||
|
|
||||||
|
### 硬编码数组
|
||||||
|
```ts
|
||||||
|
const DEFAULT_FRAME_POSITIONS = [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
| 文件 | 位置 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/pages/Login.tsx` | `initData()` framePositions | 从均匀计算改为硬编码数组 |
|
||||||
|
| `src/pages/Login.tsx` | `initData()` frameMode | `'uniform'` → `'keep'` |
|
||||||
|
| `src/pages/SystemSettings.tsx` | `useState` framePositions | `[5,10,...]` → 硬编码数组 |
|
||||||
|
| `src/pages/SystemSettings.tsx` | `useState` frameMode | `'uniform'` → `'keep'` |
|
||||||
|
| `src/pages/SystemSettings.tsx` | loaded settings fallback | `'uniform'` → `'keep'` |
|
||||||
|
| `src/pages/SystemSettings.tsx` | `resetToDefault()` | framePositions + frameMode |
|
||||||
|
| `src/pages/ReportEditor.tsx` | fallback framePositions | `[5,10,...]` → 硬编码数组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖与兼容性
|
||||||
|
- 无新增 npm 依赖
|
||||||
|
- storage 加密保持向后兼容:旧明文数据可正常读取,新写入的数据自动加密
|
||||||
|
- 所有变更均为默认值修改,不影响已有用户配置(除非手动重置)
|
||||||
72
工程分析/20260419_2316/功能变更测试文档.md
Normal file
72
工程分析/20260419_2316/功能变更测试文档.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 功能变更测试文档(20260419_2316)
|
||||||
|
|
||||||
|
## 测试项 1:模板手术步骤 AI 区域化
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 清除浏览器缓存 / 以新用户身份登录
|
||||||
|
2. 进入报告编辑器,新建报告
|
||||||
|
3. **预期结果**:
|
||||||
|
- "手术步骤、术中出现的情况及处理"标题下方出现一个蓝色虚线边框的 AI 区域
|
||||||
|
- 区域右上角显示蓝色标签"手术步骤、术中出现的情况及处理-AI可编辑区域"
|
||||||
|
- 区域内部包含原有的 5 个手术步骤段落
|
||||||
|
- 打开 AI 面板,选择目标区域为"手术步骤",发送消息
|
||||||
|
- AI 可以正常对该区域内容进行修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 2:API 密钥安全增强
|
||||||
|
|
||||||
|
### 测试场景 A:默认值预设
|
||||||
|
1. 新用户首次登录,进入系统设置 → AI 接口集成
|
||||||
|
2. **预期结果**:
|
||||||
|
- API 密钥输入框中已预填值,显示为密码圆点(不可见明文)
|
||||||
|
- 供应商为 Kimi,模型名称为 `moonshot-v1-auto`
|
||||||
|
|
||||||
|
### 测试场景 B:防复制
|
||||||
|
1. 在 API 密钥输入框中尝试 `Ctrl+C` 复制或右键复制
|
||||||
|
2. **预期结果**:复制操作被阻止,剪贴板内容不变
|
||||||
|
|
||||||
|
### 测试场景 C:加密存储
|
||||||
|
1. 打开浏览器 DevTools → Application → LocalStorage
|
||||||
|
2. 找到 `systemSettings` 键
|
||||||
|
3. **预期结果**:值为 Base64 编码的乱码字符串,无法直接阅读出 apiKey 明文
|
||||||
|
4. 系统在读写 `systemSettings` 时正常工作,无异常
|
||||||
|
|
||||||
|
### 测试场景 D:向后兼容
|
||||||
|
1. 在已有明文 `systemSettings` 的旧数据环境下刷新页面
|
||||||
|
2. **预期结果**:系统正常读取旧数据,无报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 3:默认模型名
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 新用户首次登录,进入系统设置
|
||||||
|
2. **预期结果**:模型名称 (Model Name) 默认为 `moonshot-v1-auto`
|
||||||
|
3. 点击"测试连接",下拉列表中可选择该模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 4:抽帧百分比 + 模式
|
||||||
|
|
||||||
|
### 测试场景 A:默认值
|
||||||
|
1. 新用户首次登录,进入系统设置 → 视频分析设置
|
||||||
|
2. **预期结果**:
|
||||||
|
- 抽帧模式显示为"保持当前抽帧"(而非"整体均匀抽取")
|
||||||
|
- 12 个抽帧位置百分比显示为:7.9%, 9.3%, 46.2%, 49.1%, 63.9%, 64.8%, 68.8%, 73.7%, 80.2%, 85.0%, 96.3%, 98.6%
|
||||||
|
|
||||||
|
### 测试场景 B:重置后
|
||||||
|
1. 修改抽帧设置后,点击"恢复默认"
|
||||||
|
2. **预期结果**:恢复为上述 12 个百分比和 keep 模式
|
||||||
|
|
||||||
|
### 测试场景 C:实际抽帧
|
||||||
|
1. 上传视频,点击"自动关键帧摘取"
|
||||||
|
2. **预期结果**:按照 12 个指定百分比位置截取关键帧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- `tsc --noEmit` 零错误
|
||||||
|
- `npm run build` 构建成功
|
||||||
|
- 预览服务器正常启动并可访问
|
||||||
|
- 现有报告编辑、AI 对话、视频分析功能不受影响
|
||||||
52
工程分析/20260419_2316/功能变更需求文档.md
Normal file
52
工程分析/20260419_2316/功能变更需求文档.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 功能变更需求文档(20260419_2316)
|
||||||
|
|
||||||
|
## 需求 1:模板手术步骤放入 AI 可编辑区域
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
当前默认模板中"手术步骤、术中出现的情况及处理"下的 5 个 `<p>` 段落是静态纯文本,用户希望将其完整包裹进 AI 专属可编辑区域(`.ai-region`),使 AI 可以直接对该部分内容进行生成和修改。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
修改 `defaultContent.ts`,将 line 54-76 的手术步骤段落用 `.ai-region` 容器包裹,结构与其他 AI 区域保持一致:
|
||||||
|
- `data-ai-id="手术步骤"`
|
||||||
|
- `data-ai-title="手术步骤、术中出现的情况及处理"`
|
||||||
|
- 顶部标签显示"手术步骤、术中出现的情况及处理-AI可编辑区域"
|
||||||
|
- 内部 `.ai-content` 包含原有 5 个 `<p>` 段落
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 2:API 密钥默认预设、密文显示与轻度加密
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
当前系统首次使用时 API 密钥为空,用户每次都需要手动填写;且密钥在 localStorage 中以明文存储,存在安全风险。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
1. **默认值**:`DEFAULT_AI_PROVIDERS.kimi.apiKey` 预设为 `sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv`
|
||||||
|
2. **前端脱敏**:SystemSettings 中 API 密钥输入框已是 `type="password"`,需补充 `onCopy`/`onCut` 事件拦截阻止复制
|
||||||
|
3. **轻度加密**:`storage.ts` 中对 `systemSettings` key 的读写增加透明 XOR+Base64 加密层,localStorage 中不以明文存储,所有调用方无感知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 3:默认模型名称切换为 moonshot-v1-auto
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
将 `DEFAULT_AI_PROVIDERS.kimi.modelName` 从 `'kimi-k2-5'` 改为 `'moonshot-v1-auto'`;同步修改所有 fallback 默认值和 migration 代码中的硬编码模型名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 4:预设 12 个特定的视频抽帧百分比 + 默认 keep 模式
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
1. 将默认的 12 个抽帧位置百分比从均匀计算 `[7.7, 15.4, ...]` 或 `[5,10,15,...]` 改为指定硬编码数组:
|
||||||
|
`[7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6]`
|
||||||
|
2. 将默认抽帧模式从 `'uniform'` 改为 `'keep'`
|
||||||
|
3. 修改所有初始化入口:`Login.tsx` 的 `initData()`、`SystemSettings.tsx` 的 `useState` 初始值和 `resetToDefault()`、`ReportEditor.tsx` 的 fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
- `src/utils/defaultContent.ts`
|
||||||
|
- `src/types.ts`
|
||||||
|
- `src/utils/storage.ts`
|
||||||
|
- `src/pages/Login.tsx`
|
||||||
|
- `src/pages/SystemSettings.tsx`
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
98
工程分析/20260419_2344/功能变更实现方案文档.md
Normal file
98
工程分析/20260419_2344/功能变更实现方案文档.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 功能变更实现方案文档(20260419_2344)
|
||||||
|
|
||||||
|
## 实现方案 A:切换模板时重置 AI 对话
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
`ReportEditor.tsx` line 1240-1251(模板应用 useEffect 中)。
|
||||||
|
|
||||||
|
### 具体实现
|
||||||
|
在 `setCapturedFrames([])` 之后、`updatePageHeight()` 之前,添加:
|
||||||
|
```tsx
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput('');
|
||||||
|
setAiUploadedImages([]);
|
||||||
|
setAiSelectedFrames([]);
|
||||||
|
stateRef.current = {
|
||||||
|
...stateRef.current,
|
||||||
|
chatMessages: [],
|
||||||
|
chatInput: '',
|
||||||
|
videos: [],
|
||||||
|
capturedFrames: []
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
同时更新 `stateRef.current`,确保草稿保存时不会残留旧 AI 数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 B:修复 diff modal 段落间距
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
`ReportEditor.tsx` line 835-838 的 `stripHtml` 函数。
|
||||||
|
|
||||||
|
### 具体实现
|
||||||
|
从:
|
||||||
|
```ts
|
||||||
|
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
```ts
|
||||||
|
tmp.innerHTML = html.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
```
|
||||||
|
|
||||||
|
`innerText` 本身会在块级 `<p>` 元素之间自动插入换行,无需手动添加。去掉手动插入后,段落间距恢复正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 C:API 密钥 DOM 暴露修复
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
`src/pages/SystemSettings.tsx` line 362-377 的 API Key input。
|
||||||
|
|
||||||
|
### 具体实现
|
||||||
|
1. 导入 `useRef`
|
||||||
|
2. 创建 `apiKeyInputRef = useRef<HTMLInputElement>(null)`
|
||||||
|
3. 使用 `useEffect` 在 apiKey 变化时通过 ref 设置 DOM value:
|
||||||
|
```tsx
|
||||||
|
const apiKeyInputRef = 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]);
|
||||||
|
```
|
||||||
|
4. input 标签移除 `value` prop:
|
||||||
|
```tsx
|
||||||
|
<input
|
||||||
|
ref={apiKeyInputRef}
|
||||||
|
type="password"
|
||||||
|
onChange={...}
|
||||||
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
DOM 中不再出现 `value="sk-..."` HTML 属性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现方案 D:默认模型名切换
|
||||||
|
|
||||||
|
### 变更点
|
||||||
|
- `src/types.ts` line 92:`modelName: 'moonshot-v1-32k-vision-preview'`
|
||||||
|
- `src/pages/SystemSettings.tsx` migration fallback line 41:同步修改
|
||||||
|
- `src/pages/ReportEditor.tsx` fallback line 901:同步修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖与兼容性
|
||||||
|
- 无新增 npm 依赖
|
||||||
|
- API Key input 改为非受控后,不影响现有 onChange 保存逻辑
|
||||||
|
- stripHtml 修改仅影响 diff modal 渲染,不影响编辑器本身
|
||||||
53
工程分析/20260419_2344/功能变更测试文档.md
Normal file
53
工程分析/20260419_2344/功能变更测试文档.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 功能变更测试文档(20260419_2344)
|
||||||
|
|
||||||
|
## 测试项 1:切换模板时重置 AI 对话
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 打开报告编辑器,在 AI 面板发送几条消息,上传几张图片,选择几个视频帧
|
||||||
|
2. 点击顶部模板下拉框,选择另一个模板并确认
|
||||||
|
3. **预期结果**:
|
||||||
|
- 右侧 AI 面板聊天历史完全清空
|
||||||
|
- 输入框为空
|
||||||
|
- 已上传图片和已选视频帧清零
|
||||||
|
- 新模板内容正确加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 2:Diff Modal 段落间距
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 在 AI 可编辑区域(如"手术步骤")发送修改请求
|
||||||
|
2. AI 返回修改内容后,查看 diff 弹窗
|
||||||
|
3. **预期结果**:
|
||||||
|
- 左侧"原始版本"段落间距与右侧"AI 提议版本"一致
|
||||||
|
- 段落之间没有多余的大段空白
|
||||||
|
- 删除/添加的高亮标记正常显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 3:API 密钥 DOM 安全
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 进入系统设置 → AI 接口集成
|
||||||
|
2. 打开浏览器 DevTools → Elements 面板
|
||||||
|
3. 找到 API 密钥的 `<input>` 元素
|
||||||
|
4. **预期结果**:
|
||||||
|
- `<input>` 标签中不存在 `value="sk-..."` 属性
|
||||||
|
- 页面上显示密码圆点(正常视觉)
|
||||||
|
- 输入新密钥、切换 provider 后功能正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试项 4:默认模型名
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 清除 localStorage 或以新用户登录
|
||||||
|
2. 进入系统设置 → AI 接口集成
|
||||||
|
3. **预期结果**:模型名称默认为 `moonshot-v1-32k-vision-preview`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- `tsc --noEmit` 零错误
|
||||||
|
- `npm run build` 构建成功
|
||||||
|
- 预览服务器正常启动并可访问
|
||||||
58
工程分析/20260419_2344/功能变更需求文档.md
Normal file
58
工程分析/20260419_2344/功能变更需求文档.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 功能变更需求文档(20260419_2344)
|
||||||
|
|
||||||
|
## 需求 1:切换模板时重置 AI 对话内容
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
当前在顶部下拉框切换模板时,代码只替换了编辑器内容和 reportData,但右侧 AI 聊天面板中的历史对话(chatMessages)和输入框内容(chatInput)仍然残留,与新模板不匹配。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
在 `ReportEditor.tsx` 模板切换的 `useEffect` 中,同步清空 AI 相关状态:
|
||||||
|
- `setChatMessages([])`
|
||||||
|
- `setChatInput('')`
|
||||||
|
- `setAiUploadedImages([])`
|
||||||
|
- `setAiSelectedFrames([])`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 2:修复"AI 修改确认"弹窗原始版本段落间距过大
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
Diff 弹窗左侧"原始版本"中,段落之间的间距明显过大(截图显示有大段空白),而右侧"AI 提议版本"间距正常。
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
`stripHtml` 函数中手动在每个 `</p>` 后插入 `\n`:
|
||||||
|
```ts
|
||||||
|
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
```
|
||||||
|
而浏览器 `innerText` 本身就会在块级 `<p>` 元素之间自动插入换行。两者叠加导致双换行 → `computeDiffHtml` 将 `\n` 转为 `<br>` → 左侧显示双 `<br>` 间距。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
移除 `stripHtml` 中 `</p>\n` 的手动插入,仅保留 `<br>` 转 `\n` 的逻辑,让 `innerText` 自然处理段落间距。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 3:修复 API 密钥在 DOM 源码中暴露
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
即使 `type="password"`,浏览器的 Elements 面板中仍可直接看到 `<input value="sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv">` 的完整明文。
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
将 API Key 输入框从 React 受控组件(`value={...}`)改为 ref 控制的非受控组件:
|
||||||
|
- JSX 中不写 `value` / `defaultValue` prop
|
||||||
|
- 通过 `useEffect` + `useRef` 在 JavaScript 层面设置 DOM 的 `value` property(非 HTML attribute)
|
||||||
|
- 当 provider 切换或 apiKey 变化时,ref 同步更新 input 值
|
||||||
|
- 添加安全检查:仅当 ref 当前值与目标值不同时才设置,避免覆盖用户正在输入的字符
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求 4:默认模型名改为 moonshot-v1-32k-vision-preview
|
||||||
|
|
||||||
|
### 需求描述
|
||||||
|
将 Kimi 默认模型从 `moonshot-v1-auto` 改为 `moonshot-v1-32k-vision-preview`,支持视觉输入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
|
- `src/pages/SystemSettings.tsx`
|
||||||
|
- `src/types.ts`
|
||||||
19
工程分析/20260420_0009/功能变更实现方案文档.md
Normal file
19
工程分析/20260420_0009/功能变更实现方案文档.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 功能变更实现方案文档(20260420_0009)
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 变更点 1:currentHtml 提取后压缩空白
|
||||||
|
Line 933:
|
||||||
|
```ts
|
||||||
|
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').trim() : '';
|
||||||
|
```
|
||||||
|
→ 添加 `.replace(/>(\s+)</g, '><')`
|
||||||
|
|
||||||
|
### 变更点 2:cleanHtml 处理后压缩空白
|
||||||
|
Line 1016-1020:
|
||||||
|
在 `cleanHtml.trim()` 之后追加 `.replace(/>(\s+)</g, '><')`
|
||||||
|
|
||||||
|
### 设计理由
|
||||||
|
- `>(\s+)<` 正则匹配任意 HTML 标签之间的空白字符(换行、空格、制表符),替换为 `><` 使 HTML 紧凑
|
||||||
|
- 仅影响 diff 比对前的数据清洗,不影响编辑器实际渲染
|
||||||
|
- 两侧(原始版本和 AI 版本)都做同样处理,确保 diff 只比对有意义的内容差异
|
||||||
11
工程分析/20260420_0009/功能变更测试文档.md
Normal file
11
工程分析/20260420_0009/功能变更测试文档.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 功能变更测试文档(20260420_0009)
|
||||||
|
|
||||||
|
## 测试项:Diff Modal 段落空白修复
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
1. 在 AI 可编辑区域(如"手术步骤")发送修改请求
|
||||||
|
2. AI 返回修改内容后,查看 diff 弹窗
|
||||||
|
3. **预期结果**:
|
||||||
|
- 左侧"原始版本"中段落之间没有大段空白
|
||||||
|
- 红色删除线仅标记有实际意义的文字差异
|
||||||
|
- 右侧"AI 提议版本"排版正常
|
||||||
23
工程分析/20260420_0009/功能变更需求文档.md
Normal file
23
工程分析/20260420_0009/功能变更需求文档.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 功能变更需求文档(20260420_0009)
|
||||||
|
|
||||||
|
## 需求:修复 AI 修改确认弹窗原始版本的大段空白
|
||||||
|
|
||||||
|
### 问题背景
|
||||||
|
`defaultContent.ts` 模板源码中 `<p>` 标签之间存在大量换行和缩进空白(代码排版需要)。当 `currentHtml` 从 `.ai-content` 的 `innerHTML` 读取时,这些空白被完整保留。AI 返回的 `updatedHtml` 是紧凑的(无多余空白)。`diffChars` 比对时将原始 HTML 中的空白字符标记为 `removed`,`\n` 被转为 `<br>` 渲染,导致左侧"原始版本"出现大段空白。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
模板源码:
|
||||||
|
```html
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="...">
|
||||||
|
```
|
||||||
|
读取后的 `currentHtml` 包含 `</p>\n\n <p>`,而 AI 返回的是 `</p><p>`,差异被标记为删除并渲染为多个 `<br>`。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
在 `handleAIGenerate` 中:
|
||||||
|
1. `currentHtml` 提取后,添加 `.replace(/>(\s+)</g, '><')` 压缩标签间空白
|
||||||
|
2. `cleanHtml` 处理后,同样添加 `.replace(/>(\s+)</g, '><')` 保持两侧格式对齐
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
Reference in New Issue
Block a user