Files
Mdeical_Sur_Report/src/pages/ReportEditor.tsx
admin b346b7e194 Add HTTPS demo entry for microphone access
- Add a self-signed HTTPS Nginx entrypoint on Docker port 4443 so browser microphone APIs can run in demo mode.

- Keep the existing HTTP port 4002 unchanged while exposing container port 443 and generating the demo certificate during image build.

- Update CORS defaults and Compose environment for the HTTPS frontend origin.

- Clarify the report editor microphone message with localhost, HTTPS, and browser trusted-origin demo options.

- Document the browser HTTP microphone limitation, HTTPS demo URL, and Chrome/Edge insecure-origin workaround in README and docs.
2026-05-02 03:34:31 +08:00

3150 lines
155 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';
import { canEditReport, getUsableTemplates } from '../utils/permissions';
import { getReport, saveReportToApi } from '../api/reports';
import { ApiError } from '../api/client';
import { createTemplate, listTemplates } from '../api/templates';
import { getSystemSettings } from '../api/settings';
import { createAiChatCompletion } from '../api/ai';
import { getSpeechIatWebSocketUrl } from '../api/speech';
import { getFieldLibrary, updateFieldLibrary } from '../api/library';
import { listFiles, uploadFileResource } from '../api/files';
import { isLocalFallbackEnabled } from '../config/runtime';
import { diffChars } from 'diff';
type AudioWindow = Window & typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
const getApiErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof ApiError) {
if (error.status === 401) return '登录状态已失效,请重新登录后再保存。';
return error.message || fallback;
}
if (error instanceof Error) return error.message || fallback;
return fallback;
};
export default function ReportEditor() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const reportId = searchParams.get('id');
const restoreFlag = searchParams.get('restore');
const [currentUser, setCurrentUser] = useState<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, fileId?: string}[]>([]);
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, images?: string[]}[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
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 [editorImages, setEditorImages] = useState<{id: string, src: string}[]>([]);
const [aiSelectedEditorImages, setAiSelectedEditorImages] = useState<string[]>([]);
const xfWsRef = useRef<WebSocket | null>(null);
const xfAudioContextRef = useRef<AudioContext | null>(null);
const xfMediaStreamRef = useRef<MediaStream | null>(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);
const [lastExchangeLog, setLastExchangeLog] = useState<{
startTime: string;
modelConfig: { provider: string; endpoint: string; modelName: string };
requestPayload: any;
responsePayload: any | null;
errorDetail: { status: number; statusText: string; responseText: string; message: string } | null;
} | null>(null);
useEffect(() => {
stateRef.current.chatMessages = chatMessages;
}, [chatMessages]);
// 监听编辑器中已插入的图片,同步到 AI 面板
useEffect(() => {
if (!editorRef.current) return;
const updateEditorImages = () => {
if (!editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
if (same) return prev;
// 清除已不存在的选中项
setAiSelectedEditorImages(prevSelected => prevSelected.filter(id => imgs.some(img => img.id === id)));
return imgs;
});
};
updateEditorImages();
const observer = new MutationObserver(updateEditorImages);
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
}, []);
useEffect(() => {
stateRef.current.chatInput = chatInput;
}, [chatInput]);
// 切换到 AI 面板时强制同步编辑器中的图片
useEffect(() => {
if (activeTab !== 'ai' || !editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
return same ? prev : imgs;
});
}, [activeTab]);
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
allFields.forEach(el => {
(el as HTMLElement).style.backgroundColor = '';
(el as HTMLElement).style.outline = '';
(el as HTMLElement).style.outlineOffset = '';
});
if (activeFieldKey) {
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="${activeFieldKey}"]`) as HTMLElement;
if (targetEl) {
targetEl.style.backgroundColor = '#f1f5f9';
targetEl.style.outline = '1px solid #94a3b8';
targetEl.style.outlineOffset = '1px';
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [activeFieldKey]);
const [multiSelectOptions, setMultiSelectOptions] = useState<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, chatMessages, chatInput });
const videoUploadPromisesRef = useRef<Promise<void>[]>([]);
const frameUploadPromisesRef = useRef<Promise<void>[]>([]);
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,
chatMessages: stateRef.current.chatMessages,
chatInput: stateRef.current.chatInput
});
}
}, [reportId]);
const fileToDataUrl = (file: File) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
reader.readAsDataURL(file);
});
const replaceEditorImageSrc = (from: string, to: string) => {
if (!editorRef.current || !from || !to || from === to) return;
editorRef.current.querySelectorAll('img').forEach((img) => {
if ((img as HTMLImageElement).src === from || img.getAttribute('src') === from) {
img.setAttribute('src', to);
}
});
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const startVideoUpload = (videoId: string, file: File) => {
const promise = fileToDataUrl(file)
.then((dataUrl) => uploadFileResource(dataUrl, {
kind: 'VIDEO',
filename: file.name,
reportId: reportId || undefined,
}))
.then((uploaded) => {
const next = stateRef.current.videos.map((video) =>
video.id === videoId ? { ...video, url: uploaded.url, fileId: uploaded.id } : video,
);
stateRef.current = { ...stateRef.current, videos: next };
setVideos(next);
})
.catch(() => {});
videoUploadPromisesRef.current.push(promise);
};
const startFrameUpload = (frameId: number, dataUrl: string) => {
const promise = uploadFileResource(dataUrl, {
kind: 'FRAME',
filename: `frame-${frameId}.jpg`,
reportId: reportId || undefined,
})
.then((uploaded) => {
const next = stateRef.current.capturedFrames.map((frame) =>
frame.id === frameId ? { ...frame, dataUrl: uploaded.url, fileId: uploaded.id } : frame,
);
stateRef.current = { ...stateRef.current, capturedFrames: next };
setCapturedFrames(next);
replaceEditorImageSrc(dataUrl, uploaded.url);
})
.catch(() => {});
frameUploadPromisesRef.current.push(promise);
};
const waitForMediaUploads = async () => {
const uploads = [...videoUploadPromisesRef.current, ...frameUploadPromisesRef.current];
if (uploads.length === 0) return;
await Promise.allSettled(uploads);
videoUploadPromisesRef.current = [];
frameUploadPromisesRef.current = [];
};
useEffect(() => {
const user = storage.get<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);
void getFieldLibrary().then((library) => {
if (library.formFields.length > 0) {
setFormFields(library.formFields);
storage.set('formFieldsConfig', library.formFields);
}
if (Object.keys(library.multiSelectOptions || {}).length > 0) {
setMultiSelectOptions(library.multiSelectOptions);
storage.set('multiSelectOptions', library.multiSelectOptions);
}
if (library.anesthesiaOptions.length > 0) {
setAnesthesiaOptions(library.anesthesiaOptions);
storage.set('anesthesiaOptions', library.anesthesiaOptions);
}
}).catch(() => {});
void listFiles('TEMPLATE_ASSET').then((files) => {
if (files.length === 0) return;
const assets = files.map((file) => ({ id: file.id, name: file.filename, dataUrl: file.url }));
setImageAssets(assets);
storage.set('imageAssets', assets);
}).catch(() => {});
void getSystemSettings().then((apiSettings) => {
storage.set('systemSettings', apiSettings);
}).catch(() => {});
const allTemplates = storage.get<Template[]>('templates', []);
const filteredTemplates = getUsableTemplates(user, allTemplates);
setTemplates(filteredTemplates);
void listTemplates('use').then((response) => {
if (response.items.length === 0) return;
setTemplates(response.items);
storage.set('templates', response.items);
}).catch(() => {});
if (reportId) {
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && draft.draftReportId === reportId) {
if (draft.reportData) setReportData(draft.reportData);
if (draft.videos) {
setVideos(draft.videos);
if (draft.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = draft.videos.length;
}
if (draft.capturedFrames) {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
} else {
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
const users = storage.get<User[]>('users', []);
if (!canEditReport(user, found, users)) {
alert('您没有权限编辑此报告');
navigate('/report-manage');
return;
}
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
const restoreContent = storage.getSession<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);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || []
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
}
if (!contentLoadedRef.current && editorRef.current) {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
contentRef.current = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
contentRef.current = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
}
}, [reportId, navigate, draftKey, restoreFlag]);
const persistEditorFieldLibrary = (next: Partial<{
formFields: FormField[];
multiSelectOptions: Record<string, string[]>;
anesthesiaOptions: string[];
}>) => {
void updateFieldLibrary({
formFields,
multiSelectOptions,
anesthesiaOptions,
customTimeFormats: [],
...next,
}).catch(() => {});
};
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
useEffect(() => {
if (!reportId || !currentUser) return;
const draft = storage.get<Record<string, any> | null>(draftKey, null);
if (draft && draft.draftReportId === reportId) return;
const loadApiReport = async () => {
try {
const apiReport = await getReport(reportId);
setReportData(apiReport);
setCapturedFrames((apiReport.capturedFrames || []).sort((a, b) => a.time - b.time));
setVideos(apiReport.videos || []);
if (apiReport.videos && apiReport.videos.length > 0) setCurrentVideoIndex(0);
prevVideoCountRef.current = apiReport.videos?.length || 0;
stateRef.current = {
...stateRef.current,
reportData: apiReport,
videos: apiReport.videos || [],
capturedFrames: apiReport.capturedFrames || []
};
const reports = storage.get<Report[]>('reports', []);
storage.set('reports', [...reports.filter(r => r.id !== apiReport.id), apiReport]);
if (editorRef.current && !restoreFlag) {
editorRef.current.innerHTML = apiReport.content;
contentRef.current = apiReport.content;
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}
} catch {
if (!isLocalFallbackEnabled()) {
alert('报告加载失败或无权访问');
navigate('/report-manage');
}
}
};
void loadApiReport();
}, [currentUser, draftKey, reportId, restoreFlag]);
// Auto-fill current time for fields with timeDefault === 'current'
useEffect(() => {
if (formFields.length === 0) return;
let hasChange = false;
const updates: any = {};
formFields.forEach(field => {
if (field.timeDefault === 'current') {
if (field.type === 'date') {
const current = new Date().toISOString().split('T')[0];
if (!(reportData as any)[field.key]) {
updates[field.key] = current;
hasChange = true;
}
} else if (field.type === 'time') {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
if (field.key === 'startTime') {
if (!reportData.startHour) {
updates.startHour = hh;
updates.startMinute = mm;
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
updates.endHour = hh;
updates.endMinute = mm;
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = `${hh}:${mm}`;
hasChange = true;
}
}
}
} else if (field.timeDefault === 'specific' && field.fixedTimeValue) {
if (field.type === 'date') {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
} else if (field.type === 'time') {
if (field.key === 'startTime') {
if (!reportData.startHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.startHour = hh || '';
updates.startMinute = mm || '';
hasChange = true;
}
} else if (field.key === 'endTime') {
if (!reportData.endHour) {
const [hh, mm] = field.fixedTimeValue.split(':');
updates.endHour = hh || '';
updates.endMinute = mm || '';
hasChange = true;
}
} else {
if (!(reportData as any)[field.key]) {
updates[field.key] = field.fixedTimeValue;
hasChange = true;
}
}
}
}
});
if (hasChange) {
setReportData(prev => {
const next = { ...prev, ...updates };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
}, [formFields]);
useEffect(() => {
if (!editorRef.current) return;
const observer = new MutationObserver(() => {
updatePageHeight();
});
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true, characterData: true });
return () => observer.disconnect();
}, [currentUser]);
const triggerPlaceholderUpload = (placeholder: HTMLElement) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<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 };
newVideos.forEach((video, index) => startVideoUpload(video.id, files[index]));
setCurrentVideoIndex(videos.length); // select first newly uploaded video
if (videoInputRef.current) videoInputRef.current.value = '';
saveDraftToStorage();
};
const removeVideo = (id: string) => {
const idx = videos.findIndex(v => v.id === id);
const updated = videos.filter(v => v.id !== id);
setVideos(updated);
stateRef.current = { ...stateRef.current, videos: updated };
if (currentVideoIndex >= updated.length) {
setCurrentVideoIndex(updated.length > 0 ? 0 : -1);
} else if (currentVideoIndex === idx && updated.length > 0) {
setCurrentVideoIndex(0);
}
const nextFrames = capturedFrames.filter(f => f.videoIndex !== idx).map(f => {
if (f.videoIndex > idx) return { ...f, videoIndex: f.videoIndex - 1 };
return f;
}).sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
saveDraftToStorage();
};
const selectVideo = (index: number) => {
setCurrentVideoIndex(index);
setIsPlaying(false);
};
const togglePlay = () => {
if (!videoRef.current) return;
if (isPlaying) videoRef.current.pause();
else videoRef.current.play();
setIsPlaying(!isPlaying);
};
const captureFrame = () => {
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time: video.currentTime,
timeFormatted: formatTime(video.currentTime),
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: true
};
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
setCapturedFrames(nextFrames);
stateRef.current = { ...stateRef.current, capturedFrames: nextFrames };
startFrameUpload(newFrame.id, newFrame.dataUrl);
saveDraftToStorage();
};
const autoCaptureFrames = async () => {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
const dur = video.duration || 1;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const wasPlaying = !video.paused;
if (wasPlaying) video.pause();
let accumulatedFrames = [...capturedFrames];
for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
const time = (pos / 100) * dur;
video.currentTime = time;
await new Promise<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 };
startFrameUpload(newFrame.id, newFrame.dataUrl);
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<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 stripHtml = (html: string): string => {
const tmp = document.createElement('div');
tmp.innerHTML = html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p/gi, '</p>\n\n<p');
return (tmp.innerText || tmp.textContent || '').trim();
};
const computeDiffHtml = (oldText: string, newText: string, side: 'left' | 'right'): string => {
const diffs = diffChars(oldText, newText);
let html = '';
for (const part of diffs) {
let value = part.value.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (side === 'left' && part.removed) {
html += `<span class="diff-removed" style="background-color:#fee2e2;color:#dc2626;text-decoration:line-through;">${value}</span>`;
} else if (side === 'right' && part.added) {
html += `<span class="diff-added" style="background-color:#dcfce7;color:#16a34a;font-weight:500;">${value}</span>`;
} else if (!part.added && !part.removed) {
html += value;
}
}
return html;
};
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
const output = new DataView(new ArrayBuffer(input.length * 2));
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return output.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
const toggleListening = async () => {
// 专门提取一个彻底关闭物理麦克风的函数
const stopMicrophone = () => {
if (xfAudioContextRef.current) {
try { xfAudioContextRef.current.close(); } catch {}
xfAudioContextRef.current = null;
}
if (xfMediaStreamRef.current) {
xfMediaStreamRef.current.getTracks().forEach(t => t.stop());
xfMediaStreamRef.current = null;
}
};
if (isListening) {
setIsListening(false);
stopMicrophone();
if (xfWsRef.current && xfWsRef.current.readyState === WebSocket.OPEN) {
try {
const endFrame = { data: { status: 2, format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' } };
xfWsRef.current.send(JSON.stringify(endFrame));
} catch {}
}
return;
}
try {
const mediaDevices = navigator.mediaDevices;
const AudioContextClass = window.AudioContext || (window as AudioWindow).webkitAudioContext;
if (!mediaDevices?.getUserMedia) {
alert(window.isSecureContext
? '当前浏览器不支持麦克风采集,请更换新版 Chrome/Edge 后重试。'
: '浏览器不允许在普通局域网 HTTP 页面中调用麦克风。请使用 http://localhost:4002、https://localhost:4443或用浏览器演示参数把当前 HTTP 地址标记为可信。');
return;
}
if (!AudioContextClass) {
alert('当前浏览器不支持音频采集处理,请更换新版 Chrome/Edge 后重试。');
return;
}
const ws = new WebSocket(getSpeechIatWebSocketUrl());
xfWsRef.current = ws;
let frameStatus = 0;
ws.onopen = async () => {
try {
const stream = await mediaDevices.getUserMedia({ audio: true });
xfMediaStreamRef.current = stream;
const audioContext = new AudioContextClass({ sampleRate: 16000 });
xfAudioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (ws.readyState !== WebSocket.OPEN || !xfAudioContextRef.current) return;
const inputData = e.inputBuffer.getChannelData(0);
const pcmBuffer = floatTo16BitPCM(inputData);
const base64Audio = arrayBufferToBase64(pcmBuffer);
const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } };
ws.send(JSON.stringify(frame));
frameStatus = 1;
};
source.connect(processor);
processor.connect(audioContext.destination);
setIsListening(true);
} catch (e: any) {
alert('麦克风启动失败: ' + e.message);
setIsListening(false);
ws.close();
}
};
ws.onmessage = (event) => {
try {
const jsonData = JSON.parse(event.data);
if (jsonData.code !== 0 && jsonData.code !== undefined) {
alert(`讯飞语音报错: ${jsonData.message || '未知错误'} (错误码: ${jsonData.code})`);
setIsListening(false);
stopMicrophone();
ws.close();
return;
}
if (jsonData.data?.result?.ws) {
let seg = '';
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
if (seg) {
setChatInput(prev => prev + seg);
}
}
if (jsonData.data?.status === 2) {
ws.close();
xfWsRef.current = null;
setIsListening(false);
stopMicrophone();
}
} catch {}
};
ws.onerror = () => { alert('讯飞语音连接失败,请确认已登录且超级管理员已配置语音参数'); setIsListening(false); stopMicrophone(); };
ws.onclose = () => { setIsListening(false); stopMicrophone(); };
} catch (e: any) {
alert('讯飞语音初始化失败: ' + e.message);
}
};
const handleAIGenerate = async (text: string) => {
if (!text.trim()) return;
const userMsgId = Date.now().toString();
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text, images: allImages.length > 0 ? allImages : undefined }]);
setChatInput('');
setIsGenerating(true);
try {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
let actualTargetId = aiTargetRegion;
if (aiModifyEnabled && actualTargetId === 'none') {
const availableRegions = checkAiRegions();
if (availableRegions.length > 0) {
actualTargetId = availableRegions[0].id;
setAiTargetRegion(actualTargetId);
}
}
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
// 合并溢出的段落:浏览器 contentEditable 可能在回车时把 <p> 生成到 .ai-content 之外
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
// 同步更新 contentRef 和草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').replace(/>(\s+)</g, '><').trim() : '';
const globalContextText = editorRef.current?.innerText || '';
let messageContent: any;
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
if (aiModifyEnabled && targetRegionEl) {
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
}
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 仅针对【目标区域】的主题生成对应的多段落 HTML 内容\n2. ⚠️ 绝对禁止将【全局参考内容】中的其他模块(如:基本信息、术后情况、标本描述、病理结果、医生签名、日期等)生成并混入你的输出中!你只能输出该区域应有的内容\n3. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n4. 输出紧凑 HTML标签间不要有空格或换行`;
if (allImages.length > 0) {
messageContent = [];
allImages.forEach(url => {
messageContent.push({ type: 'image_url', image_url: { url } });
});
messageContent.push({ type: 'text', text: promptText });
} else {
messageContent = promptText;
}
const systemPrompt = aiModifyEnabled
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】直接重写并输出目标区域的 HTML。\n\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json'
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】并根据【医生指令】进行专业解答。\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释';
const payload: any = {
model: modelName,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: messageContent }
],
temperature: 0.3
};
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
if (isKimiK25) {
delete payload.temperature;
delete payload.top_p;
delete payload.presence_penalty;
delete payload.frequency_penalty;
}
const logEntry = {
startTime: new Date().toISOString(),
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: '/api/ai/chat', modelName },
requestPayload: JSON.parse(JSON.stringify(payload)),
responsePayload: null as any | null,
errorDetail: null as { status: number; statusText: string; responseText: string; message: string } | null
};
const data = await createAiChatCompletion(payload);
logEntry.responsePayload = data;
setLastExchangeLog(logEntry);
const responseText = data.choices[0].message.content.trim();
const cleanedText = responseText.replace(/```json\n?|```/g, '');
let responseJson: any = {};
try {
responseJson = JSON.parse(cleanedText);
} catch {
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
responseJson = JSON.parse(jsonMatch[0]);
} catch {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
} else {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
}
if (responseJson.reply) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
}
if (aiModifyEnabled && !responseJson.updatedHtml) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]);
}
if (responseJson.updatedHtml && aiModifyEnabled) {
let cleanHtml = responseJson.updatedHtml;
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
cleanHtml = cleanHtml.trim();
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">');
if (targetRegionEl) {
setDiffModal({
isOpen: true,
originalHtml: currentHtml,
newHtml: cleanHtml,
targetId: actualTargetId
});
} else {
execCmd('insertHTML', cleanHtml);
}
}
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
} catch (error: any) {
console.error(error);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
} finally {
setIsGenerating(false);
}
};
const confirmAiInjection = (newHtml: string, regionId: string) => {
if (!editorRef.current) return;
const cleanHtml = newHtml.replace(/<span class="diff-(added|removed)"[^>]*>(.*?)<\/span>/gi, '$2');
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
if (targetContent) {
targetContent.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(targetContent);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('insertHTML', false, cleanHtml);
targetContent.style.transition = 'background-color 0.3s ease';
targetContent.style.backgroundColor = '#bfdbfe';
setTimeout(() => {
targetContent.style.backgroundColor = '#eff6ff';
setTimeout(() => {
targetContent.style.backgroundColor = 'transparent';
}, 800);
}, 400);
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
setDiffModal(null);
};
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
e.dataTransfer.setData('frameId', frame.id.toString());
};
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<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);
setChatMessages([]);
setChatInput('');
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
loadedTemplateId: tpl.id,
reportData: nextReportData,
videos: [],
capturedFrames: [],
chatMessages: [],
chatInput: '',
activeTab: stateRef.current.activeTab
};
updatePageHeight();
saveDraftToStorage();
}
setPendingTemplateId(null);
}
}, [pendingTemplateId, templates]);
// Safety net: ensure editor gets initialized if ref was not ready during init effect
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
const user = storage.get<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 || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
}
const reports = storage.get<Report[]>('reports', []);
const found = reports.find(r => r.id === reportId);
if (found) {
const users = storage.get<User[]>('users', []);
if (!user || !canEditReport(user, found, users)) {
alert('您没有权限编辑此报告');
navigate('/report-manage');
return;
}
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 filteredTemplates = userData ? getUsableTemplates(userData, allTemplates) : [];
if (settings.defaultTemplate && filteredTemplates.length > 0) {
const tpl = filteredTemplates.find(t => t.id === settings.defaultTemplate);
if (tpl) {
setLoadedTemplateId(tpl.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: tpl.id };
editorRef.current.innerHTML = tpl.content;
} else {
editorRef.current.innerHTML = defaultReportContent;
}
} else {
editorRef.current.innerHTML = defaultReportContent;
}
contentLoadedRef.current = true;
setTimeout(() => updatePageHeight(), 0);
}, []);
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
const hour12Options = Array.from({ length: 12 }, (_, i) => ((i + 1).toString().padStart(2, '0')));
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
if (!isoDate || !fmt) return isoDate || '';
const [y, m, d] = isoDate.split('-');
return fmt
.replace(/YYYY/g, y || '')
.replace(/MM/g, m || '')
.replace(/DD/g, d || '');
};
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
if (!timeStr || !fmt) return timeStr || '';
if (fmt === '24h') fmt = 'HH:mm';
const [h24str, mstr] = timeStr.split(':');
const h24 = parseInt(h24str) || 0;
const isPM = h24 >= 12;
let h12 = h24 % 12;
if (h12 === 0) h12 = 12;
return fmt
.replace(/HH/g, String(h24).padStart(2, '0'))
.replace(/mm/g, mstr || '00')
.replace(/hh/g, String(h12).padStart(2, '0'))
.replace(/A/g, isPM ? '下午' : '上午');
};
const parseDateFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
if (!nums) return text;
let y = '', m = '', d = '';
if (nums.length >= 3) { y = nums[0].padStart(4, '0'); m = nums[1].padStart(2, '0'); d = nums[2].padStart(2, '0'); }
else if (nums.length === 2) { m = nums[0].padStart(2, '0'); d = nums[1].padStart(2, '0'); y = new Date().getFullYear().toString(); }
return `${y}-${m}-${d}`;
};
const parseTimeFromFormat = (text: string, fmt?: string): string => {
if (!text || !fmt) return text;
const nums = text.match(/\d+/g);
const ampm = text.match(/上午|下午/);
if (!nums || nums.length < 2) return text;
let h = parseInt(nums[0]);
if (ampm) {
const isPM = ampm[0] === '下午';
if (isPM && h !== 12) h += 12;
if (!isPM && h === 12) h = 0;
}
return `${String(h).padStart(2, '0')}:${nums[1].padStart(2, '0')}`;
};
const to24h = (h12: number, isPM: boolean): number => {
if (isPM && h12 !== 12) return h12 + 12;
if (!isPM && h12 === 12) return 0;
return h12;
};
const from24h = (h24: number): { h: number; isPM: boolean } => {
const isPM = h24 >= 12;
let h = h24 % 12;
if (h === 0) h = 12;
return { h, isPM };
};
const addTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
if (!current.includes(value)) {
const next = { ...reportData, [field]: [...current, value] };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
}
// Persist custom value to global options for future reuse
const opts = multiSelectOptions[field] || [];
if (!opts.includes(value)) {
const next = { ...multiSelectOptions, [field]: [...opts, value] };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
persistEditorFieldLibrary({ multiSelectOptions: next });
}
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options && !fieldDef.options.includes(value)) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: [...(f.options || []), value] } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
persistEditorFieldLibrary({ formFields: updatedFields });
}
};
const removeTag = (field: string, value: string) => {
const current = (reportData as any)[field] || [];
const next = { ...reportData, [field]: current.filter((v: string) => v !== value) };
setReportData(next);
stateRef.current = { ...stateRef.current, reportData: next };
saveDraftToStorage();
};
const removeMultiOption = (field: string, value: string) => {
const current = multiSelectOptions[field] || [];
const next = { ...multiSelectOptions, [field]: current.filter(v => v !== value) };
setMultiSelectOptions(next);
storage.set('multiSelectOptions', next);
persistEditorFieldLibrary({ multiSelectOptions: next });
// Sync to formFieldsConfig
const fieldDef = formFields.find(f => f.key === field);
if (fieldDef && fieldDef.options) {
const updatedFields = formFields.map(f => f.key === field ? { ...f, options: (f.options || []).filter(v => v !== value) } : f);
setFormFields(updatedFields);
storage.set('formFieldsConfig', updatedFields);
persistEditorFieldLibrary({ formFields: updatedFields });
}
};
const removeAnesthesiaOption = (value: string) => {
const next = anesthesiaOptions.filter(v => v !== value);
setAnesthesiaOptions(next);
storage.set('anesthesiaOptions', next);
persistEditorFieldLibrary({ anesthesiaOptions: next });
};
// Close dropdowns on outside click
useEffect(() => {
const handleDocClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.select-dropdown-root')) {
setOpenDropdown(null);
}
};
document.addEventListener('click', handleDocClick);
return () => document.removeEventListener('click', handleDocClick);
}, []);
const saveReport = async (status: 'draft' | 'completed') => {
if (status === 'completed' && (!reportData.patientName || !reportData.hospitalId)) {
alert('请填写患者姓名和住院号');
return;
}
if (status === 'completed') {
const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]');
if (hasSignatureField) {
const hasSignatureImage = !!currentUser?.signature;
if (!hasSignatureImage) {
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
if (!proceed) return;
}
}
}
await waitForMediaUploads();
const content = editorRef.current?.innerHTML || '';
const now = new Date().toISOString();
const reports = storage.get<Report[]>('reports', []);
const old = reportId ? reports.find(r => r.id === reportId) : undefined;
const nextRevision = old && old.status === 'completed' ? (old.revision || 1) + 1 : (old?.revision || 1);
const finalReport: Report = {
...(reportData as Report),
id: reportId || 'RPT_' + Date.now(),
content,
author: currentUser?.username || '',
authorName: currentUser?.name || '',
createdAt: reportData.createdAt || now.split('T')[0],
department: reportData.department || currentUser?.department || '',
status,
revision: nextRevision,
capturedFrames: stateRef.current.capturedFrames,
videos: stateRef.current.videos,
updatedAt: now
};
let savedReport = finalReport;
let apiSaved = false;
try {
savedReport = await saveReportToApi(finalReport, reportId || undefined);
apiSaved = true;
} catch (error) {
if (!isLocalFallbackEnabled()) {
const message = getApiErrorMessage(error, '后端服务不可用');
alert(`保存失败:${message}`);
if (error instanceof ApiError && error.status === 401) navigate('/');
return;
}
}
let updatedReports: Report[];
if (reportId) {
const history = old?.history ? [...old.history] : [];
if (old) {
history.push({
content: old.content,
updatedAt: old.updatedAt || old.createdAt,
updatedBy: currentUser?.name || currentUser?.username || '',
action: status === 'completed' ? 'complete_report' : 'save_draft',
revision: old.revision || 1
});
}
const nextHistory = savedReport.history && savedReport.history.length > 0 ? savedReport.history : history;
updatedReports = reports.map(r => r.id === reportId ? { ...savedReport, history: nextHistory } : r);
} else {
updatedReports = [...reports, savedReport];
if (draftKey) storage.remove(draftKey);
}
if (apiSaved || isLocalFallbackEnabled()) {
storage.set('reports', updatedReports);
}
if (draftKey) storage.remove(draftKey);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
if (status === 'completed') navigate('/report-manage');
};
const saveAsPersonalTemplate = async () => {
if (!currentUser || !editorRef.current) return;
const name = window.prompt('请输入个人模板名称:', `${reportData.title || '手术报告'}-我的模板`);
if (!name || !name.trim()) return;
const allTemplates = storage.get<Template[]>('templates', []);
const newTemplate: Template = {
id: `personal_${currentUser.username}_${Date.now()}`,
name: name.trim(),
desc: '我的个人模板',
content: editorRef.current.innerHTML,
createdAt: new Date().toISOString(),
author: currentUser.username,
fields: formFields,
scope: 'personal',
ownerUser: currentUser.username,
department: currentUser.department || ''
};
let savedTemplate = newTemplate;
let apiSaved = false;
try {
savedTemplate = await createTemplate(newTemplate);
apiSaved = true;
} catch (error) {
if (!isLocalFallbackEnabled()) {
const message = getApiErrorMessage(error, '后端服务不可用');
alert(`保存个人模板失败:${message}`);
if (error instanceof ApiError && error.status === 401) navigate('/');
return;
}
}
const updated = [...allTemplates.filter(t => t.id !== savedTemplate.id), savedTemplate];
if (apiSaved || isLocalFallbackEnabled()) {
storage.set('templates', updated);
}
const usable = getUsableTemplates(currentUser, updated);
setTemplates(usable);
setLoadedTemplateId(savedTemplate.id);
stateRef.current = { ...stateRef.current, loadedTemplateId: savedTemplate.id };
alert('个人模板已保存');
};
const handleEditorInput = (e: React.FormEvent<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>
{currentUser.role === 'user' && (
<button
onClick={saveAsPersonalTemplate}
className="px-4 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 flex items-center justify-center gap-1.5 ${
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'ai' && <Bot size={16} className={activeTab === 'ai' ? 'text-accent' : 'text-text-muted'} />}
{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'}`}>
<div>{msg.content}</div>
{msg.images && msg.images.length > 0 && (
<div className="flex gap-1.5 mt-2 flex-wrap">
{msg.images.map((src, idx) => (
<img key={idx} src={src} className="w-10 h-10 object-cover rounded border border-white/30" />
))}
</div>
)}
</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>
<label className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300 cursor-pointer">
<input
type="checkbox"
checked={aiModifyEnabled}
onChange={(e) => {
e.stopPropagation();
setAiModifyEnabled(e.target.checked);
}}
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
/>
<span className="text-[11px] text-slate-600 font-bold">
</span>
</label>
</div>
{/* 视觉参考上下文 - 编辑器中已插入的图片 */}
{editorImages.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
{editorImages.map(img => {
const isSelected = aiSelectedEditorImages.includes(img.id);
return (
<div key={img.id} onClick={() => setAiSelectedEditorImages(prev => isSelected ? prev.filter(id => id !== img.id) : [...prev, img.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={img.src} 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>}
<button onClick={() => {
const data = {
exportAt: new Date().toISOString(),
url: window.location.href,
messages: chatMessages,
lastExchange: lastExchangeLog,
metadata: {
user: currentUser?.username || 'anonymous',
activeProvider: (() => { const s = storage.get<SystemSettings>('systemSettings', {} as SystemSettings); return s.activeAiProvider || 'kimi'; })(),
targetRegion: aiTargetRegion,
modifyEnabled: aiModifyEnabled,
chatInput,
uploadedImagesCount: aiUploadedImages.length,
selectedFramesCount: aiSelectedEditorImages.length
}
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}} className="px-2 py-1 bg-slate-100 text-slate-500 text-[11px] rounded-full hover:bg-slate-200 ml-auto" title="导出 AI 日志(调试用)">
AI
</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>
<button onClick={() => { if (!isGenerating && chatInput.trim()) handleAIGenerate(chatInput); }} disabled={isGenerating || !chatInput.trim()} className="p-1.5 rounded-lg transition-colors text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed" title="发送">
<Send 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>
{(() => {
const oldText = stripHtml(diffModal.originalHtml);
const newText = stripHtml(diffModal.newHtml);
const leftDiffHtml = computeDiffHtml(oldText, newText, 'left');
const rightDiffHtml = computeDiffHtml(oldText, newText, 'right');
return (
<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: leftDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></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: rightDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></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>
);
}