2026-04-19-02-26-05 集成AI撰写功能:Kimi-2.5多模态API、AI可编辑区域、Diff确认弹窗、语音与图片输入、快捷指令
This commit is contained in:
@@ -204,6 +204,15 @@
|
||||
.print-content .smart-field-wrapper .delete-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.print-content .ai-region {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.print-content .ai-region > [contenteditable="false"] {
|
||||
display: none !important;
|
||||
}
|
||||
.report-signature-img {
|
||||
max-width: 120px !important;
|
||||
max-height: 40px !important;
|
||||
|
||||
@@ -5,7 +5,8 @@ import Sidebar from '../components/Sidebar';
|
||||
import {
|
||||
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
||||
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
||||
Video, Play, Pause, Plus, X, ChevronLeft, Download
|
||||
Video, Play, Pause, Plus, X, ChevronLeft, Download,
|
||||
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
|
||||
} from 'lucide-react';
|
||||
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
@@ -53,9 +54,25 @@ export default function ReportEditor() {
|
||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
||||
const prevVideoCountRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info');
|
||||
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
|
||||
|
||||
// AI 撰写相关核心状态
|
||||
const [chatInput, setChatInput] = useState<string>('');
|
||||
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [aiSelectedFrames, setAiSelectedFrames] = useState<number[]>([]);
|
||||
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
|
||||
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
||||
const speechRecognitionRef = useRef<any>(null);
|
||||
const [quickPrompts, setQuickPrompts] = useState<string[]>([
|
||||
'请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结', '根据截图描述游离过程'
|
||||
]);
|
||||
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
|
||||
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||
@@ -604,6 +621,20 @@ export default function ReportEditor() {
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const insertAiRegion = () => {
|
||||
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
|
||||
if (!name || !name.trim()) return;
|
||||
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
|
||||
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
|
||||
return;
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" 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; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">​</div></div><p><br></p>`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []) as File[];
|
||||
const newVideos = files.map(file => ({
|
||||
@@ -767,6 +798,147 @@ export default function ReportEditor() {
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const checkAiRegions = () => {
|
||||
if (!editorRef.current) return [];
|
||||
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
|
||||
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
|
||||
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
|
||||
return { id, title };
|
||||
}).filter(r => r.id);
|
||||
};
|
||||
|
||||
const toggleListening = () => {
|
||||
if (isListening) {
|
||||
setIsListening(false);
|
||||
if (speechRecognitionRef.current) speechRecognitionRef.current.stop();
|
||||
} else {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert('您的浏览器不支持原生语音识别,请使用 Chrome。');
|
||||
return;
|
||||
}
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'zh-CN';
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
setChatInput(finalTranscript + interimTranscript);
|
||||
};
|
||||
recognition.onerror = () => setIsListening(false);
|
||||
recognition.onend = () => setIsListening(false);
|
||||
speechRecognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAIGenerate = async (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
const userMsgId = Date.now().toString();
|
||||
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text }]);
|
||||
setChatInput('');
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||
const apiKey = settings.kimiApiKey || '';
|
||||
const apiEndpoint = settings.kimiApiEndpoint || 'https://api.moonshot.cn/v1';
|
||||
if (!apiKey) {
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 Kimi API Key,请前往系统设置填写。' }]);
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
|
||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : '';
|
||||
const messageContent: any[] = [];
|
||||
const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
|
||||
const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
||||
allImages.forEach(url => {
|
||||
messageContent.push({ type: 'image_url', image_url: { url } });
|
||||
});
|
||||
let promptText = `【医生指令】: ${text}`;
|
||||
if (aiModifyEnabled && targetRegionEl) {
|
||||
promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
|
||||
}
|
||||
messageContent.push({ type: 'text', text: promptText });
|
||||
const systemPrompt = aiModifyEnabled && targetRegionEl
|
||||
? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
||||
: '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
||||
const response = await fetch(`${apiEndpoint}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'kimi-k2-5',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: messageContent }
|
||||
],
|
||||
temperature: 0.3
|
||||
})
|
||||
});
|
||||
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const responseText = data.choices[0].message.content.trim();
|
||||
const cleanedText = responseText.replace(/```json\n?|```/g, '');
|
||||
let responseJson: any = {};
|
||||
try {
|
||||
responseJson = JSON.parse(cleanedText);
|
||||
} catch {
|
||||
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) responseJson = JSON.parse(jsonMatch[0]);
|
||||
else throw new Error('AI 返回格式异常,无法解析 JSON');
|
||||
}
|
||||
if (responseJson.reply) {
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
|
||||
}
|
||||
if (responseJson.updatedHtml && aiModifyEnabled && targetRegionEl) {
|
||||
setDiffModal({
|
||||
isOpen: true,
|
||||
originalHtml: currentHtml,
|
||||
newHtml: responseJson.updatedHtml,
|
||||
targetId: aiTargetRegion
|
||||
});
|
||||
}
|
||||
setAiUploadedImages([]);
|
||||
setAiSelectedFrames([]);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
||||
if (!editorRef.current) return;
|
||||
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
||||
if (targetContent) {
|
||||
targetContent.innerHTML = newHtml;
|
||||
targetContent.style.transition = 'background-color 0.3s ease';
|
||||
targetContent.style.backgroundColor = '#bfdbfe';
|
||||
setTimeout(() => {
|
||||
targetContent.style.backgroundColor = '#eff6ff';
|
||||
setTimeout(() => {
|
||||
targetContent.style.backgroundColor = 'transparent';
|
||||
}, 800);
|
||||
}, 400);
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
setDiffModal(null);
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
|
||||
e.dataTransfer.setData('frameId', frame.id.toString());
|
||||
};
|
||||
@@ -1482,6 +1654,7 @@ export default function ReportEditor() {
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1509,7 +1682,7 @@ export default function ReportEditor() {
|
||||
{/* Right Sidebar */}
|
||||
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
|
||||
<div className="flex border-b border-border">
|
||||
{(['info', 'video'] as const).map(tab => (
|
||||
{(['info', 'video', 'ai'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
|
||||
@@ -1517,7 +1690,7 @@ export default function ReportEditor() {
|
||||
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' ? '基本信息' : '视频分析'}
|
||||
{tab === 'info' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2009,6 +2182,152 @@ export default function ReportEditor() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ai' && (
|
||||
<div className="flex flex-col h-full bg-[#f8fafc] overflow-hidden">
|
||||
{/* 聊天气泡记录区 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||
{chatMessages.length === 0 ? (
|
||||
<div className="text-center flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
|
||||
<Bot size={48} className="text-slate-300 opacity-50" />
|
||||
<p className="text-xs">我是 SurClaw 智能助理,请选择参考框架和图片后随时与我对话。</p>
|
||||
</div>
|
||||
) : (
|
||||
chatMessages.map(msg => (
|
||||
<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'}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isGenerating && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-3 shadow-sm flex gap-1.5 items-center">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制台与输入区 */}
|
||||
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
|
||||
{/* 区域锚定与沙盒控制 */}
|
||||
<div className="flex items-center justify-between bg-slate-50 p-2 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<select
|
||||
value={aiTargetRegion}
|
||||
onChange={(e) => setAiTargetRegion(e.target.value)}
|
||||
disabled={!aiModifyEnabled}
|
||||
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{checkAiRegions().length > 0 ? (
|
||||
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
|
||||
) : (
|
||||
<option value="none">无可用 AI 区域</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300">
|
||||
<input
|
||||
type="checkbox" id="aiModifyEnabled"
|
||||
checked={aiModifyEnabled}
|
||||
onChange={(e) => setAiModifyEnabled(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="aiModifyEnabled" className="text-[11px] text-slate-600 cursor-pointer font-bold">
|
||||
允许修改正文
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视觉参考上下文 */}
|
||||
{capturedFrames.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
||||
{capturedFrames.map(frame => {
|
||||
const isSelected = aiSelectedFrames.includes(frame.id);
|
||||
return (
|
||||
<div key={frame.id} onClick={() => setAiSelectedFrames(prev => isSelected ? prev.filter(id => id !== frame.id) : [...prev, frame.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'}`}>
|
||||
<img src={frame.dataUrl} 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>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义快捷指令胶囊 */}
|
||||
<div className="flex flex-wrap gap-1.5 max-h-16 overflow-y-auto">
|
||||
{quickPrompts.map((p, i) => (
|
||||
<div key={i} className="group relative">
|
||||
<button onClick={() => setChatInput(p)} className="px-3 py-1 bg-[#f1f5f9] hover:bg-blue-50 hover:text-blue-600 text-slate-600 text-[11px] rounded-full transition-colors whitespace-nowrap">
|
||||
{p}
|
||||
</button>
|
||||
{isEditingPrompts && (
|
||||
<button onClick={() => setQuickPrompts(prev => prev.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 scale-75 shadow-sm">
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => {
|
||||
if (isEditingPrompts) {
|
||||
const newP = prompt('新增快捷指令:');
|
||||
if (newP) setQuickPrompts([...quickPrompts, newP]);
|
||||
} else {
|
||||
setIsEditingPrompts(true);
|
||||
}
|
||||
}} className="px-2 py-1 bg-slate-100 text-slate-400 text-[11px] rounded-full hover:bg-slate-200">
|
||||
{isEditingPrompts ? '+ 添加' : '⚙️'}
|
||||
</button>
|
||||
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full">完成</button>}
|
||||
</div>
|
||||
|
||||
{/* 沉浸式输入框 */}
|
||||
<div className="relative border border-slate-300 rounded-xl bg-white shadow-inner focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent transition-all">
|
||||
{aiUploadedImages.length > 0 && (
|
||||
<div className="flex gap-2 p-2 border-b border-slate-100 bg-slate-50 rounded-t-xl overflow-x-auto">
|
||||
{aiUploadedImages.map(img => (
|
||||
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden shadow-sm shrink-0">
|
||||
<img src={img.dataUrl} className="w-full h-full object-cover" />
|
||||
<button onClick={() => setAiUploadedImages(prev => prev.filter(i => i.id !== img.id))} className="absolute top-0 right-0 bg-red-500/80 text-white rounded-bl-md">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isGenerating) handleAIGenerate(chatInput); } }}
|
||||
placeholder={isListening ? '正在将语音转为文字...' : '输入需求(按 Enter 发送)...'}
|
||||
className="w-full min-h-[80px] p-3 pr-12 text-sm bg-transparent outline-none resize-none custom-scrollbar"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
||||
<label className="p-1.5 text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 rounded-lg cursor-pointer transition-colors" title="上传外部图像">
|
||||
<input type="file" accept="image/*" multiple className="hidden" onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
Array.from(files).forEach((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => { if (ev.target?.result) setAiUploadedImages(prev => [...prev, { id: Date.now(), dataUrl: ev.target!.result as string }]); };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}} />
|
||||
<ImagePlus size={16} />
|
||||
</label>
|
||||
<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} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -2219,6 +2538,50 @@ export default function ReportEditor() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 修改二次确认 Diff 弹窗 */}
|
||||
{diffModal && diffModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-[800px] shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-blue-600" />
|
||||
AI 修改确认
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">您可以直接在右侧面板手动微调 AI 生成的内容。</p>
|
||||
</div>
|
||||
<button onClick={() => setDiffModal(null)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden flex gap-4 p-6 bg-slate-100">
|
||||
<div className="flex-1 flex flex-col bg-white border border-red-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-red-50 px-3 py-2 text-xs font-bold text-red-600 border-b border-red-100 uppercase tracking-wider">原始版本</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto opacity-70 cursor-not-allowed custom-scrollbar"
|
||||
dangerouslySetInnerHTML={{ __html: diffModal.originalHtml }}></div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col bg-white border border-green-400 rounded-xl overflow-hidden shadow-md relative">
|
||||
<div className="bg-green-50 px-3 py-2 text-xs font-bold text-green-700 border-b border-green-200 uppercase tracking-wider flex justify-between">
|
||||
<span>AI 提议版本 (可直接编辑)</span>
|
||||
<span className="text-[10px] bg-green-200 px-1.5 py-0.5 rounded text-green-800">编辑态</span>
|
||||
</div>
|
||||
<div
|
||||
className="p-4 flex-1 overflow-y-auto outline-none custom-scrollbar"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onBlur={(e) => setDiffModal(prev => prev ? { ...prev, newHtml: e.target.innerHTML } : null)}
|
||||
dangerouslySetInnerHTML={{ __html: diffModal.newHtml }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-100 flex justify-end gap-3 bg-white">
|
||||
<button onClick={() => setDiffModal(null)} className="px-6 py-2 rounded-lg text-slate-600 font-medium hover:bg-slate-100">放弃修改</button>
|
||||
<button onClick={() => confirmAiInjection(diffModal.newHtml, diffModal.targetId)} className="px-6 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 shadow-sm flex items-center gap-2">
|
||||
<Check size={16} /> 确认并写入报告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function SystemSettings() {
|
||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
||||
apiEndpoint: '',
|
||||
apiKey: '',
|
||||
kimiApiKey: '',
|
||||
kimiApiEndpoint: '',
|
||||
defaultTemplate: '',
|
||||
frameMode: 'uniform'
|
||||
});
|
||||
@@ -79,11 +81,31 @@ export default function SystemSettings() {
|
||||
};
|
||||
|
||||
const testApi = async () => {
|
||||
if (!settings.apiEndpoint) {
|
||||
alert('请先输入 API 接口地址');
|
||||
const endpoint = settings.kimiApiEndpoint || 'https://api.moonshot.cn/v1';
|
||||
const apiKey = settings.kimiApiKey;
|
||||
if (!apiKey) {
|
||||
alert('请先输入 Kimi API Key');
|
||||
return;
|
||||
}
|
||||
alert(`正在测试连接到: ${settings.apiEndpoint}\n(模拟测试: 连接成功)`);
|
||||
try {
|
||||
const res = await fetch(`${endpoint.replace(/\/$/, '')}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const modelCount = data?.data?.length || 0;
|
||||
alert(`Kimi API 连接成功!\nEndpoint: ${endpoint}\n可用模型数: ${modelCount}`);
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert(`Kimi API 连接失败 (HTTP ${res.status})\n${text}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`Kimi API 连接异常: ${err?.message || String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefault = () => {
|
||||
@@ -93,6 +115,8 @@ export default function SystemSettings() {
|
||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
||||
apiEndpoint: '',
|
||||
apiKey: '',
|
||||
kimiApiKey: '',
|
||||
kimiApiEndpoint: '',
|
||||
defaultTemplate: templates[0]?.id || '',
|
||||
frameMode: 'uniform',
|
||||
autoInsertFrames: true,
|
||||
@@ -308,6 +332,28 @@ export default function SystemSettings() {
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">Kimi API Endpoint</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.kimiApiEndpoint}
|
||||
onChange={(e) => setSettings({ ...settings, kimiApiEndpoint: e.target.value })}
|
||||
placeholder="https://api.moonshot.cn/v1"
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">Kimi API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.kimiApiKey}
|
||||
onChange={(e) => setSettings({ ...settings, kimiApiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="input-minimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download, Upload } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Bot, Check, Download, Upload } from 'lucide-react';
|
||||
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
import { printDocument } from '../utils/print';
|
||||
@@ -587,6 +587,20 @@ export default function TemplateManage() {
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const insertAiRegion = () => {
|
||||
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
|
||||
if (!name || !name.trim()) return;
|
||||
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
|
||||
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
|
||||
return;
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
// Insert ai-region HTML
|
||||
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" 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; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">​</div></div><p><br></p>`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
saveTemplateContent();
|
||||
};
|
||||
|
||||
const saveCurrentTemplate = () => {
|
||||
if (!currentTemplateId || !editorRef.current) return;
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
@@ -978,6 +992,7 @@ export default function TemplateManage() {
|
||||
<div className="flex gap-1">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface SystemSettings {
|
||||
autoInsertFrames?: boolean;
|
||||
autoInsertFrameIndices?: number[];
|
||||
autoInsertDelay?: number;
|
||||
kimiApiKey?: string;
|
||||
kimiApiEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface BindableField {
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
},
|
||||
"exclude": ["参考信息", "dist", "node_modules"]
|
||||
}
|
||||
|
||||
822
参考信息/参考-ReportEditor.tsx
Normal file
822
参考信息/参考-ReportEditor.tsx
Normal file
@@ -0,0 +1,822 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import {
|
||||
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
||||
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
||||
Video, Play, Pause, Plus, X, ChevronLeft, Download, Bot, Sparkles, Send, Loader2,
|
||||
Mic, MicOff, ImagePlus
|
||||
} from 'lucide-react';
|
||||
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
import { printDocument } from '../utils/print';
|
||||
import { storage } from '../utils/storage';
|
||||
|
||||
export default function ReportEditor() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const reportId = searchParams.get('id');
|
||||
const restoreFlag = searchParams.get('restore');
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [reportData, setReportData] = useState<Partial<Report>>({
|
||||
title: '腹腔镜胆囊切除术报告',
|
||||
patientName: '',
|
||||
hospitalId: '',
|
||||
patientGender: '',
|
||||
patientAge: '',
|
||||
department: '',
|
||||
bedNumber: '',
|
||||
surgeryDate: '',
|
||||
startHour: '',
|
||||
startMinute: '',
|
||||
endHour: '',
|
||||
endMinute: '',
|
||||
surgeon: [],
|
||||
assistant: [],
|
||||
anesthesiologist: [],
|
||||
anesthesiaType: '',
|
||||
reportNote: '',
|
||||
status: 'draft'
|
||||
});
|
||||
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [videos, setVideos] = useState<{id: string, name: string, url: string, duration: number}[]>([]);
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(-1);
|
||||
const [capturedFrames, setCapturedFrames] = useState<CapturedFrame[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState('');
|
||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
||||
const prevVideoCountRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info');
|
||||
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
|
||||
|
||||
// AI 撰写相关状态
|
||||
const [chatInput, setChatInput] = useState<string>('');
|
||||
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [aiSelectedFrames, setAiSelectedFrames] = useState<number[]>([]);
|
||||
const [aiTargetRegion, setAiTargetRegion] = useState<string>('surgical-steps');
|
||||
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
||||
const speechRecognitionRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||
allFields.forEach(el => {
|
||||
(el as HTMLElement).style.backgroundColor = '';
|
||||
(el as HTMLElement).style.outline = '';
|
||||
(el as HTMLElement).style.outlineOffset = '';
|
||||
});
|
||||
if (activeFieldKey) {
|
||||
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="\${activeFieldKey}"]`) as HTMLElement;
|
||||
if (targetEl) {
|
||||
targetEl.style.backgroundColor = '#f1f5f9';
|
||||
targetEl.style.outline = '1px solid #94a3b8';
|
||||
targetEl.style.outlineOffset = '1px';
|
||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [activeFieldKey]);
|
||||
|
||||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const contentLoadedRef = useRef(false);
|
||||
const contentRef = useRef('');
|
||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
|
||||
|
||||
const draftKey = currentUser ? `reportEditorDraft_\${currentUser.username}` : '';
|
||||
|
||||
const updatePageHeight = () => {
|
||||
if (!editorRef.current) return;
|
||||
const contentHeight = editorRef.current.scrollHeight;
|
||||
const pageHeightMm = 297;
|
||||
const mmToPx = 3.7795275591;
|
||||
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
|
||||
editorRef.current.style.minHeight = `\${pages * pageHeightMm}mm`;
|
||||
};
|
||||
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
const key = user ? `reportEditorDraft_\${user.username}` : '';
|
||||
if (key) {
|
||||
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
|
||||
storage.set(key, {
|
||||
content: currentContent,
|
||||
draftReportId: reportId || null,
|
||||
reportData: stateRef.current.reportData,
|
||||
videos: stateRef.current.videos,
|
||||
capturedFrames: stateRef.current.capturedFrames,
|
||||
activeTab: stateRef.current.activeTab,
|
||||
loadedTemplateId: stateRef.current.loadedTemplateId
|
||||
});
|
||||
}
|
||||
}, [reportId]);
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
if (!user) { navigate('/'); return; }
|
||||
setCurrentUser(user);
|
||||
|
||||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
||||
if (savedFields.length > 0) {
|
||||
setFormFields(savedFields);
|
||||
} else {
|
||||
setFormFields(DEFAULT_FORM_FIELDS);
|
||||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||||
}
|
||||
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
setTemplates(allTemplates);
|
||||
|
||||
if (reportId) {
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
const found = reports.find(r => r.id === reportId);
|
||||
if (found) {
|
||||
setReportData(found);
|
||||
if (found.capturedFrames) setCapturedFrames(found.capturedFrames);
|
||||
if (found.videos) setVideos(found.videos);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = found.content;
|
||||
contentRef.current = found.content;
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
}
|
||||
}
|
||||
} else if (!contentLoadedRef.current && editorRef.current) {
|
||||
editorRef.current.innerHTML = defaultReportContent;
|
||||
contentRef.current = defaultReportContent;
|
||||
contentLoadedRef.current = true;
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
}
|
||||
}, [reportId, navigate]);
|
||||
|
||||
const execCmd = (command: string, value: string | undefined = undefined) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
editorRef.current?.focus();
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []) as File[];
|
||||
const newVideos = files.map(file => ({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file),
|
||||
duration: 0
|
||||
}));
|
||||
setVideos([...videos, ...newVideos]);
|
||||
if (currentVideoIndex === -1 && newVideos.length > 0) setCurrentVideoIndex(0);
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
if (isPlaying) videoRef.current.pause();
|
||||
else videoRef.current.play();
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `\${mins.toString().padStart(2, '0')}:\${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const captureFrame = () => {
|
||||
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
// Create an unconstrained canvas to get native resolution or properly scaled frame
|
||||
const scale = Math.min(1, 800 / video.videoWidth);
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const newFrame: CapturedFrame = {
|
||||
id: Date.now(),
|
||||
videoIndex: currentVideoIndex,
|
||||
videoName: videos[currentVideoIndex].name,
|
||||
time: video.currentTime,
|
||||
timeFormatted: formatTime(video.currentTime),
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.8),
|
||||
isManual: true
|
||||
};
|
||||
|
||||
setCapturedFrames([...capturedFrames, newFrame]);
|
||||
};
|
||||
|
||||
const saveReport = (status: 'draft' | 'completed') => {
|
||||
const content = editorRef.current?.innerHTML || '';
|
||||
const now = new Date().toISOString();
|
||||
const finalReport: Report = {
|
||||
...(reportData as Report),
|
||||
id: reportId || 'RPT_' + Date.now(),
|
||||
content,
|
||||
author: currentUser?.username || '',
|
||||
authorName: currentUser?.name || '',
|
||||
createdAt: reportData.createdAt || now.split('T')[0],
|
||||
status,
|
||||
capturedFrames,
|
||||
videos,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const reports = storage.get<Report[]>('reports', []);
|
||||
let updatedReports;
|
||||
if (reportId) {
|
||||
updatedReports = reports.map(r => r.id === reportId ? finalReport : r);
|
||||
} else {
|
||||
updatedReports = [...reports, finalReport];
|
||||
}
|
||||
|
||||
storage.set('reports', updatedReports);
|
||||
setIsSaved(true);
|
||||
setTimeout(() => setIsSaved(false), 3000);
|
||||
if (status === 'completed') navigate('/report-manage');
|
||||
};
|
||||
|
||||
const handleAiLocalImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
Array.from(files).forEach((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
if (ev.target?.result) {
|
||||
setAiUploadedImages(prev => [...prev, { id: Date.now() + Math.random(), dataUrl: ev.target!.result as string }]);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
// reset input
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const toggleListening = () => {
|
||||
if (isListening) {
|
||||
setIsListening(false);
|
||||
if (speechRecognitionRef.current) {
|
||||
speechRecognitionRef.current.stop();
|
||||
}
|
||||
} else {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert("您的浏览器不支持语音识别功能,请使用 Chrome 等支持该特性的浏览器。");
|
||||
return;
|
||||
}
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'zh-CN';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
|
||||
recognition.onstart = () => setIsListening(true);
|
||||
recognition.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setChatInput(prev => prev + (prev ? ' ' : '') + transcript);
|
||||
};
|
||||
recognition.onerror = (event: any) => {
|
||||
console.error("Speech recognition error", event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
speechRecognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAIGenerate = async (text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const userMsgId = Date.now().toString();
|
||||
const newUserMsg = { id: userMsgId, role: 'user' as const, content: text };
|
||||
setChatMessages(prev => [...prev, newUserMsg]);
|
||||
setChatInput('');
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const { GoogleGenAI, Type } = await import('@google/genai');
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const sysSettings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||
const modelName = sysSettings.geminiModel || 'gemini-3-flash-preview';
|
||||
|
||||
const historyContents = chatMessages.map(msg => ({
|
||||
role: msg.role === 'user' ? 'user' : 'model',
|
||||
parts: [{ text: msg.content }]
|
||||
}));
|
||||
|
||||
const currentParts: any[] = [];
|
||||
const selectedFrameDataUrls = aiSelectedFrames
|
||||
.map(id => capturedFrames.find(f => f.id === id)?.dataUrl)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const allImages = [...selectedFrameDataUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
||||
|
||||
allImages.forEach(url => {
|
||||
const match = url.match(/^data:(image\/[a-z]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
currentParts.push({ inlineData: { mimeType: match[1], data: match[2] } });
|
||||
}
|
||||
});
|
||||
// 清空本地上传的图片以备下次
|
||||
setAiUploadedImages([]);
|
||||
|
||||
const targetRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"]`);
|
||||
const currentHtml = targetRegion ? targetRegion.innerHTML : '';
|
||||
|
||||
if (aiModifyEnabled) {
|
||||
currentParts.push({
|
||||
text: `【当前待修改内容的 HTML 源码】:\n${currentHtml}\n\n【医生的期望/修改要求】: ${text}`
|
||||
});
|
||||
} else {
|
||||
currentParts.push({
|
||||
text: `【医生的指令/要求】: ${text}`
|
||||
});
|
||||
}
|
||||
|
||||
historyContents.push({ role: 'user', parts: currentParts });
|
||||
|
||||
const systemInstruction = aiModifyEnabled
|
||||
? "你是一名专业的外科医生助理。根据用户提供的(图像)和(修改要求),修改(当前待修改内容的 HTML 源码)。\n" +
|
||||
"你需要返回 JSON 数据,其中包含两部分:\n" +
|
||||
"1. 'reply': 向医生报告您做了哪些修改(友好的文本对话回复,如‘好的,我已为您更新了相关描述。’)。\n" +
|
||||
"2. 'updatedHtml': 修改后的完整 HTML 代码片段(保留原有的 HTML 格式,如 `<p>` 标签和行内样式)。\n" +
|
||||
"你的输出必须严格符合 JSON 结构,不要包含 markdown 代码块的包裹。"
|
||||
: "你是一名专业的外科医生助理。根据用户提供的(图像)和(指令),回答问题、提取信息或生成段落总结。\n" +
|
||||
"你只需要返回 JSON 数据中的 'reply' 字段即可(友好的文本对话回复)。不要返回 updatedHtml。\n" +
|
||||
"你的输出必须严格符合 JSON 结构,不要包含 markdown 代码块的包裹。";
|
||||
|
||||
const responseSchema = aiModifyEnabled
|
||||
? {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
reply: { type: Type.STRING },
|
||||
updatedHtml: { type: Type.STRING }
|
||||
},
|
||||
required: ["reply", "updatedHtml"]
|
||||
}
|
||||
: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
reply: { type: Type.STRING }
|
||||
},
|
||||
required: ["reply"]
|
||||
};
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: modelName,
|
||||
contents: historyContents as any,
|
||||
config: {
|
||||
systemInstruction,
|
||||
responseMimeType: "application/json",
|
||||
responseSchema,
|
||||
}
|
||||
});
|
||||
|
||||
const responseJson = JSON.parse(response.text || '{}');
|
||||
if (responseJson.reply) {
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
|
||||
}
|
||||
if (responseJson.updatedHtml) {
|
||||
injectAIText(responseJson.updatedHtml);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `[系统错误]: ${error.message}` }]);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const injectAIText = (htmlContent: string) => {
|
||||
if (!editorRef.current) return;
|
||||
const targetRegion = editorRef.current.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"]`);
|
||||
if (targetRegion) {
|
||||
const regionTitle = availableAiRegions.find(r => r.id === aiTargetRegion)?.title || '';
|
||||
const badgeLabel = regionTitle ? `${regionTitle}-AI可编辑区域` : 'AI可编辑区域';
|
||||
targetRegion.innerHTML = `
|
||||
<div contenteditable="false" style="position: absolute; top: -10px; right: 12px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; cursor: default; white-space: nowrap; z-index: 10; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${badgeLabel}</div>
|
||||
${htmlContent}
|
||||
`;
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
const targetElement = targetRegion as HTMLElement;
|
||||
targetElement.style.transition = 'background-color 0.5s';
|
||||
targetElement.style.backgroundColor = '#dbeafe';
|
||||
setTimeout(() => {
|
||||
targetElement.style.backgroundColor = '#eff6ff';
|
||||
}, 500);
|
||||
} else {
|
||||
execCmd('insertHTML', htmlContent);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAiRegions = () => {
|
||||
if (!editorRef.current) return [];
|
||||
const regions = Array.from(editorRef.current.querySelectorAll('.ai-region'));
|
||||
return regions.map((el: any) => {
|
||||
const id = el.getAttribute('data-ai-id') || '';
|
||||
const title = el.getAttribute('data-ai-title') || id;
|
||||
return { id, title };
|
||||
});
|
||||
};
|
||||
|
||||
const availableAiRegions = checkAiRegions();
|
||||
|
||||
return (
|
||||
<div className="flex bg-slate-50 h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/report-manage')}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 text-slate-500 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-slate-800">
|
||||
{reportId ? `编辑报告: \${reportId}` : '新建手术报告'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isSaved && (
|
||||
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
|
||||
<Check size={14} /> 已保存
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => saveReport('draft')}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm font-medium hover:bg-slate-200"
|
||||
>
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveReport('completed')}
|
||||
className="px-4 py-2 bg-blue-600 text-white flex items-center gap-2 rounded text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
<Check size={16} />
|
||||
完成报告
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
|
||||
className="p-2 rounded bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
<Printer size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main Editor Section */}
|
||||
<div className="flex-1 flex flex-col bg-slate-200 border-r border-slate-200 overflow-hidden relative">
|
||||
<div className="flex items-center gap-1 p-2 bg-white border-b border-slate-200 shrink-0">
|
||||
{/* 简化版工具栏 */}
|
||||
<button onClick={() => execCmd('undo')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Undo size={16}/></button>
|
||||
<button onClick={() => execCmd('redo')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Redo size={16}/></button>
|
||||
<div className="w-px h-6 bg-slate-300 mx-1"></div>
|
||||
<button onClick={() => execCmd('bold')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Bold size={16}/></button>
|
||||
<button onClick={() => execCmd('italic')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Italic size={16}/></button>
|
||||
<button onClick={() => execCmd('underline')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Underline size={16}/></button>
|
||||
<div className="w-px h-6 bg-slate-300 mx-1"></div>
|
||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyLeft'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignLeft size={16}/></button>
|
||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyCenter'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignCenter size={16}/></button>
|
||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyRight'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignRight size={16}/></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto w-full flex justify-center py-8">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="bg-white shadow-md p-10 w-[800px] min-h-[1100px] outline-none"
|
||||
style={{ fontFamily: 'SimSun', lineHeight: '1.5', transition: 'width 0.2s', paddingBottom: '100px' }}
|
||||
onInput={(e) => { contentRef.current = editorRef.current?.innerHTML || ''; }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<aside className="w-[360px] bg-white flex flex-col shrink-0">
|
||||
<div className="flex border-b border-slate-200 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('info')}
|
||||
className={`flex-1 py-3 text-sm font-medium border-b-2 \${activeTab === 'info' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
||||
>信息录入</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('video')}
|
||||
className={`flex-1 py-3 text-sm font-medium border-b-2 \${activeTab === 'video' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
||||
>视频分析</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ai')}
|
||||
className={`flex-1 py-3 text-sm font-medium border-b-2 flex items-center justify-center gap-1 \${activeTab === 'ai' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
||||
>
|
||||
<Bot size={16} /> AI撰写
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100 text-sm text-blue-800">
|
||||
您可以在这里编辑右侧表单。这些值会自动同步到中间的报告占位符中。
|
||||
</div>
|
||||
{/* Basic bindings for the most common fields */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-600">患者姓名</label>
|
||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).patientName || ''} onChange={(e) => {
|
||||
const val = e.target.value; setReportData({...reportData, patientName: val});
|
||||
// Sync to span
|
||||
const s = editorRef.current?.querySelector('[data-bind="patientName"]') as HTMLElement;
|
||||
if(s) s.innerText = val;
|
||||
}} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-600">住院号</label>
|
||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).hospitalId || ''} onChange={(e) => {
|
||||
const val = e.target.value; setReportData({...reportData, hospitalId: val});
|
||||
const s = editorRef.current?.querySelector('[data-bind="hospitalId"]') as HTMLElement;
|
||||
if(s) s.innerText = val;
|
||||
}} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-600">手术名称</label>
|
||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).title || ''} onChange={(e) => {
|
||||
const val = e.target.value; setReportData({...reportData, title: val});
|
||||
const s = editorRef.current?.querySelector('[data-bind="title"]') as HTMLElement;
|
||||
if(s) s.innerText = val;
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleVideoUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
className="w-full py-3 flex items-center justify-center gap-2 border-2 border-dashed border-slate-300 rounded-lg hover:border-blue-500 hover:text-blue-600 text-slate-500 transition-colors"
|
||||
>
|
||||
<Video size={18} />
|
||||
<span className="text-sm font-medium">上传手术视频</span>
|
||||
</button>
|
||||
|
||||
{videos.length > 0 && currentVideoIndex !== -1 && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videos[currentVideoIndex].url}
|
||||
className="w-full h-full"
|
||||
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
|
||||
onLoadedMetadata={() => setDuration(videoRef.current?.duration || 0)}
|
||||
/>
|
||||
{!isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 cursor-pointer" onClick={togglePlay}>
|
||||
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center text-white">
|
||||
<Play size={24} fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] px-2 py-1 rounded">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={togglePlay} className="px-3 py-2 bg-slate-100 rounded text-slate-700 hover:bg-slate-200">
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<button onClick={captureFrame} className="flex-1 bg-blue-600 text-white py-2 rounded text-sm font-medium hover:bg-blue-700">
|
||||
截取当前画面并保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||
{capturedFrames.map((frame) => (
|
||||
<div key={frame.id} className="relative group border border-slate-200 rounded p-1 bg-white">
|
||||
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded" />
|
||||
<div className="text-[10px] text-slate-500 mt-1 flex justify-between">
|
||||
<span>{frame.timeFormatted}</span>
|
||||
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => {
|
||||
// Simple insert helper
|
||||
execCmd('insertHTML', `<img src="\${frame.dataUrl}" style="max-width:200px" />`);
|
||||
}}>插入插入文档</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCapturedFrames(prev => prev.filter(f => f.id !== frame.id))}
|
||||
className="absolute top-0 right-0 bg-red-500 text-white p-1 rounded-bl group-hover:opacity-100 opacity-0 transition-opacity"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ai' && (
|
||||
<div className="flex flex-col h-full bg-slate-50 overflow-hidden">
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{chatMessages.length === 0 ? (
|
||||
<div className="text-center flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
|
||||
<Bot size={48} className="text-slate-300" />
|
||||
<p className="text-xs">我是您的 AI 智能撰写助手,请在下方选择参考框架和图片后随时与我对话。</p>
|
||||
</div>
|
||||
) : (
|
||||
chatMessages.map(msg => (
|
||||
<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' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isGenerating && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-2.5 shadow-sm flex gap-1 items-center">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce" />
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce delay-75" />
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce delay-150" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings and Input Box */}
|
||||
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-[11px] font-bold text-slate-500 shrink-0">指定修改区域</label>
|
||||
<select
|
||||
value={aiTargetRegion}
|
||||
onChange={(e) => setAiTargetRegion(e.target.value)}
|
||||
disabled={!aiModifyEnabled}
|
||||
className="flex-1 w-0 px-2 py-1.5 border border-slate-300 rounded text-xs bg-slate-50 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{availableAiRegions.length > 0 ? (
|
||||
availableAiRegions.map(r => (
|
||||
<option key={r.id} value={r.id}>{r.title}</option>
|
||||
))
|
||||
) : (
|
||||
<option value="none">无区域 (将插入光标处)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="aiModifyEnabled"
|
||||
checked={aiModifyEnabled}
|
||||
onChange={(e) => setAiModifyEnabled(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="aiModifyEnabled" className="text-[11px] text-slate-600 cursor-pointer select-none font-bold">
|
||||
修改内容
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{capturedFrames.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11px] font-bold text-slate-500 shrink-0">参考关键帧</label>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
||||
{capturedFrames.map(frame => {
|
||||
const isSelected = aiSelectedFrames.includes(frame.id);
|
||||
return (
|
||||
<div
|
||||
key={frame.id}
|
||||
onClick={() => {
|
||||
setAiSelectedFrames(prev =>
|
||||
isSelected ? prev.filter(id => id !== frame.id) : [...prev, frame.id]
|
||||
);
|
||||
}}
|
||||
className={`relative shrink-0 w-16 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600 scale-105' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
<img src={frame.dataUrl} className="w-full h-full object-cover" />
|
||||
{isSelected && (
|
||||
<div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full w-3 h-3 flex items-center justify-center">
|
||||
<Check size={8} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Prompts */}
|
||||
<div className="flex flex-wrap gap-1.5 pb-1">
|
||||
{['请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结'].map(p => (
|
||||
<button key={p} onClick={() => setChatInput(p)} className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-[10px] rounded-full transition-colors whitespace-nowrap">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 上传图片的预览区 */}
|
||||
{aiUploadedImages.length > 0 && (
|
||||
<div className="absolute top-2 left-2 flex gap-2 z-10 bg-white/80 p-1 rounded">
|
||||
{aiUploadedImages.map(img => (
|
||||
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden border border-slate-200 shadow-sm shrink-0">
|
||||
<img src={img.dataUrl} className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => setAiUploadedImages(prev => prev.filter(i => i.id !== img.id))}
|
||||
className="absolute top-0 right-0 bg-red-500/80 hover:bg-red-500 text-white p-0.5 rounded-bl-md transition-colors"
|
||||
>
|
||||
<X size={10}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (chatInput.trim() && !isGenerating) {
|
||||
handleAIGenerate(chatInput);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{ paddingTop: aiUploadedImages.length > 0 ? '56px' : '8px' }}
|
||||
placeholder={isListening ? "正在聆听中..." : "输入修改意见... (按 Enter 发送)"}
|
||||
className="w-full h-24 px-3 pr-[50px] border border-slate-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none transition-all custom-scrollbar"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex flex-col gap-1 items-end">
|
||||
{/* 额外动作栏: 语音、上传图片 */}
|
||||
<div className="flex bg-white rounded-full border border-slate-200 shadow-sm overflow-hidden mb-1">
|
||||
<button
|
||||
onClick={toggleListening}
|
||||
className={`p-1.5 transition-colors ${isListening ? 'text-red-500 bg-red-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'}`}
|
||||
title={isListening ? "停止语音输入" : "语音输入"}
|
||||
>
|
||||
{isListening ? <Mic size={14} className="animate-pulse" /> : <MicOff size={14} />}
|
||||
</button>
|
||||
|
||||
<label className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-50 cursor-pointer transition-colors" title="上传本地图片">
|
||||
<input type="file" accept="image/*" multiple className="hidden" onChange={handleAiLocalImageUpload} />
|
||||
<ImagePlus size={14} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleAIGenerate(chatInput)}
|
||||
disabled={isGenerating || !chatInput.trim()}
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
工程分析/实现方案-2026-04-19-02-26-05.md
Normal file
78
工程分析/实现方案-2026-04-19-02-26-05.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 实现方案 — 2026-04-19-02-26-05
|
||||
|
||||
## 1. 方案概述
|
||||
基于现有 `contentEditable` 架构,引入 `ai-region` DOM 区块作为 AI 的"手术锚点"。通过标准 Fetch API 接入 Kimi 多模态接口,采用 JSON Schema 约束输出。所有 AI 状态集中在 ReportEditor 管理,TemplateManage 仅负责区域插入。
|
||||
|
||||
## 2. 详细步骤
|
||||
|
||||
### 步骤 1:`src/types.ts` — 扩展 SystemSettings
|
||||
**目标文件**:`src/types.ts`
|
||||
**修改内容**:
|
||||
- `SystemSettings` 接口新增 `kimiApiKey?: string` 和 `kimiApiEndpoint?: string`
|
||||
- 默认值兜底在 `SystemSettings.tsx` 中处理
|
||||
|
||||
### 步骤 2:`src/pages/SystemSettings.tsx` — 完善 AI 接口配置
|
||||
**目标文件**:`src/pages/SystemSettings.tsx`
|
||||
**修改内容**:
|
||||
- AI 接口集成卡片中,将现有的 `apiEndpoint` / `apiKey` 的 placeholder 和 label 改为明确指向 Kimi
|
||||
- `testApi` 函数改为真实调用 Kimi `/v1/models` 或简单 chat completion 进行连通性测试
|
||||
- `resetToDefault` 中保留新字段的默认空值
|
||||
|
||||
### 步骤 3:`src/pages/TemplateManage.tsx` — 工具栏新增 AI 区域按钮
|
||||
**目标文件**:`src/pages/TemplateManage.tsx`
|
||||
**修改内容**:
|
||||
- import 中增加 `Bot` from `lucide-react`
|
||||
- 新增 `insertAiRegion` 函数:prompt 输入名称 → 重名检查(`querySelector([data-ai-id="..."])`)→ `execCommand('insertHTML')` 插入标准 `ai-region` DOM
|
||||
- 工具栏 JSX 中在 `ImageIcon` 右侧新增蓝色 `Bot` 按钮
|
||||
- 插入后调用 `saveTemplateContent()`
|
||||
|
||||
### 步骤 4:`src/pages/ReportEditor.tsx` — 工具栏 + activeTab 扩展
|
||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
||||
**修改内容**:
|
||||
- import 中增加 `Bot`, `Mic`, `MicOff`, `ImagePlus`, `Sparkles`, `Send` 等图标
|
||||
- `activeTab` 类型扩展为 `'info' | 'video' | 'ai'`
|
||||
- 工具栏 JSX 中在 `ImageIcon` 右侧新增蓝色 `Bot` 按钮及 `insertAiRegion` 函数
|
||||
- 右侧 Tab 按钮区新增 "AI撰写" 切换按钮
|
||||
|
||||
### 步骤 5:`src/pages/ReportEditor.tsx` — AI 核心状态与逻辑
|
||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
||||
**修改内容**:
|
||||
- 新增 State:
|
||||
- `chatInput`, `chatMessages`, `isGenerating`
|
||||
- `aiSelectedFrames`, `aiTargetRegion`, `aiModifyEnabled`
|
||||
- `isListening`, `aiUploadedImages`, `speechRecognitionRef`
|
||||
- `quickPrompts`, `isEditingPrompts`
|
||||
- `diffModal`
|
||||
- 新增函数:
|
||||
- `checkAiRegions()`: 扫描编辑器内所有 `.ai-region` 返回 id/title 列表
|
||||
- `toggleListening()`: Web Speech API 语音识别
|
||||
- `handleAIGenerate(text)`: 组装多模态 message → Fetch 调用 Kimi → 解析 JSON → 更新聊天 → 触发 Diff
|
||||
- `confirmAiInjection(newHtml, regionId)`: 注入 HTML → 视觉高亮动画 → saveDraft
|
||||
- 注意:所有 DOM 修改后必须同步 `contentRef.current = editorRef.current.innerHTML` 并 `saveDraftToStorage()`
|
||||
|
||||
### 步骤 6:`src/pages/ReportEditor.tsx` — AI 面板 UI
|
||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
||||
**修改内容**:
|
||||
- 在 `activeTab === 'ai'` 分支中渲染完整面板:
|
||||
- 顶部:聊天气泡区(user 右蓝 / model 左白)
|
||||
- 中部:区域锚定下拉 + "允许修改正文" checkbox
|
||||
- 中下部:关键帧缩略图多选 + 本地图片预览
|
||||
- 底部:快捷指令胶囊 + 多行输入框(带 Mic / ImagePlus / Send 按钮)
|
||||
- 在最外层新增 `diffModal` 弹窗 DOM(左右对比、可编辑右侧、确认/放弃按钮)
|
||||
|
||||
### 步骤 7:`src/index.css` — AI 区域打印样式
|
||||
**目标文件**:`src/index.css`
|
||||
**修改内容**:
|
||||
- `@media print` 中隐藏 `.ai-region` 的蓝色边框和标签,或将其设为透明/灰色,避免打印时显示编辑态样式
|
||||
|
||||
## 3. 依赖关系
|
||||
- 步骤 1(types)必须在步骤 2/3/4/5/6 之前
|
||||
- 步骤 3(TemplateManage)和步骤 4-6(ReportEditor)可并行
|
||||
- 步骤 2(SystemSettings)可独立并行
|
||||
- 步骤 7(CSS)在最后执行
|
||||
|
||||
## 4. 风险预案
|
||||
- 若 Kimi API 调用失败,catch 中向聊天列表追加 `[系统错误]` 消息,不阻断页面
|
||||
- 若 `JSON.parse` 失败,尝试用正则提取 JSON 子串后再解析,仍失败则展示原始文本
|
||||
- 若用户浏览器不支持 Web Speech API,点击麦克风时 alert 提示并优雅降级
|
||||
- 若 `ai-region` 被用户手动删除,下拉选择器实时扫描会同步更新为空
|
||||
80
工程分析/测试方案-2026-04-19-02-26-05.md
Normal file
80
工程分析/测试方案-2026-04-19-02-26-05.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 测试方案 — 2026-04-19-02-26-05
|
||||
|
||||
## 1. 测试范围
|
||||
- TemplateManage / ReportEditor 工具栏 AI 区域插入
|
||||
- SystemSettings Kimi API 配置保存
|
||||
- ReportEditor AI 撰写面板全链路(聊天、多模态输入、API调用、Diff确认、注入高亮)
|
||||
- 构建与部署验证
|
||||
|
||||
## 2. 测试步骤与预期结果
|
||||
|
||||
### 场景 1:AI 区域插入(TemplateManage)
|
||||
1. 进入 `/template-manage`,点击工具栏蓝色 Bot 图标
|
||||
预期:弹出 prompt,要求输入区域名称
|
||||
2. 输入 "手术步骤",点击确认
|
||||
预期:编辑器中出现蓝色虚线框区域,右上角有 "手术步骤-AI可编辑区域" 标签
|
||||
3. 再次点击 Bot,再次输入 "手术步骤"
|
||||
预期:alert 提示 "该区域名称已存在..."
|
||||
4. 输入 "术后总结"
|
||||
预期:成功插入第二个区域
|
||||
|
||||
### 场景 2:AI 区域插入(ReportEditor)
|
||||
1. 进入 `/report-editor`,点击工具栏蓝色 Bot 图标
|
||||
预期:与 TemplateManage 行为一致
|
||||
|
||||
### 场景 3:SystemSettings 配置保存
|
||||
1. 进入 `/system-settings`,在 AI 接口集成中输入 API Key 和 Endpoint
|
||||
2. 点击保存
|
||||
预期:提示"设置已保存",刷新后值仍然保留
|
||||
|
||||
### 场景 4:AI 面板基础聊天(纯聊模式)
|
||||
1. 进入 `/report-editor`,确保编辑器内已有 `ai-region`
|
||||
2. 点击右侧 "AI撰写" Tab
|
||||
预期:出现聊天面板,底部有输入框
|
||||
3. 取消勾选 "允许修改正文"
|
||||
4. 输入 "你好",按 Enter
|
||||
预期:出现 user 气泡(右侧蓝色),随后出现 model 气泡(左侧白色),正文未被修改
|
||||
|
||||
### 场景 5:AI 修改正文(Diff 模式)
|
||||
1. 勾选 "允许修改正文"
|
||||
2. 在下拉框中选择一个 AI 区域
|
||||
3. 输入 "请完善这段手术步骤描述"
|
||||
4. 按 Enter
|
||||
预期:AI 返回后弹出 Diff 弹窗,左侧显示原文,右侧显示 AI 版本
|
||||
5. 在右侧编辑几个字,点击"确认并写入报告"
|
||||
预期:弹窗关闭,编辑器对应区域内容更新,区域背景出现深蓝→淡蓝→透明渐变
|
||||
6. 点击"放弃修改"
|
||||
预期:弹窗关闭,正文保持原样
|
||||
|
||||
### 场景 6:多模态输入
|
||||
1. 上传一个本地图片到 AI 面板
|
||||
预期:输入框上方出现小图预览,带删除按钮
|
||||
2. 在视频分析中截取一帧,回到 AI 面板勾选该帧
|
||||
预期:缩略图上有蓝色勾选标记
|
||||
3. 发送消息
|
||||
预期:消息正常发送,图片随请求提交
|
||||
|
||||
### 场景 7:快捷指令
|
||||
1. 点击快捷指令旁的 "⚙️"
|
||||
预期:进入编辑模式,每个指令右上角出现红色删除按钮
|
||||
2. 点击 "+ 添加",输入新的快捷指令
|
||||
预期:新指令出现在列表中
|
||||
3. 点击胶囊按钮
|
||||
预期:指令文本自动填充到输入框
|
||||
|
||||
### 场景 8:路由切换数据保持
|
||||
1. 在 AI 面板中发送几条消息
|
||||
2. 切换到 `/report-manage`,再返回 `/report-editor`
|
||||
预期:AI 聊天记录丢失(因未设计持久化,属于预期行为),但报告正文、AI 区域结构保留
|
||||
|
||||
### 场景 9:构建与部署
|
||||
1. 执行 `npm run lint`
|
||||
预期:无 TypeScript 类型错误
|
||||
2. 执行 `npm run build`
|
||||
预期:构建成功,dist/ 生成
|
||||
3. 执行 `npm run preview -- --host`
|
||||
预期:`http://localhost:4173/` 返回 200
|
||||
|
||||
## 3. 回滚检查
|
||||
- 若测试失败,执行 `git checkout main` 恢复到修改前状态(本次 commit 之前)
|
||||
- 或从 Gitea 拉取上一个可用版本
|
||||
43
工程分析/需求分析-2026-04-19-02-26-05.md
Normal file
43
工程分析/需求分析-2026-04-19-02-26-05.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 需求分析 — 2026-04-19-02-26-05
|
||||
|
||||
## 1. 需求背景
|
||||
为 ReportEditor 加入 AI 撰写功能,接入 Kimi-2.5 多模态模型,实现智能化的手术报告辅助撰写。参考 "参考-ReportEditor.tsx" 的设计范式,构建现代化的沉浸式 AI 对话 UI。
|
||||
|
||||
## 2. 需求拆解
|
||||
- [ ] **Task 1**:TemplateManage 工具栏新增 "插入AI可编辑区域" 按钮,支持命名且不可重名
|
||||
- [ ] **Task 2**:ReportEditor 工具栏同步新增同样的 AI 区域插入按钮
|
||||
- [ ] **Task 3**:SystemSettings 完善 AI 接口集成,适配 Kimi-2.5 API(baseURL + apiKey)
|
||||
- [ ] **Task 4**:ReportEditor 右侧新增 "AI撰写" Tab 面板,包含:
|
||||
- 类微信风格的聊天对话 UI(气泡、Loading 动画)
|
||||
- 定向区域锚定(下拉选择 `ai-region`)
|
||||
- "允许修改正文" 沙盒开关
|
||||
- 多模态输入:语音输入(Web Speech API)、视频关键帧选择、本地图片上传
|
||||
- 快捷指令胶囊(可添加/删除/编辑)
|
||||
- Diff 二次确认弹窗(左右对比、可编辑右侧、确认后注入)
|
||||
- 注入后视觉反馈(深蓝→淡蓝→透明渐变高亮)
|
||||
- [ ] **Task 5**:AI 调用逻辑:Fetch API 调用 Kimi API,严格 JSON Schema 输出(reply + updatedHtml)
|
||||
|
||||
## 3. 影响范围
|
||||
| 文件 | 修改类型 | 风险等级 |
|
||||
|------|----------|----------|
|
||||
| `src/types.ts` | 修改(扩展 SystemSettings) | 低 |
|
||||
| `src/pages/SystemSettings.tsx` | 修改(AI设置UI) | 中 |
|
||||
| `src/pages/TemplateManage.tsx` | 修改(工具栏+插入逻辑) | 中 |
|
||||
| `src/pages/ReportEditor.tsx` | 大规模修改(工具栏、状态、右侧面板、Diff弹窗、API调用) | **高** |
|
||||
|
||||
## 4. 优先级
|
||||
- P0:ReportEditor AI 面板核心功能(聊天、API调用、Diff确认)
|
||||
- P1:TemplateManage / ReportEditor 工具栏 AI 区域插入
|
||||
- P1:SystemSettings Kimi 接口配置
|
||||
- P2:语音输入、快捷指令编辑等增强体验
|
||||
|
||||
## 5. 验收标准
|
||||
- [ ] 可在 TemplateManage 中插入 `ai-region` 区域,重名时弹窗阻止
|
||||
- [ ] 可在 ReportEditor 中插入 `ai-region` 区域
|
||||
- [ ] SystemSettings 可配置 Kimi API Key 和 Base URL,保存后持久化
|
||||
- [ ] ReportEditor 右侧有 "AI撰写" Tab,点击可展开聊天面板
|
||||
- [ ] 可向 AI 发送文本+图片(关键帧/本地上传),AI 返回 JSON 结构化回复
|
||||
- [ ] 勾选"允许修改正文"时,AI 返回的 HTML 会触发 Diff 弹窗,确认后才注入
|
||||
- [ ] 不勾选"允许修改正文"时,AI 回复仅在聊天气泡中展示,不修改正文
|
||||
- [ ] 注入后目标区域有视觉高亮反馈
|
||||
- [ ] 重新部署后页面可正常访问,无构建错误
|
||||
Reference in New Issue
Block a user