Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5648077e8 | ||
|
|
9c09e6cccc | ||
|
|
f98177938f | ||
|
|
8ffb9162d3 | ||
|
|
32f8b2a7ec | ||
|
|
519cc6fc82 | ||
|
|
4a7051b6db | ||
|
|
5f4ae1ff29 | ||
|
|
db1c11f7eb | ||
|
|
55ce78d898 | ||
|
|
e1dc961ecf | ||
|
|
67fb2c9080 | ||
|
|
a46ecffadf |
@@ -5,7 +5,7 @@ import Sidebar from '../components/Sidebar';
|
|||||||
import {
|
import {
|
||||||
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
||||||
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
||||||
Video, Play, Pause, Plus, X, ChevronLeft
|
Video, Play, Pause, Plus, X, ChevronLeft, Download
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
@@ -48,11 +48,13 @@ export default function ReportEditor() {
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [loadedTemplateId, setLoadedTemplateId] = useState('');
|
const [loadedTemplateId, setLoadedTemplateId] = useState('');
|
||||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
||||||
const prevVideoCountRef = useRef(0);
|
const prevVideoCountRef = useRef(0);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
|
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
|
||||||
|
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
|
||||||
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
|
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
|
||||||
surgeon: ['张医生', '李医生', '王医生'],
|
surgeon: ['张医生', '李医生', '王医生'],
|
||||||
assistant: ['赵医生', '钱医生', '孙医生'],
|
assistant: ['赵医生', '钱医生', '孙医生'],
|
||||||
@@ -354,11 +356,24 @@ export default function ReportEditor() {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const src = event.target?.result as string;
|
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 = `
|
placeholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
<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.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;
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
@@ -377,6 +392,28 @@ export default function ReportEditor() {
|
|||||||
const targetEl = node as HTMLElement | null;
|
const targetEl = node as HTMLElement | null;
|
||||||
if (!targetEl) return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
|
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
|
||||||
if (!placeholder) return;
|
if (!placeholder) return;
|
||||||
|
|
||||||
@@ -385,12 +422,25 @@ export default function ReportEditor() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (placeholder.classList.contains('has-image')) {
|
if (placeholder.classList.contains('has-image')) {
|
||||||
placeholder.classList.remove('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 = `
|
placeholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
|
||||||
`;
|
`;
|
||||||
placeholder.style.border = '1px dashed #cbd5e1';
|
placeholder.style.border = '1px dashed #cbd5e1';
|
||||||
placeholder.style.background = '#f8fafc';
|
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;
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
} else {
|
} else {
|
||||||
@@ -465,13 +515,24 @@ export default function ReportEditor() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
|
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 = `
|
placeholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
<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.classList.add('has-image');
|
||||||
placeholder.style.border = 'none';
|
placeholder.style.border = 'none';
|
||||||
placeholder.style.background = 'transparent';
|
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;
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
@@ -484,6 +545,32 @@ export default function ReportEditor() {
|
|||||||
saveDraftToStorage();
|
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 = () => {
|
const insertTable = () => {
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||||
@@ -624,11 +711,20 @@ export default function ReportEditor() {
|
|||||||
if (emptyPlaceholder) {
|
if (emptyPlaceholder) {
|
||||||
emptyPlaceholder.innerHTML = `
|
emptyPlaceholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
<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.classList.add('has-image');
|
||||||
emptyPlaceholder.style.border = 'none';
|
emptyPlaceholder.style.border = 'none';
|
||||||
emptyPlaceholder.style.background = 'transparent';
|
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;
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
}
|
}
|
||||||
@@ -653,13 +749,24 @@ export default function ReportEditor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
|
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 = `
|
placeholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
<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.classList.add('has-image');
|
||||||
placeholder.style.border = 'none';
|
placeholder.style.border = 'none';
|
||||||
placeholder.style.background = 'transparent';
|
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;
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
saveDraftToStorage();
|
saveDraftToStorage();
|
||||||
};
|
};
|
||||||
@@ -1272,6 +1379,13 @@ export default function ReportEditor() {
|
|||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
完成报告
|
完成报告
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
|
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"
|
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
|
||||||
@@ -1292,6 +1406,7 @@ export default function ReportEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
<select
|
<select
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
|
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"
|
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
|
||||||
>
|
>
|
||||||
@@ -1301,6 +1416,27 @@ export default function ReportEditor() {
|
|||||||
<option value="SimHei">黑体</option>
|
<option value="SimHei">黑体</option>
|
||||||
<option value="KaiTi">楷体</option>
|
<option value="KaiTi">楷体</option>
|
||||||
</select>
|
</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>
|
||||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
<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('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>
|
||||||
@@ -1316,9 +1452,9 @@ export default function ReportEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
<button 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 onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
<button 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 onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
<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>
|
||||||
<div className="flex gap-1">
|
<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={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>
|
||||||
@@ -1366,14 +1502,30 @@ export default function ReportEditor() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||||
{activeTab === 'info' && (
|
{activeTab === 'info' && (
|
||||||
<div className="report-info-form space-y-4">
|
<div className="report-info-form space-y-4">
|
||||||
{formFields.filter(f => f.visibleInForm).map(field => {
|
{(() => {
|
||||||
|
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 isRequired = field.isSystemLocked;
|
||||||
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
|
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
|
||||||
|
|
||||||
if (field.type === 'text' || field.type === 'date') {
|
if (field.type === 'text' || field.type === 'date') {
|
||||||
const inputType = field.type === 'date' ? 'date' : 'text';
|
const inputType = field.type === 'date' ? 'date' : 'text';
|
||||||
return (
|
return (
|
||||||
<div key={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'}>
|
<div key={field.key} id={`input-${field.key}`} className={`${field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'} 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">
|
<label className="block text-xs font-bold text-text-main">
|
||||||
{field.label} {isRequired && <span className="text-red-500">*</span>}
|
{field.label} {isRequired && <span className="text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -1393,7 +1545,7 @@ export default function ReportEditor() {
|
|||||||
const isOpen = openDropdown === field.key;
|
const isOpen = openDropdown === field.key;
|
||||||
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
|
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="space-y-1 select-dropdown-root relative">
|
<div key={field.key} id={`input-${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>
|
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||||||
<div
|
<div
|
||||||
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
|
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
|
||||||
@@ -1502,7 +1654,7 @@ export default function ReportEditor() {
|
|||||||
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
|
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="space-y-1 select-dropdown-root relative">
|
<div key={field.key} id={`input-${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>
|
<label className="block text-xs font-bold text-text-main">{field.label}(可多选)</label>
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -1583,7 +1735,7 @@ export default function ReportEditor() {
|
|||||||
const { h: h12, isPM } = from24h(h24val);
|
const { h: h12, isPM } = from24h(h24val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="space-y-1">
|
<div key={field.key} id={`input-${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>
|
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -1640,7 +1792,7 @@ export default function ReportEditor() {
|
|||||||
const { h: h12g, isPM: isPMg } = from24h(h24);
|
const { h: h12g, isPM: isPMg } = from24h(h24);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="space-y-1">
|
<div key={field.key} id={`input-${field.key}`} className="space-y-1">
|
||||||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -1697,7 +1849,7 @@ export default function ReportEditor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'video' && (
|
{activeTab === 'video' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
ref={videoInputRef}
|
ref={videoInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1706,49 +1858,44 @@ export default function ReportEditor() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleVideoUpload}
|
onChange={handleVideoUpload}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => videoInputRef.current?.click()}
|
|
||||||
className="w-full flex items-center justify-center gap-2 p-3 border border-dashed border-border rounded-lg hover:border-accent hover:bg-slate-50 transition-all"
|
|
||||||
>
|
|
||||||
<Video size={18} />
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-xs font-bold text-text-main">点击上传手术视频</p>
|
|
||||||
<p className="text-[10px] text-text-muted">支持 MP4, MOV 格式</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{videos.length > 0 && (
|
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
|
||||||
<div className="space-y-4">
|
{videos.map((v, i) => (
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
<div
|
||||||
{videos.map((v, i) => (
|
key={v.id}
|
||||||
<div
|
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
|
||||||
key={v.id}
|
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
|
||||||
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)}
|
||||||
<div
|
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
|
||||||
onClick={() => selectVideo(i)}
|
>
|
||||||
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
|
<Play size={16} />
|
||||||
>
|
</div>
|
||||||
<Play size={16} />
|
<div
|
||||||
</div>
|
onClick={() => selectVideo(i)}
|
||||||
<div
|
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
|
||||||
onClick={() => selectVideo(i)}
|
>{v.name}</div>
|
||||||
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
|
<button
|
||||||
>{v.name}</div>
|
onClick={() => removeVideo(v.id)}
|
||||||
<button
|
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"
|
||||||
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>
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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 && (
|
{currentVideoIndex !== -1 && videos.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
|
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -1786,7 +1933,7 @@ export default function ReportEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2">
|
<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>
|
<span className="text-[10px] font-bold text-text-main uppercase tracking-wider">关键帧摘取</span>
|
||||||
<button
|
<button
|
||||||
onClick={captureFrame}
|
onClick={captureFrame}
|
||||||
@@ -1837,8 +1984,6 @@ export default function ReportEditor() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1890,14 +2035,14 @@ export default function ReportEditor() {
|
|||||||
const id = 'ph_' + Date.now();
|
const id = 'ph_' + Date.now();
|
||||||
let html: string;
|
let html: string;
|
||||||
if (inTable) {
|
if (inTable) {
|
||||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
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;">${hintText}</span></div>`;
|
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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></div>`;
|
||||||
} else {
|
} else {
|
||||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
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;`;
|
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 showShortText = w > 0 && w < 80;
|
||||||
const text = showShortText ? '插图' : hintText;
|
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;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||||
}
|
}
|
||||||
execCmd('insertHTML', html);
|
execCmd('insertHTML', html);
|
||||||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||||||
@@ -1949,6 +2094,48 @@ export default function ReportEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{exportModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||||
|
<h3 className="text-lg font-bold text-text-main mb-4">导出报告</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const title = reportData.title || '无标题';
|
||||||
|
const patient = reportData.patientName || '未知';
|
||||||
|
const hid = reportData.hospitalId || '无号';
|
||||||
|
printDocument(editorRef.current?.innerHTML || '', `图文报告-${title}-${patient}-${hid}-${ts}`);
|
||||||
|
setExportModalOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
|
||||||
|
>导出 PDF</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const title = reportData.title || '无标题';
|
||||||
|
const patient = reportData.patientName || '未知';
|
||||||
|
const hid = reportData.hospitalId || '无号';
|
||||||
|
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `图文报告-${title}-${patient}-${hid}-${ts}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setExportModalOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||||
|
>导出 JSON</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExportModalOpen(false)}
|
||||||
|
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
|
||||||
|
>取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{imagePickerOpen && imagePickerTarget && (
|
{imagePickerOpen && imagePickerTarget && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
<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">
|
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export default function ReportManage() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">报告信息</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">报告信息</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">患者号</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">住院号</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">创建者</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border">创建者</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40">时间</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40">时间</th>
|
||||||
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24">状态</th>
|
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24">状态</th>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
|
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download, Upload } from 'lucide-react';
|
||||||
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
|
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { printDocument } from '../utils/print';
|
import { printDocument } from '../utils/print';
|
||||||
@@ -13,8 +13,11 @@ export default function TemplateManage() {
|
|||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
|
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [formData, setFormData] = useState({ name: '', desc: '' });
|
const [formData, setFormData] = useState({ name: '', desc: '' });
|
||||||
|
const [importedContent, setImportedContent] = useState<{content: string; fields: FormField[]} | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const savedRangeRef = useRef<Range | null>(null);
|
const savedRangeRef = useRef<Range | null>(null);
|
||||||
@@ -34,9 +37,11 @@ export default function TemplateManage() {
|
|||||||
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
|
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
|
||||||
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||||
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
|
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
|
||||||
|
const [editFieldHasUnderline, setEditFieldHasUnderline] = useState(true);
|
||||||
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
|
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
|
||||||
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||||
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
|
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
|
||||||
|
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(true);
|
||||||
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
|
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
|
||||||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||||||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||||||
@@ -125,6 +130,10 @@ export default function TemplateManage() {
|
|||||||
const template = templates.find(t => t.id === currentTemplateId);
|
const template = templates.find(t => t.id === currentTemplateId);
|
||||||
if (template) {
|
if (template) {
|
||||||
editorRef.current.innerHTML = template.content;
|
editorRef.current.innerHTML = template.content;
|
||||||
|
if (template.fields && template.fields.length > 0) {
|
||||||
|
setFormFields(template.fields);
|
||||||
|
storage.set('formFieldsConfig', template.fields);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => updatePageHeight(), 0);
|
setTimeout(() => updatePageHeight(), 0);
|
||||||
}
|
}
|
||||||
@@ -140,13 +149,24 @@ export default function TemplateManage() {
|
|||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
|
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 = `
|
placeholder.innerHTML = `
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
<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.classList.add('has-image');
|
||||||
placeholder.style.border = 'none';
|
placeholder.style.border = 'none';
|
||||||
placeholder.style.background = 'transparent';
|
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';
|
||||||
saveTemplateContent();
|
saveTemplateContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,6 +379,30 @@ export default function TemplateManage() {
|
|||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
saveTemplateContent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
saveTemplateContent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveTemplateContent = () => {
|
const saveTemplateContent = () => {
|
||||||
if (!currentTemplateId || !editorRef.current) return;
|
if (!currentTemplateId || !editorRef.current) return;
|
||||||
const allTemplates = storage.get<Template[]>('templates', []);
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
@@ -378,7 +422,8 @@ export default function TemplateManage() {
|
|||||||
}
|
}
|
||||||
pushHistory();
|
pushHistory();
|
||||||
|
|
||||||
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
const underlineClass = field.hasUnderline === false ? ' no-underline' : '';
|
||||||
|
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value${underlineClass}" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||||
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel && sel.rangeCount > 0) {
|
if (sel && sel.rangeCount > 0) {
|
||||||
@@ -447,6 +492,7 @@ export default function TemplateManage() {
|
|||||||
next.timeDefault = editFieldTimeDefault;
|
next.timeDefault = editFieldTimeDefault;
|
||||||
next.fixedTimeValue = editFieldFixedTimeValue;
|
next.fixedTimeValue = editFieldFixedTimeValue;
|
||||||
}
|
}
|
||||||
|
next.hasUnderline = editFieldHasUnderline;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setFormFields(updated);
|
setFormFields(updated);
|
||||||
@@ -464,6 +510,7 @@ export default function TemplateManage() {
|
|||||||
type: newFieldForm.type,
|
type: newFieldForm.type,
|
||||||
visibleInForm: true,
|
visibleInForm: true,
|
||||||
isSystemLocked: false,
|
isSystemLocked: false,
|
||||||
|
hasUnderline: newFieldHasUnderline,
|
||||||
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
|
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
|
||||||
? newFieldOptions.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
? newFieldOptions.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -481,6 +528,7 @@ export default function TemplateManage() {
|
|||||||
setNewFieldTimeFormat('YYYY年MM月DD日');
|
setNewFieldTimeFormat('YYYY年MM月DD日');
|
||||||
setNewFieldTimeDefault('specific');
|
setNewFieldTimeDefault('specific');
|
||||||
setNewFieldFixedTimeValue('');
|
setNewFieldFixedTimeValue('');
|
||||||
|
setNewFieldHasUnderline(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -556,6 +604,48 @@ export default function TemplateManage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.target?.result as string);
|
||||||
|
if (json.type !== 'surclaw_template_package') {
|
||||||
|
alert('无效的模板包文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormData({ name: json.title || '', desc: json.description || '' });
|
||||||
|
setImportedContent({
|
||||||
|
content: json.content || '',
|
||||||
|
fields: Array.isArray(json.fields) ? json.fields : []
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
alert('文件解析失败,请检查 JSON 格式');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
if (e.target) e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTemplate = (template: Template) => {
|
||||||
|
const exportData = {
|
||||||
|
version: '1.0',
|
||||||
|
type: 'surclaw_template_package',
|
||||||
|
title: template.name,
|
||||||
|
description: template.desc || '',
|
||||||
|
content: template.content,
|
||||||
|
fields: template.fields || formFields
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `模板导出-${template.name}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const handleModalSubmit = (e: React.FormEvent) => {
|
const handleModalSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const allTemplates = storage.get<Template[]>('templates', []);
|
const allTemplates = storage.get<Template[]>('templates', []);
|
||||||
@@ -573,14 +663,19 @@ export default function TemplateManage() {
|
|||||||
id: 'tpl_' + Date.now(),
|
id: 'tpl_' + Date.now(),
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
desc: formData.desc,
|
desc: formData.desc,
|
||||||
content: defaultReportContent,
|
content: importedContent?.content || defaultReportContent,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author: currentUser?.username || 'admin'
|
author: currentUser?.username || 'admin',
|
||||||
|
fields: importedContent?.fields || formFields
|
||||||
};
|
};
|
||||||
const updated = [...allTemplates, newTpl];
|
const updated = [...allTemplates, newTpl];
|
||||||
setTemplates([...templates, newTpl]);
|
setTemplates([...templates, newTpl]);
|
||||||
storage.set('templates', updated);
|
storage.set('templates', updated);
|
||||||
setCurrentTemplateId(newTpl.id);
|
setCurrentTemplateId(newTpl.id);
|
||||||
|
if (importedContent?.fields && importedContent.fields.length > 0) {
|
||||||
|
setFormFields(importedContent.fields);
|
||||||
|
storage.set('formFieldsConfig', importedContent.fields);
|
||||||
|
}
|
||||||
|
|
||||||
const savedUsers = storage.get<User[]>('users', []);
|
const savedUsers = storage.get<User[]>('users', []);
|
||||||
let updatedUsers = savedUsers;
|
let updatedUsers = savedUsers;
|
||||||
@@ -621,6 +716,7 @@ export default function TemplateManage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
|
setImportedContent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
@@ -666,6 +762,12 @@ export default function TemplateManage() {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleExportTemplate(tpl); }}
|
||||||
|
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
{templates.length > 1 && (
|
{templates.length > 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
|
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
|
||||||
@@ -708,6 +810,13 @@ export default function TemplateManage() {
|
|||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
保存模板
|
保存模板
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
|
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"
|
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
|
||||||
@@ -739,6 +848,27 @@ export default function TemplateManage() {
|
|||||||
<option value="SimHei">黑体</option>
|
<option value="SimHei">黑体</option>
|
||||||
<option value="KaiTi">楷体</option>
|
<option value="KaiTi">楷体</option>
|
||||||
</select>
|
</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>
|
||||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
<button onMouseDown={(e) => e.preventDefault()} 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 onMouseDown={(e) => e.preventDefault()} 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>
|
||||||
@@ -755,9 +885,9 @@ export default function TemplateManage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
<button 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={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
<button 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={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
<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>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button onMouseDown={(e) => e.preventDefault()} 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 onMouseDown={(e) => e.preventDefault()} 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>
|
||||||
@@ -854,6 +984,7 @@ export default function TemplateManage() {
|
|||||||
setEditFieldTimeFormat(field.timeFormat || '');
|
setEditFieldTimeFormat(field.timeFormat || '');
|
||||||
setEditFieldTimeDefault(field.timeDefault || 'specific');
|
setEditFieldTimeDefault(field.timeDefault || 'specific');
|
||||||
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
|
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
|
||||||
|
setEditFieldHasUnderline(field.hasUnderline !== false);
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
@@ -958,6 +1089,10 @@ export default function TemplateManage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={editFieldHasUnderline} onChange={(e) => setEditFieldHasUnderline(e.target.checked)} />
|
||||||
|
打印时显示下划线
|
||||||
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => saveFieldEdit(field.key)}
|
onClick={() => saveFieldEdit(field.key)}
|
||||||
@@ -1154,6 +1289,10 @@ export default function TemplateManage() {
|
|||||||
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
|
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<label className="flex items-center gap-1 text-xs text-slate-600 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={newFieldHasUnderline} onChange={(e) => setNewFieldHasUnderline(e.target.checked)} />
|
||||||
|
打印时显示下划线
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={addField}
|
onClick={addField}
|
||||||
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
|
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
|
||||||
@@ -1168,11 +1307,63 @@ export default function TemplateManage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exportModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||||
|
<h3 className="text-lg font-bold text-text-main mb-4">导出模板</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const name = currentTemplate?.name || '模板';
|
||||||
|
printDocument(editorRef.current?.innerHTML || '', `${name}-${ts}`);
|
||||||
|
setExportModalOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
|
||||||
|
>导出 PDF</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const name = currentTemplate?.name || '模板';
|
||||||
|
const data = currentTemplate ? { ...currentTemplate, content: editorRef.current?.innerHTML } : { content: editorRef.current?.innerHTML };
|
||||||
|
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 = `${name}-${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>
|
||||||
|
)}
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
<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-10 w-full max-w-[500px] shadow-2xl border border-border">
|
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] shadow-2xl border border-border">
|
||||||
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
|
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
|
||||||
<p className="text-sm text-text-muted mb-8">设置模板的基本名称和描述</p>
|
<p className="text-sm text-text-muted mb-8">设置模板的基本名称和描述</p>
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex items-center gap-3 mb-6 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||||
|
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleModalSubmit} className="space-y-6">
|
<form onSubmit={handleModalSubmit} className="space-y-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模板名称 *</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模板名称 *</label>
|
||||||
@@ -1197,7 +1388,7 @@ export default function TemplateManage() {
|
|||||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => { setIsModalOpen(false); setImportedContent(null); }}
|
||||||
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -1257,16 +1448,38 @@ export default function TemplateManage() {
|
|||||||
const id = 'ph_' + Date.now();
|
const id = 'ph_' + Date.now();
|
||||||
let html: string;
|
let html: string;
|
||||||
if (inTable) {
|
if (inTable) {
|
||||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
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;">${hintText}</span></div>`;
|
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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></div>`;
|
||||||
} else {
|
} else {
|
||||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
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;`;
|
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 showShortText = w > 0 && w < 80;
|
||||||
const text = showShortText ? '插图' : hintText;
|
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;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||||
}
|
}
|
||||||
execCmd('insertHTML', html);
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = html;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
while (wrapper.firstChild) {
|
||||||
|
fragment.appendChild(wrapper.firstChild);
|
||||||
|
}
|
||||||
|
const sel2 = window.getSelection();
|
||||||
|
if (sel2 && sel2.rangeCount > 0) {
|
||||||
|
const range = sel2.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(fragment);
|
||||||
|
const lastNode = fragment.lastChild;
|
||||||
|
if (lastNode) {
|
||||||
|
range.setStartAfter(lastNode);
|
||||||
|
range.collapse(true);
|
||||||
|
sel2.removeAllRanges();
|
||||||
|
sel2.addRange(range);
|
||||||
|
}
|
||||||
|
} else if (editorRef.current) {
|
||||||
|
editorRef.current.appendChild(fragment);
|
||||||
|
}
|
||||||
|
editorRef.current?.focus();
|
||||||
|
saveTemplateContent();
|
||||||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface Template {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
fields?: FormField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
@@ -116,11 +117,12 @@ export interface FormField {
|
|||||||
timeFormat?: string;
|
timeFormat?: string;
|
||||||
timeDefault?: 'current' | 'specific';
|
timeDefault?: 'current' | 'specific';
|
||||||
fixedTimeValue?: string;
|
fixedTimeValue?: string;
|
||||||
|
hasUnderline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
||||||
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: true },
|
||||||
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: true },
|
||||||
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||||
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
||||||
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||||
|
|||||||
@@ -1,85 +1,79 @@
|
|||||||
const smartField = (key: string) => `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
const noUnderlineKeys = ['patientName', 'patientGender', 'patientAge', 'department', 'bedNumber', 'hospitalId'];
|
||||||
|
const smartField = (key: string) => {
|
||||||
|
const noUlClass = noUnderlineKeys.includes(key) ? ' no-underline' : '';
|
||||||
|
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value${noUlClass}" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||||
|
};
|
||||||
|
|
||||||
export const defaultReportContent = `
|
export const defaultReportContent = `
|
||||||
<!-- 医院Logo -->
|
<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;">
|
||||||
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
|
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
|
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 14pt; font-family: SimSun; border-bottom: 1px solid #000; padding-bottom: 0; margin-bottom: 8px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div>
|
||||||
<!-- 医院名称 -->
|
<div style="font-size: 16pt; font-family: SimSun;">手术记录</div>
|
||||||
<p style="text-align: center; font-family: SimSun; margin-bottom: 8px;" contenteditable="false">
|
</div>
|
||||||
<strong><u>西 安 交 通 大 学 第 一 附 属 医 院</u></strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 报告标题 -->
|
|
||||||
<h1 style="font-family: SimSun; font-size: 20px; margin: 16px 0; text-align: center;" contenteditable="false">手术记录</h1>
|
|
||||||
|
|
||||||
<div class="template-info-section">
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
姓名:${smartField('patientName')}
|
|
||||||
性别:${smartField('patientGender')}
|
|
||||||
年龄:${smartField('patientAge')}
|
|
||||||
科别:${smartField('department')}
|
|
||||||
床号:${smartField('bedNumber')}
|
|
||||||
住院号:${smartField('hospitalId')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
<strong>手术日期:</strong>${smartField('surgeryDate')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
<strong>术后诊断:</strong>${smartField('postoperativeDiagnosis')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
<strong>手术名称:</strong>${smartField('title')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
手术开始时间:${smartField('startTime')}
|
|
||||||
手术终止时间:${smartField('endTime')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
手术者:${smartField('surgeon')}
|
|
||||||
助手:${smartField('assistant')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
|
||||||
麻醉师:${smartField('anesthesiologist')}
|
|
||||||
麻醉方式:${smartField('anesthesiaType')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0 0 1px 0; line-height: 1.2; border-bottom: 1px solid #000;">
|
||||||
|
姓名:${smartField('patientName')}
|
||||||
|
性别:${smartField('patientGender')}
|
||||||
|
年龄:${smartField('patientAge')}
|
||||||
|
科别:${smartField('department')}
|
||||||
|
床号:${smartField('bedNumber')}
|
||||||
|
住院号:${smartField('hospitalId')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
|
<strong>手术日期:</strong>${smartField('surgeryDate')}
|
||||||
|
</p>
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
|
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
|
||||||
|
</p>
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
|
<strong>术中诊断:</strong>${smartField('postoperativeDiagnosis')}
|
||||||
|
</p>
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
|
<strong>手术名称:</strong>${smartField('title')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border: none; font-family: SimSun; font-size: 12pt; margin-top: 0; margin-bottom: 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术开始时间:${smartField('startTime')}</td>
|
||||||
|
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术终止时间:${smartField('endTime')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border: none; padding: 0; line-height: 1.5;">手术者:${smartField('surgeon')}</td>
|
||||||
|
<td style="border: none; padding: 0; line-height: 1.5;">助手:${smartField('assistant')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border: none; padding: 0; line-height: 1.5;">麻醉师:${smartField('anesthesiologist')}</td>
|
||||||
|
<td style="border: none; padding: 0; line-height: 1.5;">麻醉方式:${smartField('anesthesiaType')}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
<strong>手术步骤、术中出现的情况及处理:</strong>
|
<strong>手术步骤、术中出现的情况及处理:</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
|
1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
|
2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
|
3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
|
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。
|
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -87,74 +81,76 @@ export const defaultReportContent = `
|
|||||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
||||||
<tbody><tr>
|
<tbody><tr>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图A 腹腔镜探查</p>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图B 胆囊管夹闭与离断</p>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图C 胆囊动脉夹闭与离断</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图D 胆囊剥离与床面止血</p>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图E 胆囊取出与钛夹确认</p>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
|
||||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||||
<span class="delete-btn" contenteditable="false">×</span>
|
<span class="delete-btn" contenteditable="false">×</span>
|
||||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||||
</span>
|
</div>
|
||||||
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
|
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图F 止血材料覆盖及检查</p>
|
||||||
</td>
|
</td>
|
||||||
</tr></tbody>
|
</tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="template-info-section">
|
<div class="template-info-section">
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
<strong>手术后情况</strong>:${smartField('postOpCondition')}
|
<strong>手术后情况</strong>:${smartField('postOpCondition')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
<strong>切除标本描述</strong>:${smartField('specimenDescription')}
|
<strong>切除标本描述</strong>:${smartField('specimenDescription')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
<strong>是否送病理检查</strong>:${smartField('pathologyCheck')}
|
<strong>是否送病理检查</strong>:${smartField('pathologyCheck')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
<strong>冰冻病理结果</strong>:${smartField('frozenPathology')}
|
<strong>冰冻病理结果</strong>:${smartField('frozenPathology')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun;">
|
<p style="text-align: right; font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0; white-space: nowrap;">
|
||||||
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
|
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:200px;height:40px;line-height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;"><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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="text-align: right; font-family: SimSun;">
|
<p style="margin: 0; padding: 0; line-height: 1.5;"> </p>
|
||||||
|
|
||||||
|
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
${smartField('reportDate')}
|
${smartField('reportDate')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const printDocument = (htmlContent: string) => {
|
export const printDocument = (htmlContent: string, docTitle: string = '图文报告') => {
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.style.position = 'fixed';
|
iframe.style.position = 'fixed';
|
||||||
iframe.style.right = '0';
|
iframe.style.right = '0';
|
||||||
@@ -23,7 +23,7 @@ export const printDocument = (htmlContent: string) => {
|
|||||||
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
||||||
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
||||||
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
|
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
|
||||||
p { margin: 0; padding: 4px 0; line-height: 1.6; }
|
p { margin: 0; padding: 0; line-height: 1.5; }
|
||||||
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||||
strong, b { font-weight: 600; }
|
strong, b { font-weight: 600; }
|
||||||
u { text-decoration: underline; }
|
u { text-decoration: underline; }
|
||||||
@@ -31,7 +31,7 @@ export const printDocument = (htmlContent: string) => {
|
|||||||
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||||
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
|
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
|
||||||
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
|
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
|
||||||
.image-placeholder .delete-btn { display: none !important; }
|
.delete-btn { display: none !important; }
|
||||||
.image-placeholder:not(.has-image) { display: none !important; }
|
.image-placeholder:not(.has-image) { display: none !important; }
|
||||||
.template-info-section { position: relative; margin-bottom: 16px; }
|
.template-info-section { position: relative; margin-bottom: 16px; }
|
||||||
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
|
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 2px; vertical-align: text-bottom; }
|
||||||
@@ -40,6 +40,7 @@ export const printDocument = (htmlContent: string) => {
|
|||||||
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||||
@media print {
|
@media print {
|
||||||
.smart-field-wrapper .field-value { border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px !important; }
|
.smart-field-wrapper .field-value { border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px !important; }
|
||||||
|
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
83
工程分析/实现方案-2026-04-18-16-45-02.md
Normal file
83
工程分析/实现方案-2026-04-18-16-45-02.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-16-45-02
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
建立一套标准化、可复用的代码编纂工作流,确保后续所有项目修改需求都能按统一流程执行,减少遗漏和错误。
|
||||||
|
|
||||||
|
## 方案内容
|
||||||
|
|
||||||
|
### 阶段一:工程分析文件夹确认(步骤 1)
|
||||||
|
1. 检查 `.\工程分析` 文件夹是否存在。
|
||||||
|
2. 若不存在则创建;若存在则确认其包含以下文件类型:
|
||||||
|
- `需求分析-*.md`
|
||||||
|
- `实现方案-*.md`
|
||||||
|
- `测试方案-*.md`
|
||||||
|
- `经验记录.md`
|
||||||
|
|
||||||
|
### 阶段二:需求分析文档生成(步骤 2)
|
||||||
|
每次用户提出修改需求时:
|
||||||
|
1. 记录开始时间 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`。
|
||||||
|
2. 创建 `.\工程分析\需求分析-{时间}.md`。
|
||||||
|
3. 文档内容包含:
|
||||||
|
- 需求来源
|
||||||
|
- 需求概述(一句话描述)
|
||||||
|
- 功能详细描述
|
||||||
|
- 涉及文件/模块清单
|
||||||
|
- 需求影响范围
|
||||||
|
|
||||||
|
### 阶段三:实现方案文档生成与用户审核(步骤 3)
|
||||||
|
1. 基于需求分析,编写 `.\工程分析\实现方案-{时间}.md`。
|
||||||
|
2. 文档内容包含:
|
||||||
|
- 方案目标
|
||||||
|
- 具体实现步骤(分阶段)
|
||||||
|
- 涉及文件及修改点
|
||||||
|
- 风险与注意事项
|
||||||
|
3. **此处为强制审核节点**:文档生成后停止执行,等待用户确认"方案无误,继续执行"。
|
||||||
|
4. 用户确认后方可进入下一阶段。
|
||||||
|
|
||||||
|
### 阶段四:测试方案文档生成与用户审核(步骤 4)
|
||||||
|
1. 基于实现方案,编写 `.\工程分析\测试方案-{时间}.md`。
|
||||||
|
2. 文档内容包含:
|
||||||
|
- 测试目标
|
||||||
|
- 测试用例清单(编号、操作步骤、预期结果)
|
||||||
|
- 回归测试范围
|
||||||
|
3. **此处为强制审核节点**:文档生成后停止执行,等待用户确认"方案无误,继续执行"。
|
||||||
|
4. 用户确认后方可进入下一阶段。
|
||||||
|
|
||||||
|
### 阶段五:经验记录阅读与执行(步骤 5)
|
||||||
|
1. **执行前**:读取 `.\工程分析\经验记录.md`,提取与本次修改相关的经验条目。
|
||||||
|
2. **执行中**:按照已审核的实现方案修改代码。
|
||||||
|
3. **执行后**:若过程中遇到新的关键问题,按四段式追加到 `.\工程分析\经验记录.md`:
|
||||||
|
- A. 具体问题
|
||||||
|
- B. 产生问题原因
|
||||||
|
- C. 解决问题方案
|
||||||
|
- D. 后续如何避免问题
|
||||||
|
|
||||||
|
### 阶段六:Gitea 备份(步骤 6)
|
||||||
|
1. 执行以下 Git 操作:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - {简要描述}"
|
||||||
|
git push origin main
|
||||||
|
git tag -a v{版本号} -m "{版本描述}"
|
||||||
|
git push origin v{版本号}
|
||||||
|
```
|
||||||
|
2. 向用户汇报备份完成。
|
||||||
|
|
||||||
|
### 阶段七:重新部署(步骤 7)
|
||||||
|
1. 执行 `npm run build` 构建生产版本。
|
||||||
|
2. 验证构建产物 `dist/` 已生成。
|
||||||
|
3. 启动预览服务 `npm run preview`(或用户指定的部署方式)。
|
||||||
|
|
||||||
|
## 工作流强制审核点
|
||||||
|
| 审核点 | 触发条件 | 用户操作 |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| 实现方案审核 | 实现方案文档生成完毕 | 用户阅读并回复"确认" |
|
||||||
|
| 测试方案审核 | 测试方案文档生成完毕 | 用户阅读并回复"确认" |
|
||||||
|
|
||||||
|
## 本次(建立工作流)的执行差异
|
||||||
|
由于本次需求不涉及业务代码修改,阶段五(代码执行)、阶段六(备份)、阶段七(部署)均跳过或简化处理。重点在于将工作流规范文档化并确认用户理解。
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. 用户必须理解两个强制审核节点(实现方案、测试方案),不可跳过。
|
||||||
|
2. 若用户在审核阶段提出修改意见,需重新生成对应文档并再次等待确认。
|
||||||
|
3. 经验记录文档需持续维护,成为项目知识库的核心资产。
|
||||||
56
工程分析/实现方案-2026-04-18-16-55-47.md
Normal file
56
工程分析/实现方案-2026-04-18-16-55-47.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-16-55-47
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
实现 report-editor 中正文与侧边栏的点击联动、字段动态排序、以及默认模板的手术图片表格替换。
|
||||||
|
|
||||||
|
## 需求 1:点击 field-value 联动右侧基本信息
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. **修改 `handleEditorClick`**:在 `ReportEditor.tsx` 的 `handleEditorClick` 函数中,增加对 `.field-value` 的点击捕获。
|
||||||
|
- 通过 `e.target.closest('.field-value')` 获取被点击的 field-value 元素。
|
||||||
|
- 读取其 `data-bind` 属性值(如 `patientName`)。
|
||||||
|
2. **切换 Tab**:调用 `setActiveTab('info')` 将右侧面板切回「基本信息」。
|
||||||
|
3. **聚焦与滚动**:
|
||||||
|
- 为右侧表单中的每个输入组件增加 `id={\`input-\${field.key}\`}`。
|
||||||
|
- 使用 `setTimeout` 等待 React DOM 渲染完成后,通过 `document.getElementById(\`input-\${bindKey}\`)` 获取对应元素。
|
||||||
|
- 调用 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 并 `focus()`。
|
||||||
|
|
||||||
|
## 需求 2:右侧基本信息字段按正文出现顺序动态排序
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. **提取正文字段顺序**:
|
||||||
|
- 使用 `contentRef.current`(当前编辑器 HTML 字符串)或 `editorRef.current?.innerHTML`。
|
||||||
|
- 对 `formFields.filter(f => f.visibleInForm)` 中的每个非置顶字段,计算 `data-bind="${field.key}"` 在 HTML 中的首次出现位置(`indexOf`)。
|
||||||
|
2. **排序策略**:
|
||||||
|
- **置顶组**:`const topKeys = ['patientName', 'hospitalId', 'title'];`,按此固定顺序排列。
|
||||||
|
- **正文组**:非置顶字段,按 `indexOf` 升序排列(越早出现越靠前)。
|
||||||
|
- **末尾组**:正文中未出现的字段(`indexOf === -1`),统一排在最后,保持原有相对顺序。
|
||||||
|
3. **渲染表单**:将排序后的字段数组直接用于右侧表单 `.map()` 渲染。
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
- 使用 `useMemo` 缓存排序结果,仅在 `formFields` 或编辑器内容变化时重新计算。
|
||||||
|
- 排序逻辑放在 `useMemo` 中,避免每次渲染重复计算。
|
||||||
|
|
||||||
|
## 需求 3:替换默认手术图片说明表格
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. 定位 `src/utils/defaultContent.ts` 中的 `defaultReportContent`。
|
||||||
|
2. 找到 `<!-- 手术图片说明表格 -->` 注释所在的 `<table>` 区域。
|
||||||
|
3. 替换为用户提供的 HTML 代码:
|
||||||
|
- 2 行 × 3 列布局
|
||||||
|
- 每格包含 `.image-placeholder`(表格内模式:`<div>` 块级容器,`width:100%; height:100%; max-width:200px; max-height:200px;`)
|
||||||
|
- 每格底部含图注(图A~图F)
|
||||||
|
- 保留 `data-placeholder="true"` 和 `contenteditable="false"`
|
||||||
|
4. 清理复制时产生的冗余内联样式(如 `background-image: initial` 等),保留功能必需的样式。
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/ReportEditor.tsx` | `handleEditorClick` 增加 field-value 点击捕获;表单渲染增加 `id`;右侧字段排序逻辑 |
|
||||||
|
| `src/utils/defaultContent.ts` | 替换手术图片说明表格 HTML |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `contentRef.current` 在组件首次挂载前可能为空,排序逻辑需做空值保护。
|
||||||
|
2. `setActiveTab` 后 DOM 切换有短暂延迟,`scrollIntoView` 需包裹在 `setTimeout` 中。
|
||||||
|
3. 默认模板替换后,需验证新建报告时表格渲染是否正常、占位符点击事件是否生效。
|
||||||
|
4. 置顶字段的 `key` 名称需与 `DEFAULT_FORM_FIELDS` 中严格一致。
|
||||||
82
工程分析/实现方案-2026-04-18-17-27-51.md
Normal file
82
工程分析/实现方案-2026-04-18-17-27-51.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-17-27-51
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
修复 TemplateManage 静态占位符插入 Bug,重构默认报告模板顶部排版,修复 Logo 删除按钮交互。
|
||||||
|
|
||||||
|
## 需求 1:修复静态图片占位符插入不显示
|
||||||
|
|
||||||
|
### 问题根因
|
||||||
|
`TemplateManage.tsx` 中 `insertImage()` 使用 `document.execCommand('insertHTML', false, html)`。现代浏览器对含 `contenteditable="false"` 的复杂嵌套标签会自动修正/拍平,导致外层 `.image-placeholder` 容器丢失,DOM 仅剩零散子元素,视觉上不可见。
|
||||||
|
|
||||||
|
### 解决步骤
|
||||||
|
1. **定位 `insertImage` 函数**:找到 `TemplateManage.tsx` 中通过 `document.execCommand('insertHTML')` 插入占位符的逻辑。
|
||||||
|
2. **替换为 `Range.insertNode`**:
|
||||||
|
- 创建临时 `div`,将 HTML 字符串写入 `innerHTML`。
|
||||||
|
- 将子节点逐个移入 `DocumentFragment`。
|
||||||
|
- 获取当前 `Selection` 的 `RangeAt(0)`。
|
||||||
|
- 调用 `range.deleteContents()` 清空当前选区。
|
||||||
|
- 调用 `range.insertNode(fragment)` 精确插入。
|
||||||
|
- 将光标移动到插入内容之后。
|
||||||
|
3. **保持原有弹窗逻辑不变**:Modal 中的模式选择(frame/manual)、宽高输入等逻辑不受影响。
|
||||||
|
|
||||||
|
## 需求 2:重构默认报告模板排版
|
||||||
|
|
||||||
|
### 排版设计
|
||||||
|
|
||||||
|
#### 页眉(Logo + 医院名 + 标题)
|
||||||
|
使用 3 列 `<table>`(左 20%、中 60%、右 20%),中间列绝对居中:
|
||||||
|
- 左列:Logo 占位符(65×65,`data-mode="manual"`,`position:relative`)
|
||||||
|
- 中列:
|
||||||
|
- 第一行:14pt SimSun「西 安 交 通 大 学 第 一 附 属 医 院」(带 `border-bottom: 1px solid #000` 下划线,使用 `display: inline-block`)
|
||||||
|
- 第二行:16pt SimSun「手术记录」
|
||||||
|
- 右列:留空
|
||||||
|
|
||||||
|
#### 基本信息栏(下划线贯穿)
|
||||||
|
使用 `<div style="border-bottom: 1px solid #000; padding-bottom: 4px; margin-bottom: 12px;">` 包裹一行:
|
||||||
|
- 11pt SimSun,不加粗
|
||||||
|
- 姓名、性别、年龄、科别、床号、住院号,用 ` ` 间隔
|
||||||
|
|
||||||
|
#### 诊断/手术信息(单行加粗)
|
||||||
|
每项独立 `<p>`:
|
||||||
|
- 12pt SimSun,`font-weight: bold`
|
||||||
|
- 手术日期、术前诊断、术中诊断、手术名称
|
||||||
|
|
||||||
|
#### 双列信息(两项一行,不加粗)
|
||||||
|
使用 `<table style="width: 100%; border: none;">`:
|
||||||
|
- 三行两列,每列 50%
|
||||||
|
- 12pt SimSun,不加粗
|
||||||
|
- 手术开始/终止时间、手术者/助手、麻醉师/麻醉方式
|
||||||
|
|
||||||
|
#### 手术步骤标题
|
||||||
|
- 12pt SimSun,`font-weight: bold`
|
||||||
|
- 「手术步骤、术中出现的情况及处理:」
|
||||||
|
|
||||||
|
#### 保留内容
|
||||||
|
- 5 条手术步骤段落文字(不变)
|
||||||
|
- 手术图片说明表格(需求 3 中已替换的最新 6 图格表格)
|
||||||
|
- 手术后情况段落(术后诊断、标本描述、病理检查、冰冻病理)
|
||||||
|
- 手术者签名占位符 + 撰写时间字段
|
||||||
|
|
||||||
|
### 涉及文件
|
||||||
|
`src/utils/defaultContent.ts` —— 完全重写 `defaultReportContent` 变量。
|
||||||
|
|
||||||
|
## 需求 3:修复顶部 Logo 删除按钮
|
||||||
|
|
||||||
|
### 解决步骤
|
||||||
|
在 `defaultContent.ts` 中 Logo 占位符的 `style` 属性中增加 `position: relative;`,使绝对定位的 `.delete-btn` 相对于占位符自身定位,而非向外层逃逸。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="position:relative;display:inline-flex;...">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/TemplateManage.tsx` | `insertImage` 中 `execCommand('insertHTML')` → `Range.insertNode` |
|
||||||
|
| `src/utils/defaultContent.ts` | 完全重写顶部排版;Logo 增加 `position:relative`;保留手术步骤/表格/底部段落 |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `Range.insertNode` 要求编辑器有有效光标/选区。若编辑器未聚焦或选区不在编辑器内,需增加保护逻辑(fallback 到 `editorRef.current.appendChild`)。
|
||||||
|
2. 默认模板重写后,需验证 `smartField()` 生成的所有字段占位符在新排版中是否正确渲染。
|
||||||
|
3. 打印时需确认新排版的下划线、表格边框在 `@media print` 中正常显示。
|
||||||
|
4. ` ` 分隔的基本信息栏在打印时可能换行,需测试实际打印效果。
|
||||||
100
工程分析/实现方案-2026-04-18-17-48-59.md
Normal file
100
工程分析/实现方案-2026-04-18-17-48-59.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-17-48-59
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
修复默认模板排版细节和打印样式问题,提升报告的视觉一致性和打印输出质量。
|
||||||
|
|
||||||
|
## 需求 1:缩减基本信息栏字段间空格
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将基本信息栏 `<p>` 中字段之间的 ` ` 替换为单个 ` `。
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```html
|
||||||
|
姓名:${smartField('patientName')}
|
||||||
|
性别:${smartField('patientGender')}
|
||||||
|
年龄:${smartField('patientAge')}
|
||||||
|
科别:${smartField('department')}
|
||||||
|
床号:${smartField('bedNumber')}
|
||||||
|
住院号:${smartField('hospitalId')}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```html
|
||||||
|
姓名:${smartField('patientName')}
|
||||||
|
性别:${smartField('patientGender')}
|
||||||
|
年龄:${smartField('patientAge')}
|
||||||
|
科别:${smartField('department')}
|
||||||
|
床号:${smartField('bedNumber')}
|
||||||
|
住院号:${smartField('hospitalId')}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 2:Logo 与医院名/标题靠拢并整体居中
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将顶部 3 列 `<table>` 替换为 `<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 16px;">`。
|
||||||
|
- Logo 占位符放在 flex 子 div 中
|
||||||
|
- 医院名和标题放在另一个 flex 子 div 中(`text-align: center`)
|
||||||
|
- 整体通过 `justify-content: center` 实现居中
|
||||||
|
- `gap: 12px` 或 `margin-right: 12px` 控制 Logo 与文字间距
|
||||||
|
|
||||||
|
## 需求 3:打印时隐藏所有「×」删除按钮
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/print.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在 `print.ts` 生成的 `<style>` 标签中,将 `.image-placeholder .delete-btn { display: none !important; }` 扩展为全局规则:
|
||||||
|
```css
|
||||||
|
.delete-btn { display: none !important; }
|
||||||
|
```
|
||||||
|
|
||||||
|
这样无论删除按钮位于 `.image-placeholder` 内还是 `.smart-field-wrapper` 内,打印时均不可见。
|
||||||
|
|
||||||
|
## 需求 4:统一全文行距为 1.5,消除段前段后间距
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts` 和 `src/utils/print.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **`defaultContent.ts`**:将所有 `<p>` 标签的内联样式统一为 `line-height: 1.5; margin: 0; padding: 0;`。移除原有的 `line-height: 1.8`、默认 margin 等不一致设置。
|
||||||
|
2. **`print.ts`**:将全局 `p` 样式从 `margin: 0; padding: 4px 0; line-height: 1.6;` 修改为 `margin: 0; padding: 0; line-height: 1.5;`。
|
||||||
|
|
||||||
|
## 需求 5:下划线紧贴文字底部
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **医院名称下划线**:将包裹医院名的 `div` 的 `padding-bottom: 4px` 移除或改为 `0`。同时在该 `div` 上增加 `line-height: 1`,消除中文字体自带的底部留白,使 `border-bottom` 紧贴文字。
|
||||||
|
2. **基本信息栏下划线**:将外层 `<div style="border-bottom: 1px solid #000; ...">` 的 `padding-bottom: 4px` 移除。内部 `<p>` 的 `line-height` 已统一为 1.5(需求 4),若仍有间距问题,可进一步在该 `<p>` 上设置 `line-height: 1.2` 或让下划线直接由 `<p>` 的 `border-bottom` 实现。
|
||||||
|
|
||||||
|
### 优化策略
|
||||||
|
更简洁的做法:让下划线直接由承载文字的 `<p>` 元素生成,而非由外层 `<div>` 生成。例如:
|
||||||
|
```html
|
||||||
|
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0 0 2px 0; line-height: 1.2; border-bottom: 1px solid #000;">
|
||||||
|
姓名:... 性别:...
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
这样文字底部与下划线之间仅由 `padding-bottom: 2px` 或 `line-height` 控制,可精确调整。
|
||||||
|
|
||||||
|
对于医院名称,同理:
|
||||||
|
```html
|
||||||
|
<div style="font-size: 14pt; font-family: SimSun; line-height: 1; border-bottom: 1px solid #000; padding-bottom: 0; margin-bottom: 8px; display: inline-block;">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/utils/defaultContent.ts` | 缩减空格;改 Flex 抬头;统一 line-height/margin/padding;调整下划线贴底 |
|
||||||
|
| `src/utils/print.ts` | 全局隐藏 `.delete-btn`;统一 p 标签 line-height/margin/padding |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `print.ts` 的全局 `.delete-btn { display: none !important; }` 会覆盖所有删除按钮,包括未来可能新增的其他类型。这是预期行为(打印时不应显示任何交互按钮)。
|
||||||
|
2. `line-height: 1` 在部分中文字体下可能导致字符上下紧贴甚至重叠,需在实际打印中验证。若出现问题,可微调为 `line-height: 1.1`。
|
||||||
|
3. 修改 `defaultContent.ts` 后,新建报告会加载新模板,但已有报告(保存在 localStorage 中)不会自动更新。这是预期行为。
|
||||||
91
工程分析/实现方案-2026-04-18-18-08-37.md
Normal file
91
工程分析/实现方案-2026-04-18-18-08-37.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-18-08-37
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
修复并增强编辑器工具栏的字体/字号/行距功能,调整默认模板排版细节。
|
||||||
|
|
||||||
|
## 需求 1:修复字体选择并新增字号、行距功能
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx` 和 `src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. **修复字体选择**:确保工具栏中的字体选择 `<select>` 使用 `execCmd('fontName', value)`。若失效,检查是否有全局 CSS `font-family: !important` 覆盖。如有,在打印样式中保留覆盖,但在编辑器样式中移除。
|
||||||
|
2. **新增字号选择**:在工具栏字体选择旁边增加 `<select>`:
|
||||||
|
```tsx
|
||||||
|
<select onChange={e => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}>
|
||||||
|
<option value="">字号</option>
|
||||||
|
<option value="3">12pt</option>
|
||||||
|
<option value="4">14pt</option>
|
||||||
|
<option value="5">18pt</option>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
`execCommand('fontSize')` 使用 1-7 的相对字号,3 对应 12pt,4 对应 14pt,5 对应 18pt。
|
||||||
|
3. **新增行距选择**:`execCommand` 不支持行距,需手写 `changeLineHeight` 函数:
|
||||||
|
```tsx
|
||||||
|
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');
|
||||||
|
if (block) {
|
||||||
|
(block as HTMLElement).style.lineHeight = height;
|
||||||
|
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||||
|
saveDraftToStorage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
在工具栏增加行距 `<select>` 绑定此函数。
|
||||||
|
|
||||||
|
## 需求 2:修复手术者签名右对齐时图片框换行
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将「手术者签名」所在 `<p>` 增加 `white-space: nowrap;`,并将图片占位符的 `display` 改为 `inline-block`:
|
||||||
|
```html
|
||||||
|
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0; white-space: nowrap;">
|
||||||
|
手术者签名:<span class="image-placeholder" ... style="display:inline-block; vertical-align:middle; ...">...</span>
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 3:缩减「手术记录」与「姓名」之间的距离
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将顶部 Flex 容器的 `margin-bottom: 16px` 缩小为 `margin-bottom: 4px`。
|
||||||
|
|
||||||
|
## 需求 4:消除「手术名称」与「手术开始时间」之间的多余间距
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将双列信息 `<table>` 的 `margin-bottom: 12pt` 改为 `margin-bottom: 0; margin-top: 0;`。同时确保「手术名称」`<p>` 的 `margin: 0; padding: 0;`。
|
||||||
|
|
||||||
|
## 需求 5:统一「手术日期」及以下内容为 12pt、1.5 行距、无段间距
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
为所有手术步骤段落(1~5)以及手术后情况段落补充 `font-size: 12pt;`:
|
||||||
|
```html
|
||||||
|
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/ReportEditor.tsx` | 工具栏新增字号选择、行距选择;修复字体选择 |
|
||||||
|
| `src/pages/TemplateManage.tsx` | 工具栏新增字号选择、行距选择;修复字体选择 |
|
||||||
|
| `src/utils/defaultContent.ts` | 签名行 `white-space: nowrap`; 顶部 `margin-bottom: 4px`; 表格 `margin: 0`; 补全 `font-size: 12pt` |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `execCommand('fontSize')` 生成的是 `<font size="N">` 标签,与现代 HTML5 规范不完全兼容,但在 `contentEditable` 中是浏览器广泛支持的方式。
|
||||||
|
2. `changeLineHeight` 直接操作 DOM style,在 `ReportEditor` 中修改后需同步 `contentRef.current` 和调用 `saveDraftToStorage()`。
|
||||||
|
3. `TemplateManage` 中修改行距后需调用 `saveTemplateContent()`。
|
||||||
|
4. `white-space: nowrap` 在签名行可能导致超长内容溢出,但考虑到签名行通常较短,风险可控。
|
||||||
91
工程分析/实现方案-2026-04-18-18-36-43.md
Normal file
91
工程分析/实现方案-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-18-36-43
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
实现五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
|
||||||
|
|
||||||
|
## 需求 1:ReportManage 列名修正
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
找到 `<thead>` 中「患者号」`<th>`,将文本改为「住院号」。同步检查表格数据渲染中是否有对应的 patientId/hospitalId 显示逻辑需调整。
|
||||||
|
|
||||||
|
## 需求 2:字段管理增加下划线控制
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `src/types.ts`
|
||||||
|
- `src/pages/TemplateManage.tsx`
|
||||||
|
- `src/utils/print.ts`
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. **扩展 FormField 接口**:增加 `hasUnderline?: boolean`(默认 `true`)。
|
||||||
|
2. **修改 DEFAULT_FORM_FIELDS**:为所有默认字段设置 `hasUnderline: true`。
|
||||||
|
3. **TemplateManage 字段管理 UI**:
|
||||||
|
- 新增字段表单中增加「打印时显示下划线」checkbox,默认勾选。
|
||||||
|
- 编辑字段面板中同样增加该 checkbox。
|
||||||
|
- 保存字段配置时将 `hasUnderline` 写入 `formFieldsConfig`。
|
||||||
|
4. **insertSmartField 注入类名**:
|
||||||
|
- 在生成 `smart-field-wrapper` HTML 时,若 `field.hasUnderline === false`,给 `.field-value` 增加 `no-underline` 类。
|
||||||
|
5. **print.ts 打印样式**:
|
||||||
|
- 在 `@media print` 中增加 `.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }`
|
||||||
|
|
||||||
|
## 需求 3:ReportEditor / TemplateManage 新增下载按钮
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
|
- `src/pages/TemplateManage.tsx`
|
||||||
|
- `src/utils/print.ts`
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
1. **print.ts 支持自定义标题**:
|
||||||
|
- `printDocument(htmlContent: string, docTitle?: string)` 增加可选 `docTitle` 参数。
|
||||||
|
- 在 iframe HTML 的 `<head>` 中注入 `<title>${docTitle || '图文报告'}</title>`,使浏览器保存 PDF 时使用该文件名。
|
||||||
|
2. **ReportEditor 下载功能**:
|
||||||
|
- 引入 `Download` 图标。
|
||||||
|
- 在顶部操作栏打印按钮旁增加下载按钮。
|
||||||
|
- 新增 `exportModalOpen` 状态控制导出弹窗。
|
||||||
|
- 实现 `getExportFilename()`:基于 `reportData.title`、`patientName`、`hospitalId` 和当前时间生成文件名。
|
||||||
|
- 实现 `downloadJSON()`:将 `reportData` 序列化为 JSON Blob 并触发下载。
|
||||||
|
- 导出 PDF 时调用 `printDocument(editorRef.current.innerHTML, getExportFilename())`。
|
||||||
|
3. **TemplateManage 下载功能**:
|
||||||
|
- 类似实现。模板管理页面没有 reportData,文件名中患者信息使用"模板"或空值替代。
|
||||||
|
- PDF 导出调用 `printDocument(editorRef.current.innerHTML, filename)`。
|
||||||
|
- JSON 导出下载模板内容。
|
||||||
|
|
||||||
|
## 需求 4:修复右对齐时签名与图片框分离
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `src/pages/TemplateManage.tsx`(占位符插入逻辑)
|
||||||
|
- `src/pages/ReportEditor.tsx`(占位符插入逻辑,如有)
|
||||||
|
- `src/utils/defaultContent.ts`(默认模板签名占位符)
|
||||||
|
|
||||||
|
### 实现步骤
|
||||||
|
将 `display: inline-flex` 改为 `display: inline-block`,并通过 `line-height` 实现垂直居中:
|
||||||
|
- **运行时插入**:`styleStr` 从 `display:inline-flex;align-items:center;justify-content:center;` 改为 `display:inline-block;text-align:center;position:relative;line-height:${h}px;`
|
||||||
|
- **占位文本**:`.placeholder-text` 增加 `display:inline-block;vertical-align:middle;line-height:normal;`
|
||||||
|
- **默认模板**:手术者签名占位符同步应用上述样式。
|
||||||
|
|
||||||
|
## 需求 5:默认模板手术者签名右对齐
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将「手术者签名」`<p>` 增加 `text-align: right;`,并应用需求 4 的 `inline-block` 样式修复。
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/ReportManage.tsx` | 「患者号」→「住院号」 |
|
||||||
|
| `src/types.ts` | `FormField` 增加 `hasUnderline?: boolean` |
|
||||||
|
| `src/pages/TemplateManage.tsx` | 字段管理 UI 增加下划线 checkbox;insertSmartField 注入 no-underline 类;工具栏增加下载按钮和弹窗 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | 工具栏增加下载按钮和弹窗;占位符插入样式改为 inline-block |
|
||||||
|
| `src/utils/print.ts` | 增加 `docTitle` 参数;打印样式支持 `.no-underline` |
|
||||||
|
| `src/utils/defaultContent.ts` | 签名占位符改为 inline-block;签名行设为 `text-align: right` |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `FormField` 接口扩展后,需确保 `DEFAULT_FORM_FIELDS` 和所有已有字段配置(localStorage 中的 `formFieldsConfig`)兼容。对于旧数据缺少 `hasUnderline` 的情况,按 `true` 处理。
|
||||||
|
2. `printDocument` 增加 `docTitle` 参数后,需检查所有调用方是否已更新。现有调用方(如 ReportView)可保持默认行为。
|
||||||
|
3. `inline-block` 替换 `inline-flex` 后,需验证占位符在非右对齐场景(如正常左对齐)下的垂直居中效果是否正常。
|
||||||
|
4. 下载 JSON 时,TemplateManage 的 JSON 内容与 ReportEditor 不同(模板 vs 报告),需分别处理。
|
||||||
97
工程分析/实现方案-2026-04-18-19-08-43.md
Normal file
97
工程分析/实现方案-2026-04-18-19-08-43.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-19-08-43
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
优化编辑器交互体验和模板排版细节,提升视频面板空间利用率和图片占位符自适应能力。
|
||||||
|
|
||||||
|
## 需求 1:基础信息字段默认无下划线
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
修改 `smartField()` 函数,对 6 个基础字段自动附加 `.no-underline` 类:
|
||||||
|
```ts
|
||||||
|
const noUnderlineKeys = ['patientName', 'patientGender', 'patientAge', 'department', 'bedNumber', 'hospitalId'];
|
||||||
|
const noUlClass = noUnderlineKeys.includes(key) ? ' no-underline' : '';
|
||||||
|
```
|
||||||
|
在生成的 HTML 中,`.field-value` 的 class 变为 `field-value${noUlClass}`。
|
||||||
|
|
||||||
|
## 需求 2:字段联动高亮并居中滚动
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **新增状态**:`const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);`
|
||||||
|
2. **修改点击处理**:在 `handleEditorClick` 的 `.field-value` 点击分支中,增加 `setActiveFieldKey(bindKey)`。
|
||||||
|
3. **修改滚动逻辑**:将 `scrollIntoView` 的 `block` 从 `'center'` 改为更精确的控制(`block: 'center'` 本身就是居中,满足 1/3~2/3 需求)。
|
||||||
|
4. **高亮样式**:在右侧表单渲染中,为每个字段容器 `div` 增加动态类名:
|
||||||
|
```tsx
|
||||||
|
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' : ''}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 3:视频上传按钮整合进缩略图列表
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. 删除原本独立的「上传视频」大按钮区域。
|
||||||
|
2. 在 `videos.map()` 所在的滚动容器 `<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">` 的第一个位置,插入缩小版的上传按钮:
|
||||||
|
```tsx
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 4:视频模块间距紧凑化
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. 最外层容器从 `space-y-4` 改为 `space-y-2`。
|
||||||
|
2. 视频播放器与控制按钮之间从 `space-y-4` 改为 `space-y-2`。
|
||||||
|
3. 控制按钮区域(播放/暂停/进度条等)的 `gap` 或 `margin` 适当缩减。
|
||||||
|
4. 「关键帧摘取」标题区域的 `padding-top` 缩减,可增加 `border-t` 作为视觉分隔。
|
||||||
|
|
||||||
|
## 需求 5:签名与日期之间增加空行
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在「手术者签名」`<p>` 和「撰写时间」`<p>` 之间插入:
|
||||||
|
```html
|
||||||
|
<p style="margin: 0; padding: 0; line-height: 1.5;"> </p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 6:图片占位符填充后高度自适应
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx` 和 `src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在所有填充图片的逻辑中(`fillPlaceholderSrc`、`handleDrop`、`autoCaptureFrames` 等),在 `placeholder.classList.add('has-image')` 之后,增加:
|
||||||
|
```ts
|
||||||
|
placeholder.style.height = 'auto';
|
||||||
|
placeholder.style.width = 'auto';
|
||||||
|
placeholder.style.lineHeight = 'normal';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/utils/defaultContent.ts` | `smartField()` 注入 `.no-underline`;签名与日期之间插入空行 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | `activeFieldKey` 状态 + 高亮样式;视频上传按钮整合;视频面板间距缩减;占位符自适应样式 |
|
||||||
|
| `src/pages/TemplateManage.tsx` | 占位符自适应样式 |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `smartField()` 中硬编码的 6 个 key 需与 `DEFAULT_FORM_FIELDS` 严格一致。
|
||||||
|
2. `activeFieldKey` 高亮样式使用 `transition-all duration-300`,需确保不会与现有样式冲突。
|
||||||
|
3. 视频上传按钮移入缩略图列表后,需确保 `videoInputRef` 的点击触发逻辑不受影响。
|
||||||
|
4. 占位符 `height: auto` 后,需验证图片在表格内(table cell)和正文中的显示是否正常。
|
||||||
82
工程分析/实现方案-2026-04-18-19-23-31.md
Normal file
82
工程分析/实现方案-2026-04-18-19-23-31.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-19-23-31
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
修复视频分析模块空白问题,重构图片占位符的填充后尺寸逻辑。
|
||||||
|
|
||||||
|
## 需求 1:修复视频分析模块空白
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将「上传视频」按钮和视频缩略图列表从 `videos.length > 0` 条件内部移出,使其始终渲染。仅保留视频播放器和关键帧网格在 `currentVideoIndex !== -1 && videos.length > 0` 条件下渲染。
|
||||||
|
|
||||||
|
修改后结构:
|
||||||
|
```tsx
|
||||||
|
{activeTab === 'video' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input ref={videoInputRef} ... />
|
||||||
|
|
||||||
|
{/* 始终可见:上传按钮 + 视频缩略图列表 */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
|
||||||
|
<button>上传视频</button>
|
||||||
|
{videos.map(...)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条件渲染:视频播放器和关键帧 */}
|
||||||
|
{currentVideoIndex !== -1 && videos.length > 0 && (
|
||||||
|
<div className="space-y-2">...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 2:图片占位符尺寸自适应
|
||||||
|
|
||||||
|
### 核心逻辑
|
||||||
|
1. **插入占位符时**:在 `style` 中注入 `max-width` 和 `max-height`,与 `width`/`height` 相同,便于后续读取限制值。
|
||||||
|
2. **填充图片时**:
|
||||||
|
- 读取占位符当前的 `max-width` / `max-height`(或回退到 `width` / `height`)
|
||||||
|
- 将这两个值赋给内部 `<img>` 的 `max-width` / `max-height`
|
||||||
|
- 设置 `object-fit: contain; object-position: left top`
|
||||||
|
- 将占位符外壳的 `width`、`height`、`line-height` 设为 `auto` / `normal`
|
||||||
|
- 保留 `max-width`、`max-height` 作为硬限制
|
||||||
|
- 设置 `text-align: left; vertical-align: top`
|
||||||
|
|
||||||
|
### 修改文件及位置
|
||||||
|
| 文件 | 函数/位置 | 修改内容 |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `src/pages/ReportEditor.tsx` | `fillPlaceholderSrc` | 填充后读取限制值,设置 img 和外壳样式 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | `fillPlaceholder` | 同上 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | `autoCaptureFrames` | 同上 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | placeholderModal 确认插入 | style 中增加 `max-width` / `max-height` |
|
||||||
|
| `src/pages/TemplateManage.tsx` | `fillPlaceholderSrc` | 同上 |
|
||||||
|
| `src/pages/TemplateManage.tsx` | placeholderModal 确认插入 | style 中增加 `max-width` / `max-height` |
|
||||||
|
|
||||||
|
### 样式值示例
|
||||||
|
```ts
|
||||||
|
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:100%;max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
|
||||||
|
`;
|
||||||
|
|
||||||
|
placeholder.style.width = 'auto';
|
||||||
|
placeholder.style.height = 'auto';
|
||||||
|
placeholder.style.maxWidth = mw;
|
||||||
|
placeholder.style.maxHeight = mh;
|
||||||
|
placeholder.style.lineHeight = 'normal';
|
||||||
|
placeholder.style.textAlign = 'left';
|
||||||
|
placeholder.style.verticalAlign = 'top';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 3:Logo 框大小保持 65px × 65px
|
||||||
|
默认模板中 Logo 占位符的 `width:65px;height:65px` 保持不变。此需求通过不修改 Logo 占位符相关代码即可满足。
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. 视频按钮移出条件渲染后,需确保 `videoInputRef` 的引用始终有效。
|
||||||
|
2. 占位符 `width:auto` 后,在表格单元格(`td`)内的表现需要验证,确保不会超出单元格。
|
||||||
|
3. `object-position: left top` 仅在 `object-fit: contain` 时生效。
|
||||||
|
4. 需确保 `max-width` / `max-height` 在打印样式中不会被 `@media print` 规则覆盖。
|
||||||
101
工程分析/实现方案-2026-04-18-19-37-56.md
Normal file
101
工程分析/实现方案-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-19-37-56
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
修复编辑器中的 4 个体验问题,提升视频面板、图片占位符和对齐功能的稳定性。
|
||||||
|
|
||||||
|
## 需求 1:视频上传按钮位置调整
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在「视频分析」面板的缩略图滚动容器中,将 `<button>上传视频</button>` 从 `videos.map()` 之前移至之后。保持按钮样式和点击逻辑不变。
|
||||||
|
|
||||||
|
## 需求 2:图片占位符提示文字绝对居中
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`、`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将 `.placeholder-text` 的样式改为绝对定位居中:
|
||||||
|
```css
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
```
|
||||||
|
|
||||||
|
需要确保 `.image-placeholder` 父容器带有 `position: relative;`(默认模板和运行时插入逻辑中已具备)。
|
||||||
|
|
||||||
|
修改位置:
|
||||||
|
1. `defaultContent.ts` 中 8 个占位符的 `.placeholder-text` style
|
||||||
|
2. `ReportEditor.tsx` 中 `placeholderModal` 确认插入时的 `.placeholder-text` style
|
||||||
|
3. `TemplateManage.tsx` 中 `placeholderModal` 确认插入时的 `.placeholder-text` style
|
||||||
|
4. `ReportEditor.tsx` 和 `TemplateManage.tsx` 中 `handleEditorClick` 删除图片后重建 `.placeholder-text` 的 innerHTML
|
||||||
|
|
||||||
|
## 需求 3:删除图片后占位符恢复原始大小
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在 `handleEditorClick` 中处理 `.delete-btn` 点击、恢复占位符为空的逻辑中,增加尺寸恢复:
|
||||||
|
```ts
|
||||||
|
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';
|
||||||
|
```
|
||||||
|
|
||||||
|
同时需要恢复其他被修改的样式:
|
||||||
|
- `border: 1px dashed #cbd5e1`
|
||||||
|
- `background: #f8fafc`
|
||||||
|
- `vertical-align: middle`(inline-block 占位符)
|
||||||
|
- `justify-content: center; align-items: center`(flex 占位符)
|
||||||
|
|
||||||
|
由于无法直接区分 flex 和 inline-block,可以通过检查 `placeholder.style.display` 或简单地将 `justifyContent` 和 `alignItems` 重置为 `center`(对 inline-block 无影响)。
|
||||||
|
|
||||||
|
## 需求 4:对齐按钮改用安全的 DOM 操作
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **新增 `changeAlignment` 方法**:
|
||||||
|
```ts
|
||||||
|
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, h4, h5, h6, li');
|
||||||
|
if (block) {
|
||||||
|
(block as HTMLElement).style.textAlign = align;
|
||||||
|
if (editorRef.current) {
|
||||||
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
|
saveDraftToStorage(); // ReportEditor
|
||||||
|
// saveTemplateContent(); // TemplateManage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **替换工具栏按钮**:将三个对齐按钮的 `onClick={() => execCmd('justifyLeft')}` 等替换为 `onClick={() => changeAlignment('left')}` 等。保留 `onMouseDown={(e) => e.preventDefault()}` 以防止编辑器失焦。
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/ReportEditor.tsx` | 视频按钮位置;placeholder-text 样式(3 处:插入、删除恢复、Modal);删除恢复时尺寸复原;新增 changeAlignment;替换对齐按钮 |
|
||||||
|
| `src/pages/TemplateManage.tsx` | placeholder-text 样式(3 处);删除恢复时尺寸复原;新增 changeAlignment;替换对齐按钮 |
|
||||||
|
| `src/utils/defaultContent.ts` | 8 个占位符的 placeholder-text 样式更新为绝对居中 |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. `changeAlignment` 中 `closest('p, div, ...')` 如果选中了编辑器根容器(`contenteditable` div),可能会对齐整个文档。但由于工具栏按钮要求编辑器已聚焦,通常选区在正文内部,风险较低。
|
||||||
|
2. 占位符删除恢复时,`maxWidth`/`maxHeight` 的回退逻辑需确保在所有场景下(默认模板、运行时插入)都能正确读取。
|
||||||
|
3. 绝对居中的 `position:absolute` 需要父容器 `position:relative`,需验证所有占位符均满足。
|
||||||
130
工程分析/实现方案-2026-04-18-20-03-44.md
Normal file
130
工程分析/实现方案-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# 实现方案 —— 2026-04-18-20-03-44
|
||||||
|
|
||||||
|
## 方案目标
|
||||||
|
实现模板的导入/导出迁移能力,统一默认模板 Logo 的交互行为。
|
||||||
|
|
||||||
|
## 需求 1:模板导出功能
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
在模板列表的每个模板行操作列中增加「导出」按钮(使用 Download 图标)。点击时:
|
||||||
|
```ts
|
||||||
|
const handleExportTemplate = (template: Template) => {
|
||||||
|
const exportData = {
|
||||||
|
version: '1.0',
|
||||||
|
type: 'surclaw_template_package',
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
content: template.content,
|
||||||
|
fields: template.fields || []
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `模板导出-${template.title}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 需求 2:模板导入功能
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/pages/TemplateManage.tsx`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
1. **新增状态**:
|
||||||
|
```ts
|
||||||
|
const [importedContent, setImportedContent] = useState<{content: string, fields: FormField[]} | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **新增导入处理函数**:
|
||||||
|
```ts
|
||||||
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.target?.result as string);
|
||||||
|
if (json.type !== 'surclaw_template_package') {
|
||||||
|
alert('无效的模板包文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewTemplateTitle(json.title || '');
|
||||||
|
setNewTemplateDescription(json.description || '');
|
||||||
|
setImportedContent({
|
||||||
|
content: json.content || '',
|
||||||
|
fields: Array.isArray(json.fields) ? json.fields : []
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
alert('文件解析失败,请检查 JSON 格式');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **修改创建逻辑**:在 `handleCreateTemplate` 中,如果有 `importedContent`,优先使用导入的内容和字段:
|
||||||
|
```ts
|
||||||
|
const newTemplate: Template = {
|
||||||
|
id: 'tmpl_' + Date.now(),
|
||||||
|
title: newTemplateTitle,
|
||||||
|
description: newTemplateDescription,
|
||||||
|
content: importedContent?.content || `<div style="font-size:12pt;line-height:1.5;"><p>请输入模板内容...</p></div>`,
|
||||||
|
fields: importedContent?.fields || [],
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **UI 调整**:在新增模板 Modal 中标题下方加入导入区域:
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||||
|
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **关闭 Modal 时重置**:`setImportedContent(null)`
|
||||||
|
|
||||||
|
## 需求 3:Logo 替换为可交互占位符
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
`src/utils/defaultContent.ts`
|
||||||
|
|
||||||
|
### 修改内容
|
||||||
|
将默认模板顶部的 Logo HTML 替换为标准 `image-placeholder`:
|
||||||
|
```html
|
||||||
|
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
|
||||||
|
<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%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
- `class="image-placeholder"`:触发编辑器中的占位符交互逻辑
|
||||||
|
- `data-mode="manual"`:标记为静态图片占位,不支持自动帧插入
|
||||||
|
- `position:relative` + `position:absolute` 居中:确保提示文字绝对居中
|
||||||
|
- `delete-btn`:支持点击右上方的「×」删除
|
||||||
|
|
||||||
|
## 涉及文件及修改点
|
||||||
|
| 文件 | 修改点 |
|
||||||
|
|------|--------|
|
||||||
|
| `src/pages/TemplateManage.tsx` | 新增 `handleExportTemplate`;新增 `importedContent` 状态和 `handleImportFile`;修改 `handleCreateTemplate` 使用导入数据;新增模板 Modal 中增加导入 UI;模板列表操作列增加导出按钮 |
|
||||||
|
| `src/utils/defaultContent.ts` | 顶部 Logo 替换为标准 `image-placeholder` |
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
1. 导入的 JSON 中 `fields` 数组需要与 `FormField` 类型结构兼容。由于 JSON 导入的是纯数据,直接赋值给 `template.fields` 即可(TypeScript 编译时类型校验通过)。
|
||||||
|
2. 导出文件名中包含模板标题,需注意标题中的特殊字符可能影响文件名(但浏览器通常会自动处理)。
|
||||||
|
3. Logo 占位符替换后,原有「西安交通大学第一附属医院」的样式应保持不变,仅替换 Logo 部分。
|
||||||
|
4. 新增模板弹窗关闭时,需同步重置 `importedContent` 为 `null`,避免影响下一次创建。
|
||||||
96
工程分析/测试方案-2026-04-18-16-45-02.md
Normal file
96
工程分析/测试方案-2026-04-18-16-45-02.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-16-45-02
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证代码编纂工作流是否能够正确运行,确保后续所有项目修改需求都能按规范流程执行,无步骤遗漏。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:工作流文档完整性验证
|
||||||
|
**前置条件**:用户已提出建立工作流的需求
|
||||||
|
**操作步骤**:
|
||||||
|
1. 检查 `.\工程分析` 文件夹是否存在
|
||||||
|
2. 检查 `需求分析-2026-04-18-16-45-02.md` 是否生成
|
||||||
|
3. 检查 `实现方案-2026-04-18-16-45-02.md` 是否生成
|
||||||
|
4. 检查 `测试方案-2026-04-18-16-45-02.md` 是否生成
|
||||||
|
5. 检查 `经验记录.md` 是否存在
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- `.\工程分析` 文件夹存在
|
||||||
|
- 需求分析、实现方案、测试方案文档均已生成,内容完整
|
||||||
|
- `经验记录.md` 存在且格式正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:实现方案审核节点验证
|
||||||
|
**前置条件**:实现方案文档已生成
|
||||||
|
**操作步骤**:
|
||||||
|
1. AI 展示实现方案文档内容
|
||||||
|
2. 用户阅读文档
|
||||||
|
3. 用户回复"确认"或提出修改意见
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- AI 在实现方案生成后主动停止,等待用户输入
|
||||||
|
- 用户未确认前,AI 不进入下一阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:测试方案审核节点验证
|
||||||
|
**前置条件**:测试方案文档已生成,且实现方案已通过用户审核
|
||||||
|
**操作步骤**:
|
||||||
|
1. AI 展示测试方案文档内容
|
||||||
|
2. 用户阅读文档
|
||||||
|
3. 用户回复"确认"或提出修改意见
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- AI 在测试方案生成后主动停止,等待用户输入
|
||||||
|
- 用户未确认前,AI 不进入下一阶段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:经验记录读取验证
|
||||||
|
**前置条件**:存在历史经验记录文档
|
||||||
|
**操作步骤**:
|
||||||
|
1. AI 读取 `.\工程分析\经验记录.md`
|
||||||
|
2. 检查是否能正确解析四段式格式
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- AI 能正确读取并理解经验记录内容
|
||||||
|
- 执行代码修改前能引用相关经验防止重复犯错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:Gitea 备份验证(后续真实需求执行时)
|
||||||
|
**前置条件**:代码修改已完成
|
||||||
|
**操作步骤**:
|
||||||
|
1. AI 执行 `git add .`
|
||||||
|
2. AI 执行 `git commit -m "{时间戳} - {描述}"`
|
||||||
|
3. AI 执行 `git push origin main`
|
||||||
|
4. AI 执行 `git tag` 并推送
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- Commit 成功推送到远程 main 分支
|
||||||
|
- 标签成功推送到远程
|
||||||
|
- AI 向用户汇报备份完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:重新部署验证(后续真实需求执行时)
|
||||||
|
**前置条件**:代码修改已提交
|
||||||
|
**操作步骤**:
|
||||||
|
1. AI 执行 `npm run build`
|
||||||
|
2. 检查 `dist/` 目录是否存在且包含构建产物
|
||||||
|
3. AI 执行 `npm run preview` 或等效部署命令
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 构建成功,无报错
|
||||||
|
- `dist/` 目录包含 `index.html` 和 `assets/`
|
||||||
|
- 预览服务正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 无业务代码变更,不涉及回归测试
|
||||||
|
- 需确认 `.\工程分析` 目录下的新文档不会影响项目构建(即 `.gitignore` 或构建配置不会误处理 `.md` 文件)
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
本次测试的核心是验证"工作流机制本身是否成立"。由于不涉及业务代码修改,TC-05 和 TC-06 将在后续真实需求中实际执行并验证。
|
||||||
123
工程分析/测试方案-2026-04-18-16-55-47.md
Normal file
123
工程分析/测试方案-2026-04-18-16-55-47.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-16-55-47
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证 report-editor 的三项改进是否正确实现:field-value 点击联动、右侧字段动态排序、默认模板表格替换。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:点击正文 field-value 切换至基本信息 Tab 并聚焦
|
||||||
|
**前置条件**:进入 /report-editor,加载默认模板,右侧当前在「视频分析」Tab
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击报告正文中「姓名」后的 field-value 方格
|
||||||
|
2. 观察右侧 Tab 切换
|
||||||
|
3. 观察页面滚动位置
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 右侧 Tab 自动从「视频分析」切换为「基本信息」
|
||||||
|
- 页面平滑滚动至「患者姓名」输入框位置
|
||||||
|
- 「患者姓名」输入框获得焦点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:点击不同 field-value 聚焦对应不同表单字段
|
||||||
|
**前置条件**:report-editor 已加载模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击正文中的「住院号」field-value
|
||||||
|
2. 点击正文中的「手术名称」field-value
|
||||||
|
3. 点击正文中的「手术日期」field-value
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 每次点击后右侧均切换至「基本信息」Tab
|
||||||
|
- 对应字段输入框均被聚焦并滚动至可视区域
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:置顶字段顺序验证
|
||||||
|
**前置条件**:report-editor 右侧显示基本信息表单
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看右侧表单字段的从上到下顺序
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 第1个字段为「患者姓名」
|
||||||
|
- 第2个字段为「住院号」
|
||||||
|
- 第3个字段为「手术名称」
|
||||||
|
- 这三个字段始终固定在最上方
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:动态排序验证——按正文出现顺序
|
||||||
|
**前置条件**:默认模板中正文字段有固定出现顺序
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看右侧表单第4个及之后的字段顺序
|
||||||
|
2. 对比正文中 `data-bind` 的首次出现顺序
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 右侧第4个及之后的字段顺序与正文中 `data-bind` 首次出现的先后顺序一致
|
||||||
|
- 正文中越靠前的字段,在右侧表单中也越靠前
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:动态排序验证——修改正文后排序更新
|
||||||
|
**前置条件**:report-editor 中已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 将正文中某个靠后的字段(如「术后诊断」)剪切并粘贴到正文开头
|
||||||
|
2. 观察右侧表单字段顺序变化
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 「术后诊断」在右侧表单中的位置相应提前
|
||||||
|
- 排序随正文内容变化实时更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:默认模板手术图片表格验证
|
||||||
|
**前置条件**:新建报告或重置系统后进入 report-editor
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看编辑器中的「手术图片说明表格」
|
||||||
|
2. 检查每个单元格内容
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 表格为 2 行 × 3 列布局
|
||||||
|
- 每格包含 `image-placeholder` 占位符
|
||||||
|
- 每格底部有对应图注(图A~图F)
|
||||||
|
- 占位符可正常点击上传图片
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07:表格内占位符图片上传
|
||||||
|
**前置条件**:默认模板已加载
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击表格中某个 `image-placeholder`
|
||||||
|
2. 在弹窗中选择本地上传一张图片
|
||||||
|
3. 确认图片正确填充到占位符中
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 弹窗正常出现(三选一:本地上传/我的签名/系统素材)
|
||||||
|
- 图片正确显示在占位符内
|
||||||
|
- 图片不溢出单元格边界
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08:新建报告默认内容完整性
|
||||||
|
**前置条件**:退出并重新登录,确保系统使用默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 进入 /report-editor(新建报告)
|
||||||
|
2. 检查整个报告内容
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 报告头部 Logo 和标题正常
|
||||||
|
- 基本信息段落正常
|
||||||
|
- 手术步骤段落正常
|
||||||
|
- 手术图片说明表格为新模板
|
||||||
|
- 手术后情况段落正常
|
||||||
|
- 底部撰写时间字段正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 验证 `image-placeholder` 的拖拽填充、点击上传、删除功能不受影响
|
||||||
|
- 验证右侧 Tab 手动切换(「基本信息」↔「视频分析」)正常
|
||||||
|
- 验证 `smart-field-wrapper` 的双向绑定(表单→正文、正文→表单)正常
|
||||||
|
- 验证打印功能中表格和图片正常显示
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
以上 TC-01~TC-08 全部通过,即可确认三项需求均正确实现。
|
||||||
155
工程分析/测试方案-2026-04-18-17-27-51.md
Normal file
155
工程分析/测试方案-2026-04-18-17-27-51.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-17-27-51
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证 TemplateManage 静态占位符插入修复、默认模板排版重构、Logo 删除按钮修复。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:TemplateManage 插入静态图片占位符
|
||||||
|
**前置条件**:进入 /template-manage,编辑器有焦点
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击工具栏「插入图片占位符」
|
||||||
|
2. 在弹窗中选择「静态图片占位」
|
||||||
|
3. 输入宽度 200,高度 200
|
||||||
|
4. 点击「确认插入」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 编辑器中出现虚线边框的占位符框
|
||||||
|
- 占位符带有 `class="image-placeholder"` 和 `data-mode="manual"`
|
||||||
|
- 占位符内部显示「插入/点击放置图片」文字
|
||||||
|
- 占位符右上角显示红色「×」删除按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:TemplateManage 插入手术影像占位符
|
||||||
|
**前置条件**:进入 /template-manage
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击工具栏「插入图片占位符」
|
||||||
|
2. 选择「手术影像占位」
|
||||||
|
3. 点击「确认插入」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符正常显示
|
||||||
|
- 带有 `data-mode="frame"`
|
||||||
|
- 可接受关键帧拖拽填充
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:TemplateManage 占位符删除按钮
|
||||||
|
**前置条件**:已插入占位符
|
||||||
|
**操作步骤**:
|
||||||
|
1. 鼠标悬浮在占位符上
|
||||||
|
2. 点击右上角的红色「×」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符被删除
|
||||||
|
- 撤销按钮可恢复该占位符
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:新建报告默认模板排版——抬头
|
||||||
|
**前置条件**:退出重新登录,进入 /report-editor(新建报告)
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看报告顶部
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 左侧有 65×65 的 Logo 占位符(虚线框)
|
||||||
|
- 中间偏右有 14pt 下划线文字「西 安 交 通 大 学 第 一 附 属 医 院」
|
||||||
|
- 下方有 16pt 文字「手术记录」
|
||||||
|
- 整体居中对齐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:新建报告默认模板排版——基本信息栏
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看抬头下方的基本信息行
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 一行显示:姓名、性别、年龄、科别、床号、住院号
|
||||||
|
- 字体 11pt,不加粗
|
||||||
|
- 整行下方有一条黑色贯穿下划线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:新建报告默认模板排版——诊断信息
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看手术日期、术前诊断、术中诊断、手术名称
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 每项独立一行
|
||||||
|
- 12pt 字体,加粗
|
||||||
|
- 格式为:「手术日期:」+ smartField 占位符
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07:新建报告默认模板排版——双列信息
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看时间、人员、麻醉信息
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 手术开始/终止时间在同一行,左右各 50%
|
||||||
|
- 手术者/助手在同一行
|
||||||
|
- 麻醉师/麻醉方式在同一行
|
||||||
|
- 12pt 字体,不加粗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08:新建报告默认模板排版——手术步骤标题
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「手术步骤、术中出现的情况及处理:」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 12pt 字体,加粗
|
||||||
|
- 位于双列信息下方
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-09:Logo 占位符删除按钮可点击
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 鼠标悬浮在顶部 Logo 占位符上
|
||||||
|
2. 点击右上角的红色「×」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- Logo 占位符被删除
|
||||||
|
- 可撤销恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-10:Logo 占位符图片上传
|
||||||
|
**前置条件**:新建报告已加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击顶部 Logo 占位符
|
||||||
|
2. 选择本地上传一张图片
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 图片正确显示在 65×65 区域内
|
||||||
|
- 图片不溢出占位符
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-11:打印效果验证
|
||||||
|
**前置条件**:新建报告,填写部分内容
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击打印按钮
|
||||||
|
2. 检查打印预览
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 抬头排版正确(Logo + 医院名 + 标题)
|
||||||
|
- 基本信息下划线可见
|
||||||
|
- 双列信息左右对齐
|
||||||
|
- 无多余虚线边框(placeholder 填充后 border 应消失)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 验证 `ReportEditor` 中已有的 `image-placeholder` 点击上传、拖拽填充功能不受影响
|
||||||
|
- 验证 `TemplateManage` 中智能字段插入、删除、撤销/重做功能正常
|
||||||
|
- 验证 `smart-field-wrapper` 双向绑定正常工作
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
TC-01~TC-11 全部通过,即可确认三项需求均正确实现。
|
||||||
111
工程分析/测试方案-2026-04-18-17-48-59.md
Normal file
111
工程分析/测试方案-2026-04-18-17-48-59.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-17-48-59
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证默认模板排版微调和打印样式修复是否正确生效。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:基本信息栏字段间距
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「姓名:性别:年龄:科别:床号:住院号:」一行
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 各字段之间仅有一个空格间距
|
||||||
|
- 字段分布紧凑,不会过度分散
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:抬头整体居中
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看报告最顶部
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- Logo 与「西 安 交 通 大 学 第 一 附 属 医 院 + 手术记录」作为一个整体水平居中
|
||||||
|
- Logo 与文字之间间距较小(约 12px)
|
||||||
|
- 不会出现 Logo 偏左、文字偏右的分离感
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:打印时不显示删除按钮
|
||||||
|
**前置条件**:新建报告,填写部分字段内容
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击打印按钮
|
||||||
|
2. 检查打印预览
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 所有红色「×」删除按钮均不可见
|
||||||
|
- `.image-placeholder` 中的 × 不可见
|
||||||
|
- `.smart-field-wrapper` 中的 × 不可见
|
||||||
|
- 已填充的图片占位符正常显示图片
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:全文行距统一
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看手术日期、术前诊断等段落
|
||||||
|
2. 查看手术步骤段落
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 所有段落行距一致,为 1.5 倍
|
||||||
|
- 段落之间无额外 margin/padding 间距
|
||||||
|
- 整体排版紧凑均匀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:打印行距验证
|
||||||
|
**前置条件**:报告有内容
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击打印
|
||||||
|
2. 检查打印预览中的段落间距
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 打印输出行距为 1.5 倍
|
||||||
|
- 无段前段后 padding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:医院名称下划线贴底
|
||||||
|
**前置条件**:新建报告
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「西 安 交 通 大 学 第 一 附 属 医 院」下方横线
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 下边框紧贴文字底部
|
||||||
|
- 无明显的 padding-bottom 间隙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07:基本信息栏下划线贴底
|
||||||
|
**前置条件**:新建报告
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「姓名:...住院号:」整行下方的横线
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 下边框紧贴文字底部
|
||||||
|
- 无明显的 padding-bottom 间隙
|
||||||
|
- 横线与文字之间仅有极小间距(≤ 2px)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08:打印下划线验证
|
||||||
|
**前置条件**:报告有内容
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击打印
|
||||||
|
2. 检查医院名和基本信息栏的下划线位置
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 打印时下边框紧贴文字底部
|
||||||
|
- 与屏幕预览一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 验证 smart-field-wrapper 的双向绑定(表单→正文、正文→表单)正常工作
|
||||||
|
- 验证 image-placeholder 的点击上传、拖拽填充、删除功能不受影响
|
||||||
|
- 验证手术图片说明表格的 6 图格布局正常
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
TC-01~TC-08 全部通过,即可确认五项排版优化均正确实现。
|
||||||
134
工程分析/测试方案-2026-04-18-18-08-37.md
Normal file
134
工程分析/测试方案-2026-04-18-18-08-37.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-18-08-37
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证编辑器工具栏字号/行距功能、字体选择修复,以及默认模板排版调整。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:ReportEditor 字体选择修复
|
||||||
|
**前置条件**:进入 /report-editor,编辑器中有文字
|
||||||
|
**操作步骤**:
|
||||||
|
1. 选中一段文字
|
||||||
|
2. 从工具栏字体下拉框选择「微软雅黑」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 选中的文字字体变为微软雅黑
|
||||||
|
- 编辑器未失去焦点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:ReportEditor 字号选择
|
||||||
|
**前置条件**:进入 /report-editor,编辑器中有文字
|
||||||
|
**操作步骤**:
|
||||||
|
1. 选中一段文字
|
||||||
|
2. 从工具栏字号下拉框选择「14pt」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 选中的文字字号变大
|
||||||
|
- 编辑器未失去焦点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:ReportEditor 行距选择
|
||||||
|
**前置条件**:进入 /report-editor,编辑器中有多行文字
|
||||||
|
**操作步骤**:
|
||||||
|
1. 将光标放在某一段落内
|
||||||
|
2. 从工具栏行距下拉框选择「2.0」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 当前段落行距变为 2.0
|
||||||
|
- 其他段落不受影响
|
||||||
|
- 草稿自动保存
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:TemplateManage 工具栏功能
|
||||||
|
**前置条件**:进入 /template-manage
|
||||||
|
**操作步骤**:
|
||||||
|
1. 分别测试字体、字号、行距选择功能
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 字体选择生效
|
||||||
|
- 字号选择生效
|
||||||
|
- 行距选择生效
|
||||||
|
- 撤销/重做能恢复行距修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:手术者签名右对齐不换行
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 找到「手术者签名」行
|
||||||
|
2. 将光标放在该行,点击工具栏「右对齐」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 「手术者签名:」文字和图片占位符在同一行
|
||||||
|
- 两者一起靠右对齐
|
||||||
|
- 图片框不会单独换到下一行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:手术记录与姓名间距
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「手术记录」标题与「姓名:」之间的间距
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 间距明显缩小(约 4px)
|
||||||
|
- 不再有过大的空白区域
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07:手术名称与手术开始时间间距
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「手术名称」与「手术开始时间」之间的间距
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 两者间距仅为 1.5 行距的自然间距
|
||||||
|
- 无额外 margin/padding 造成的空白
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08:手术步骤段落字体统一
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看手术步骤 1~5 的字体大小
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 所有手术步骤段落均为 12pt 字体
|
||||||
|
- 与上方「手术日期」等诊断信息字体大小一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-09:手术后情况段落字体
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看「手术后情况」「切除标本描述」等段落的字体大小
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 均为 12pt 字体
|
||||||
|
- 行距 1.5,无段前段后间距
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-10:打印效果验证
|
||||||
|
**前置条件**:报告有内容
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击打印
|
||||||
|
2. 检查打印预览
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 字体、字号、行距设置正确反映在打印输出中
|
||||||
|
- 所有删除按钮(×)不可见
|
||||||
|
- 排版紧凑一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 验证 `smart-field-wrapper` 双向绑定正常工作
|
||||||
|
- 验证 `image-placeholder` 点击上传、拖拽填充功能正常
|
||||||
|
- 验证手术图片说明表格布局未受影响
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
TC-01~TC-10 全部通过,即可确认所有需求均正确实现。
|
||||||
117
工程分析/测试方案-2026-04-18-18-36-43.md
Normal file
117
工程分析/测试方案-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-18-36-43
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-01:ReportManage 列名显示
|
||||||
|
**前置条件**:进入 /report-manage
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看表格表头
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 表头显示为「住院号」而非「患者号」
|
||||||
|
- 数据列正确显示 hospitalId 值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02:字段管理下划线开关
|
||||||
|
**前置条件**:进入 /template-manage,点击字段管理
|
||||||
|
**操作步骤**:
|
||||||
|
1. 新建一个字段
|
||||||
|
2. 观察「打印时显示下划线」checkbox,默认应为勾选
|
||||||
|
3. 取消勾选并保存
|
||||||
|
4. 将该字段插入模板
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 新建字段表单中有「打印时显示下划线」选项
|
||||||
|
- 编辑字段时也可修改该选项
|
||||||
|
- 取消下划线的字段插入后,`.field-value` 带有 `no-underline` 类
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03:打印时下划线控制
|
||||||
|
**前置条件**:模板中有带/不带下划线的字段
|
||||||
|
**操作步骤**:
|
||||||
|
1. 进入 report-editor,新建报告
|
||||||
|
2. 填写字段内容
|
||||||
|
3. 点击打印
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 默认勾选下划线的字段,打印时 `.field-value` 底部有黑色下划线
|
||||||
|
- 取消下划线的字段,打印时 `.field-value` 底部无下划线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04:ReportEditor 下载按钮
|
||||||
|
**前置条件**:进入 /report-editor,有内容的报告
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击顶部下载按钮
|
||||||
|
2. 在弹窗中选择「导出 PDF」
|
||||||
|
3. 在弹窗中选择「导出 JSON」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 弹窗正常显示两个导出选项
|
||||||
|
- PDF 导出时浏览器保存对话框的文件名包含「图文报告-{手术名称}-{患者}-{住院号}-{时间}」
|
||||||
|
- JSON 导出时下载的文件名格式同上,内容包含 reportData
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05:TemplateManage 下载按钮
|
||||||
|
**前置条件**:进入 /template-manage
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击顶部下载按钮
|
||||||
|
2. 选择导出 PDF/JSON
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 导出功能正常
|
||||||
|
- 文件名格式合理(模板名称 + 时间)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06:右对齐时签名不换行
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 找到「手术者签名」行
|
||||||
|
2. 选中该行,点击工具栏「右对齐」
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 「手术者签名:」文字与图片占位符在同一行
|
||||||
|
- 两者一起靠右对齐
|
||||||
|
- 图片框不会单独换到下一行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07:默认模板签名右对齐
|
||||||
|
**前置条件**:新建报告,加载默认模板
|
||||||
|
**操作步骤**:
|
||||||
|
1. 查看报告底部「手术者签名」行
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 默认即为右对齐
|
||||||
|
- 文字与图片框在同一行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08:占位符 inline-block 样式
|
||||||
|
**前置条件**:在 template-manage 中插入静态图片占位符
|
||||||
|
**操作步骤**:
|
||||||
|
1. 点击工具栏「插入图片占位符」
|
||||||
|
2. 选择「静态图片占位」
|
||||||
|
3. 确认插入
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符的 style 中 `display` 为 `inline-block` 而非 `inline-flex`
|
||||||
|
- 占位符在编辑器中正常显示,垂直居中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试范围
|
||||||
|
- 验证所有现有字段(默认模板中的)打印时仍显示下划线
|
||||||
|
- 验证 smart-field-wrapper 双向绑定正常工作
|
||||||
|
- 验证 image-placeholder 点击上传、拖拽填充、删除功能正常
|
||||||
|
- 验证 report-manage 的搜索、筛选、批量操作不受影响
|
||||||
|
|
||||||
|
## 测试结论
|
||||||
|
TC-01~TC-08 全部通过,即可确认所有需求均正确实现。
|
||||||
64
工程分析/测试方案-2026-04-18-19-08-43.md
Normal file
64
工程分析/测试方案-2026-04-18-19-08-43.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-19-08-43
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证六项需求修改的正确性和稳定性。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-1:基础信息字段打印无下划线
|
||||||
|
**前置条件**:新建报告,默认模板已加载。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击「打印预览」或「下载 PDF」。
|
||||||
|
2. 检查「姓名、性别、年龄、科别、床号、住院号」区域。
|
||||||
|
**预期结果**:这 6 个字段不显示下划线,其他字段(如手术名称、诊断等)正常显示下划线。
|
||||||
|
|
||||||
|
### TC-2:点击 field-value 联动高亮并居中滚动
|
||||||
|
**前置条件**:编辑器已加载默认模板,右侧基本信息 Tab 可见。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击正文中任意 `class="field-value"`(如「手术名称」)。
|
||||||
|
2. 观察右侧对应输入框。
|
||||||
|
**预期结果**:
|
||||||
|
- 对应输入框出现蓝色背景高亮(`bg-blue-50 ring-1 ring-accent`)。
|
||||||
|
- 页面平滑滚动,使该输入框位于可视区域中部。
|
||||||
|
- 输入框获得焦点。
|
||||||
|
|
||||||
|
### TC-3:视频上传按钮整合进缩略图列表
|
||||||
|
**前置条件**:已上传至少一个视频。
|
||||||
|
**步骤**:
|
||||||
|
1. 切换到「视频分析」Tab。
|
||||||
|
2. 观察视频缩略图列表。
|
||||||
|
**预期结果**:
|
||||||
|
- 列表第一个位置是一个缩小版「上传视频」按钮,尺寸与视频卡片一致(约 `w-24`)。
|
||||||
|
- 点击该按钮能正常打开文件选择器。
|
||||||
|
- 原本独立的「点击上传手术视频」大按钮已消失。
|
||||||
|
|
||||||
|
### TC-4:视频模块间距紧凑化
|
||||||
|
**前置条件**:视频分析面板展开,有视频和关键帧。
|
||||||
|
**步骤**:
|
||||||
|
1. 观察缩略图列表与播放器之间的间距。
|
||||||
|
2. 观察播放器与控制按钮之间的间距。
|
||||||
|
3. 观察控制按钮与「关键帧摘取」标题之间的间距。
|
||||||
|
**预期结果**:各项间距明显缩小,下方关键帧列表获得更多展示空间。
|
||||||
|
|
||||||
|
### TC-5:签名与日期之间增加空行
|
||||||
|
**前置条件**:默认模板已加载。
|
||||||
|
**步骤**:
|
||||||
|
1. 滚动到模板底部,查看「手术者签名」与「撰写时间」之间。
|
||||||
|
**预期结果**:两者之间有一个空行(约一行高度的空白)。
|
||||||
|
|
||||||
|
### TC-6:图片占位符填充后高度自适应
|
||||||
|
**前置条件**:模板中有空图片占位符,有较小的图片(高度 < 200px)。
|
||||||
|
**步骤**:
|
||||||
|
1. 将图片插入占位符(通过上传、拖拽或自动摘取)。
|
||||||
|
2. 观察占位符区域。
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符高度随图片实际尺寸自适应,不再保留 200px 固定高度。
|
||||||
|
- 图片下方不会出现大片空白。
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- 确保打印功能(PDF 导出)正常工作。
|
||||||
|
- 确保视频播放、关键帧摘取、拖拽插入功能正常。
|
||||||
|
- 确保 `template-manage` 中的图片占位符同样支持高度自适应。
|
||||||
|
|
||||||
|
## 测试通过标准
|
||||||
|
所有用例均通过,无控制台报错,打印样式正常。
|
||||||
54
工程分析/测试方案-2026-04-18-19-23-31.md
Normal file
54
工程分析/测试方案-2026-04-18-19-23-31.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-19-23-31
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证视频分析模块空白修复和图片占位符自适应逻辑。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-1:视频分析模块无视频时显示上传按钮
|
||||||
|
**前置条件**:新建报告,切换到「视频分析」Tab,尚未上传任何视频。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击「视频分析」Tab。
|
||||||
|
**预期结果**:
|
||||||
|
- 面板显示「上传视频」按钮(缩小版,在水平滚动区域首位)。
|
||||||
|
- 面板不显示视频播放器和关键帧区域。
|
||||||
|
- 点击上传按钮可正常打开文件选择器。
|
||||||
|
|
||||||
|
### TC-2:视频分析模块有视频时正常显示
|
||||||
|
**前置条件**:已上传至少一个视频。
|
||||||
|
**步骤**:
|
||||||
|
1. 切换到「视频分析」Tab。
|
||||||
|
**预期结果**:
|
||||||
|
- 上传按钮和视频缩略图列表均可见。
|
||||||
|
- 选中视频后,播放器和关键帧区域正常显示。
|
||||||
|
|
||||||
|
### TC-3:图片占位符填充后尺寸自适应(小图片)
|
||||||
|
**前置条件**:模板中有 200×200 的图片占位符,准备一张 100×80 的小图片。
|
||||||
|
**步骤**:
|
||||||
|
1. 将小图片插入占位符。
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符宽度收缩为约 100px,高度收缩为约 80px。
|
||||||
|
- 图片靠左上方放置,无多余空白。
|
||||||
|
|
||||||
|
### TC-4:图片占位符填充后尺寸自适应(大图片)
|
||||||
|
**前置条件**:模板中有 200×200 的图片占位符,准备一张 800×600 的大图片。
|
||||||
|
**步骤**:
|
||||||
|
1. 将大图片插入占位符。
|
||||||
|
**预期结果**:
|
||||||
|
- 图片等比例缩小,最大不超过 200×200。
|
||||||
|
- 占位符宽度收缩为缩小后的图片宽度(≤200px),高度同理。
|
||||||
|
- 图片靠左上方放置。
|
||||||
|
|
||||||
|
### TC-5:Logo 占位符大小保持 65px × 65px
|
||||||
|
**前置条件**:默认模板已加载。
|
||||||
|
**步骤**:
|
||||||
|
1. 检查顶部 Logo 占位符。
|
||||||
|
**预期结果**:占位符尺寸为 65px × 65px,不受本次修改影响。
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- 确保视频播放、关键帧摘取、拖拽插入功能正常。
|
||||||
|
- 确保 `template-manage` 中的图片占位符同样支持尺寸自适应。
|
||||||
|
- 确保打印样式正常,图片不会被截断。
|
||||||
|
|
||||||
|
## 测试通过标准
|
||||||
|
所有用例均通过,无控制台报错,视频模块和图片占位符行为符合预期。
|
||||||
48
工程分析/测试方案-2026-04-18-19-37-56.md
Normal file
48
工程分析/测试方案-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-19-37-56
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证 4 项编辑器体验修复的正确性和稳定性。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-1:视频上传按钮位于列表末尾
|
||||||
|
**前置条件**:已上传至少一个视频。
|
||||||
|
**步骤**:
|
||||||
|
1. 切换到「视频分析」Tab。
|
||||||
|
**预期结果**:
|
||||||
|
- 视频缩略图列表中,已有视频在前,「上传视频」按钮在最后。
|
||||||
|
- 点击上传按钮可正常打开文件选择器。
|
||||||
|
|
||||||
|
### TC-2:图片占位符提示文字绝对居中
|
||||||
|
**前置条件**:默认模板已加载。
|
||||||
|
**步骤**:
|
||||||
|
1. 查看顶部 Logo 占位符和表格内图片占位符。
|
||||||
|
**预期结果**:
|
||||||
|
- 「插入图片」或「插入/点击放置图片」文字在占位框正中心,不偏上也不偏下。
|
||||||
|
- 占位符高度不同时(65px vs 200px),文字始终居中。
|
||||||
|
|
||||||
|
### TC-3:删除图片后占位符恢复原始大小
|
||||||
|
**前置条件**:模板中有 200×200 的图片占位符,已插入图片。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击图片上的「×」删除按钮。
|
||||||
|
**预期结果**:
|
||||||
|
- 占位符恢复为虚线框,宽度恢复为 200px,高度恢复为 200px。
|
||||||
|
- 提示文字居中显示。
|
||||||
|
- 占位符仍可重新插入图片。
|
||||||
|
|
||||||
|
### TC-4:对齐按钮不破坏混合排版
|
||||||
|
**前置条件**:默认模板已加载,「手术者签名:」行包含文字和签名占位符。
|
||||||
|
**步骤**:
|
||||||
|
1. 将光标放在「手术者签名:」这一行。
|
||||||
|
2. 分别点击「左对齐」「居中」「右对齐」按钮。
|
||||||
|
**预期结果**:
|
||||||
|
- 整行(文字 + 占位符)作为一个整体对齐,不会换行分离。
|
||||||
|
- `.field-value` 所在行同样适用,对齐时不破坏字段与文字的同行关系。
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- 确保视频上传、播放、关键帧摘取功能正常。
|
||||||
|
- 确保图片占位符的插入、拖拽、自动帧填充功能正常。
|
||||||
|
- 确保打印样式正常,图片和字段显示正确。
|
||||||
|
|
||||||
|
## 测试通过标准
|
||||||
|
所有用例均通过,无控制台报错,排版结构完整。
|
||||||
62
工程分析/测试方案-2026-04-18-20-03-44.md
Normal file
62
工程分析/测试方案-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 测试方案 —— 2026-04-18-20-03-44
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
验证模板导入/导出功能和默认模板 Logo 替换的正确性。
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### TC-1:模板导出
|
||||||
|
**前置条件**:模板列表中已有至少一个模板,且该模板有内容和字段配置。
|
||||||
|
**步骤**:
|
||||||
|
1. 在模板列表中找到目标模板。
|
||||||
|
2. 点击操作列的「导出」按钮。
|
||||||
|
**预期结果**:
|
||||||
|
- 浏览器下载一个 JSON 文件,文件名为 `模板导出-{模板名称}.json`。
|
||||||
|
- JSON 内容包含 `version`、`type`、`title`、`description`、`content`、`fields` 字段。
|
||||||
|
- `fields` 数组与模板原有的字段配置一致。
|
||||||
|
|
||||||
|
### TC-2:模板导入(自动填充名称和描述)
|
||||||
|
**前置条件**:已有一个有效的模板导出 JSON 文件。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击「新增模板」按钮。
|
||||||
|
2. 在弹窗中点击导入图标,选择 JSON 文件。
|
||||||
|
**预期结果**:
|
||||||
|
- 模板名称输入框自动填充为 JSON 中的 `title`。
|
||||||
|
- 模板描述输入框自动填充为 JSON 中的 `description`。
|
||||||
|
- 无控制台报错。
|
||||||
|
|
||||||
|
### TC-3:模板导入后创建
|
||||||
|
**前置条件**:已完成 TC-2 的导入操作。
|
||||||
|
**步骤**:
|
||||||
|
1. 点击「创建」按钮。
|
||||||
|
2. 在新创建的模板中点击「编辑模板」。
|
||||||
|
**预期结果**:
|
||||||
|
- 编辑器中显示的内容与导入 JSON 中的 `content` 一致。
|
||||||
|
- 字段管理中的配置与导入 JSON 中的 `fields` 一致。
|
||||||
|
|
||||||
|
### TC-4:导入无效文件
|
||||||
|
**前置条件**:准备一个非 JSON 文件或格式错误的 JSON。
|
||||||
|
**步骤**:
|
||||||
|
1. 在新增模板弹窗中选择无效文件。
|
||||||
|
**预期结果**:
|
||||||
|
- 弹出提示「文件解析失败,请检查 JSON 格式」或「无效的模板包文件」。
|
||||||
|
- 表单保持空白,不填充任何数据。
|
||||||
|
|
||||||
|
### TC-5:Logo 占位符交互
|
||||||
|
**前置条件**:新建报告,默认模板已加载。
|
||||||
|
**步骤**:
|
||||||
|
1. 查看顶部 Logo 区域。
|
||||||
|
2. 点击 Logo 占位符右上方的「×」。
|
||||||
|
3. 再次点击 Logo 区域。
|
||||||
|
**预期结果**:
|
||||||
|
- Logo 区域显示为虚线框,提示文字「LOGO」居中显示。
|
||||||
|
- 点击「×」后 Logo 占位符被删除。
|
||||||
|
- 再次点击可打开图片选择器插入图片。
|
||||||
|
|
||||||
|
## 回归测试
|
||||||
|
- 确保模板列表的加载、编辑、删除功能正常。
|
||||||
|
- 确保默认模板的其他部分(基础信息、手术步骤、图片表格等)不受影响。
|
||||||
|
- 确保打印样式正常。
|
||||||
|
|
||||||
|
## 测试通过标准
|
||||||
|
所有用例均通过,无控制台报错,导入/导出数据完整准确。
|
||||||
105
工程分析/经验记录.md
105
工程分析/经验记录.md
@@ -942,3 +942,108 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
|||||||
**D. 后续如何避免问题**
|
**D. 后续如何避免问题**
|
||||||
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode`、`data-allow-source`)时,必须同步检索 `defaultContent.ts` 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
|
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode`、`data-allow-source`)时,必须同步检索 `defaultContent.ts` 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
|
||||||
- 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。
|
- 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 31:六项 UI/UX 优化集中实施
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
用户提出六项体验优化需求:基础信息字段打印无下划线、编辑器字段联动高亮、视频上传按钮整合、视频面板间距紧凑化、签名与日期之间空行、图片占位符填充后高度自适应。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
均为长期使用中积累的交互和排版细节问题:
|
||||||
|
1. 默认模板的基础字段(姓名/性别/年龄/科别/床号/住院号)打印时默认带下划线,但临床场景中这些字段通常不需要下划线。
|
||||||
|
2. 编辑器中点击正文 `field-value` 后右侧没有视觉反馈,用户不知道对应哪个输入框。
|
||||||
|
3. 视频上传按钮独立占一行,浪费垂直空间。
|
||||||
|
4. 视频面板各区域间距过大,挤压了关键帧列表的展示空间。
|
||||||
|
5. 签名和日期之间缺少空行,排版拥挤。
|
||||||
|
6. 图片占位符填充后仍保留固定高度(如 200px),导致图片下方出现大片空白。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **基础字段无下划线**:在 `defaultContent.ts` 的 `smartField()` 中硬编码 6 个 key(`patientName`, `patientGender`, `patientAge`, `department`, `bedNumber`, `hospitalId`),自动注入 `.no-underline` 类;同时保留 `hasUnderline` 配置机制供 TemplateManage 自定义。
|
||||||
|
2. **字段联动高亮**:新增 `activeFieldKey` 状态;点击 `field-value` 时设置该状态并滚动到对应 `id={`input-${bindKey}`}` 元素;为右侧所有字段类型(text/date/single_select/multi_select/time)的容器统一添加 `p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`。
|
||||||
|
3. **视频按钮整合**:删除独立的大按钮,在缩略图滚动容器的首位插入缩小版按钮(`shrink-0 w-24 h-[68px]`),样式与视频卡片一致。
|
||||||
|
4. **视频间距紧凑**:将 `space-y-4` 逐层改为 `space-y-2`;关键帧摘取标题区域改为 `pt-1 border-t border-border`。
|
||||||
|
5. **签名空行**:在签名 `<p>` 和日期 `<p>` 之间插入 `<p style="margin:0;padding:0;line-height:1.5;"> </p>`。
|
||||||
|
6. **占位符高度自适应**:在 `fillPlaceholderSrc`、`fillPlaceholder`、`autoCaptureFrames`(ReportEditor)以及 `fillPlaceholderSrc`(TemplateManage)中,填充图片后统一设置 `placeholder.style.height = 'auto'; placeholder.style.width = 'auto'; placeholder.style.lineHeight = 'normal';`,并将图片 style 中的 `max-height:100%;object-fit:contain` 改为 `height:auto`。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当为 `image-placeholder` 修改填充后的样式行为时,必须同步检索所有填充入口(`fillPlaceholderSrc`、`fillPlaceholder`、自动帧插入、拖拽填充等),并同步到 `TemplateManage.tsx`。
|
||||||
|
- 右侧表单字段容器样式如果统一(如高亮背景),应在所有字段类型的渲染分支中同步添加,避免某些类型遗漏。
|
||||||
|
- 默认模板修改后应通过「新建报告 → 检查 DOM 结构」快速验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 32:视频分析模块空白修复与图片占位符自适应逻辑重构
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
1. 上一轮优化中将「上传视频」按钮移入了 `videos.length > 0` 条件渲染内部,导致无视频时整个「视频分析」面板空白,用户无法上传第一个视频。
|
||||||
|
2. 图片占位符填充后仅将 `height` 设为 `auto`,但宽度仍保持预设值(如 200px),导致图片周围有大量空白,用户希望占位符能紧缩包围图片。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. **视频按钮位置错误**:重构视频面板时,将上传按钮和缩略图列表全部包裹在 `{videos.length > 0 && (...)}` 中,未意识到上传按钮必须始终可见。
|
||||||
|
2. **占位符尺寸逻辑不完整**:此前仅将 `height` 改为 `auto`,未同步处理 `width`,也未利用 `max-width`/`max-height` 作为硬限制来实现等比例缩放。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **修复视频面板**:将上传按钮和缩略图列表移出 `videos.length > 0` 条件,使其始终渲染;仅保留视频播放器和关键帧网格在 `{currentVideoIndex !== -1 && videos.length > 0 && (...)}` 中条件渲染。注意:移出后需同步删除对应的 `</div>)}` 关闭标签,否则会导致 JSX 结构不匹配(esbuild 报错「Unexpected closing tag」)。
|
||||||
|
2. **重构占位符尺寸逻辑**:
|
||||||
|
- **插入时**:在 `placeholderModal` 确认插入的 `styleStr` 中,为 inline-block 占位符追加 `max-width:${w}px;max-height:${h}px;`(表格内占位符原本就有)。
|
||||||
|
- **填充时**:在 `fillPlaceholderSrc`、`fillPlaceholder`、`autoCaptureFrames`(ReportEditor)和 `fillPlaceholderSrc`(TemplateManage)中统一执行以下步骤:
|
||||||
|
- 读取 `placeholder.style.maxWidth || placeholder.style.width` 和 `placeholder.style.maxHeight || placeholder.style.height` 作为硬限制值 `mw` / `mh`
|
||||||
|
- 将 `<img>` 的 style 设为 `max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;`
|
||||||
|
- 将占位符外壳设为 `width:auto;height:auto;line-height:normal;max-width:${mw};max-height:${mh};text-align:left;vertical-align:top;justify-content:flex-start;align-items:flex-start;`
|
||||||
|
- 这样,小图片会 shrink-wrap 到实际尺寸,大图片会等比例缩小到限制范围内,且靠左上方放置。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 重构条件渲染的 JSX 结构时,必须仔细核对打开和关闭标签的数量和层级。建议使用编辑器格式化或 build 工具(如 esbuild)立即验证。
|
||||||
|
- `image-placeholder` 的尺寸逻辑涉及「创建时预设」和「填充后自适应」两个阶段,修改时必须同时考虑:
|
||||||
|
- 创建时是否写入了 `max-width`/`max-height`
|
||||||
|
- 填充时是否正确读取并应用这些限制值
|
||||||
|
- 所有填充入口(本地上传、签名插入、系统素材、自动帧插入、拖拽填充)是否同步更新
|
||||||
|
- 默认模板中的占位符如果没有 `max-width`/`max-height`,回退逻辑 `|| placeholder.style.width` 仍能正确获取限制值,但后续修改默认模板时应注意统一添加 `max-width`/`max-height` 以显式声明意图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 33:四项编辑器体验优化集中实施
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
1. 视频分析面板中「上传视频」按钮位于视频缩略图列表首位,不符合「先列出现有项,最后提供添加操作」的操作直觉。
|
||||||
|
2. 图片占位符内的提示文字未在框中绝对居中,当占位符高度较大时文字明显偏上。
|
||||||
|
3. 删除占位符内已插入的图片后,占位符保持收缩后的 `width:auto; height:auto` 尺寸,未恢复为原始预设大小。
|
||||||
|
4. 点击「左对齐/居中/右对齐」按钮时,浏览器原生 `execCommand('justifyLeft')` 会用 `<div align="left">` 包裹选区,导致包含 `.field-value` 或 `.image-placeholder` 的段落被肢解,文字与输入框/图片强制换行分离。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. 上一轮重构视频面板时,将上传按钮移入了缩略图列表,但放在了首位而非末尾。
|
||||||
|
2. 占位符提示文字使用默认的行内流布局居中,依赖于 `line-height` 和父容器的 `align-items: center`,在填充后 `line-height` 被改为 `normal`,导致文字不再居中。
|
||||||
|
3. 删除恢复逻辑仅重置了 `border` 和 `background`,未恢复 `width`、`height`、`lineHeight` 等尺寸属性。
|
||||||
|
4. `execCommand` 的对齐命令实现过于粗暴,会直接修改 DOM 树结构以创建对齐容器,无法安全地处理混合排版(文字 + 交互元素)。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **视频按钮位置**:将上传按钮从 `videos.map()` 之前移至之后,保持所有样式和点击逻辑不变。
|
||||||
|
2. **占位符文字绝对居中**:
|
||||||
|
- 将 `.placeholder-text` 的样式统一改为 `position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); display:block; width:100%;`
|
||||||
|
- 给所有表格内的 `.image-placeholder` 父容器添加 `position:relative;`(inline-block 和签名占位符原本已有)
|
||||||
|
- 修改范围覆盖 `defaultContent.ts`(8 个占位符)、`ReportEditor.tsx`(Modal 插入 + 删除恢复)、`TemplateManage.tsx`(Modal 插入 + 删除恢复)
|
||||||
|
3. **删除后恢复尺寸**:在删除恢复逻辑中增加:
|
||||||
|
```ts
|
||||||
|
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';
|
||||||
|
```
|
||||||
|
同时根据占位符原始宽度(`maxWidth || width`)判断显示「插图」(<80px)或「插入/点击放置图片」。
|
||||||
|
4. **安全对齐**:弃用 `execCommand('justifyLeft'/'justifyCenter'/'justifyRight')`,新增 `changeAlignment(align)` 方法:
|
||||||
|
- 通过 `window.getSelection()` 获取选区
|
||||||
|
- 使用 `closest('p, div, td, h1, h2, h3, li')` 找到最近的块级祖先
|
||||||
|
- 直接设置 `(block as HTMLElement).style.textAlign = align`
|
||||||
|
- 同步保存内容快照
|
||||||
|
- 对齐按钮增加 `onMouseDown={(e) => e.preventDefault()}` 防止编辑器失焦
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当修改 `image-placeholder` 的创建或恢复逻辑时,必须在所有入口同步更新:`defaultContent.ts`(静态模板)、`ReportEditor.tsx`(运行时插入/填充/删除恢复)、`TemplateManage.tsx`(模板管理)。
|
||||||
|
- 任何涉及 `execCommand` 的富文本操作都应评估其安全性,优先使用直接 DOM 样式操作(如 `style.textAlign`、`style.lineHeight`)替代,避免浏览器原生命令对复杂 DOM 结构的不可控修改。
|
||||||
|
- 绝对定位的居中方案(`transform: translate(-50%, -50%)`)虽然效果稳定,但要求父容器必须带有 `position: relative`,修改时需同步检查所有父容器的样式。
|
||||||
|
|||||||
55
工程分析/需求分析-2026-04-18-16-45-02.md
Normal file
55
工程分析/需求分析-2026-04-18-16-45-02.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-16-45-02
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户明确要求建立一套标准化的代码编纂工作流,用于规范后续所有项目修改需求的处理流程。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
新建一个完整的代码编纂工作流。后续用户提出的任何项目修改相关需求,都必须严格按照该工作流执行。
|
||||||
|
|
||||||
|
## 工作流步骤定义
|
||||||
|
|
||||||
|
### 0. 时间记录
|
||||||
|
每次执行前,记录问题开始的时间,格式为 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`。
|
||||||
|
|
||||||
|
### 1. 工程分析文件夹
|
||||||
|
阅读或创建 `.\工程分析` 文件夹,用于存放对整个工程的整体分析文档。
|
||||||
|
|
||||||
|
### 2. 需求分析文档
|
||||||
|
每次用户提出的需求,都整理写入 `.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
|
||||||
|
|
||||||
|
### 3. 实现方案文档
|
||||||
|
每次将实现方案写入 `.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
|
||||||
|
**关键约束**:该文档写完后必须经过用户二次人工审核确认,方可进入下一步。
|
||||||
|
|
||||||
|
### 4. 测试方案文档
|
||||||
|
将测试方案写入 `.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
|
||||||
|
**关键约束**:该文档写完后必须经过用户二次人工审核确认,方可进入下一步。
|
||||||
|
|
||||||
|
### 5. 经验记录与知识库
|
||||||
|
- **执行前**:阅读 `.\工程分析\经验记录.md`,防止重复犯错。
|
||||||
|
- **执行后**:在 `.\工程分析\经验记录.md` 中,将执行过程中遇到的关键问题及解决方案,按以下四段式格式追加记录:
|
||||||
|
- A. 具体问题
|
||||||
|
- B. 产生问题原因
|
||||||
|
- C. 解决问题方案
|
||||||
|
- D. 后续如何避免问题
|
||||||
|
|
||||||
|
### 6. Gitea 备份
|
||||||
|
最终执行完成后,使用 Gitea 对文档进行备份。Commit 信息需包含:
|
||||||
|
- `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}` 时间戳
|
||||||
|
- 对本次修改的简要描述
|
||||||
|
完成后提醒用户已完成文档备份。
|
||||||
|
|
||||||
|
### 7. 重新部署
|
||||||
|
最终执行完成后,重新执行 `npm` 部署本项目。
|
||||||
|
|
||||||
|
## 本次需求的特殊性
|
||||||
|
本次需求本身即为"建立工作流规范",不涉及具体业务代码的增删改。因此实现内容主要是:
|
||||||
|
1. 确认 `.\工程分析` 目录结构已就绪
|
||||||
|
2. 将工作流规范固化为可重复执行的流程文档
|
||||||
|
3. 确保用户理解每个步骤的审核节点
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 工程分析文档体系
|
||||||
|
- 后续所有项目修改需求的执行方式
|
||||||
|
- Gitea 备份流程
|
||||||
|
- 无业务代码变更
|
||||||
27
工程分析/需求分析-2026-04-18-16-55-47.md
Normal file
27
工程分析/需求分析-2026-04-18-16-55-47.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-16-55-47
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户直接提出三项 report-editor 相关改进需求。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:点击正文 field-value 联动右侧基本信息
|
||||||
|
在 `report-editor` 中,点击报告正文内 `class="field-value"` 的元素时,自动将右侧面板切换至「基本信息」栏目,并聚焦/滚动到该字段对应的表单输入框。
|
||||||
|
|
||||||
|
### 需求 2:右侧基本信息字段按正文出现顺序动态排序
|
||||||
|
右侧「基本信息」栏目中:
|
||||||
|
- **固定置顶**:患者姓名 (`patientName`)、住院号 (`hospitalId`)、手术名称 (`title`) 始终排在最上方,顺序固定。
|
||||||
|
- **动态排序**:其余字段按其在报告正文 HTML 中 `data-bind` 出现的先后顺序排列。
|
||||||
|
- **兜底处理**:正文中未出现的字段排在末尾。
|
||||||
|
|
||||||
|
### 需求 3:替换默认模板中的手术图片说明表格
|
||||||
|
将 `src/utils/defaultContent.ts` 中的 `<!-- 手术图片说明表格 -->` 默认模板替换为用户提供的 6 图格 HTML 代码(含腹腔镜探查、胆囊管夹闭与离断、胆囊动脉夹闭与离断、胆囊剥离与床面止血、胆囊取出与钛夹确认、止血材料覆盖及检查)。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 1、2)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 3)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 报告编辑器交互体验
|
||||||
|
- 右侧基本信息面板渲染逻辑
|
||||||
|
- 默认报告模板内容
|
||||||
29
工程分析/需求分析-2026-04-18-17-27-51.md
Normal file
29
工程分析/需求分析-2026-04-18-17-27-51.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-17-27-51
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户提出 TemplateManage 功能修复与默认报告模板排版重构需求。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:修复 TemplateManage 静态图片占位符插入不显示
|
||||||
|
在 `template-manage` 中通过弹窗选择「静态图片占位」并点击「确认插入」后,编辑器中没有出现 `class="image-placeholder"` 的占位符框。经分析,原因是 `document.execCommand('insertHTML')` 对复杂嵌套 HTML(含 `contenteditable="false"`)的自动修正/过滤行为不可靠。
|
||||||
|
|
||||||
|
### 需求 2:重构默认报告模板顶部排版
|
||||||
|
根据用户提供的视觉参考图片,重写 `defaultContent.ts` 顶部排版:
|
||||||
|
- **抬头**:左侧 Logo(65×65 静态占位),右侧 14 号字体的「西 安 交 通 大 学 第 一 附 属 医 院」(带下划线),下方 16 号字体「手术记录」。
|
||||||
|
- **基本信息栏**:11 号字体、不加粗、带贯穿下划线的一行:姓名、性别、年龄、科别、床号、住院号。
|
||||||
|
- **诊断/手术信息**:12 号字体、加粗的单行:手术日期、术前诊断、术中诊断、手术名称。
|
||||||
|
- **双列信息**:12 号字体、不加粗、两项一行:手术开始/终止时间、手术者/助手、麻醉师/麻醉方式。
|
||||||
|
- **手术步骤标题**:12 号字体、加粗的「手术步骤、术中出现的情况及处理:」。
|
||||||
|
|
||||||
|
### 需求 3:修复顶部 Logo 占位符删除按钮无法点击
|
||||||
|
当前默认模板中 65px×65px 的 Logo 占位符右上角的「×」删除按钮无法点击。原因是占位符缺少 `position: relative`,导致绝对定位的删除按钮点击区域溢出或被遮挡。需保留其「静态图片占位 (`data-mode="manual"`)」逻辑。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 1:修复 insertImage 插入方式)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 2、3:重构模板排版 + Logo 修复)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 模板管理页面的图片占位符插入功能
|
||||||
|
- 新建报告时的默认模板视觉效果
|
||||||
|
- 打印输出时的顶部排版
|
||||||
30
工程分析/需求分析-2026-04-18-17-48-59.md
Normal file
30
工程分析/需求分析-2026-04-18-17-48-59.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-17-48-59
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户基于打印预览效果,提出默认模板排版微调和打印样式修复需求。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:缩减基本信息栏字段间空格
|
||||||
|
当前默认模板中「姓名:性别:年龄:科别:床号:住院号:」之间使用了 ` `(三个不间断空格),间距过大。需缩减为单个空格 ` `。
|
||||||
|
|
||||||
|
### 需求 2:Logo 与医院名/标题靠拢并整体居中
|
||||||
|
当前顶部使用 3 列 table(20%-60%-20%),Logo 固定在左侧 20% 区域,与中间标题距离过远。需改为 Flex 布局,使 Logo 与文字内容作为一个整体水平居中,且两者间距缩小。
|
||||||
|
|
||||||
|
### 需求 3:打印时隐藏所有「×」删除按钮
|
||||||
|
打印预览中,`.smart-field-wrapper` 内的 `.delete-btn`(红色×)仍然可见。`print.ts` 中仅隐藏了 `.image-placeholder .delete-btn`,遗漏了文本字段中的删除按钮。需全局隐藏 `.delete-btn`。
|
||||||
|
|
||||||
|
### 需求 4:统一全文行距为 1.5,消除段前段后间距
|
||||||
|
当前模板中各 `<p>` 标签的 `line-height` 不统一(有 1.8、默认行高等),且部分段落有默认 margin/padding。需统一为 `line-height: 1.5; margin: 0; padding: 0;`。
|
||||||
|
|
||||||
|
### 需求 5:下划线紧贴文字底部
|
||||||
|
「西 安 交 通 大 学 第 一 附 属 医 院」下方的 `border-bottom` 和「姓名:」等基本信息栏下方的 `border-bottom` 与文字间距过大。需移除 `padding-bottom`,并通过 `line-height: 1` 或类似手段消除字体底部留白,使横线紧贴文字底部。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/utils/defaultContent.ts`(需求 1、2、4、5)
|
||||||
|
- `src/utils/print.ts`(需求 3、4)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 默认报告模板的视觉效果
|
||||||
|
- 打印输出样式
|
||||||
|
- 无业务逻辑变更
|
||||||
34
工程分析/需求分析-2026-04-18-18-08-37.md
Normal file
34
工程分析/需求分析-2026-04-18-18-08-37.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-18-08-37
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户提出报告编辑器与模板管理器的工具栏功能增强,以及默认模板排版细节调整。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:修复字体选择并新增字号、行距功能
|
||||||
|
在 `report-editor` 和 `template-manage` 的工具栏中:
|
||||||
|
- **修复字体选择**:当前 `document.execCommand('fontName')` 可能因浏览器兼容性或 CSS 覆盖而失效,需确保字体选择能正确生效。
|
||||||
|
- **新增字号选择**:在工具栏字体选择旁边增加字号下拉框,支持 12pt/14pt/18pt 等常用字号。
|
||||||
|
- **新增行距选择**:在工具栏增加行距下拉框,支持 1.0/1.5/2.0 等行距。由于 `execCommand` 不原生支持行距,需通过直接修改 DOM 元素的 `style.lineHeight` 实现。
|
||||||
|
|
||||||
|
### 需求 2:修复手术者签名右对齐时图片框换行
|
||||||
|
当「手术者签名」所在行设置 `text-align: right` 时,文字跑到最右侧,而图片占位符(`display: inline-flex`)换到了下一行。需确保文字和图片在同一行内保持连续。
|
||||||
|
|
||||||
|
### 需求 3:缩减「手术记录」与「姓名」之间的距离
|
||||||
|
当前顶部 Flex 容器的 `margin-bottom: 16px` 导致标题与基本信息栏间距过大。需缩小该间距。
|
||||||
|
|
||||||
|
### 需求 4:消除「手术名称」与「手术开始时间」之间的多余间距
|
||||||
|
「手术名称」是 `<p>` 标签,「手术开始时间」在 `<table>` 中。`<table>` 的默认 margin 或 `<p>` 的默认间距导致两者距离过远。需消除多余间距,保持 1.5 行距且无段前段后间距。
|
||||||
|
|
||||||
|
### 需求 5:统一「手术日期」及以下内容为 12pt、1.5 行距、无段间距
|
||||||
|
当前手术步骤段落(1~5)缺少 `font-size: 12pt`,导致与上方诊断信息字体大小不一致。需统一从「手术日期」开始往下的所有正文内容为 12pt、1.5 行距、无段前段后间距。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 1:工具栏增强)
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 1:工具栏增强)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 2~5:模板排版修复)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 编辑器工具栏交互
|
||||||
|
- 默认报告模板视觉效果
|
||||||
|
- 打印输出样式
|
||||||
39
工程分析/需求分析-2026-04-18-18-36-43.md
Normal file
39
工程分析/需求分析-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-18-36-43
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户提出报告管理列名修正、字段下划线控制、下载功能、右对齐排版修复及默认模板调整等五项改进需求。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:ReportManage 列名"患者号"改为"住院号"
|
||||||
|
报告管理列表中表头显示为"患者号",但实际数据对应的是住院号字段,需修正列名。
|
||||||
|
|
||||||
|
### 需求 2:TemplateManage 字段管理增加"下划线"控制
|
||||||
|
在模板管理的字段管理面板中,每个字段增加"打印时显示下划线"单选框,默认勾选。若取消勾选,则该字段在打印输出时不显示底部下划线。需在 `FormField` 数据结构中增加 `hasUnderline` 属性,并在打印样式中支持 `.no-underline` 类。
|
||||||
|
|
||||||
|
### 需求 3:ReportEditor / TemplateManage 新增下载按钮
|
||||||
|
在报告编辑器和模板管理页面的打印按钮旁新增"下载"按钮,点击弹出模态框,支持导出 PDF 和 JSON:
|
||||||
|
- PDF:复用现有 `printDocument()`,传入自定义文件名
|
||||||
|
- JSON:通过 `Blob` + `URL.createObjectURL` 实现下载
|
||||||
|
- 默认文件名格式:`图文报告-{手术名称}-{患者}-{住院号}-{下载时间}.pdf/.json`
|
||||||
|
|
||||||
|
### 需求 4:修复右对齐时签名与图片框分离
|
||||||
|
当编辑器中设置右对齐时,"手术者签名:"文字与 `class="image-placeholder"` 图片框被拆分为两行。根本原因是 `display: inline-flex` 在右对齐布局下容易触发换行。需将运行时插入的占位符以及默认模板中的占位符 `display` 属性从 `inline-flex` 改为 `inline-block`,并配合 `line-height` 垂直居中。
|
||||||
|
|
||||||
|
### 需求 5:默认模板手术者签名右对齐
|
||||||
|
将 `defaultContent.ts` 中「手术者签名」行默认设为 `text-align: right`,并应用需求 4 中的 `inline-block` 修复。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/ReportManage.tsx`(需求 1)
|
||||||
|
- `src/types.ts`(需求 2:FormField 扩展)
|
||||||
|
- `src/utils/print.ts`(需求 2、3:打印样式 + 文件名支持)
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 2、3、4)
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 3、4)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 4、5)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 报告管理列表展示
|
||||||
|
- 模板字段配置体系
|
||||||
|
- 编辑器/模板管理器工具栏交互
|
||||||
|
- 打印输出样式
|
||||||
|
- 文件导出功能
|
||||||
41
工程分析/需求分析-2026-04-18-19-08-43.md
Normal file
41
工程分析/需求分析-2026-04-18-19-08-43.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-19-08-43
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户基于实际使用体验,提出六项界面交互和排版优化需求。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:基础信息字段默认无下划线
|
||||||
|
默认模板中的「姓名、性别、年龄、科别、床号、住院号」6 个基础信息字段,在打印时默认不显示下划线。通过在 `smartField()` 函数中根据 key 自动注入 `.no-underline` 类实现。
|
||||||
|
|
||||||
|
### 需求 2:点击 field-value 联动高亮并居中滚动
|
||||||
|
在 `report-editor` 中点击正文 `class="field-value"` 时:
|
||||||
|
- 右侧基本信息对应输入框高亮显示蓝色背景(类似 `template-manage` 中的字段高亮效果)
|
||||||
|
- 自动滚动到屏幕可视区域的 1/3~2/3 位置(使用 `block: 'center'`)
|
||||||
|
|
||||||
|
### 需求 3:视频上传按钮整合进缩略图列表
|
||||||
|
将 `report-editor` 右侧「视频分析」Tab 中原本独立占据一行的「上传视频」大按钮,缩小并移入水平滚动的视频缩略图列表首位(`flex gap-2 overflow-x-auto`),尺寸与视频卡片保持一致(约 `w-24 h-[68px]`),节省垂直空间。
|
||||||
|
|
||||||
|
### 需求 4:视频模块间距紧凑化
|
||||||
|
缩减「视频分析」面板中以下间距:
|
||||||
|
- 视频缩略图列表与下方视频播放器之间的距离
|
||||||
|
- 视频播放器与播放控制按钮之间的距离
|
||||||
|
- 播放控制按钮与「关键帧摘取」标题之间的距离
|
||||||
|
为下方关键帧列表腾出更多展示空间。
|
||||||
|
|
||||||
|
### 需求 5:签名与日期之间增加空行
|
||||||
|
在 `defaultContent.ts` 中,「手术者签名」行与「撰写时间」行之间插入一个空段落 `<p> </p>`,使排版更美观。
|
||||||
|
|
||||||
|
### 需求 6:图片占位符填充后高度自适应
|
||||||
|
当图片占位符被填充图片后,若图片实际高度小于占位符预设高度(如 200px),占位符仍保留固定高度导致下方出现大片空白。需在填充图片后,将占位符的 `height`、`width`、`line-height` 重置为 `auto` / `normal`,让高度由图片实际尺寸决定。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/utils/defaultContent.ts`(需求 1、5)
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 2、3、4、6)
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 6)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 默认模板打印样式
|
||||||
|
- 编辑器交互体验
|
||||||
|
- 视频分析面板布局
|
||||||
|
- 图片占位符自适应行为
|
||||||
30
工程分析/需求分析-2026-04-18-19-23-31.md
Normal file
30
工程分析/需求分析-2026-04-18-19-23-31.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-19-23-31
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户在实际使用中发现两个问题,要求进行修复和优化。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:修复视频分析模块空白问题
|
||||||
|
在 `ReportEditor` 中,上一轮修改将「上传视频」按钮移入了 `videos.length > 0` 的条件渲染内部,导致当没有视频时,整个「视频分析」面板变为空白,用户无法上传第一个视频。
|
||||||
|
|
||||||
|
**预期行为**:无论是否有已上传视频,「上传视频」按钮和缩略图滚动列表都应始终可见。
|
||||||
|
|
||||||
|
### 需求 2:图片占位符尺寸自适应与等比例缩放限制
|
||||||
|
当前图片占位符填充图片后,虽然高度变为 `auto`,但宽度仍保持预设值(如 200px),导致图片在占位符内居中显示,周围仍有大量空白。用户希望:
|
||||||
|
- 预设的宽高仅作为**最大限制**(`max-width` / `max-height`)
|
||||||
|
- 如果图片超出限制,则等比例缩小
|
||||||
|
- 图片靠左上方放置(`object-position: left top`)
|
||||||
|
- 占位符自身的虚线框大小要**紧缩包围(shrink-wrap)**成图片实际缩放后的尺寸
|
||||||
|
|
||||||
|
### 需求 3:Logo 框大小保持 65px × 65px
|
||||||
|
默认模板中顶部医院 Logo 占位符的尺寸应保持 65px × 65px 不变。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 1、2)
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 2)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 视频分析面板的可见性逻辑
|
||||||
|
- 图片占位符的填充后样式行为
|
||||||
|
- 打印/预览时的图片尺寸表现
|
||||||
29
工程分析/需求分析-2026-04-18-19-37-56.md
Normal file
29
工程分析/需求分析-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-19-37-56
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户在实际使用中发现 4 个编辑器体验问题,要求进行修复和优化。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:视频上传按钮位置调整
|
||||||
|
在 `ReportEditor` 的「视频分析」面板中,「上传视频」按钮当前位于视频缩略图列表的首位。用户希望将其移至列表末尾,以符合「先列出已有视频,最后提供添加操作」的操作直觉。
|
||||||
|
|
||||||
|
### 需求 2:图片占位符提示文字绝对居中
|
||||||
|
图片占位符(`.image-placeholder`)内的提示文字(如「插入/点击放置图片」)目前未在框中绝对居中。当占位符高度较大或行高不一时,文字会偏上或偏下。用户希望文字在占位符内绝对居中显示。
|
||||||
|
|
||||||
|
### 需求 3:删除图片后占位符恢复原始大小
|
||||||
|
当向图片占位符插入图片后,占位符会收缩到图片实际尺寸(`width:auto; height:auto`)。但点击「×」删除图片后,占位符不会恢复为原始预设大小,而是保持收缩后的尺寸。用户希望删除后占位符能恢复为最初创建时的宽度和高度。
|
||||||
|
|
||||||
|
### 需求 4:对齐按钮导致混合排版换行
|
||||||
|
点击富文本工具栏的「左对齐/居中/右对齐」按钮时,浏览器原生的 `document.execCommand('justifyLeft')` 等命令会粗暴地用 `<div align="left">` 包裹选区,导致包含 `.field-value` 或 `.image-placeholder` 的段落被肢解,文字与输入框/图片强制换行分离。用户希望对齐操作安全地作用于整个段落,不破坏混合排版结构。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/ReportEditor.tsx`(需求 1、2、3、4)
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 2、3、4)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 2、3)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 视频分析面板布局
|
||||||
|
- 图片占位符的视觉表现和交互反馈
|
||||||
|
- 富文本对齐功能的实现方式
|
||||||
|
- 默认模板中占位符的 HTML 结构
|
||||||
52
工程分析/需求分析-2026-04-18-20-03-44.md
Normal file
52
工程分析/需求分析-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 需求分析 —— 2026-04-18-20-03-44
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户希望增强模板管理模块的数据迁移能力和默认模板的交互一致性。
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
### 需求 1:模板导出功能
|
||||||
|
在 `TemplateManage` 的模板列表中,新增「导出」按钮。导出内容需包含:
|
||||||
|
- 模板名称(`title`)
|
||||||
|
- 模板描述(`description`)
|
||||||
|
- 模板内容(`content`)
|
||||||
|
- 字段管理配置(`fields`)
|
||||||
|
|
||||||
|
导出格式为 JSON,结构如下:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"type": "surclaw_template_package",
|
||||||
|
"title": "...",
|
||||||
|
"description": "...",
|
||||||
|
"content": "...",
|
||||||
|
"fields": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 需求 2:模板导入功能
|
||||||
|
在「新增模板」弹窗中,新增「导入本地模板」选项。用户选择 JSON 文件后:
|
||||||
|
- 自动解析并填充模板名称和描述到表单
|
||||||
|
- 暂存模板内容和字段配置
|
||||||
|
- 点击「创建」时,将暂存的内容和字段一并写入新模板
|
||||||
|
|
||||||
|
导入 UI 使用指定的样式类名:`w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm`
|
||||||
|
|
||||||
|
### 需求 3:Logo 替换为可交互占位符
|
||||||
|
默认模板 `defaultContent.ts` 中顶部医院 Logo 当前为硬编码的 `<span>` 结构(非标准 `image-placeholder`),导致:
|
||||||
|
- 无法点击右上方的「×」删除
|
||||||
|
- 无法触发图片上传/选择逻辑
|
||||||
|
- 与编辑器中其他图片占位符的交互不一致
|
||||||
|
|
||||||
|
需将其替换为标准的 65×65 `image-placeholder`(`data-mode="manual"`),使其支持删除、点击插入等完整交互。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
- `src/pages/TemplateManage.tsx`(需求 1、2)
|
||||||
|
- `src/utils/defaultContent.ts`(需求 3)
|
||||||
|
- `src/types.ts`(确认 Template 类型结构)
|
||||||
|
|
||||||
|
## 需求影响范围
|
||||||
|
- 模板列表操作列新增导出按钮
|
||||||
|
- 新增模板弹窗新增导入 UI 和逻辑
|
||||||
|
- 默认模板头部 Logo 的 HTML 结构
|
||||||
|
- 模板创建流程需支持字段配置写入
|
||||||
Reference in New Issue
Block a user