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 } 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'>('info'); const [activeFieldKey, setActiveFieldKey] = useState(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 [multiSelectOptions, setMultiSelectOptions] = useState>({ surgeon: ['张医生', '李医生', '王医生'], assistant: ['赵医生', '钱医生', '孙医生'], anesthesiologist: ['周医生', '吴医生', '郑医生'] }); const [anesthesiaOptions, setAnesthesiaOptions] = useState(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']); const [openDropdown, setOpenDropdown] = useState(null); const [multiInputText, setMultiInputText] = useState>({}); const [touched, setTouched] = useState>({}); const [formFields, setFormFields] = useState([]); const [imagePickerOpen, setImagePickerOpen] = useState(false); const [imagePickerTarget, setImagePickerTarget] = useState(null); const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]); const [placeholderModal, setPlaceholderModal] = useState({ isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual' }); const [tableModal, setTableModal] = useState({ isOpen: false, rows: '2', cols: '3' }); 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 savedMulti = storage.get | null>('multiSelectOptions', null); if (savedMulti) setMultiSelectOptions(savedMulti); const savedAnesthesia = storage.get('anesthesiaOptions', null); if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia); const savedFields = storage.get('formFieldsConfig', []); if (savedFields.length > 0) { setFormFields(savedFields); } else { setFormFields(DEFAULT_FORM_FIELDS); storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS); } const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []); setImageAssets(savedAssets); const allTemplates = storage.get('templates', []); const visibleTplIds = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : allTemplates.map(t => t.id); const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id)); setTemplates(filteredTemplates); if (reportId) { const draft = storage.get | null>(draftKey, null); if (draft && draft.draftReportId === reportId) { if (draft.reportData) setReportData(draft.reportData); if (draft.videos) { setVideos(draft.videos); if (draft.videos.length > 0) setCurrentVideoIndex(0); prevVideoCountRef.current = draft.videos.length; } if (draft.capturedFrames) { setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); } if (draft.activeTab) setActiveTab(draft.activeTab); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '' }; if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; contentRef.current = draft.content; contentLoadedRef.current = true; setLoadedTemplateId(draft.loadedTemplateId || ''); setTimeout(() => updatePageHeight(), 0); } } else { const reports = storage.get('reports', []); const found = reports.find(r => r.id === reportId); if (found) { setReportData(found); stateRef.current = { ...stateRef.current, reportData: found, videos: found.videos || [], capturedFrames: found.capturedFrames || [] }; if (editorRef.current) { const restoreContent = storage.getSession(`restore_${reportId}`, null); if (restoreFlag && restoreContent) { editorRef.current.innerHTML = restoreContent; storage.removeSession(`restore_${reportId}`); } else { editorRef.current.innerHTML = found.content; contentRef.current = found.content; } contentLoadedRef.current = true; setTimeout(() => updatePageHeight(), 0); } if (found.capturedFrames) { setCapturedFrames(found.capturedFrames.sort((a, b) => a.time - b.time)); } if (found.videos) { setVideos(found.videos); if (found.videos.length > 0) setCurrentVideoIndex(0); prevVideoCountRef.current = found.videos.length; } } } } else { const draft = storage.get | null>(draftKey, null); if (draft && !draft.draftReportId) { if (draft.reportData) setReportData(draft.reportData); if (draft.videos) { setVideos(draft.videos); if (draft.videos.length > 0) setCurrentVideoIndex(0); prevVideoCountRef.current = draft.videos.length; } if (draft.capturedFrames) { setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); } if (draft.activeTab) setActiveTab(draft.activeTab); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '' }; if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; contentRef.current = draft.content; contentLoadedRef.current = true; setLoadedTemplateId(draft.loadedTemplateId || ''); setTimeout(() => updatePageHeight(), 0); } } if (!contentLoadedRef.current && editorRef.current) { const settings = storage.get('systemSettings', {} as SystemSettings); if (settings.defaultTemplate && filteredTemplates.length > 0) { const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate); if (tpl) { setLoadedTemplateId(tpl.id); stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id }; editorRef.current.innerHTML = tpl.content; contentRef.current = tpl.content; } else { editorRef.current.innerHTML = defaultReportContent; contentRef.current = defaultReportContent; } } else { editorRef.current.innerHTML = defaultReportContent; contentRef.current = defaultReportContent; } contentLoadedRef.current = true; setTimeout(() => updatePageHeight(), 0); } } }, [reportId, navigate, draftKey, restoreFlag]); useEffect(() => { const handleBeforeUnload = () => saveDraftToStorage(); const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') saveDraftToStorage(); }; window.addEventListener('beforeunload', handleBeforeUnload); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); document.removeEventListener('visibilitychange', handleVisibilityChange); saveDraftToStorage(); }; }, [saveDraftToStorage]); // Auto-fill current time for fields with timeDefault === 'current' useEffect(() => { if (formFields.length === 0) return; let hasChange = false; const updates: any = {}; formFields.forEach(field => { if (field.timeDefault === 'current') { if (field.type === 'date') { const current = new Date().toISOString().split('T')[0]; if (!(reportData as any)[field.key]) { updates[field.key] = current; hasChange = true; } } else if (field.type === 'time') { const now = new Date(); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); if (field.key === 'startTime') { if (!reportData.startHour) { updates.startHour = hh; updates.startMinute = mm; hasChange = true; } } else if (field.key === 'endTime') { if (!reportData.endHour) { updates.endHour = hh; updates.endMinute = mm; hasChange = true; } } else { if (!(reportData as any)[field.key]) { updates[field.key] = `${hh}:${mm}`; hasChange = true; } } } } else if (field.timeDefault === 'specific' && field.fixedTimeValue) { if (field.type === 'date') { if (!(reportData as any)[field.key]) { updates[field.key] = field.fixedTimeValue; hasChange = true; } } else if (field.type === 'time') { if (field.key === 'startTime') { if (!reportData.startHour) { const [hh, mm] = field.fixedTimeValue.split(':'); updates.startHour = hh || ''; updates.startMinute = mm || ''; hasChange = true; } } else if (field.key === 'endTime') { if (!reportData.endHour) { const [hh, mm] = field.fixedTimeValue.split(':'); updates.endHour = hh || ''; updates.endMinute = mm || ''; hasChange = true; } } else { if (!(reportData as any)[field.key]) { updates[field.key] = field.fixedTimeValue; hasChange = true; } } } } }); if (hasChange) { setReportData(prev => { const next = { ...prev, ...updates }; stateRef.current = { ...stateRef.current, reportData: next }; return next; }); } }, [formFields]); useEffect(() => { if (!editorRef.current) return; const observer = new MutationObserver(() => { updatePageHeight(); }); observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true }); return () => observer.disconnect(); }, [currentUser]); const triggerPlaceholderUpload = (placeholder: HTMLElement) => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = (ev) => { const file = (ev.target as HTMLInputElement).files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { const src = event.target?.result as string; const mw = placeholder.style.maxWidth || placeholder.style.width || '200px'; const mh = placeholder.style.maxHeight || placeholder.style.height || '200px'; placeholder.innerHTML = ` × `; placeholder.classList.add('has-image'); placeholder.style.border = 'none'; placeholder.style.background = 'transparent'; placeholder.style.width = 'auto'; placeholder.style.height = 'auto'; placeholder.style.lineHeight = 'normal'; placeholder.style.maxWidth = mw; placeholder.style.maxHeight = mh; placeholder.style.textAlign = 'left'; placeholder.style.verticalAlign = 'top'; placeholder.style.justifyContent = 'flex-start'; placeholder.style.alignItems = 'flex-start'; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); }; reader.readAsDataURL(file); } }; input.click(); }; // Handle image placeholder interactions via click capture for reliable contenteditable behavior useEffect(() => { const handleEditorClick = (e: MouseEvent) => { // e.target may be a text node; safely resolve to an Element let node: Node | null = e.target as Node; if (node.nodeType === Node.TEXT_NODE) node = node.parentElement; const targetEl = node as HTMLElement | null; if (!targetEl) return; // Handle click on field-value: switch to info tab, highlight and focus corresponding input const fieldValue = targetEl.closest('.field-value') as HTMLElement | null; if (fieldValue) { const bindKey = fieldValue.getAttribute('data-bind'); if (bindKey) { setActiveTab('info'); stateRef.current = { ...stateRef.current, activeTab: 'info' }; setActiveFieldKey(bindKey); setTimeout(() => { const inputEl = document.getElementById(`input-${bindKey}`); if (inputEl) { inputEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); const focusable = inputEl.querySelector('input, select') as HTMLElement | null; if (focusable) { focusable.focus(); } } }, 100); } return; } // 点击空白处清除高亮 setActiveFieldKey(null); const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null; if (!placeholder) return; if (targetEl.closest('.delete-btn')) { e.stopPropagation(); e.preventDefault(); if (placeholder.classList.contains('has-image')) { placeholder.classList.remove('has-image'); const w = parseInt(placeholder.style.maxWidth || placeholder.style.width || '0'); const text = w > 0 && w < 80 ? '插图' : '插入/点击放置图片'; placeholder.innerHTML = ` × ${text} `; placeholder.style.border = '1px dashed #cbd5e1'; placeholder.style.background = '#f8fafc'; const mw = placeholder.style.maxWidth; const mh = placeholder.style.maxHeight; if (mw) placeholder.style.width = mw; if (mh) { placeholder.style.height = mh; placeholder.style.lineHeight = mh; } placeholder.style.textAlign = 'center'; placeholder.style.verticalAlign = 'middle'; placeholder.style.justifyContent = 'center'; placeholder.style.alignItems = 'center'; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } else { const range = document.createRange(); range.selectNode(placeholder); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); document.execCommand('delete'); if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } return; } if (!placeholder.classList.contains('has-image')) { e.preventDefault(); e.stopPropagation(); setImagePickerTarget(placeholder); setImagePickerOpen(true); } }; const editor = editorRef.current; if (editor) { editor.addEventListener('click', handleEditorClick, true); } return () => { if (editor) { editor.removeEventListener('click', handleEditorClick, true); } }; }, [saveDraftToStorage, currentUser]); // Prevent backspace from deleting image-placeholder elements useEffect(() => { const editor = editorRef.current; if (!editor) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Backspace' && e.key !== 'Delete') return; const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); // If selection is inside a placeholder, or if the range intersects a placeholder let node: Node | null = range.commonAncestorContainer; if (node.nodeType === Node.TEXT_NODE) node = node.parentElement; const placeholder = (node as HTMLElement)?.closest('.image-placeholder'); if (placeholder) { e.preventDefault(); return; } // If collapsed and next/previous sibling is a placeholder if (range.collapsed) { const container = range.startContainer; const offset = range.startOffset; if (container.nodeType === Node.ELEMENT_NODE) { const next = (container as Element).children[offset]; const prev = (container as Element).children[offset - 1]; if ((e.key === 'Delete' && next?.classList?.contains('image-placeholder')) || (e.key === 'Backspace' && prev?.classList?.contains('image-placeholder'))) { e.preventDefault(); } } } }; editor.addEventListener('keydown', handleKeyDown); return () => editor.removeEventListener('keydown', handleKeyDown); }, []); const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => { const mw = placeholder.style.maxWidth || placeholder.style.width || '200px'; const mh = placeholder.style.maxHeight || placeholder.style.height || '200px'; placeholder.innerHTML = ` × `; placeholder.classList.add('has-image'); placeholder.style.border = 'none'; placeholder.style.background = 'transparent'; placeholder.style.width = 'auto'; placeholder.style.height = 'auto'; placeholder.style.lineHeight = 'normal'; placeholder.style.maxWidth = mw; placeholder.style.maxHeight = mh; placeholder.style.textAlign = 'left'; placeholder.style.verticalAlign = 'top'; placeholder.style.justifyContent = 'flex-start'; placeholder.style.alignItems = 'flex-start'; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); }; 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 changeLineHeight = (height: string) => { const sel = window.getSelection(); if (!sel || !sel.rangeCount) return; let node = sel.getRangeAt(0).commonAncestorContainer; if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node; const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li'); if (block) { (block as HTMLElement).style.lineHeight = height; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } }; const changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => { const sel = window.getSelection(); if (!sel || !sel.rangeCount) return; let node = sel.getRangeAt(0).commonAncestorContainer; if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node; const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li'); if (block) { (block as HTMLElement).style.textAlign = align; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } }; const insertTable = () => { editorRef.current?.focus(); setTableModal({ isOpen: true, rows: '2', cols: '3' }); }; const insertImage = () => { editorRef.current?.focus(); setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' }); }; 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 })); const combined = [...videos, ...newVideos]; setVideos(combined); stateRef.current = { ...stateRef.current, videos: combined }; setCurrentVideoIndex(videos.length); // select first newly uploaded video if (videoInputRef.current) videoInputRef.current.value = ''; saveDraftToStorage(); }; const removeVideo = (id: string) => { const idx = videos.findIndex(v => v.id === id); const updated = videos.filter(v => v.id !== id); setVideos(updated); stateRef.current = { ...stateRef.current, videos: updated }; if (currentVideoIndex >= updated.length) { setCurrentVideoIndex(updated.length > 0 ? 0 : -1); } else if (currentVideoIndex === idx && updated.length > 0) { setCurrentVideoIndex(0); } const nextFrames = capturedFrames.filter(f => f.videoIndex !== idx).map(f => { if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 }; return f; }).sort((a, b) => a.time - b.time); setCapturedFrames(nextFrames); stateRef.current = { ...stateRef.current, capturedFrames: nextFrames }; saveDraftToStorage(); }; const selectVideo = (index: number) => { setCurrentVideoIndex(index); setIsPlaying(false); }; const togglePlay = () => { if (!videoRef.current) return; if (isPlaying) videoRef.current.pause(); else videoRef.current.play(); setIsPlaying(!isPlaying); }; const captureFrame = () => { if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return; const video = videoRef.current; const canvas = canvasRef.current; const MAX_WIDTH = 800; const scale = Math.min(1, MAX_WIDTH / 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.6), isManual: true }; const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time); setCapturedFrames(nextFrames); stateRef.current = { ...stateRef.current, capturedFrames: nextFrames }; saveDraftToStorage(); }; const autoCaptureFrames = async () => { if (!videoRef.current || currentVideoIndex === -1) return; const video = videoRef.current; const settings = storage.get('systemSettings', {} as SystemSettings); const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; const dur = video.duration || 1; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const wasPlaying = !video.paused; if (wasPlaying) video.pause(); let accumulatedFrames = [...capturedFrames]; for (let i = 0; i < positions.length; i++) { const pos = positions[i]; const time = (pos / 100) * dur; video.currentTime = time; await new Promise(resolve => { const onSeeked = () => { video.removeEventListener('seeked', onSeeked); resolve(); }; video.addEventListener('seeked', onSeeked); }); const MAX_WIDTH = 800; const scale = Math.min(1, MAX_WIDTH / video.videoWidth); canvas.width = video.videoWidth * scale; canvas.height = video.videoHeight * scale; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const newFrame: CapturedFrame = { id: Date.now() + Math.random(), videoIndex: currentVideoIndex, videoName: videos[currentVideoIndex].name, time, timeFormatted: formatTime(time), dataUrl: canvas.toDataURL('image/jpeg', 0.6), isManual: false }; accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time); flushSync(() => { setCapturedFrames(accumulatedFrames); }); stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames }; if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) { const baseDelay = (settings.autoInsertDelay || 0) * 1000; const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i); const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0; setTimeout(() => { if (!editorRef.current) return; const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null; if (emptyPlaceholder) { emptyPlaceholder.innerHTML = ` × `; emptyPlaceholder.classList.add('has-image'); emptyPlaceholder.style.border = 'none'; emptyPlaceholder.style.background = 'transparent'; emptyPlaceholder.style.width = 'auto'; emptyPlaceholder.style.height = 'auto'; emptyPlaceholder.style.lineHeight = 'normal'; emptyPlaceholder.style.maxWidth = emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'; emptyPlaceholder.style.maxHeight = emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'; emptyPlaceholder.style.textAlign = 'left'; emptyPlaceholder.style.verticalAlign = 'top'; emptyPlaceholder.style.justifyContent = 'flex-start'; emptyPlaceholder.style.alignItems = 'flex-start'; contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } }, actualDelay); } } if (settings.autoInsertFrames && editorRef.current) { contentRef.current = editorRef.current.innerHTML; } if (wasPlaying) video.play(); saveDraftToStorage(); }; 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 handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => { e.dataTransfer.setData('frameId', frame.id.toString()); }; const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => { const mw = placeholder.style.maxWidth || placeholder.style.width || '200px'; const mh = placeholder.style.maxHeight || placeholder.style.height || '200px'; placeholder.innerHTML = ` × `; placeholder.classList.add('has-image'); placeholder.style.border = 'none'; placeholder.style.background = 'transparent'; placeholder.style.width = 'auto'; placeholder.style.height = 'auto'; placeholder.style.lineHeight = 'normal'; placeholder.style.maxWidth = mw; placeholder.style.maxHeight = mh; placeholder.style.textAlign = 'left'; placeholder.style.verticalAlign = 'top'; placeholder.style.justifyContent = 'flex-start'; placeholder.style.alignItems = 'flex-start'; if (editorRef.current) contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); }; const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => { e.preventDefault(); if (placeholder.getAttribute('data-mode') === 'manual') { alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧'); return; } const frameId = e.dataTransfer.getData('frameId'); const frame = capturedFrames.find(f => f.id.toString() === frameId); if (frame) { fillPlaceholder(placeholder, frame); } }; const insertFrameToPlaceholder = (frame: CapturedFrame) => { if (!editorRef.current) { alert('编辑器未准备好'); return; } const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null; if (!emptyPlaceholder) { alert('没有可插入图片的空位'); return; } fillPlaceholder(emptyPlaceholder, frame); }; const seekToFrame = (frame: CapturedFrame) => { if (!videoRef.current) return; if (frame.videoIndex !== currentVideoIndex) { setCurrentVideoIndex(frame.videoIndex); } setTimeout(() => { if (videoRef.current) { videoRef.current.currentTime = frame.time; } }, 50); }; const handleFrameKeyNav = (direction: 'left' | 'right') => { if (!videoRef.current) return; const delta = direction === 'left' ? -5 : 5; videoRef.current.currentTime = Math.max(0, Math.min(duration, videoRef.current.currentTime + delta)); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { // Only if video tab is active or video element is focused if (activeTab === 'video' || document.activeElement === videoRef.current) { handleFrameKeyNav(e.key === 'ArrowLeft' ? 'left' : 'right'); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeTab, duration]); // Auto-capture frames when new videos are uploaded useEffect(() => { if (videos.length > prevVideoCountRef.current && currentVideoIndex !== -1) { const timer = setTimeout(() => { if (videoRef.current && videoRef.current.duration) { autoCaptureFrames(); } else if (videoRef.current) { const onMeta = () => { autoCaptureFrames(); videoRef.current?.removeEventListener('loadedmetadata', onMeta); }; videoRef.current.addEventListener('loadedmetadata', onMeta); } }, 300); return () => clearTimeout(timer); } prevVideoCountRef.current = videos.length; }, [videos.length, currentVideoIndex]); // Apply selected template content useEffect(() => { if (pendingTemplateId && editorRef.current) { const tpl = templates.find(t => t.id === pendingTemplateId); if (tpl) { editorRef.current.innerHTML = tpl.content; contentRef.current = tpl.content; const nextReportData: any = { title: tpl.name || '腹腔镜胆囊切除术报告', patientName: '', hospitalId: '', patientGender: '', patientAge: '', department: '', bedNumber: '', surgeryDate: '', startHour: '', startMinute: '', endHour: '', endMinute: '', surgeon: [], assistant: [], anesthesiologist: [], anesthesiaType: '', status: 'draft' }; formFields.forEach(field => { if (field.category === '时间') { if (field.timeDefault === 'specific' && field.fixedTimeValue) { if (field.type === 'date') { nextReportData[field.key] = field.fixedTimeValue; } else if (field.type === 'time') { const [hh, mm] = field.fixedTimeValue.split(':'); if (field.key === 'startTime') { nextReportData.startHour = hh || ''; nextReportData.startMinute = mm || ''; } else if (field.key === 'endTime') { nextReportData.endHour = hh || ''; nextReportData.endMinute = mm || ''; } else { nextReportData[field.key] = field.fixedTimeValue; } } } else if (field.timeDefault === 'current') { if (field.type === 'date') { nextReportData[field.key] = new Date().toISOString().split('T')[0]; } else if (field.type === 'time') { const now = new Date(); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); if (field.key === 'startTime') { nextReportData.startHour = hh; nextReportData.startMinute = mm; } else if (field.key === 'endTime') { nextReportData.endHour = hh; nextReportData.endMinute = mm; } else { nextReportData[field.key] = `${hh}:${mm}`; } } } } }); if (!nextReportData.surgeryDate) { nextReportData.surgeryDate = new Date().toISOString().split('T')[0]; } setLoadedTemplateId(tpl.id); setReportData(nextReportData); setVideos([]); setCapturedFrames([]); setCurrentVideoIndex(-1); prevVideoCountRef.current = 0; stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id, reportData: nextReportData, videos: [], capturedFrames: [], activeTab: stateRef.current.activeTab }; updatePageHeight(); saveDraftToStorage(); } setPendingTemplateId(null); } }, [pendingTemplateId, templates]); // Safety net: ensure editor gets initialized if ref was not ready during init effect React.useLayoutEffect(() => { if (contentLoadedRef.current || !editorRef.current) return; const user = storage.get('currentUser', null); const key = user ? `reportEditorDraft_${user.username}` : ''; const draft = key ? storage.get | null>(key, null) : null; if (reportId) { if (draft && draft.draftReportId === reportId && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; contentRef.current = draft.content; contentLoadedRef.current = true; setLoadedTemplateId(draft.loadedTemplateId || ''); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '' }; setTimeout(() => updatePageHeight(), 0); return; } const reports = storage.get('reports', []); const found = reports.find(r => r.id === reportId); if (found) { const restoreContent = storage.getSession(`restore_${reportId}`, null); if (restoreFlag && restoreContent) { editorRef.current.innerHTML = restoreContent; storage.removeSession(`restore_${reportId}`); } else { editorRef.current.innerHTML = found.content; } contentLoadedRef.current = true; stateRef.current = { ...stateRef.current, reportData: found, videos: found.videos || [], capturedFrames: found.capturedFrames || [] }; setTimeout(() => updatePageHeight(), 0); return; } } else { if (draft && !draft.draftReportId && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; contentRef.current = draft.content; contentLoadedRef.current = true; setLoadedTemplateId(draft.loadedTemplateId || ''); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '' }; setTimeout(() => updatePageHeight(), 0); return; } } const settings = storage.get('systemSettings', {} as SystemSettings); const allTemplates = storage.get('templates', []); const userData = storage.get('currentUser', null); const visibleTplIds = Array.isArray(userData?.visibleTemplates) ? userData.visibleTemplates : allTemplates.map(t => t.id); const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id)); if (settings.defaultTemplate && filteredTemplates.length > 0) { const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate); if (tpl) { setLoadedTemplateId(tpl.id); stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id }; editorRef.current.innerHTML = tpl.content; } else { editorRef.current.innerHTML = defaultReportContent; } } else { editorRef.current.innerHTML = defaultReportContent; } contentLoadedRef.current = true; setTimeout(() => updatePageHeight(), 0); }, []); const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0')); const hour12Options = Array.from({ length: 12 }, (_, i) => ((i + 1).toString().padStart(2, '0'))); const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0')); const formatDateDisplay = (isoDate: string, fmt?: string): string => { if (!isoDate || !fmt) return isoDate || ''; const [y, m, d] = isoDate.split('-'); return fmt .replace(/YYYY/g, y || '') .replace(/MM/g, m || '') .replace(/DD/g, d || ''); }; const formatTimeDisplay = (timeStr: string, fmt?: string): string => { if (!timeStr || !fmt) return timeStr || ''; if (fmt === '24h') fmt = 'HH:mm'; const [h24str, mstr] = timeStr.split(':'); const h24 = parseInt(h24str) || 0; const isPM = h24 >= 12; let h12 = h24 % 12; if (h12 === 0) h12 = 12; return fmt .replace(/HH/g, String(h24).padStart(2, '0')) .replace(/mm/g, mstr || '00') .replace(/hh/g, String(h12).padStart(2, '0')) .replace(/A/g, isPM ? '下午' : '上午'); }; const parseDateFromFormat = (text: string, fmt?: string): string => { if (!text || !fmt) return text; const nums = text.match(/\d+/g); if (!nums) return text; let y = '', m = '', d = ''; if (nums.length >= 3) { y = nums[0].padStart(4, '0'); m = nums[1].padStart(2, '0'); d = nums[2].padStart(2, '0'); } else if (nums.length === 2) { m = nums[0].padStart(2, '0'); d = nums[1].padStart(2, '0'); y = new Date().getFullYear().toString(); } return `${y}-${m}-${d}`; }; const parseTimeFromFormat = (text: string, fmt?: string): string => { if (!text || !fmt) return text; const nums = text.match(/\d+/g); const ampm = text.match(/上午|下午/); if (!nums || nums.length < 2) return text; let h = parseInt(nums[0]); if (ampm) { const isPM = ampm[0] === '下午'; if (isPM && h !== 12) h += 12; if (!isPM && h === 12) h = 0; } return `${String(h).padStart(2, '0')}:${nums[1].padStart(2, '0')}`; }; const to24h = (h12: number, isPM: boolean): number => { if (isPM && h12 !== 12) return h12 + 12; if (!isPM && h12 === 12) return 0; return h12; }; const from24h = (h24: number): { h: number; isPM: boolean } => { const isPM = h24 >= 12; let h = h24 % 12; if (h === 0) h = 12; return { h, isPM }; }; const addTag = (field: string, value: string) => { const current = (reportData as any)[field] || []; if (!current.includes(value)) { const next = { ...reportData, [field]: [...current, value] }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); } // Persist custom value to global options for future reuse const opts = multiSelectOptions[field] || []; if (!opts.includes(value)) { const next = { ...multiSelectOptions, [field]: [...opts, value] }; setMultiSelectOptions(next); storage.set('multiSelectOptions', next); } // Sync to formFieldsConfig const fieldDef = formFields.find(f => f.key === field); if (fieldDef && fieldDef.options && !fieldDef.options.includes(value)) { const updatedFields = formFields.map(f => f.key === field ? { ...f, options: [...(f.options || []), value] } : f); setFormFields(updatedFields); storage.set('formFieldsConfig', updatedFields); } }; const removeTag = (field: string, value: string) => { const current = (reportData as any)[field] || []; const next = { ...reportData, [field]: current.filter((v: string) => v !== value) }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }; const removeMultiOption = (field: string, value: string) => { const current = multiSelectOptions[field] || []; const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) }; setMultiSelectOptions(next); storage.set('multiSelectOptions', next); // Sync to formFieldsConfig const fieldDef = formFields.find(f => f.key === field); if (fieldDef && fieldDef.options) { const updatedFields = formFields.map(f => f.key === field ? { ...f, options: (f.options || []).filter(v => v !== value) } : f); setFormFields(updatedFields); storage.set('formFieldsConfig', updatedFields); } }; const removeAnesthesiaOption = (value: string) => { const next = anesthesiaOptions.filter(v => v !== value); setAnesthesiaOptions(next); storage.set('anesthesiaOptions', next); }; // Close dropdowns on outside click useEffect(() => { const handleDocClick = (e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest('.select-dropdown-root')) { setOpenDropdown(null); } }; document.addEventListener('click', handleDocClick); return () => document.removeEventListener('click', handleDocClick); }, []); const saveReport = (status: 'draft' | 'completed') => { if (status === 'completed' && (!reportData.patientName || !reportData.hospitalId)) { alert('请填写患者姓名和住院号'); return; } if (status === 'completed') { const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]'); if (hasSignatureField) { const hasSignatureImage = !!currentUser?.signature; if (!hasSignatureImage) { const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?'); if (!proceed) return; } } } 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: Report[]; if (reportId) { const old = reports.find(r => r.id === reportId); const history = old?.history ? [...old.history] : []; if (old) { history.push({ content: old.content, updatedAt: old.updatedAt || old.createdAt, updatedBy: currentUser?.name || currentUser?.username || '', action: status === 'completed' ? 'complete_report' : 'save_draft' }); } updatedReports = reports.map(r => r.id === reportId ? { ...finalReport, history } : r); } else { updatedReports = [...reports, finalReport]; if (draftKey) storage.remove(draftKey); } storage.set('reports', updatedReports); if (draftKey) storage.remove(draftKey); setIsSaved(true); setTimeout(() => setIsSaved(false), 3000); if (status === 'completed') navigate('/report-manage'); }; const handleEditorInput = (e: React.FormEvent) => { if (editorRef.current) { contentRef.current = editorRef.current.innerHTML; } updatePageHeight(); saveDraftToStorage(); const target = e.target as HTMLElement; if (target && target.hasAttribute('data-bind')) { const fieldKey = target.getAttribute('data-bind')!; const newValue = target.innerText; const fieldDef = formFields.find(f => f.key === fieldKey); if (fieldKey === 'startTime') { let raw = newValue; if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) { raw = parseTimeFromFormat(newValue, fieldDef.timeFormat); } const parts = raw.split(':'); setReportData((prev) => { const next = { ...prev, startHour: parts[0] || '', startMinute: parts[1] || '' }; stateRef.current = { ...stateRef.current, reportData: next }; return next; }); } else if (fieldKey === 'endTime') { let raw = newValue; if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) { raw = parseTimeFromFormat(newValue, fieldDef.timeFormat); } const parts = raw.split(':'); setReportData((prev) => { const next = { ...prev, endHour: parts[0] || '', endMinute: parts[1] || '' }; stateRef.current = { ...stateRef.current, reportData: next }; return next; }); } else { let raw = newValue; if (fieldDef?.type === 'date') { raw = parseDateFromFormat(newValue, fieldDef.timeFormat); } else if (fieldDef?.type === 'time') { raw = parseTimeFromFormat(newValue, fieldDef.timeFormat); } setReportData((prev) => { const next = { ...prev, [fieldKey]: raw }; stateRef.current = { ...stateRef.current, reportData: next }; return next; }); } } }; // Sync form state -> rich text field values useEffect(() => { if (!editorRef.current) return; const bindNodes = editorRef.current.querySelectorAll('[data-bind]'); bindNodes.forEach((node) => { const el = node as HTMLElement; const fieldKey = el.getAttribute('data-bind')!; if (fieldKey === 'surgeonSignature') { const signatureData = currentUser?.signature; if (signatureData) { const imgHtml = `签名`; if (el.innerHTML !== imgHtml) { el.innerHTML = imgHtml; el.style.border = 'none'; el.style.backgroundColor = 'transparent'; } } else { const placeholder = '【请上传电子签】'; if (el.innerText !== placeholder) { el.innerText = placeholder; el.style.border = ''; el.style.backgroundColor = ''; } } return; } let newValue = ''; const fieldDef = formFields.find(f => f.key === fieldKey); if (fieldKey === 'startTime') { newValue = `${reportData.startHour || ''}:${reportData.startMinute || ''}`; if (newValue === ':') newValue = ''; newValue = formatTimeDisplay(newValue, fieldDef?.timeFormat); } else if (fieldKey === 'endTime') { newValue = `${reportData.endHour || ''}:${reportData.endMinute || ''}`; if (newValue === ':') newValue = ''; newValue = formatTimeDisplay(newValue, fieldDef?.timeFormat); } else { const rawValue = (reportData as any)[fieldKey]; if (Array.isArray(rawValue)) { newValue = rawValue.join(', '); } else if (rawValue !== undefined && rawValue !== null) { newValue = String(rawValue); } if (fieldDef?.type === 'date') { newValue = formatDateDisplay(newValue, fieldDef.timeFormat); } else if (fieldDef?.type === 'time') { newValue = formatTimeDisplay(newValue, fieldDef.timeFormat); } } if (el.innerText !== newValue) { el.innerText = newValue; } }); }, [reportData]); if (!currentUser) return null; const hasVisibleTemplates = templates.length > 0; return (
{/* Header */}

图文报告生成

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

当前模板(及重置模板):
{isSaved && ( 已保存 )}
{/* Editor Main */}
{/* Toolbar */}
execCmd('foreColor', e.target.value)} className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors" title="文字颜色" />
)} {exportModalOpen && (

导出报告

)} {imagePickerOpen && imagePickerTarget && (

选择图片来源

系统素材
{imageAssets.map(asset => ( ))} {imageAssets.length === 0 &&
暂无素材
}
)}
); }