- 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.
3150 lines
155 KiB
TypeScript
3150 lines
155 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import Sidebar from '../components/Sidebar';
|
||
import {
|
||
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
||
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
||
Video, Play, Pause, Plus, X, ChevronLeft, Download,
|
||
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;">​</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, '<').replace(/>/g, '>').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(/​/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>​`;
|
||
}
|
||
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>
|
||
);
|
||
}
|