Files
Mdeical_Sur_Report/src/pages/ReportEditor.tsx

2588 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft, Download,
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
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' | 'ai'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
// AI 撰写相关核心状态
const [chatInput, setChatInput] = useState<string>('');
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string}[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [aiSelectedFrames, setAiSelectedFrames] = useState<number[]>([]);
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
const [isListening, setIsListening] = useState(false);
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
const speechRecognitionRef = useRef<any>(null);
const [quickPrompts, setQuickPrompts] = useState<string[]>([
'请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结', '根据截图描述游离过程'
]);
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
allFields.forEach(el => {
(el as HTMLElement).style.backgroundColor = '';
(el as HTMLElement).style.outline = '';
(el as HTMLElement).style.outlineOffset = '';
});
if (activeFieldKey) {
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="${activeFieldKey}"]`) as HTMLElement;
if (targetEl) {
targetEl.style.backgroundColor = '#f1f5f9';
targetEl.style.outline = '1px solid #94a3b8';
targetEl.style.outlineOffset = '1px';
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [activeFieldKey]);
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
surgeon: ['张医生', '李医生', '王医生'],
assistant: ['赵医生', '钱医生', '孙医生'],
anesthesiologist: ['周医生', '吴医生', '郑医生']
});
const [anesthesiaOptions, setAnesthesiaOptions] = useState<string[]>(['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉']);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [multiInputText, setMultiInputText] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [formFields, setFormFields] = useState<FormField[]>([]);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]);
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
const editorRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
const updatePageHeight = () => {
if (!editorRef.current) return;
const contentHeight = editorRef.current.scrollHeight;
const pageHeightMm = 297;
const mmToPx = 3.7795275591;
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
editorRef.current.style.minHeight = `${pages * pageHeightMm}mm`;
};
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, {
content: currentContent,
draftReportId: reportId || null,
reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
});
}
}, [reportId]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user) { navigate('/'); return; }
setCurrentUser(user);
const savedMulti = storage.get<Record<string, string[]> | null>('multiSelectOptions', null);
if (savedMulti) setMultiSelectOptions(savedMulti);
const savedAnesthesia = storage.get<string[] | null>('anesthesiaOptions', null);
if (savedAnesthesia) setAnesthesiaOptions(savedAnesthesia);
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
if (savedFields.length > 0) {
setFormFields(savedFields);
} else {
setFormFields(DEFAULT_FORM_FIELDS);
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []);
setImageAssets(savedAssets);
const allTemplates = storage.get<Template[]>('templates', []);
const visibleTplIds = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
setTemplates(filteredTemplates);
if (reportId) {
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && draft.draftReportId === reportId) {
if (draft.reportData) setReportData(draft.reportData);
if (draft.videos) {
setVideos(draft.videos);
if (draft.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = draft.videos.length;
}
if (draft.capturedFrames) {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
} else {
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) {
editorRef.current.innerHTML = restoreContent;
storage.removeSession(`restore_${reportId}`);
} else {
editorRef.current.innerHTML = found.content;
contentRef.current = found.content;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
if (found.capturedFrames) {
setCapturedFrames(found.capturedFrames.sort((a, b) => a.time - b.time));
}
if (found.videos) {
setVideos(found.videos);
if (found.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = found.videos.length;
}
}
}
} else {
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && !draft.draftReportId) {
if (draft.reportData) setReportData(draft.reportData);
if (draft.videos) {
setVideos(draft.videos);
if (draft.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = draft.videos.length;
}
if (draft.capturedFrames) {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
}
if (!contentLoadedRef.current && editorRef.current) {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
}
}, [reportId, navigate, draftKey, restoreFlag]);
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
// Auto-fill current time for fields with timeDefault === 'current'
useEffect(() => {
if (formFields.length === 0) return;
let hasChange = false;
const updates: any = {};
formFields.forEach(field => {
if (field.timeDefault === 'current') {
if (field.type === 'date') {
const current = new Date().toISOString().split('T')[0];
if (!(reportData as any)[field.key]) {
updates[field.key] = current;
hasChange = true;
}
} else if (field.type === 'time') {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
if (field.key === 'startTime') {
if (!reportData.startHour) {
updates.startHour = hh;
updates.startMinute = mm;
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
updates.endHour = hh;
updates.endMinute = mm;
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = `${hh}:${mm}`;
hasChange = true;
}
}
}
} else if (field.timeDefault === 'specific' && field.fixedTimeValue) {
if (field.type === 'date') {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
} else if (field.type === 'time') {
if (field.key === 'startTime') {
if (!reportData.startHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.startHour = hh || '';
updates.startMinute = mm || '';
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.endHour = hh || '';
updates.endMinute = mm || '';
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
}
}
}
});
if (hasChange) {
setReportData(prev => {
const next = { ...prev, ...updates };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
}, [formFields]);
useEffect(() => {
if (!editorRef.current) return;
const observer = new MutationObserver(() => {
updatePageHeight();
});
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true });
return () => observer.disconnect();
}, [currentUser]);
const triggerPlaceholderUpload = (placeholder: HTMLElement) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
reader.readAsDataURL(file);
}
};
input.click();
};
// Handle image placeholder interactions via click capture for reliable contenteditable behavior
useEffect(() => {
const handleEditorClick = (e: MouseEvent) => {
// e.target may be a text node; safely resolve to an Element
let node: Node | null = e.target as Node;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
// Handle click on field-value: switch to info tab, highlight and focus corresponding input
const fieldValue = targetEl.closest('.field-value') as HTMLElement | null;
if (fieldValue) {
const bindKey = fieldValue.getAttribute('data-bind');
if (bindKey) {
setActiveTab('info');
stateRef.current = { ...stateRef.current, activeTab: 'info' };
setActiveFieldKey(bindKey);
setTimeout(() => {
const inputEl = document.getElementById(`input-${bindKey}`);
if (inputEl) {
inputEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = inputEl.querySelector('input, select') as HTMLElement | null;
if (focusable) {
focusable.focus();
}
}
}, 100);
}
return;
}
// 点击空白处清除高亮
setActiveFieldKey(null);
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
if (targetEl.closest('.delete-btn')) {
e.stopPropagation();
e.preventDefault();
if (placeholder.classList.contains('has-image')) {
placeholder.classList.remove('has-image');
const w = parseInt(placeholder.style.maxWidth || placeholder.style.width || '0');
const text = w > 0 && w < 80 ? '插图' : '插入/点击放置图片';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
`;
placeholder.style.border = '1px dashed #cbd5e1';
placeholder.style.background = '#f8fafc';
const mw = placeholder.style.maxWidth;
const mh = placeholder.style.maxHeight;
if (mw) placeholder.style.width = mw;
if (mh) {
placeholder.style.height = mh;
placeholder.style.lineHeight = mh;
}
placeholder.style.textAlign = 'center';
placeholder.style.verticalAlign = 'middle';
placeholder.style.justifyContent = 'center';
placeholder.style.alignItems = 'center';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
return;
}
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
};
const editor = editorRef.current;
if (editor) {
editor.addEventListener('click', handleEditorClick, true);
}
return () => {
if (editor) {
editor.removeEventListener('click', handleEditorClick, true);
}
};
}, [saveDraftToStorage, currentUser]);
// Prevent backspace from deleting image-placeholder elements
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// If selection is inside a placeholder, or if the range intersects a placeholder
let node: Node | null = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const placeholder = (node as HTMLElement)?.closest('.image-placeholder');
if (placeholder) {
e.preventDefault();
return;
}
// If collapsed and next/previous sibling is a placeholder
if (range.collapsed) {
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType === Node.ELEMENT_NODE) {
const next = (container as Element).children[offset];
const prev = (container as Element).children[offset - 1];
if ((e.key === 'Delete' && next?.classList?.contains('image-placeholder')) ||
(e.key === 'Backspace' && prev?.classList?.contains('image-placeholder'))) {
e.preventDefault();
}
}
}
};
editor.addEventListener('keydown', handleKeyDown);
return () => editor.removeEventListener('keydown', handleKeyDown);
}, []);
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const execCmd = (command: string, value: string | undefined = undefined) => {
editorRef.current?.focus();
document.execCommand(command, false, value);
editorRef.current?.focus();
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.lineHeight = height;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
const changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.textAlign = align;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
const insertTable = () => {
editorRef.current?.focus();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
const insertImage = () => {
editorRef.current?.focus();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []) as File[];
const newVideos = files.map(file => ({
id: Math.random().toString(36).substr(2, 9),
name: file.name,
url: URL.createObjectURL(file),
duration: 0
}));
const combined = [...videos, ...newVideos];
setVideos(combined);
stateRef.current = { ...stateRef.current, videos: combined };
setCurrentVideoIndex(videos.length); // select first newly uploaded video
if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage();
};
const removeVideo = (id: string) => {
const idx = videos.findIndex(v => v.id === id);
const updated = videos.filter(v => v.id !== id);
setVideos(updated);
stateRef.current = { ...stateRef.current, videos: updated };
if (currentVideoIndex >= updated.length) {
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
} else if (currentVideoIndex === idx && updated.length > 0) {
setCurrentVideoIndex(0);
}
const nextFrames = capturedFrames.filter(f => f.videoIndex !== idx).map(f => {
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
return f;
}).sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
saveDraftToStorage();
};
const selectVideo = (index: number) => {
setCurrentVideoIndex(index);
setIsPlaying(false);
};
const togglePlay = () => {
if (!videoRef.current) return;
if (isPlaying) videoRef.current.pause();
else videoRef.current.play();
setIsPlaying(!isPlaying);
};
const captureFrame = () => {
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time: video.currentTime,
timeFormatted: formatTime(video.currentTime),
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: true
};
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
saveDraftToStorage();
};
const autoCaptureFrames = async () => {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
const dur = video.duration || 1;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
let accumulatedFrames = [...capturedFrames];
for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
const time = (pos / 100) * dur;
video.currentTime = time;
await new Promise<void>(resolve => {
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
});
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now() + Math.random(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time,
timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: false
};
accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width:${emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'};max-height:${emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
emptyPlaceholder.style.width = 'auto';
emptyPlaceholder.style.height = 'auto';
emptyPlaceholder.style.lineHeight = 'normal';
emptyPlaceholder.style.maxWidth = emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px';
emptyPlaceholder.style.maxHeight = emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px';
emptyPlaceholder.style.textAlign = 'left';
emptyPlaceholder.style.verticalAlign = 'top';
emptyPlaceholder.style.justifyContent = 'flex-start';
emptyPlaceholder.style.alignItems = 'flex-start';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}, actualDelay);
}
}
if (settings.autoInsertFrames && editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
if (wasPlaying) video.play();
saveDraftToStorage();
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const checkAiRegions = () => {
if (!editorRef.current) return [];
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter(r => r.id);
};
const toggleListening = () => {
if (isListening) {
setIsListening(false);
if (speechRecognitionRef.current) speechRecognitionRef.current.stop();
} else {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('您的浏览器不支持原生语音识别,请使用 Chrome。');
return;
}
const recognition = new SpeechRecognition();
recognition.lang = 'zh-CN';
recognition.continuous = true;
recognition.interimResults = true;
let finalTranscript = chatInput;
recognition.onstart = () => setIsListening(true);
recognition.onresult = (event: any) => {
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
finalTranscript += event.results[i][0].transcript;
} else {
interimTranscript += event.results[i][0].transcript;
}
}
setChatInput(finalTranscript + interimTranscript);
};
recognition.onerror = () => setIsListening(false);
recognition.onend = () => setIsListening(false);
speechRecognitionRef.current = recognition;
recognition.start();
}
};
const handleAIGenerate = async (text: string) => {
if (!text.trim()) return;
const userMsgId = Date.now().toString();
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text }]);
setChatInput('');
setIsGenerating(true);
try {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const apiKey = settings.kimiApiKey || '';
const apiEndpoint = settings.kimiApiEndpoint || 'https://api.moonshot.cn/v1';
if (!apiKey) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 Kimi API Key请前往系统设置填写。' }]);
setIsGenerating(false);
return;
}
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : '';
const messageContent: any[] = [];
const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
allImages.forEach(url => {
messageContent.push({ type: 'image_url', image_url: { url } });
});
let promptText = `【医生指令】: ${text}`;
if (aiModifyEnabled && targetRegionEl) {
promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
}
messageContent.push({ type: 'text', text: promptText });
const systemPrompt = aiModifyEnabled && targetRegionEl
? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
: '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
const response = await fetch(`${apiEndpoint}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'kimi-k2-5',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: messageContent }
],
temperature: 0.3
})
});
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
const data = await response.json();
const responseText = data.choices[0].message.content.trim();
const cleanedText = responseText.replace(/```json\n?|```/g, '');
let responseJson: any = {};
try {
responseJson = JSON.parse(cleanedText);
} catch {
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
if (jsonMatch) responseJson = JSON.parse(jsonMatch[0]);
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
if (responseJson.reply) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
}
if (responseJson.updatedHtml && aiModifyEnabled && targetRegionEl) {
setDiffModal({
isOpen: true,
originalHtml: currentHtml,
newHtml: responseJson.updatedHtml,
targetId: aiTargetRegion
});
}
setAiUploadedImages([]);
setAiSelectedFrames([]);
} catch (error: any) {
console.error(error);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
} finally {
setIsGenerating(false);
}
};
const confirmAiInjection = (newHtml: string, regionId: string) => {
if (!editorRef.current) return;
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
if (targetContent) {
targetContent.innerHTML = newHtml;
targetContent.style.transition = 'background-color 0.3s ease';
targetContent.style.backgroundColor = '#bfdbfe';
setTimeout(() => {
targetContent.style.backgroundColor = '#eff6ff';
setTimeout(() => {
targetContent.style.backgroundColor = 'transparent';
}, 800);
}, 400);
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
setDiffModal(null);
};
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
e.dataTransfer.setData('frameId', frame.id.toString());
};
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
if (placeholder.getAttribute('data-mode') === 'manual') {
alert('此处为静态图片占位符仅支持点击插入如Logo/签名),不支持拖入关键帧');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
fillPlaceholder(placeholder, frame);
}
};
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
if (!editorRef.current) {
alert('编辑器未准备好');
return;
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (!emptyPlaceholder) {
alert('没有可插入图片的空位');
return;
}
fillPlaceholder(emptyPlaceholder, frame);
};
const seekToFrame = (frame: CapturedFrame) => {
if (!videoRef.current) return;
if (frame.videoIndex !== currentVideoIndex) {
setCurrentVideoIndex(frame.videoIndex);
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.currentTime = frame.time;
}
}, 50);
};
const handleFrameKeyNav = (direction: 'left' | 'right') => {
if (!videoRef.current) return;
const delta = direction === 'left' ? -5 : 5;
videoRef.current.currentTime = Math.max(0, Math.min(duration, videoRef.current.currentTime + delta));
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Only if video tab is active or video element is focused
if (activeTab === 'video' || document.activeElement === videoRef.current) {
handleFrameKeyNav(e.key === 'ArrowLeft' ? 'left' : 'right');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTab, duration]);
// Auto-capture frames when new videos are uploaded
useEffect(() => {
if (videos.length > prevVideoCountRef.current && currentVideoIndex !== -1) {
const timer = setTimeout(() => {
if (videoRef.current && videoRef.current.duration) {
autoCaptureFrames();
} else if (videoRef.current) {
const onMeta = () => {
autoCaptureFrames();
videoRef.current?.removeEventListener('loadedmetadata', onMeta);
};
videoRef.current.addEventListener('loadedmetadata', onMeta);
}
}, 300);
return () => clearTimeout(timer);
}
prevVideoCountRef.current = videos.length;
}, [videos.length, currentVideoIndex]);
// Apply selected template content
useEffect(() => {
if (pendingTemplateId && editorRef.current) {
const tpl = templates.find(t => t.id === pendingTemplateId);
if (tpl) {
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
const nextReportData: any = {
title: tpl.name || '腹腔镜胆囊切除术报告',
patientName: '',
hospitalId: '',
patientGender: '',
patientAge: '',
department: '',
bedNumber: '',
surgeryDate: '',
startHour: '',
startMinute: '',
endHour: '',
endMinute: '',
surgeon: [],
assistant: [],
anesthesiologist: [],
anesthesiaType: '',
status: 'draft'
};
formFields.forEach(field => {
if (field.category === '时间') {
if (field.timeDefault === 'specific' && field.fixedTimeValue) {
if (field.type === 'date') {
nextReportData[field.key] = field.fixedTimeValue;
} else if (field.type === 'time') {
const [hh, mm] = field.fixedTimeValue.split(':');
if (field.key === 'startTime') {
nextReportData.startHour = hh || '';
nextReportData.startMinute = mm || '';
} else if (field.key === 'endTime') {
nextReportData.endHour = hh || '';
nextReportData.endMinute = mm || '';
} else {
nextReportData[field.key] = field.fixedTimeValue;
}
}
} else if (field.timeDefault === 'current') {
if (field.type === 'date') {
nextReportData[field.key] = new Date().toISOString().split('T')[0];
} else if (field.type === 'time') {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
if (field.key === 'startTime') {
nextReportData.startHour = hh;
nextReportData.startMinute = mm;
} else if (field.key === 'endTime') {
nextReportData.endHour = hh;
nextReportData.endMinute = mm;
} else {
nextReportData[field.key] = `${hh}:${mm}`;
}
}
}
}
});
if (!nextReportData.surgeryDate) {
nextReportData.surgeryDate = new Date().toISOString().split('T')[0];
}
setLoadedTemplateId(tpl.id);
setReportData(nextReportData);
setVideos([]);
setCapturedFrames([]);
setCurrentVideoIndex(-1);
prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
loadedTemplateId: tpl.id,
reportData: nextReportData,
videos: [],
capturedFrames: [],
activeTab: stateRef.current.activeTab
};
updatePageHeight();
saveDraftToStorage();
}
setPendingTemplateId(null);
}
}, [pendingTemplateId, templates]);
// Safety net: ensure editor gets initialized if ref was not ready during init effect
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
const draft = key ? storage.get<Record<string, any> | null>(key, null) : null;
if (reportId) {
if (draft && draft.draftReportId === reportId && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
}
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
const restoreContent = storage.getSession<string | null>(`restore_${reportId}`, null);
if (restoreFlag && restoreContent) {
editorRef.current.innerHTML = restoreContent;
storage.removeSession(`restore_${reportId}`);
} else {
editorRef.current.innerHTML = found.content;
}
contentLoadedRef.current = true;
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
setTimeout(() => updatePageHeight(), 0);
return;
}
} else {
if (draft && !draft.draftReportId && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
}
}
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const allTemplates = storage.get<Template[]>('templates', []);
const userData = storage.get<User | null>('currentUser', null);
const visibleTplIds = Array.isArray(userData?.visibleTemplates) ? userData.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}, []);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const hour12Options = Array.from({ length: 12 }, (_, i) => ((i + 1).toString().padStart(2, '0')));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
if (!isoDate || !fmt) return isoDate || '';
const [y, m, d] = isoDate.split('-');
return fmt
.replace(/YYYY/g, y || '')
.replace(/MM/g, m || '')
.replace(/DD/g, d || '');
};
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
if (!timeStr || !fmt) return timeStr || '';
if (fmt === '24h') fmt = 'HH:mm';
const [h24str, mstr] = timeStr.split(':');
const h24 = parseInt(h24str) || 0;
const isPM = h24 >= 12;
let h12 = h24 % 12;
if (h12 === 0) h12 = 12;
return fmt
.replace(/HH/g, String(h24).padStart(2, '0'))
.replace(/mm/g, mstr || '00')
.replace(/hh/g, String(h12).padStart(2, '0'))
.replace(/A/g, isPM ? '下午' : '上午');
};
const parseDateFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
if (!nums) return text;
let y = '', m = '', d = '';
if (nums.length >= 3) { y = nums[0].padStart(4, '0'); m = nums[1].padStart(2, '0'); d = nums[2].padStart(2, '0'); }
else if (nums.length === 2) { m = nums[0].padStart(2, '0'); d = nums[1].padStart(2, '0'); y = new Date().getFullYear().toString(); }
return `${y}-${m}-${d}`;
};
const parseTimeFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
const ampm = text.match(/上午|下午/);
if (!nums || nums.length < 2) return text;
let h = parseInt(nums[0]);
if (ampm) {
const isPM = ampm[0] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
}
return `${String(h).padStart(2, '0')}:${nums[1].padStart(2, '0')}`;
};
const to24h = (h12: number, isPM: boolean): number => {
if (isPM && h12 !== 12) return h12 + 12;
if (!isPM && h12 === 12) return 0;
return h12;
};
const from24h = (h24: number): { h: number; isPM: boolean } => {
const isPM = h24 >= 12;
let h = h24 % 12;
if (h === 0) h = 12;
return { h, isPM };
};
const addTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
if (!current.includes(value)) {
const next = { ...reportData, [field]: [...current, value] };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
}
// Persist custom value to global options for future reuse
const opts = multiSelectOptions[field] || [];
if (!opts.includes(value)) {
const next = { ...multiSelectOptions, [field]: [...opts, value] };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
}
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options && !fieldDef.options.includes(value)) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: [...(f.options || []), value] } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
};
const removeTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
const next = { ...reportData, [field]: current.filter((v: string) => v !== value) };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
};
const removeMultiOption = (field: string, value: string) => {
const current = multiSelectOptions[field] || [];
const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: (f.options || []).filter(v => v !== value) } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
};
const removeAnesthesiaOption = (value: string) => {
const next = anesthesiaOptions.filter(v => v !== value);
setAnesthesiaOptions(next);
storage.set('anesthesiaOptions', next);
};
// Close dropdowns on outside click
useEffect(() => {
const handleDocClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.select-dropdown-root')) {
setOpenDropdown(null);
}
};
document.addEventListener('click', handleDocClick);
return () => document.removeEventListener('click', handleDocClick);
}, []);
const saveReport = (status: 'draft' | 'completed') => {
if (status === 'completed' && (!reportData.patientName || !reportData.hospitalId)) {
alert('请填写患者姓名和住院号');
return;
}
if (status === 'completed') {
const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]');
if (hasSignatureField) {
const hasSignatureImage = !!currentUser?.signature;
if (!hasSignatureImage) {
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
if (!proceed) return;
}
}
}
const content = editorRef.current?.innerHTML || '';
const now = new Date().toISOString();
const finalReport: Report = {
...(reportData as Report),
id: reportId || 'RPT_' + Date.now(),
content,
author: currentUser?.username || '',
authorName: currentUser?.name || '',
createdAt: reportData.createdAt || now.split('T')[0],
status,
capturedFrames,
videos,
updatedAt: now
};
const reports = storage.get<Report[]>('reports', []);
let updatedReports: Report[];
if (reportId) {
const old = reports.find(r => r.id === reportId);
const history = old?.history ? [...old.history] : [];
if (old) {
history.push({
content: old.content,
updatedAt: old.updatedAt || old.createdAt,
updatedBy: currentUser?.name || currentUser?.username || '',
action: status === 'completed' ? 'complete_report' : 'save_draft'
});
}
updatedReports = reports.map(r => r.id === reportId ? { ...finalReport, history } : r);
} else {
updatedReports = [...reports, finalReport];
if (draftKey) storage.remove(draftKey);
}
storage.set('reports', updatedReports);
if (draftKey) storage.remove(draftKey);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
if (status === 'completed') navigate('/report-manage');
};
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
updatePageHeight();
saveDraftToStorage();
const target = e.target as HTMLElement;
if (target && target.hasAttribute('data-bind')) {
const fieldKey = target.getAttribute('data-bind')!;
const newValue = target.innerText;
const fieldDef = formFields.find(f => f.key === fieldKey);
if (fieldKey === 'startTime') {
let raw = newValue;
if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
const parts = raw.split(':');
setReportData((prev) => {
const next = { ...prev, startHour: parts[0] || '', startMinute: parts[1] || '' };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else if (fieldKey === 'endTime') {
let raw = newValue;
if (fieldDef?.timeFormat && (fieldDef.timeFormat.includes('hh') || fieldDef.timeFormat.includes('A'))) {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
const parts = raw.split(':');
setReportData((prev) => {
const next = { ...prev, endHour: parts[0] || '', endMinute: parts[1] || '' };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
} else {
let raw = newValue;
if (fieldDef?.type === 'date') {
raw = parseDateFromFormat(newValue, fieldDef.timeFormat);
} else if (fieldDef?.type === 'time') {
raw = parseTimeFromFormat(newValue, fieldDef.timeFormat);
}
setReportData((prev) => {
const next = { ...prev, [fieldKey]: raw };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
}
};
// Sync form state -> rich text field values
useEffect(() => {
if (!editorRef.current) return;
const bindNodes = editorRef.current.querySelectorAll('[data-bind]');
bindNodes.forEach((node) => {
const el = node as HTMLElement;
const fieldKey = el.getAttribute('data-bind')!;
if (fieldKey === 'surgeonSignature') {
const signatureData = currentUser?.signature;
if (signatureData) {
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
if (el.innerHTML !== imgHtml) {
el.innerHTML = imgHtml;
el.style.border = 'none';
el.style.backgroundColor = 'transparent';
}
} else {
const placeholder = '【请上传电子签】';
if (el.innerText !== placeholder) {
el.innerText = placeholder;
el.style.border = '';
el.style.backgroundColor = '';
}
}
return;
}
let newValue = '';
const fieldDef = formFields.find(f => f.key === fieldKey);
if (fieldKey === 'startTime') {
newValue = `${reportData.startHour || ''}:${reportData.startMinute || ''}`;
if (newValue === ':') newValue = '';
newValue = formatTimeDisplay(newValue, fieldDef?.timeFormat);
} else if (fieldKey === 'endTime') {
newValue = `${reportData.endHour || ''}:${reportData.endMinute || ''}`;
if (newValue === ':') newValue = '';
newValue = formatTimeDisplay(newValue, fieldDef?.timeFormat);
} else {
const rawValue = (reportData as any)[fieldKey];
if (Array.isArray(rawValue)) {
newValue = rawValue.join(', ');
} else if (rawValue !== undefined && rawValue !== null) {
newValue = String(rawValue);
}
if (fieldDef?.type === 'date') {
newValue = formatDateDisplay(newValue, fieldDef.timeFormat);
} else if (fieldDef?.type === 'time') {
newValue = formatTimeDisplay(newValue, fieldDef.timeFormat);
}
}
if (el.innerText !== newValue) {
el.innerText = newValue;
}
});
}, [reportData]);
if (!currentUser) return null;
const hasVisibleTemplates = templates.length > 0;
return (
<div className="flex h-screen bg-bg overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-20 bg-white border-b border-border flex items-center justify-between px-8 shrink-0">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/report-manage')}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<ChevronLeft size={24} />
</button>
<div>
<h1 className="text-lg font-bold text-text-main"></h1>
<p className="text-[10px] text-text-muted mt-0.5 uppercase tracking-wider font-bold">
{reportId ? `编辑报告: ${reportId}` : '新建手术报告'}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-xs text-text-muted whitespace-nowrap"></span>
<select
value=""
onChange={(e) => {
const tplId = e.target.value;
const tpl = templates.find(t => t.id === tplId);
if (tpl && window.confirm(`确定要应用模板 "${tpl.name}" 吗?当前内容、基本信息、视频分析将被重置。`)) {
setPendingTemplateId(tplId);
}
}}
className="h-8 px-2 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value="" disabled>
{templates.find(t => t.id === loadedTemplateId)?.name || '无'}
</option>
{templates.map(tpl => (
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
))}
</select>
</div>
{isSaved && (
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
<Check size={14} />
</span>
)}
<button
onClick={() => saveReport('draft')}
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
>
稿
</button>
<button
onClick={() => saveReport('completed')}
className="btn-accent inline-flex items-center gap-2"
>
<Check size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
>
<Printer size={18} />
</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Editor Main */}
<div className="flex-1 flex flex-col overflow-hidden border-r border-border">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onChange={(e) => execCmd('foreColor', e.target.value)}
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
title="文字颜色"
/>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('left')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('center')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('right')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
</div>
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot 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', 'ai'] 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' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
{(() => {
const topKeys = ['patientName', 'hospitalId', 'title'];
const contentHtml = contentRef.current || editorRef.current?.innerHTML || '';
return [...formFields.filter(f => f.visibleInForm)].sort((a, b) => {
const aTop = topKeys.indexOf(a.key);
const bTop = topKeys.indexOf(b.key);
if (aTop !== -1 && bTop !== -1) return aTop - bTop;
if (aTop !== -1) return -1;
if (bTop !== -1) return 1;
const aIndex = contentHtml.indexOf(`data-bind="${a.key}"`);
const bIndex = contentHtml.indexOf(`data-bind="${b.key}"`);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
})().map(field => {
const isRequired = field.isSystemLocked;
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`${field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'} p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
<input
type={inputType}
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onBlur={() => setTouched(t => ({ ...t, [field.key]: true }))}
className={`input-minimal ${hasError ? 'border-red-500' : ''}`}
placeholder={field.label}
/>
</div>
);
}
if (field.type === 'single_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
<input
type="text"
className="outline-none text-sm flex-1 bg-transparent"
placeholder={`输入或选择${field.label}`}
value={(reportData as any)[field.key] || ''}
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
onFocus={() => setOpenDropdown(field.key)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
const next = { ...reportData, [field.key]: val };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
if (!opts.includes(val)) {
const updatedOpts = [...opts, val];
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
setOpenDropdown(null);
}
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => { const next = { ...reportData, [field.key]: opt }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); setOpenDropdown(null); }}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => {
e.stopPropagation();
const updatedOpts = opts.filter(v => v !== opt);
if (field.key === 'anesthesiaType') {
setAnesthesiaOptions(updatedOpts);
storage.set('anesthesiaOptions', updatedOpts);
}
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: updatedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}}
>×</span>
</div>
))}
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
}
if (field.type === 'multi_select') {
const isOpen = openDropdown === field.key;
const opts = field.options || multiSelectOptions[field.key] || [];
const rawValue = (reportData as any)[field.key];
const currentValues = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
const displayText = currentValues.join(', ');
const parseMultiInput = (text: string): string[] => {
return Array.from(new Set(text.split(/[,;;、]/).map(s => s.trim()).filter(Boolean)));
};
const handleMultiCommit = (text: string) => {
const values = parseMultiInput(text);
const next = { ...reportData, [field.key]: values };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
const currentOpts = field.options || multiSelectOptions[field.key] || [];
const newOpts = values.filter(v => !currentOpts.includes(v));
if (newOpts.length > 0) {
const mergedOpts = [...currentOpts, ...newOpts];
const nextMulti = { ...multiSelectOptions, [field.key]: mergedOpts };
setMultiSelectOptions(nextMulti);
storage.set('multiSelectOptions', nextMulti);
const fieldDef = formFields.find(f => f.key === field.key);
if (fieldDef) {
const updatedFields = formFields.map(f => f.key === field.key ? { ...f, options: mergedOpts } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
}
}
};
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
onClick={() => setOpenDropdown(field.key)}
>
<input
type="text"
className="outline-none text-sm w-full bg-transparent"
placeholder="输入或选择,多个用逗号分隔"
value={currentInputText}
onChange={(e) => {
setMultiInputText(prev => ({ ...prev, [field.key]: e.target.value }));
}}
onFocus={() => setOpenDropdown(field.key)}
onBlur={(e) => {
handleMultiCommit(e.target.value);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleMultiCommit((e.target as HTMLInputElement).value);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}
}}
/>
</div>
{isOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded-lg shadow-lg max-h-[150px] overflow-y-auto">
{opts.map(opt => (
<div
key={opt}
className="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex justify-between items-center"
onClick={() => {
const base = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
const newText = base.length > 0 ? `${base}, ${opt}` : opt;
setMultiInputText(prev => ({ ...prev, [field.key]: newText }));
handleMultiCommit(newText);
setMultiInputText(prev => {
const next = { ...prev };
delete next[field.key];
return next;
});
}}
>
<span>{opt}</span>
<span
className="text-red-500 text-xs hover:bg-red-50 px-1 rounded"
onClick={(e) => { e.stopPropagation(); removeMultiOption(field.key, opt); }}
>×</span>
</div>
))}
{opts.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
}
if (field.type === 'time') {
const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
if (field.key === 'startTime' || field.key === 'endTime') {
const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour';
const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute';
const h24val = parseInt((reportData as any)[hourKey]) || 0;
const m = (reportData as any)[minuteKey] || '';
const { h: h12, isPM } = from24h(h24val);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
value={is12h ? String(h12).padStart(2, '0') : ((reportData as any)[hourKey] || '')}
onChange={(e) => {
let h24new = parseInt(e.target.value) || 0;
if (is12h) {
const currentPM = from24h(parseInt((reportData as any)[hourKey]) || 0).isPM;
h24new = to24h(h24new, currentPM);
}
const next = { ...reportData, [hourKey]: String(h24new).padStart(2, '0') };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{(is12h ? hour12Options : hourOptions).map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={m}
onChange={(e) => { const next = { ...reportData, [minuteKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(mo => <option key={mo} value={mo}>{mo}</option>)}
</select>
{is12h && (
<select
value={isPM ? '下午' : '上午'}
onChange={(e) => {
const isPMnew = e.target.value === '下午';
const currentH12 = from24h(parseInt((reportData as any)[hourKey]) || 0).h;
const h24new = to24h(currentH12, isPMnew);
const next = { ...reportData, [hourKey]: String(h24new).padStart(2, '0') };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="上午"></option>
<option value="下午"></option>
</select>
)}
</div>
</div>
);
}
// 通用 time 字段
const timeVal = (reportData as any)[field.key] || '';
const [h24str, mstr] = timeVal.split(':');
const h24 = parseInt(h24str) || 0;
const m = mstr || '';
const { h: h12g, isPM: isPMg } = from24h(h24);
return (
<div key={field.key} id={`input-${field.key}`} onClick={() => setActiveFieldKey(field.key)} className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
value={is12h ? String(h12g).padStart(2, '0') : (h24str || '')}
onChange={(e) => {
let h24new = parseInt(e.target.value) || 0;
if (is12h) h24new = to24h(h24new, isPMg);
const nextVal = `${String(h24new).padStart(2, '0')}:${m || '00'}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{(is12h ? hour12Options : hourOptions).map(h => <option key={h} value={h}>{h}</option>)}
</select>
<span className="text-text-muted">:</span>
<select
value={m}
onChange={(e) => {
const nextVal = `${String(h24).padStart(2, '0')}:${e.target.value}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="">--</option>
{minuteOptions.map(mo => <option key={mo} value={mo}>{mo}</option>)}
</select>
{is12h && (
<select
value={isPMg ? '下午' : '上午'}
onChange={(e) => {
const isPMnew = e.target.value === '下午';
const h24new = to24h(h12g, isPMnew);
const nextVal = `${String(h24new).padStart(2, '0')}:${m || '00'}`;
const next = { ...reportData, [field.key]: nextVal };
setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage();
}}
className="input-minimal bg-white flex-1"
>
<option value="上午"></option>
<option value="下午"></option>
</select>
)}
</div>
</div>
);
}
return null;
})}
</div>
)}
{activeTab === 'video' && (
<div className="space-y-2">
<input
ref={videoInputRef}
type="file"
accept="video/*"
multiple
className="hidden"
onChange={handleVideoUpload}
/>
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
{videos.map((v, i) => (
<div
key={v.id}
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
}`}
>
<div
onClick={() => selectVideo(i)}
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
>
<Play size={16} />
</div>
<div
onClick={() => selectVideo(i)}
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
>{v.name}</div>
<button
onClick={() => removeVideo(v.id)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
>
<X size={12} />
</button>
</div>
))}
<button
onClick={() => videoInputRef.current?.click()}
className="shrink-0 w-24 h-[68px] flex flex-col items-center justify-center gap-1 border-2 border-dashed border-border rounded-xl hover:border-accent hover:bg-slate-50 transition-all text-text-muted hover:text-accent"
>
<Video size={18} />
<span className="text-[10px] font-bold"></span>
</button>
</div>
{currentVideoIndex !== -1 && videos.length > 0 && (
<div className="space-y-2">
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
<video
ref={videoRef}
src={videos[currentVideoIndex].url}
className="w-full h-full"
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
onLoadedMetadata={() => {
const dur = videoRef.current?.duration || 0;
setDuration(dur);
setVideos(prev => prev.map((v, i) => i === currentVideoIndex ? { ...v, duration: dur } : v));
}}
/>
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 cursor-pointer" onClick={togglePlay}>
<div className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white">
<Play size={32} fill="currentColor" />
</div>
</div>
)}
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-3 py-1.5 rounded-full">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<div className="flex items-center gap-4">
<button onClick={togglePlay} className="w-10 h-10 flex items-center justify-center bg-slate-100 rounded-xl text-text-main hover:bg-slate-200 transition-colors">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden cursor-pointer relative" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (videoRef.current) videoRef.current.currentTime = pos * duration;
}}>
<div className="h-full bg-accent" style={{ width: `${(currentTime / duration) * 100}%` }} />
</div>
</div>
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-[10px] font-bold text-text-main uppercase tracking-wider"></span>
<button
onClick={captureFrame}
className="px-4 py-2 bg-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-wider hover:bg-blue-700 transition-colors shadow-sm"
>
</button>
</div>
<div className="grid grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{capturedFrames.filter(f => f.videoIndex === currentVideoIndex).map((frame) => (
<div
key={frame.id}
draggable
onDragStart={(e) => handleDragStart(e, frame)}
onClick={() => seekToFrame(frame)}
className={`group relative card-minimal p-1 border-2 cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${
frame.isManual ? 'border-yellow-400 hover:border-yellow-500' : 'border-transparent hover:border-accent'
}`}
>
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="px-2 py-0.5 bg-accent text-white text-[9px] font-bold rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-700"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setCapturedFrames(prev => prev.filter(f => f.id !== frame.id).sort((a, b) => a.time - b.time)); saveDraftToStorage(); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-all shadow-md"
>
<X size={14} />
</button>
</div>
))}
</div>
<p className="text-[10px] text-text-muted text-center">
</p>
</div>
)}
</div>
)}
{activeTab === 'ai' && (
<div className="flex flex-col h-full bg-[#f8fafc] overflow-hidden">
{/* 聊天气泡记录区 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
{chatMessages.length === 0 ? (
<div className="text-center flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<Bot size={48} className="text-slate-300 opacity-50" />
<p className="text-xs"> SurClaw </p>
</div>
) : (
chatMessages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`rounded-2xl px-4 py-2.5 max-w-[85%] text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none shadow-md' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
{msg.content}
</div>
</div>
))
)}
{isGenerating && (
<div className="flex justify-start">
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-3 shadow-sm flex gap-1.5 items-center">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
</div>
</div>
)}
</div>
{/* 控制台与输入区 */}
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
{/* 区域锚定与沙盒控制 */}
<div className="flex items-center justify-between bg-slate-50 p-2 rounded-lg border border-slate-200">
<div className="flex items-center gap-2 flex-1">
<select
value={aiTargetRegion}
onChange={(e) => setAiTargetRegion(e.target.value)}
disabled={!aiModifyEnabled}
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
>
{checkAiRegions().length > 0 ? (
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
) : (
<option value="none"> AI </option>
)}
</select>
</div>
<div className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300">
<input
type="checkbox" id="aiModifyEnabled"
checked={aiModifyEnabled}
onChange={(e) => setAiModifyEnabled(e.target.checked)}
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
/>
<label htmlFor="aiModifyEnabled" className="text-[11px] text-slate-600 cursor-pointer font-bold">
</label>
</div>
</div>
{/* 视觉参考上下文 */}
{capturedFrames.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
{capturedFrames.map(frame => {
const isSelected = aiSelectedFrames.includes(frame.id);
return (
<div key={frame.id} onClick={() => setAiSelectedFrames(prev => isSelected ? prev.filter(id => id !== frame.id) : [...prev, frame.id])}
className={`relative shrink-0 w-12 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600' : 'border-transparent opacity-50'}`}>
<img src={frame.dataUrl} className="w-full h-full object-cover" />
{isSelected && <div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full p-0.5"><Check size={8} className="text-white" /></div>}
</div>
);
})}
</div>
)}
{/* 自定义快捷指令胶囊 */}
<div className="flex flex-wrap gap-1.5 max-h-16 overflow-y-auto">
{quickPrompts.map((p, i) => (
<div key={i} className="group relative">
<button onClick={() => setChatInput(p)} className="px-3 py-1 bg-[#f1f5f9] hover:bg-blue-50 hover:text-blue-600 text-slate-600 text-[11px] rounded-full transition-colors whitespace-nowrap">
{p}
</button>
{isEditingPrompts && (
<button onClick={() => setQuickPrompts(prev => prev.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 scale-75 shadow-sm">
<X size={10} />
</button>
)}
</div>
))}
<button onClick={() => {
if (isEditingPrompts) {
const newP = prompt('新增快捷指令:');
if (newP) setQuickPrompts([...quickPrompts, newP]);
} else {
setIsEditingPrompts(true);
}
}} className="px-2 py-1 bg-slate-100 text-slate-400 text-[11px] rounded-full hover:bg-slate-200">
{isEditingPrompts ? '+ 添加' : '⚙️'}
</button>
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full"></button>}
</div>
{/* 沉浸式输入框 */}
<div className="relative border border-slate-300 rounded-xl bg-white shadow-inner focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent transition-all">
{aiUploadedImages.length > 0 && (
<div className="flex gap-2 p-2 border-b border-slate-100 bg-slate-50 rounded-t-xl overflow-x-auto">
{aiUploadedImages.map(img => (
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden shadow-sm shrink-0">
<img src={img.dataUrl} className="w-full h-full object-cover" />
<button onClick={() => setAiUploadedImages(prev => prev.filter(i => i.id !== img.id))} className="absolute top-0 right-0 bg-red-500/80 text-white rounded-bl-md">
<X size={10} />
</button>
</div>
))}
</div>
)}
<textarea
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isGenerating) handleAIGenerate(chatInput); } }}
placeholder={isListening ? '正在将语音转为文字...' : '输入需求(按 Enter 发送)...'}
className="w-full min-h-[80px] p-3 pr-12 text-sm bg-transparent outline-none resize-none custom-scrollbar"
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
<label className="p-1.5 text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 rounded-lg cursor-pointer transition-colors" title="上传外部图像">
<input type="file" accept="image/*" multiple className="hidden" onChange={(e) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file: File) => {
const reader = new FileReader();
reader.onload = (ev) => { if (ev.target?.result) setAiUploadedImages(prev => [...prev, { id: Date.now(), dataUrl: ev.target!.result as string }]); };
reader.readAsDataURL(file);
});
}} />
<ImagePlus size={16} />
</label>
<button onClick={toggleListening} className={`p-1.5 rounded-lg transition-colors ${isListening ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50'}`}>
{isListening ? <Mic size={16} /> : <MicOff size={16} />}
</button>
</div>
</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 = 'position:relative;display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
execCmd('insertHTML', html);
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{exportModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
printDocument(editorRef.current?.innerHTML || '', `图文报告-${title}-${patient}-${hid}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `图文报告-${title}-${patient}-${hid}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</div>
</div>
)}
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
fillPlaceholderSrc(imagePickerTarget, src);
setImagePickerOpen(false);
setImagePickerTarget(null);
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded text-sm font-semibold"
></button>
<button
onClick={() => {
if (currentUser?.signature) {
fillPlaceholderSrc(imagePickerTarget, currentUser.signature);
}
setImagePickerOpen(false);
setImagePickerTarget(null);
}}
disabled={!currentUser?.signature}
className={`w-full py-2 rounded text-sm font-semibold ${currentUser?.signature ? 'bg-slate-100 hover:bg-slate-200 text-slate-700' : 'bg-slate-50 text-slate-400 cursor-not-allowed'}`}
> {!currentUser?.signature && '(未上传)'}</button>
<div>
<div className="text-xs text-slate-500 mb-2"></div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{imageAssets.map(asset => (
<button
key={asset.id}
onClick={() => { fillPlaceholderSrc(imagePickerTarget, asset.dataUrl); setImagePickerOpen(false); setImagePickerTarget(null); }}
className="w-16 h-16 border rounded overflow-hidden hover:ring-2 ring-accent"
>
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
</button>
))}
{imageAssets.length === 0 && <div className="text-xs text-slate-400"></div>}
</div>
</div>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => { setImagePickerOpen(false); setImagePickerTarget(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
</div>
</div>
</div>
)}
{/* AI 修改二次确认 Diff 弹窗 */}
{diffModal && diffModal.isOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-[800px] shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div>
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Sparkles size={18} className="text-blue-600" />
AI
</h3>
<p className="text-xs text-slate-500 mt-1"> AI </p>
</div>
<button onClick={() => setDiffModal(null)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
<div className="flex-1 overflow-hidden flex gap-4 p-6 bg-slate-100">
<div className="flex-1 flex flex-col bg-white border border-red-200 rounded-xl overflow-hidden shadow-sm">
<div className="bg-red-50 px-3 py-2 text-xs font-bold text-red-600 border-b border-red-100 uppercase tracking-wider"></div>
<div className="p-4 flex-1 overflow-y-auto opacity-70 cursor-not-allowed custom-scrollbar"
dangerouslySetInnerHTML={{ __html: diffModal.originalHtml }}></div>
</div>
<div className="flex-1 flex flex-col bg-white border border-green-400 rounded-xl overflow-hidden shadow-md relative">
<div className="bg-green-50 px-3 py-2 text-xs font-bold text-green-700 border-b border-green-200 uppercase tracking-wider flex justify-between">
<span>AI ()</span>
<span className="text-[10px] bg-green-200 px-1.5 py-0.5 rounded text-green-800"></span>
</div>
<div
className="p-4 flex-1 overflow-y-auto outline-none custom-scrollbar"
contentEditable
suppressContentEditableWarning
onBlur={(e) => setDiffModal(prev => prev ? { ...prev, newHtml: e.target.innerHTML } : null)}
dangerouslySetInnerHTML={{ __html: diffModal.newHtml }}
></div>
</div>
</div>
<div className="px-6 py-4 border-t border-slate-100 flex justify-end gap-3 bg-white">
<button onClick={() => setDiffModal(null)} className="px-6 py-2 rounded-lg text-slate-600 font-medium hover:bg-slate-100"></button>
<button onClick={() => confirmAiInjection(diffModal.newHtml, diffModal.targetId)} className="px-6 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 shadow-sm flex items-center gap-2">
<Check size={16} />
</button>
</div>
</div>
</div>
)}
</div>
);
}