Files
Mdeical_Sur_Report/参考信息/参考-ReportEditor.tsx

823 lines
39 KiB
TypeScript
Raw Blame History

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