Files
Mdeical_Sur_Report/src/pages/ReportEditor.tsx

2225 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft, Download
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
export default function ReportEditor() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const reportId = searchParams.get('id');
const restoreFlag = searchParams.get('restore');
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [reportData, setReportData] = useState<Partial<Report>>({
title: '腹腔镜胆囊切除术报告',
patientName: '',
hospitalId: '',
patientGender: '',
patientAge: '',
department: '',
bedNumber: '',
surgeryDate: '',
startHour: '',
startMinute: '',
endHour: '',
endMinute: '',
surgeon: [],
assistant: [],
anesthesiologist: [],
anesthesiaType: '',
reportNote: '',
status: 'draft'
});
const [templates, setTemplates] = useState<Template[]>([]);
const [videos, setVideos] = useState<{id: string, name: string, url: string, duration: number}[]>([]);
const [currentVideoIndex, setCurrentVideoIndex] = useState(-1);
const [capturedFrames, setCapturedFrames] = useState<CapturedFrame[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isSaved, setIsSaved] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [loadedTemplateId, setLoadedTemplateId] = useState('');
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(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<Record<string, string[]>>({
surgeon: ['张医生', '李医生', '王医生'],
assistant: ['赵医生', '钱医生', '孙医生'],
anesthesiologist: ['周医生', '吴医生', '郑医生']
});
const [anesthesiaOptions, setAnesthesiaOptions] = useState<string[]>(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [multiInputText, setMultiInputText] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [formFields, setFormFields] = useState<FormField[]>([]);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(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<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
const updatePageHeight = () => {
if (!editorRef.current) return;
const contentHeight = editorRef.current.scrollHeight;
const pageHeightMm = 297;
const mmToPx = 3.7795275591;
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, {
content: currentContent,
draftReportId: reportId || null,
reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
});
}
}, [reportId]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user) { navigate('/'); return; }
setCurrentUser(user);
const savedMulti = storage.get<Record<string, string[]> | null>('multiSelectOptions', null);
if (savedMulti) setMultiSelectOptions(savedMulti);
const savedAnesthesia = storage.get<string[] | null>('anesthesiaOptions', null);
if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia);
const savedFields = storage.get<FormField[]>('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<Template[]>('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<Record<string, any> | 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<Report[]>('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<string | null>(`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<Record<string, any> | 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>('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 = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
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 = `
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
`;
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 = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
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<HTMLInputElement>) => {
const files = Array.from(e.target.files || []) as File[];
const newVideos = files.map(file => ({
id: Math.random().toString(36).substr(2, 9),
name: file.name,
url: URL.createObjectURL(file),
duration: 0
}));
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>('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<void>(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 = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width:${emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'};max-height:${emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
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 = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
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<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
const draft = key ? storage.get<Record<string, any> | 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<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
const restoreContent = storage.getSession<string | null>(`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>('systemSettings', {} as SystemSettings);
const allTemplates = storage.get<Template[]>('templates', []);
const userData = storage.get<User | null>('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<Report[]>('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<HTMLDivElement>) => {
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 = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
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 (
<div className="flex h-screen bg-bg overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-20 bg-white border-b border-border flex items-center justify-between px-8 shrink-0">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/report-manage')}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<ChevronLeft size={24} />
</button>
<div>
<h1 className="text-lg font-bold text-text-main"></h1>
<p className="text-[10px] text-text-muted mt-0.5 uppercase tracking-wider font-bold">
{reportId ? `编辑报告: ${reportId}` : '新建手术报告'}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-xs text-text-muted whitespace-nowrap"></span>
<select
value=""
onChange={(e) => {
const tplId = e.target.value;
const tpl = templates.find(t => t.id === tplId);
if (tpl && window.confirm(`确定要应用模板 "${tpl.name}" 吗?当前内容、基本信息、视频分析将被重置。`)) {
setPendingTemplateId(tplId);
}
}}
className="h-8 px-2 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value="" disabled>
{templates.find(t => t.id === loadedTemplateId)?.name || '无'}
</option>
{templates.map(tpl => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
{isSaved && (
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
<Check size={14} />
</span>
)}
<button
onClick={() => saveReport('draft')}
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
>
稿
</button>
<button
onClick={() => saveReport('completed')}
className="btn-accent inline-flex items-center gap-2"
>
<Check size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
>
<Printer size={18} />
</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Editor Main */}
<div className="flex-1 flex flex-col overflow-hidden border-r border-border">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onChange={(e) => 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="文字颜色"
/>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('left')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('center')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('right')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
</div>
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
</div>
</div>
{/* Editor Content */}
<div
className="editor-content-wrapper print-wrapper"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const target = e.target as HTMLElement;
const placeholder = target.closest('.image-placeholder') as HTMLElement;
if (placeholder) handleDrop(e, placeholder);
}}
>
<div
ref={editorRef}
contentEditable
onInput={handleEditorInput}
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
className="editor-content print-content"
>
</div>
</div>
</div>
{/* Right Sidebar */}
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['info', 'video'] as const).map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'info' ? '基本信息' : '视频分析'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
{(() => {
const topKeys = ['patientName', 'hospitalId', 'title'];
const contentHtml = contentRef.current || editorRef.current?.innerHTML || '';
return [...formFields.filter(f => f.visibleInForm)].sort((a, b) => {
const aTop = topKeys.indexOf(a.key);
const bTop = topKeys.indexOf(b.key);
if (aTop !== -1 && bTop !== -1) return aTop - bTop;
if (aTop !== -1) return -1;
if (bTop !== -1) return 1;
const aIndex = contentHtml.indexOf(`data-bind="${a.key}"`);
const bIndex = contentHtml.indexOf(`data-bind="${b.key}"`);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
})().map(field => {
const isRequired = field.isSystemLocked;
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`${field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'} p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
<input
type={inputType}
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, [field.key]: true }))}
className={`input-minimal ${hasError ? 'border-red-500' : ''}`}
placeholder={field.label}
/>
</div>
);
}
if (field.type === 'single_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
<input
type="text"
className="outline-none text-sm flex-1 bg-transparent"
placeholder={`输入或选择${field.label}`}
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onFocus={() => setOpenDropdown(field.key)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
const next = { ...reportData, [field.key]: val };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
if (!opts.includes(val)) {
const updatedOpts = [...opts, val];
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
setOpenDropdown(null);
}
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { const next = { ...reportData, [field.key]: opt }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => {
e.stopPropagation();
const updatedOpts = opts.filter(v => v !== opt);
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}}
>×</span>
</div>
))}
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
}
if (field.type === 'multi_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || multiSelectOptions[field.key] || [];
const rawValue = (reportData as any)[field.key];
const currentValues = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
const displayText = currentValues.join(', ');
const parseMultiInput = (text: string): string[] => {
return Array.from(new Set(text.split(/[,;;、]/).map(s => s.trim()).filter(Boolean)));
};
const handleMultiCommit = (text: string) => {
const values = parseMultiInput(text);
const next = { ...reportData, [field.key]: values };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
const currentOpts = field.options || multiSelectOptions[field.key] || [];
const newOpts = values.filter(v => !currentOpts.includes(v));
if (newOpts.length > 0) {
const mergedOpts = [...currentOpts, ...newOpts];
const nextMulti = { ...multiSelectOptions, [field.key]: mergedOpts };
setMultiSelectOptions(nextMulti);
storage.set('multiSelectOptions', nextMulti);
const fieldDef = formFields.find(f => f.key === field.key);
if (fieldDef) {
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: mergedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
}
};
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
<input
type="text"
className="outline-none text-sm w-full bg-transparent"
placeholder="输入或选择,多个用逗号分隔"
value={currentInputText}
onChange={(e) => {
setMultiInputText(prev => ({ ...prev, [field.key]: e.target.value }));
}}
onFocus={() => setOpenDropdown(field.key)}
onBlur={(e) => {
handleMultiCommit(e.target.value);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleMultiCommit((e.target as HTMLInputElement).value);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => {
const base = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
const newText = base.length > 0 ? `${base}, ${opt}` : opt;
setMultiInputText(prev => ({ ...prev, [field.key]: newText }));
handleMultiCommit(newText);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeMultiOption(field.key, opt); }}
>×</span>
</div>
))}
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
}
if (field.type === 'time') {
const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
if (field.key === 'startTime' || field.key === 'endTime') {
const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour';
const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute';
const h24val = parseInt((reportData as any)[hourKey]) || 0;
const m = (reportData as any)[minuteKey] || '';
const { h: h12, isPM } = from24h(h24val);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
value={is12h ? String(h12).padStart(2, '0') : ((reportData as any)[hourKey] || '')}
onChange={(e) => {
let h24new = parseInt(e.target.value) || 0;
if (is12h) {
const currentPM = from24h(parseInt((reportData as any)[hourKey]) || 0).isPM;
h24new = to24h(h24new, currentPM);
}
const next = { ...reportData, [hourKey]: String(h24new).padStart(2, '0') };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{(is12h ? hour12Options : hourOptions).map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={m}
onChange={(e) => { const next = { ...reportData, [minuteKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(mo => <option key={mo} value={mo}>{mo}</option>)}
</select>
{is12h && (
<select
value={isPM ? '下午' : '上午'}
onChange={(e) => {
const isPMnew = e.target.value === '下午';
const currentH12 = from24h(parseInt((reportData as any)[hourKey]) || 0).h;
const h24new = to24h(currentH12, isPMnew);
const next = { ...reportData, [hourKey]: String(h24new).padStart(2, '0') };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="上午"></option>
<option value="下午"></option>
</select>
)}
</div>
</div>
);
}
// 通用 time 字段
const timeVal = (reportData as any)[field.key] || '';
const [h24str, mstr] = timeVal.split(':');
const h24 = parseInt(h24str) || 0;
const m = mstr || '';
const { h: h12g, isPM: isPMg } = from24h(h24);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
value={is12h ? String(h12g).padStart(2, '0') : (h24str || '')}
onChange={(e) => {
let h24new = parseInt(e.target.value) || 0;
if (is12h) h24new = to24h(h24new, isPMg);
const nextVal = `${String(h24new).padStart(2, '0')}:${m || '00'}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{(is12h ? hour12Options : hourOptions).map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={m}
onChange={(e) => {
const nextVal = `${String(h24).padStart(2, '0')}:${e.target.value}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(mo => <option key={mo} value={mo}>{mo}</option>)}
</select>
{is12h && (
<select
value={isPMg ? '下午' : '上午'}
onChange={(e) => {
const isPMnew = e.target.value === '下午';
const h24new = to24h(h12g, isPMnew);
const nextVal = `${String(h24new).padStart(2, '0')}:${m || '00'}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="上午"></option>
<option value="下午"></option>
</select>
)}
</div>
</div>
);
}
return null;
})}
</div>
)}
{activeTab === 'video' && (
<div className="space-y-2">
<input
ref={videoInputRef}
type="file"
accept="video/*"
multiple
className="hidden"
onChange={handleVideoUpload}
/>
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
{videos.map((v, i) => (
<div
key={v.id}
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
}`}
>
<div
onClick={() => selectVideo(i)}
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
>
<Play size={16} />
</div>
<div
onClick={() => selectVideo(i)}
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
>{v.name}</div>
<button
onClick={() => removeVideo(v.id)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
>
<X size={12} />
</button>
</div>
))}
<button
onClick={() => videoInputRef.current?.click()}
className="shrink-0 w-24 h-[68px] flex flex-col items-center justify-center gap-1 border-2 border-dashed border-border rounded-xl hover:border-accent hover:bg-slate-50 transition-all text-text-muted hover:text-accent"
>
<Video size={18} />
<span className="text-[10px] font-bold"></span>
</button>
</div>
{currentVideoIndex !== -1 && videos.length > 0 && (
<div className="space-y-2">
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
<video
ref={videoRef}
src={videos[currentVideoIndex].url}
className="w-full h-full"
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
onLoadedMetadata={() => {
const dur = videoRef.current?.duration || 0;
setDuration(dur);
setVideos(prev => prev.map((v, i) => i === currentVideoIndex ? { ...v, duration: dur } : v));
}}
/>
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 cursor-pointer" onClick={togglePlay}>
<div className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white">
<Play size={32} fill="currentColor" />
</div>
</div>
)}
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-3 py-1.5 rounded-full">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div className="flex items-center gap-4">
<button onClick={togglePlay} className="w-10 h-10 flex items-center justify-center bg-slate-100 rounded-xl text-text-main hover:bg-slate-200 transition-colors">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden cursor-pointer relative" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (videoRef.current) videoRef.current.currentTime = pos * duration;
}}>
<div className="h-full bg-accent" style={{ width: `${(currentTime / duration) * 100}%` }} />
</div>
</div>
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-[10px] font-bold text-text-main uppercase tracking-wider"></span>
<button
onClick={captureFrame}
className="px-4 py-2 bg-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-wider hover:bg-blue-700 transition-colors shadow-sm"
>
</button>
</div>
<div className="grid grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{capturedFrames.filter(f => f.videoIndex === currentVideoIndex).map((frame) => (
<div
key={frame.id}
draggable
onDragStart={(e) => handleDragStart(e, frame)}
onClick={() => seekToFrame(frame)}
className={`group relative card-minimal p-1 border-2 cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${
frame.isManual ? 'border-yellow-400 hover:border-yellow-500' : 'border-transparent hover:border-accent'
}`}
>
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="px-2 py-0.5 bg-accent text-white text-[9px] font-bold rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-700"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setCapturedFrames(prev => prev.filter(f => f.id !== frame.id).sort((a, b) => a.time - b.time)); saveDraftToStorage(); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-all shadow-md"
>
<X size={14} />
</button>
</div>
))}
</div>
<p className="text-[10px] text-text-muted text-center">
</p>
</div>
)}
</div>
)}
</div>
</aside>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
{placeholderModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1"></label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80">/</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80"></span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
const styleStr = 'position:relative;display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
execCmd('insertHTML', html);
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{exportModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
printDocument(editorRef.current?.innerHTML || '', `图文报告-${title}-${patient}-${hid}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `图文报告-${title}-${patient}-${hid}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</div>
</div>
)}
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
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;
fillPlaceholderSrc(imagePickerTarget, src);
setImagePickerOpen(false);
setImagePickerTarget(null);
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded text-sm font-semibold"
></button>
<button
onClick={() => {
if (currentUser?.signature) {
fillPlaceholderSrc(imagePickerTarget, currentUser.signature);
}
setImagePickerOpen(false);
setImagePickerTarget(null);
}}
disabled={!currentUser?.signature}
className={`w-full py-2 rounded text-sm font-semibold ${currentUser?.signature ? 'bg-slate-100 hover:bg-slate-200 text-slate-700' : 'bg-slate-50 text-slate-400 cursor-not-allowed'}`}
> {!currentUser?.signature && '(未上传)'}</button>
<div>
<div className="text-xs text-slate-500 mb-2"></div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{imageAssets.map(asset => (
<button
key={asset.id}
onClick={() => { fillPlaceholderSrc(imagePickerTarget, asset.dataUrl); setImagePickerOpen(false); setImagePickerTarget(null); }}
className="w-16 h-16 border rounded overflow-hidden hover:ring-2 ring-accent"
>
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
</button>
))}
{imageAssets.length === 0 && <div className="text-xs text-slate-400"></div>}
</div>
</div>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => { setImagePickerOpen(false); setImagePickerTarget(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
</div>
</div>
</div>
)}
</div>
);
}