1333 lines
62 KiB
TypeScript
1333 lines
62 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
||
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
|
||
} from 'lucide-react';
|
||
import { User, Report, Template, CapturedFrame, SystemSettings } 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: new Date().toISOString().split('T')[0],
|
||
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 [loadedTemplateId, setLoadedTemplateId] = useState('');
|
||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
||
const prevVideoCountRef = useRef(0);
|
||
|
||
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
|
||
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
|
||
surgeon: ['张医生', '李医生', '王医生'],
|
||
assistant: ['赵医生', '钱医生', '孙医生'],
|
||
anesthesiologist: ['周医生', '吴医生', '郑医生']
|
||
});
|
||
const [anesthesiaOptions, setAnesthesiaOptions] = useState<string[]>(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']);
|
||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||
|
||
const editorRef = useRef<HTMLDivElement>(null);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||
const contentLoadedRef = useRef(false);
|
||
const contentRef = useRef('');
|
||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab });
|
||
|
||
stateRef.current = { reportData, videos, capturedFrames, activeTab };
|
||
|
||
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) {
|
||
storage.set(key, {
|
||
content: contentRef.current,
|
||
loadedTemplateId,
|
||
draftReportId: reportId || null,
|
||
...stateRef.current
|
||
});
|
||
}
|
||
}, [reportId, loadedTemplateId]);
|
||
|
||
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 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);
|
||
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);
|
||
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;
|
||
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);
|
||
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);
|
||
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 save = () => {
|
||
const user = storage.get<User | null>('currentUser', null);
|
||
const key = user ? `reportEditorDraft_${user.username}` : '';
|
||
if (key) {
|
||
storage.set(key, {
|
||
content: contentRef.current,
|
||
draftReportId: reportId || null,
|
||
...stateRef.current
|
||
});
|
||
}
|
||
};
|
||
window.addEventListener('beforeunload', save);
|
||
document.addEventListener('visibilitychange', save);
|
||
return () => {
|
||
window.removeEventListener('beforeunload', save);
|
||
document.removeEventListener('visibilitychange', save);
|
||
save();
|
||
};
|
||
}, [reportId]);
|
||
|
||
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;
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||
`;
|
||
placeholder.classList.add('has-image');
|
||
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;
|
||
|
||
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');
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||
`;
|
||
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');
|
||
saveDraftToStorage();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!placeholder.classList.contains('has-image')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
triggerPlaceholderUpload(placeholder);
|
||
}
|
||
};
|
||
|
||
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 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 insertTable = () => {
|
||
const rowsStr = prompt('请输入行数:', '2');
|
||
const colsStr = prompt('请输入列数:', '3');
|
||
if (rowsStr && colsStr) {
|
||
const rows = parseInt(rowsStr);
|
||
const cols = parseInt(colsStr);
|
||
if (isNaN(rows) || isNaN(cols)) 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);
|
||
}
|
||
};
|
||
|
||
const insertImage = () => {
|
||
editorRef.current?.focus();
|
||
const id = 'ph_' + Date.now();
|
||
const html = `
|
||
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||
</div>
|
||
`;
|
||
execCmd('insertHTML', html);
|
||
};
|
||
|
||
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);
|
||
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);
|
||
if (currentVideoIndex >= updated.length) {
|
||
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
|
||
} else if (currentVideoIndex === idx && updated.length > 0) {
|
||
setCurrentVideoIndex(0);
|
||
}
|
||
setCapturedFrames(prev => prev.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));
|
||
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;
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
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.9),
|
||
isManual: true
|
||
};
|
||
setCapturedFrames(prev => [...prev, newFrame].sort((a, b) => a.time - b.time));
|
||
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();
|
||
|
||
const newFrames: CapturedFrame[] = [];
|
||
for (const pos of positions) {
|
||
const time = (pos / 100) * dur;
|
||
video.currentTime = time;
|
||
await new Promise<void>(resolve => {
|
||
const onSeeked = () => {
|
||
video.removeEventListener('seeked', onSeeked);
|
||
resolve();
|
||
};
|
||
video.addEventListener('seeked', onSeeked);
|
||
});
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
newFrames.push({
|
||
id: Date.now() + Math.random(),
|
||
videoIndex: currentVideoIndex,
|
||
videoName: videos[currentVideoIndex].name,
|
||
time,
|
||
timeFormatted: formatTime(time),
|
||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||
isManual: false
|
||
});
|
||
}
|
||
setCapturedFrames(prev => [...prev, ...newFrames].sort((a, b) => a.time - b.time));
|
||
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 handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||
e.preventDefault();
|
||
const frameId = e.dataTransfer.getData('frameId');
|
||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||
if (frame) {
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<img src="${frame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" />
|
||
`;
|
||
placeholder.classList.add('has-image');
|
||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||
saveDraftToStorage();
|
||
}
|
||
};
|
||
|
||
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;
|
||
setLoadedTemplateId(tpl.id);
|
||
setReportData({
|
||
title: tpl.name || '腹腔镜胆囊切除术报告',
|
||
patientName: '',
|
||
hospitalId: '',
|
||
patientGender: '',
|
||
patientAge: '',
|
||
department: '',
|
||
bedNumber: '',
|
||
surgeryDate: new Date().toISOString().split('T')[0],
|
||
startHour: '',
|
||
startMinute: '',
|
||
endHour: '',
|
||
endMinute: '',
|
||
surgeon: [],
|
||
assistant: [],
|
||
anesthesiologist: [],
|
||
anesthesiaType: '',
|
||
status: 'draft'
|
||
});
|
||
setVideos([]);
|
||
setCapturedFrames([]);
|
||
setCurrentVideoIndex(-1);
|
||
prevVideoCountRef.current = 0;
|
||
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 || '');
|
||
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;
|
||
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 || '');
|
||
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);
|
||
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 minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
|
||
|
||
const addTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
|
||
const current = reportData[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);
|
||
}
|
||
};
|
||
|
||
const removeTag = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
|
||
const current = reportData[field] || [];
|
||
const next = { ...reportData, [field]: current.filter(v => v !== value) };
|
||
setReportData(next);
|
||
stateRef.current = { ...stateRef.current, reportData: next };
|
||
saveDraftToStorage();
|
||
};
|
||
|
||
const removeMultiOption = (field: 'surgeon' | 'assistant' | 'anesthesiologist', value: string) => {
|
||
const current = multiSelectOptions[field] || [];
|
||
const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) };
|
||
setMultiSelectOptions(next);
|
||
storage.set('multiSelectOptions', next);
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
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');
|
||
};
|
||
|
||
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={() => 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
|
||
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>
|
||
</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 onClick={() => execCmd('justifyLeft')} 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 onClick={() => execCmd('justifyCenter')} 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 onClick={() => execCmd('justifyRight')} 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={() => { contentRef.current = editorRef.current?.innerHTML || ''; updatePageHeight(); saveDraftToStorage(); }}
|
||
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">
|
||
<div className="flex gap-4">
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">患者姓名 <span className="text-red-500">*</span></label>
|
||
<input
|
||
type="text"
|
||
value={reportData.patientName}
|
||
onChange={(e) => { const next = {...reportData, patientName: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
onBlur={() => setTouched(t => ({ ...t, patientName: true }))}
|
||
className={`input-minimal ${touched.patientName && !reportData.patientName ? 'border-red-500' : ''}`}
|
||
placeholder="患者姓名"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">住院号 <span className="text-red-500">*</span></label>
|
||
<input
|
||
type="text"
|
||
value={reportData.hospitalId}
|
||
onChange={(e) => { const next = {...reportData, hospitalId: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
onBlur={() => setTouched(t => ({ ...t, hospitalId: true }))}
|
||
className={`input-minimal ${touched.hospitalId && !reportData.hospitalId ? 'border-red-500' : ''}`}
|
||
placeholder="住院号"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">手术名称</label>
|
||
<input
|
||
type="text"
|
||
value={reportData.title}
|
||
onChange={(e) => { const next = {...reportData, title: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
placeholder="请输入手术名称"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">患者性别</label>
|
||
<select
|
||
value={reportData.patientGender}
|
||
onChange={(e) => { const next = {...reportData, patientGender: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white"
|
||
>
|
||
<option value="">请选择</option>
|
||
<option value="男">男</option>
|
||
<option value="女">女</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">患者年龄</label>
|
||
<input
|
||
type="text"
|
||
value={reportData.patientAge}
|
||
onChange={(e) => { const next = {...reportData, patientAge: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
placeholder="年龄"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">科别</label>
|
||
<input
|
||
type="text"
|
||
value={reportData.department}
|
||
onChange={(e) => { const next = {...reportData, department: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
placeholder="科室"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">床号</label>
|
||
<input
|
||
type="text"
|
||
value={reportData.bedNumber}
|
||
onChange={(e) => { const next = {...reportData, bedNumber: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
placeholder="床号"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">手术日期</label>
|
||
<input
|
||
type="date"
|
||
value={reportData.surgeryDate}
|
||
onChange={(e) => { const next = {...reportData, surgeryDate: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">手术开始时间</label>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={reportData.startHour}
|
||
onChange={(e) => { const next = {...reportData, startHour: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
|
||
</select>
|
||
<span className="text-text-muted">:</span>
|
||
<select
|
||
value={reportData.startMinute}
|
||
onChange={(e) => { const next = {...reportData, startMinute: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<label className="block text-xs font-bold text-text-main">手术终止时间</label>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={reportData.endHour}
|
||
onChange={(e) => { const next = {...reportData, endHour: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
|
||
</select>
|
||
<span className="text-text-muted">:</span>
|
||
<select
|
||
value={reportData.endMinute}
|
||
onChange={(e) => { const next = {...reportData, endMinute: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
className="input-minimal bg-white flex-1"
|
||
>
|
||
<option value="">--</option>
|
||
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{(['surgeon', 'assistant', 'anesthesiologist'] as const).map((field) => {
|
||
const labels = { surgeon: '手术者', assistant: '助手', anesthesiologist: '麻醉师' };
|
||
const isOpen = openDropdown === field;
|
||
return (
|
||
<div key={field} className="space-y-1 select-dropdown-root relative">
|
||
<label className="block text-xs font-bold text-text-main">{labels[field]}</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)}
|
||
>
|
||
{(reportData[field] || []).map(tag => (
|
||
<span key={tag} className="px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 flex items-center gap-1">
|
||
{tag}
|
||
<span className="cursor-pointer hover:text-amber-900" onClick={(e) => { e.stopPropagation(); removeTag(field, tag); }}>×</span>
|
||
</span>
|
||
))}
|
||
<input
|
||
type="text"
|
||
className="outline-none text-sm min-w-[60px] flex-1 bg-transparent"
|
||
placeholder="输入或选择"
|
||
onFocus={() => setOpenDropdown(field)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const val = (e.target as HTMLInputElement).value.trim();
|
||
if (val) { addTag(field, val); (e.target as HTMLInputElement).value = ''; }
|
||
}
|
||
}}
|
||
/>
|
||
</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">
|
||
{(multiSelectOptions[field] || []).map(opt => (
|
||
<div
|
||
key={opt}
|
||
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
|
||
onClick={() => { addTag(field, opt); }}
|
||
>
|
||
<span>{opt}</span>
|
||
<span
|
||
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
|
||
onClick={(e) => { e.stopPropagation(); removeMultiOption(field, opt); }}
|
||
>×</span>
|
||
</div>
|
||
))}
|
||
{(multiSelectOptions[field] || []).length === 0 && (
|
||
<div className="px-3 py-2 text-xs text-text-muted">暂无选项</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="space-y-1 select-dropdown-root relative">
|
||
<label className="block text-xs font-bold text-text-main">麻醉方式</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('anesthesia')}
|
||
>
|
||
<input
|
||
type="text"
|
||
className="outline-none text-sm flex-1 bg-transparent"
|
||
placeholder="输入或选择麻醉方式"
|
||
value={reportData.anesthesiaType || ''}
|
||
onChange={(e) => { const next = {...reportData, anesthesiaType: e.target.value}; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||
onFocus={() => setOpenDropdown('anesthesia')}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const val = (e.target as HTMLInputElement).value.trim();
|
||
if (val) {
|
||
const next = {...reportData, anesthesiaType: val};
|
||
setReportData(next);
|
||
stateRef.current = { ...stateRef.current, reportData: next };
|
||
saveDraftToStorage();
|
||
if (!anesthesiaOptions.includes(val)) {
|
||
const next = [...anesthesiaOptions, val];
|
||
setAnesthesiaOptions(next);
|
||
storage.set('anesthesiaOptions', next);
|
||
}
|
||
setOpenDropdown(null);
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
{openDropdown === 'anesthesia' && (
|
||
<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">
|
||
{anesthesiaOptions.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, anesthesiaType: 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(); removeAnesthesiaOption(opt); }}
|
||
>×</span>
|
||
</div>
|
||
))}
|
||
{anesthesiaOptions.length === 0 && (
|
||
<div className="px-3 py-2 text-xs text-text-muted">暂无选项</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'video' && (
|
||
<div className="space-y-4">
|
||
<input
|
||
ref={videoInputRef}
|
||
type="file"
|
||
accept="video/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleVideoUpload}
|
||
/>
|
||
<button
|
||
onClick={() => videoInputRef.current?.click()}
|
||
className="w-full flex items-center justify-center gap-2 p-3 border border-dashed border-border rounded-lg hover:border-accent hover:bg-slate-50 transition-all"
|
||
>
|
||
<Video size={18} />
|
||
<div className="text-left">
|
||
<p className="text-xs font-bold text-text-main">点击上传手术视频</p>
|
||
<p className="text-[10px] text-text-muted">支持 MP4, MOV 格式</p>
|
||
</div>
|
||
</button>
|
||
|
||
{videos.length > 0 && (
|
||
<div className="space-y-4">
|
||
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||
{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>
|
||
))}
|
||
</div>
|
||
|
||
{currentVideoIndex !== -1 && (
|
||
<div className="space-y-4">
|
||
<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-2">
|
||
<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>
|
||
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity">可拖拽</span>
|
||
</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>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
<canvas ref={canvasRef} className="hidden" />
|
||
</div>
|
||
);
|
||
}
|