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, 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'; import { printDocument } from '../utils/print'; import { storage } from '../utils/storage'; import { canEditReport, getUsableTemplates } from '../utils/permissions'; import { getReport, saveReportToApi } from '../api/reports'; import { ApiError } from '../api/client'; import { createTemplate, listTemplates } from '../api/templates'; import { getSystemSettings } from '../api/settings'; import { createAiChatCompletion } from '../api/ai'; import { getSpeechIatWebSocketUrl } from '../api/speech'; import { getFieldLibrary, updateFieldLibrary } from '../api/library'; import { listFiles, uploadFileResource } from '../api/files'; import { isLocalFallbackEnabled } from '../config/runtime'; import { diffChars } from 'diff'; type AudioWindow = Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext; }; const getApiErrorMessage = (error: unknown, fallback: string) => { if (error instanceof ApiError) { if (error.status === 401) return '登录状态已失效,请重新登录后再保存。'; return error.message || fallback; } if (error instanceof Error) return error.message || fallback; return fallback; }; 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, fileId?: string}[]>([]); 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, images?: string[]}[]>([]); const [isGenerating, setIsGenerating] = useState(false); const [aiTargetRegion, setAiTargetRegion] = useState('none'); const [aiModifyEnabled, setAiModifyEnabled] = useState(true); const [isListening, setIsListening] = useState(false); const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]); const [editorImages, setEditorImages] = useState<{id: string, src: string}[]>([]); const [aiSelectedEditorImages, setAiSelectedEditorImages] = useState([]); const xfWsRef = useRef(null); const xfAudioContextRef = useRef(null); const xfMediaStreamRef = useRef(null); const [quickPrompts, setQuickPrompts] = useState([ '请完善报告内容', '请对内容做如下修改:' ]); const [isEditingPrompts, setIsEditingPrompts] = useState(false); const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null); const [lastExchangeLog, setLastExchangeLog] = useState<{ startTime: string; modelConfig: { provider: string; endpoint: string; modelName: string }; requestPayload: any; responsePayload: any | null; errorDetail: { status: number; statusText: string; responseText: string; message: string } | null; } | null>(null); useEffect(() => { stateRef.current.chatMessages = chatMessages; }, [chatMessages]); // 监听编辑器中已插入的图片,同步到 AI 面板 useEffect(() => { if (!editorRef.current) return; const updateEditorImages = () => { if (!editorRef.current) return; const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img')) .map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src })) .filter(img => img.src); setEditorImages(prev => { const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src); if (same) return prev; // 清除已不存在的选中项 setAiSelectedEditorImages(prevSelected => prevSelected.filter(id => imgs.some(img => img.id === id))); return imgs; }); }; updateEditorImages(); const observer = new MutationObserver(updateEditorImages); observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true }); return () => observer.disconnect(); }, []); useEffect(() => { stateRef.current.chatInput = chatInput; }, [chatInput]); // 切换到 AI 面板时强制同步编辑器中的图片 useEffect(() => { if (activeTab !== 'ai' || !editorRef.current) return; const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img')) .map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src })) .filter(img => img.src); setEditorImages(prev => { const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src); return same ? prev : imgs; }); }, [activeTab]); useEffect(() => { 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, chatMessages, chatInput }); const videoUploadPromisesRef = useRef[]>([]); const frameUploadPromisesRef = useRef[]>([]); 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, chatMessages: stateRef.current.chatMessages, chatInput: stateRef.current.chatInput }); } }, [reportId]); const fileToDataUrl = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(reader.error || new Error('文件读取失败')); reader.readAsDataURL(file); }); const replaceEditorImageSrc = (from: string, to: string) => { if (!editorRef.current || !from || !to || from === to) return; editorRef.current.querySelectorAll('img').forEach((img) => { if ((img as HTMLImageElement).src === from || img.getAttribute('src') === from) { img.setAttribute('src', to); } }); contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); }; const startVideoUpload = (videoId: string, file: File) => { const promise = fileToDataUrl(file) .then((dataUrl) => uploadFileResource(dataUrl, { kind: 'VIDEO', filename: file.name, reportId: reportId || undefined, })) .then((uploaded) => { const next = stateRef.current.videos.map((video) => video.id === videoId ? { ...video, url: uploaded.url, fileId: uploaded.id } : video, ); stateRef.current = { ...stateRef.current, videos: next }; setVideos(next); }) .catch(() => {}); videoUploadPromisesRef.current.push(promise); }; const startFrameUpload = (frameId: number, dataUrl: string) => { const promise = uploadFileResource(dataUrl, { kind: 'FRAME', filename: `frame-${frameId}.jpg`, reportId: reportId || undefined, }) .then((uploaded) => { const next = stateRef.current.capturedFrames.map((frame) => frame.id === frameId ? { ...frame, dataUrl: uploaded.url, fileId: uploaded.id } : frame, ); stateRef.current = { ...stateRef.current, capturedFrames: next }; setCapturedFrames(next); replaceEditorImageSrc(dataUrl, uploaded.url); }) .catch(() => {}); frameUploadPromisesRef.current.push(promise); }; const waitForMediaUploads = async () => { const uploads = [...videoUploadPromisesRef.current, ...frameUploadPromisesRef.current]; if (uploads.length === 0) return; await Promise.allSettled(uploads); videoUploadPromisesRef.current = []; frameUploadPromisesRef.current = []; }; 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); void getFieldLibrary().then((library) => { if (library.formFields.length > 0) { setFormFields(library.formFields); storage.set('formFieldsConfig', library.formFields); } if (Object.keys(library.multiSelectOptions || {}).length > 0) { setMultiSelectOptions(library.multiSelectOptions); storage.set('multiSelectOptions', library.multiSelectOptions); } if (library.anesthesiaOptions.length > 0) { setAnesthesiaOptions(library.anesthesiaOptions); storage.set('anesthesiaOptions', library.anesthesiaOptions); } }).catch(() => {}); void listFiles('TEMPLATE_ASSET').then((files) => { if (files.length === 0) return; const assets = files.map((file) => ({ id: file.id, name: file.filename, dataUrl: file.url })); setImageAssets(assets); storage.set('imageAssets', assets); }).catch(() => {}); void getSystemSettings().then((apiSettings) => { storage.set('systemSettings', apiSettings); }).catch(() => {}); const allTemplates = storage.get('templates', []); const filteredTemplates = getUsableTemplates(user, allTemplates); setTemplates(filteredTemplates); void listTemplates('use').then((response) => { if (response.items.length === 0) return; setTemplates(response.items); storage.set('templates', response.items); }).catch(() => {}); 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); if (draft.chatMessages) setChatMessages(draft.chatMessages); if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '', chatMessages: draft.chatMessages || [], chatInput: draft.chatInput || '' }; 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) { const users = storage.get('users', []); if (!canEditReport(user, found, users)) { alert('您没有权限编辑此报告'); navigate('/report-manage'); return; } 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); if (draft.chatMessages) setChatMessages(draft.chatMessages); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, loadedTemplateId: draft.loadedTemplateId || '', chatMessages: draft.chatMessages || [] }; 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]); const persistEditorFieldLibrary = (next: Partial<{ formFields: FormField[]; multiSelectOptions: Record; anesthesiaOptions: string[]; }>) => { void updateFieldLibrary({ formFields, multiSelectOptions, anesthesiaOptions, customTimeFormats: [], ...next, }).catch(() => {}); }; 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]); useEffect(() => { if (!reportId || !currentUser) return; const draft = storage.get | null>(draftKey, null); if (draft && draft.draftReportId === reportId) return; const loadApiReport = async () => { try { const apiReport = await getReport(reportId); setReportData(apiReport); setCapturedFrames((apiReport.capturedFrames || []).sort((a, b) => a.time - b.time)); setVideos(apiReport.videos || []); if (apiReport.videos && apiReport.videos.length > 0) setCurrentVideoIndex(0); prevVideoCountRef.current = apiReport.videos?.length || 0; stateRef.current = { ...stateRef.current, reportData: apiReport, videos: apiReport.videos || [], capturedFrames: apiReport.capturedFrames || [] }; const reports = storage.get('reports', []); storage.set('reports', [...reports.filter(r => r.id !== apiReport.id), apiReport]); if (editorRef.current && !restoreFlag) { editorRef.current.innerHTML = apiReport.content; contentRef.current = apiReport.content; contentLoadedRef.current = true; setTimeout(() => updatePageHeight(), 0); } } catch { if (!isLocalFallbackEnabled()) { alert('报告加载失败或无权访问'); navigate('/report-manage'); } } }; void loadApiReport(); }, [currentUser, draftKey, reportId, restoreFlag]); // 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 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 = `
${name}-AI可编辑区域


`; document.execCommand('insertHTML', false, html); 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 })); const combined = [...videos, ...newVideos]; setVideos(combined); stateRef.current = { ...stateRef.current, videos: combined }; newVideos.forEach((video, index) => startVideoUpload(video.id, files[index])); 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 }; startFrameUpload(newFrame.id, newFrame.dataUrl); 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 || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6]; const dur = video.duration || 1; const 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 }; startFrameUpload(newFrame.id, newFrame.dataUrl); 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 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 stripHtml = (html: string): string => { const tmp = document.createElement('div'); tmp.innerHTML = html .replace(//gi, '\n') .replace(/<\/p>\s*

\n\n { const diffs = diffChars(oldText, newText); let html = ''; for (const part of diffs) { let value = part.value.replace(//g, '>').replace(/\n/g, '
'); if (side === 'left' && part.removed) { html += `${value}`; } else if (side === 'right' && part.added) { html += `${value}`; } else if (!part.added && !part.removed) { html += value; } } return html; }; function floatTo16BitPCM(input: Float32Array): ArrayBuffer { const output = new DataView(new ArrayBuffer(input.length * 2)); for (let i = 0; i < input.length; i++) { const s = Math.max(-1, Math.min(1, input[i])); output.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true); } return output.buffer; } function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } const toggleListening = async () => { // 专门提取一个彻底关闭物理麦克风的函数 const stopMicrophone = () => { if (xfAudioContextRef.current) { try { xfAudioContextRef.current.close(); } catch {} xfAudioContextRef.current = null; } if (xfMediaStreamRef.current) { xfMediaStreamRef.current.getTracks().forEach(t => t.stop()); xfMediaStreamRef.current = null; } }; if (isListening) { setIsListening(false); stopMicrophone(); if (xfWsRef.current && xfWsRef.current.readyState === WebSocket.OPEN) { try { const endFrame = { data: { status: 2, format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' } }; xfWsRef.current.send(JSON.stringify(endFrame)); } catch {} } return; } try { const mediaDevices = navigator.mediaDevices; const AudioContextClass = window.AudioContext || (window as AudioWindow).webkitAudioContext; if (!mediaDevices?.getUserMedia) { alert(window.isSecureContext ? '当前浏览器不支持麦克风采集,请更换新版 Chrome/Edge 后重试。' : '浏览器不允许在普通局域网 HTTP 页面中调用麦克风。请使用 http://localhost:4002、https://localhost:4443,或用浏览器演示参数把当前 HTTP 地址标记为可信。'); return; } if (!AudioContextClass) { alert('当前浏览器不支持音频采集处理,请更换新版 Chrome/Edge 后重试。'); return; } const ws = new WebSocket(getSpeechIatWebSocketUrl()); xfWsRef.current = ws; let frameStatus = 0; ws.onopen = async () => { try { const stream = await mediaDevices.getUserMedia({ audio: true }); xfMediaStreamRef.current = stream; const audioContext = new AudioContextClass({ sampleRate: 16000 }); xfAudioContextRef.current = audioContext; const source = audioContext.createMediaStreamSource(stream); const processor = audioContext.createScriptProcessor(4096, 1, 1); processor.onaudioprocess = (e) => { if (ws.readyState !== WebSocket.OPEN || !xfAudioContextRef.current) return; const inputData = e.inputBuffer.getChannelData(0); const pcmBuffer = floatTo16BitPCM(inputData); const base64Audio = arrayBufferToBase64(pcmBuffer); const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } }; ws.send(JSON.stringify(frame)); frameStatus = 1; }; source.connect(processor); processor.connect(audioContext.destination); setIsListening(true); } catch (e: any) { alert('麦克风启动失败: ' + e.message); setIsListening(false); ws.close(); } }; ws.onmessage = (event) => { try { const jsonData = JSON.parse(event.data); if (jsonData.code !== 0 && jsonData.code !== undefined) { alert(`讯飞语音报错: ${jsonData.message || '未知错误'} (错误码: ${jsonData.code})`); setIsListening(false); stopMicrophone(); ws.close(); return; } if (jsonData.data?.result?.ws) { let seg = ''; for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; } if (seg) { setChatInput(prev => prev + seg); } } if (jsonData.data?.status === 2) { ws.close(); xfWsRef.current = null; setIsListening(false); stopMicrophone(); } } catch {} }; ws.onerror = () => { alert('讯飞语音连接失败,请确认已登录且超级管理员已配置语音参数'); setIsListening(false); stopMicrophone(); }; ws.onclose = () => { setIsListening(false); stopMicrophone(); }; } catch (e: any) { alert('讯飞语音初始化失败: ' + e.message); } }; const handleAIGenerate = async (text: string) => { if (!text.trim()) return; const userMsgId = Date.now().toString(); const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src); const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)]; setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text, images: allImages.length > 0 ? allImages : undefined }]); setChatInput(''); setIsGenerating(true); try { const settings = storage.get('systemSettings', {} as SystemSettings); const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi']; const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview'; let actualTargetId = aiTargetRegion; if (aiModifyEnabled && actualTargetId === 'none') { const availableRegions = checkAiRegions(); if (availableRegions.length > 0) { actualTargetId = availableRegions[0].id; setAiTargetRegion(actualTargetId); } } const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null; // 合并溢出的段落:浏览器 contentEditable 可能在回车时把

生成到 .ai-content 之外 const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`); if (aiRegion && targetRegionEl) { let nextSibling = targetRegionEl.nextElementSibling; while (nextSibling) { const toMove = nextSibling; nextSibling = nextSibling.nextElementSibling; if (toMove.tagName === 'P') { targetRegionEl.appendChild(toMove); } } // 同步更新 contentRef 和草稿 if (editorRef.current) { contentRef.current = editorRef.current.innerHTML; saveDraftToStorage(); } } const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').replace(/>(\s+)<').trim() : ''; const globalContextText = editorRef.current?.innerText || ''; let messageContent: any; const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src); const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)]; let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`; if (aiModifyEnabled && targetRegionEl) { promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`; } promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 仅针对【目标区域】的主题生成对应的多段落 HTML 内容\n2. ⚠️ 绝对禁止将【全局参考内容】中的其他模块(如:基本信息、术后情况、标本描述、病理结果、医生签名、日期等)生成并混入你的输出中!你只能输出该区域应有的内容\n3. 段落使用

标签,段落之间不要使用
标签或换行符\n4. 输出紧凑 HTML,标签间不要有空格或换行`; if (allImages.length > 0) { messageContent = []; allImages.forEach(url => { messageContent.push({ type: 'image_url', image_url: { url } }); }); messageContent.push({ type: 'text', text: promptText }); } else { messageContent = promptText; } const systemPrompt = aiModifyEnabled ? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】,直接重写并输出目标区域的 HTML。\n\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用

标签包裹,段落间绝对不要使用
标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json)' : '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释'; const payload: any = { model: modelName, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: messageContent } ], temperature: 0.3 }; const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName); if (isKimiK25) { delete payload.temperature; delete payload.top_p; delete payload.presence_penalty; delete payload.frequency_penalty; } const logEntry = { startTime: new Date().toISOString(), modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: '/api/ai/chat', modelName }, requestPayload: JSON.parse(JSON.stringify(payload)), responsePayload: null as any | null, errorDetail: null as { status: number; statusText: string; responseText: string; message: string } | null }; const data = await createAiChatCompletion(payload); logEntry.responsePayload = data; setLastExchangeLog(logEntry); 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) { try { responseJson = JSON.parse(jsonMatch[0]); } catch { if (!aiModifyEnabled) responseJson = { reply: cleanedText }; else throw new Error('AI 返回格式异常,无法解析 JSON'); } } else { if (!aiModifyEnabled) responseJson = { reply: cleanedText }; else throw new Error('AI 返回格式异常,无法解析 JSON'); } } if (responseJson.reply) { setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]); } if (aiModifyEnabled && !responseJson.updatedHtml) { setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]); } if (responseJson.updatedHtml && aiModifyEnabled) { let cleanHtml = responseJson.updatedHtml; cleanHtml = cleanHtml.replace(//gi, ''); cleanHtml = cleanHtml.replace(/<\/p>\s*

/gi, '

'); cleanHtml = cleanHtml.trim(); cleanHtml = cleanHtml.replace(/>(\s+)<'); cleanHtml = cleanHtml.replace(/

/gi, '

'); if (targetRegionEl) { setDiffModal({ isOpen: true, originalHtml: currentHtml, newHtml: cleanHtml, targetId: actualTargetId }); } else { execCmd('insertHTML', cleanHtml); } } setAiUploadedImages([]); setAiSelectedEditorImages([]); } 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 cleanHtml = newHtml.replace(/]*>(.*?)<\/span>/gi, '$2'); const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement; if (targetContent) { targetContent.focus(); const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(targetContent); sel?.removeAllRanges(); sel?.addRange(range); document.execCommand('insertHTML', false, cleanHtml); 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()); }; 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); setChatMessages([]); setChatInput(''); setAiUploadedImages([]); setAiSelectedEditorImages([]); prevVideoCountRef.current = 0; stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id, reportData: nextReportData, videos: [], capturedFrames: [], chatMessages: [], chatInput: '', 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 || '', chatMessages: draft.chatMessages || [], chatInput: draft.chatInput || '' }; setTimeout(() => updatePageHeight(), 0); return; } const reports = storage.get('reports', []); const found = reports.find(r => r.id === reportId); if (found) { const users = storage.get('users', []); if (!user || !canEditReport(user, found, users)) { alert('您没有权限编辑此报告'); navigate('/report-manage'); return; } 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 filteredTemplates = userData ? getUsableTemplates(userData, allTemplates) : []; 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); persistEditorFieldLibrary({ 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); persistEditorFieldLibrary({ formFields: 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); persistEditorFieldLibrary({ 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); persistEditorFieldLibrary({ formFields: updatedFields }); } }; const removeAnesthesiaOption = (value: string) => { const next = anesthesiaOptions.filter(v => v !== value); setAnesthesiaOptions(next); storage.set('anesthesiaOptions', next); persistEditorFieldLibrary({ 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 = async (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; } } } await waitForMediaUploads(); const content = editorRef.current?.innerHTML || ''; const now = new Date().toISOString(); const reports = storage.get('reports', []); const old = reportId ? reports.find(r => r.id === reportId) : undefined; const nextRevision = old && old.status === 'completed' ? (old.revision || 1) + 1 : (old?.revision || 1); 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], department: reportData.department || currentUser?.department || '', status, revision: nextRevision, capturedFrames: stateRef.current.capturedFrames, videos: stateRef.current.videos, updatedAt: now }; let savedReport = finalReport; let apiSaved = false; try { savedReport = await saveReportToApi(finalReport, reportId || undefined); apiSaved = true; } catch (error) { if (!isLocalFallbackEnabled()) { const message = getApiErrorMessage(error, '后端服务不可用'); alert(`保存失败:${message}`); if (error instanceof ApiError && error.status === 401) navigate('/'); return; } } let updatedReports: Report[]; if (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', revision: old.revision || 1 }); } const nextHistory = savedReport.history && savedReport.history.length > 0 ? savedReport.history : history; updatedReports = reports.map(r => r.id === reportId ? { ...savedReport, history: nextHistory } : r); } else { updatedReports = [...reports, savedReport]; if (draftKey) storage.remove(draftKey); } if (apiSaved || isLocalFallbackEnabled()) { storage.set('reports', updatedReports); } if (draftKey) storage.remove(draftKey); setIsSaved(true); setTimeout(() => setIsSaved(false), 3000); if (status === 'completed') navigate('/report-manage'); }; const saveAsPersonalTemplate = async () => { if (!currentUser || !editorRef.current) return; const name = window.prompt('请输入个人模板名称:', `${reportData.title || '手术报告'}-我的模板`); if (!name || !name.trim()) return; const allTemplates = storage.get('templates', []); const newTemplate: Template = { id: `personal_${currentUser.username}_${Date.now()}`, name: name.trim(), desc: '我的个人模板', content: editorRef.current.innerHTML, createdAt: new Date().toISOString(), author: currentUser.username, fields: formFields, scope: 'personal', ownerUser: currentUser.username, department: currentUser.department || '' }; let savedTemplate = newTemplate; let apiSaved = false; try { savedTemplate = await createTemplate(newTemplate); apiSaved = true; } catch (error) { if (!isLocalFallbackEnabled()) { const message = getApiErrorMessage(error, '后端服务不可用'); alert(`保存个人模板失败:${message}`); if (error instanceof ApiError && error.status === 401) navigate('/'); return; } } const updated = [...allTemplates.filter(t => t.id !== savedTemplate.id), savedTemplate]; if (apiSaved || isLocalFallbackEnabled()) { storage.set('templates', updated); } const usable = getUsableTemplates(currentUser, updated); setTemplates(usable); setLoadedTemplateId(savedTemplate.id); stateRef.current = { ...stateRef.current, loadedTemplateId: savedTemplate.id }; alert('个人模板已保存'); }; 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 && ( 已保存 )} {currentUser.role === 'user' && ( )}
{/* 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="文字颜色" />