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(null); const [reportData, setReportData] = useState>({ title: '腹腔镜胆囊切除术报告', patientName: '', hospitalId: '', patientGender: '', patientAge: '', department: '', bedNumber: '', surgeryDate: '', startHour: '', startMinute: '', endHour: '', endMinute: '', surgeon: [], assistant: [], anesthesiologist: [], anesthesiaType: '', reportNote: '', status: 'draft' }); const [templates, setTemplates] = useState([]); const [videos, setVideos] = useState<{id: string, name: string, url: string, duration: number}[]>([]); const [currentVideoIndex, setCurrentVideoIndex] = useState(-1); const [capturedFrames, setCapturedFrames] = useState([]); 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(null); const prevVideoCountRef = useRef(0); const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info'); const [activeFieldKey, setActiveFieldKey] = useState(null); // AI 撰写相关状态 const [chatInput, setChatInput] = useState(''); const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]); const [isGenerating, setIsGenerating] = useState(false); const [aiSelectedFrames, setAiSelectedFrames] = useState([]); const [aiTargetRegion, setAiTargetRegion] = useState('surgical-steps'); const [aiModifyEnabled, setAiModifyEnabled] = useState(true); const [isListening, setIsListening] = useState(false); const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]); const speechRecognitionRef = useRef(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([]); const [openDropdown, setOpenDropdown] = useState(null); const [touched, setTouched] = useState>({}); const editorRef = useRef(null); const videoRef = useRef(null); const canvasRef = useRef(null); const videoInputRef = useRef(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('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('currentUser', null); if (!user) { navigate('/'); return; } setCurrentUser(user); const savedFields = storage.get('formFieldsConfig', []); if (savedFields.length > 0) { setFormFields(savedFields); } else { setFormFields(DEFAULT_FORM_FIELDS); storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS); } const allTemplates = storage.get('templates', []); setTemplates(allTemplates); if (reportId) { const reports = storage.get('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) => { 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('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) => { 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', {} 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 格式,如 `

` 标签和行内样式)。\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 = `

${badgeLabel}
${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 (
{/* Header */}

{reportId ? `编辑报告: \${reportId}` : '新建手术报告'}

{isSaved && ( 已保存 )}
{/* Main Editor Section */}
{/* 简化版工具栏 */}
{ contentRef.current = editorRef.current?.innerHTML || ''; }} >
{/* Right Sidebar */}