2137 lines
102 KiB
TypeScript
2137 lines
102 KiB
TypeScript
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 [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;
|
||
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;
|
||
|
||
// Handle click on field-value: switch to info tab 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' };
|
||
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;
|
||
}
|
||
|
||
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>
|
||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||
`;
|
||
placeholder.style.border = '1px dashed #cbd5e1';
|
||
placeholder.style.background = '#f8fafc';
|
||
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) => {
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
||
`;
|
||
placeholder.classList.add('has-image');
|
||
placeholder.style.border = 'none';
|
||
placeholder.style.background = 'transparent';
|
||
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 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: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||
`;
|
||
emptyPlaceholder.classList.add('has-image');
|
||
emptyPlaceholder.style.border = 'none';
|
||
emptyPlaceholder.style.background = 'transparent';
|
||
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) => {
|
||
placeholder.innerHTML = `
|
||
<span class="delete-btn" contenteditable="false">×</span>
|
||
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
||
`;
|
||
placeholder.classList.add('has-image');
|
||
placeholder.style.border = 'none';
|
||
placeholder.style.background = 'transparent';
|
||
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 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={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}`} 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'}>
|
||
<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}`} className="space-y-1 select-dropdown-root relative">
|
||
<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}`} className="space-y-1 select-dropdown-root relative">
|
||
<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}`} className="space-y-1">
|
||
<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}`} className="space-y-1">
|
||
<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-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>
|
||
<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>
|
||
)}
|
||
</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 = '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;">${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;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;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||
}
|
||
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().toISOString().replace(/[:.]/g, '-');
|
||
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().toISOString().replace(/[:.]/g, '-');
|
||
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>
|
||
);
|
||
}
|