7 Commits

27 changed files with 2027 additions and 124 deletions

View File

@@ -5,7 +5,7 @@ import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft
Video, Play, Pause, Plus, X, ChevronLeft, Download
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
@@ -48,11 +48,13 @@ export default function ReportEditor() {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isSaved, setIsSaved] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [loadedTemplateId, setLoadedTemplateId] = useState('');
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
surgeon: ['张医生', '李医生', '王医生'],
assistant: ['赵医生', '钱医生', '孙医生'],
@@ -377,6 +379,28 @@ export default function ReportEditor() {
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
// Handle click on field-value: switch to info tab, highlight and focus corresponding input
const fieldValue = targetEl.closest('.field-value') as HTMLElement | null;
if (fieldValue) {
const bindKey = fieldValue.getAttribute('data-bind');
if (bindKey) {
setActiveTab('info');
stateRef.current = { ...stateRef.current, activeTab: 'info' };
setActiveFieldKey(bindKey);
setTimeout(() => {
const inputEl = document.getElementById(`input-${bindKey}`);
if (inputEl) {
inputEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = inputEl.querySelector('input, select') as HTMLElement | null;
if (focusable) {
focusable.focus();
}
}
}, 100);
}
return;
}
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
@@ -467,11 +491,14 @@ export default function ReportEditor() {
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
<img src="${src}" style="max-width:100%;height:auto;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.height = 'auto';
placeholder.style.width = 'auto';
placeholder.style.lineHeight = 'normal';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
@@ -484,6 +511,19 @@ export default function ReportEditor() {
saveDraftToStorage();
};
const changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.lineHeight = height;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
const insertTable = () => {
editorRef.current?.focus();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
@@ -629,6 +669,9 @@ export default function ReportEditor() {
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
emptyPlaceholder.style.height = 'auto';
emptyPlaceholder.style.width = 'auto';
emptyPlaceholder.style.lineHeight = 'normal';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
@@ -655,11 +698,14 @@ export default function ReportEditor() {
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
<img src="${frame.dataUrl}" style="max-width:100%;height:auto;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.height = 'auto';
placeholder.style.width = 'auto';
placeholder.style.lineHeight = 'normal';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
@@ -1272,6 +1318,13 @@ export default function ReportEditor() {
<Check size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
@@ -1292,6 +1345,7 @@ export default function ReportEditor() {
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
@@ -1301,6 +1355,27 @@ export default function ReportEditor() {
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
@@ -1366,14 +1441,30 @@ export default function ReportEditor() {
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<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 hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} 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">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
@@ -1393,7 +1484,7 @@ export default function ReportEditor() {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
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>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
@@ -1502,7 +1593,7 @@ export default function ReportEditor() {
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
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>
<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"
@@ -1583,7 +1674,7 @@ export default function ReportEditor() {
const { h: h12, isPM } = from24h(h24val);
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>
<div className="flex items-center gap-2">
<select
@@ -1640,7 +1731,7 @@ export default function ReportEditor() {
const { h: h12g, isPM: isPMg } = from24h(h24);
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>
<div className="flex items-center gap-2">
<select
@@ -1697,7 +1788,7 @@ export default function ReportEditor() {
)}
{activeTab === 'video' && (
<div className="space-y-4">
<div className="space-y-2">
<input
ref={videoInputRef}
type="file"
@@ -1706,20 +1797,17 @@ export default function ReportEditor() {
className="hidden"
onChange={handleVideoUpload}
/>
<button
onClick={() => videoInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 p-3 border border-dashed border-border rounded-lg hover:border-accent hover:bg-slate-50 transition-all"
>
<Video size={18} />
<div className="text-left">
<p className="text-xs font-bold text-text-main"></p>
<p className="text-[10px] text-text-muted"> MP4, MOV </p>
</div>
</button>
{videos.length > 0 && (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
<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>
{videos.map((v, i) => (
<div
key={v.id}
@@ -1748,7 +1836,7 @@ export default function ReportEditor() {
</div>
{currentVideoIndex !== -1 && (
<div className="space-y-4">
<div className="space-y-2">
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
<video
ref={videoRef}
@@ -1786,7 +1874,7 @@ export default function ReportEditor() {
</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>
<button
onClick={captureFrame}
@@ -1893,11 +1981,11 @@ export default function ReportEditor() {
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
styleStr += `width:${w}px;height:${h}px;`;
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
execCmd('insertHTML', html);
setPlaceholderModal({...placeholderModal, isOpen: false});
@@ -1949,6 +2037,48 @@ export default function ReportEditor() {
</div>
)}
{exportModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
printDocument(editorRef.current?.innerHTML || '', `图文报告-${title}-${patient}-${hid}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `图文报告-${title}-${patient}-${hid}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</div>
</div>
)}
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">

View File

@@ -283,7 +283,7 @@ export default function ReportManage() {
</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-24"></th>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
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 } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
@@ -13,6 +13,7 @@ export default function TemplateManage() {
const [templates, setTemplates] = useState<Template[]>([]);
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' });
const [isSaved, setIsSaved] = useState(false);
@@ -34,9 +35,11 @@ export default function TemplateManage() {
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
const [editFieldHasUnderline, setEditFieldHasUnderline] = useState(true);
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(true);
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
@@ -142,11 +145,14 @@ export default function TemplateManage() {
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
<img src="${src}" style="max-width:100%;height:auto;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.height = 'auto';
placeholder.style.width = 'auto';
placeholder.style.lineHeight = 'normal';
saveTemplateContent();
};
@@ -359,6 +365,18 @@ export default function TemplateManage() {
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 saveTemplateContent = () => {
if (!currentTemplateId || !editorRef.current) return;
const allTemplates = storage.get<Template[]>('templates', []);
@@ -378,7 +396,8 @@ export default function TemplateManage() {
}
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>&#8203;`;
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>&#8203;`;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
@@ -447,6 +466,7 @@ export default function TemplateManage() {
next.timeDefault = editFieldTimeDefault;
next.fixedTimeValue = editFieldFixedTimeValue;
}
next.hasUnderline = editFieldHasUnderline;
return next;
});
setFormFields(updated);
@@ -464,6 +484,7 @@ export default function TemplateManage() {
type: newFieldForm.type,
visibleInForm: true,
isSystemLocked: false,
hasUnderline: newFieldHasUnderline,
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
? newFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean)
: undefined
@@ -481,6 +502,7 @@ export default function TemplateManage() {
setNewFieldTimeFormat('YYYY年MM月DD日');
setNewFieldTimeDefault('specific');
setNewFieldFixedTimeValue('');
setNewFieldHasUnderline(true);
};
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -708,6 +730,13 @@ export default function TemplateManage() {
<Save size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
@@ -739,6 +768,27 @@ export default function TemplateManage() {
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(e.target.value); } e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button 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>
@@ -854,6 +904,7 @@ export default function TemplateManage() {
setEditFieldTimeFormat(field.timeFormat || '');
setEditFieldTimeDefault(field.timeDefault || 'specific');
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
setEditFieldHasUnderline(field.hasUnderline !== false);
const target = e.currentTarget;
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -958,6 +1009,10 @@ export default function TemplateManage() {
</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">
<button
onClick={() => saveFieldEdit(field.key)}
@@ -1154,6 +1209,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"
/>
)}
<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
onClick={addField}
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
@@ -1168,6 +1227,45 @@ export default function TemplateManage() {
</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 && (
<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">
@@ -1260,13 +1358,35 @@ export default function TemplateManage() {
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
styleStr += `width:${w}px;height:${h}px;`;
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
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});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>

View File

@@ -116,11 +116,12 @@ export interface FormField {
timeFormat?: string;
timeDefault?: 'current' | 'specific';
fixedTimeValue?: string;
hasUnderline?: boolean;
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'hospitalId', 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, hasUnderline: true },
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },

View File

@@ -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>&#8203;`;
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>&#8203;`;
};
export const defaultReportContent = `
<!-- 医院Logo -->
<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-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;">
<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="position:relative;display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;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>
</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>
</div>
</div>
<!-- 医院名称 -->
<p style="text-align: center; font-family: SimSun; margin-bottom: 8px;" contenteditable="false">
<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')}
<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')}&nbsp;
性别:${smartField('patientGender')}&nbsp;
年龄:${smartField('patientAge')}&nbsp;
科别:${smartField('department')}&nbsp;
床号:${smartField('bedNumber')}&nbsp;
住院号:${smartField('hospitalId')}
</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('surgeryDate')}
</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('preoperativeDiagnosis')}
</p>
<p style="font-family: SimSun;">
<strong>术后诊断:</strong>${smartField('postoperativeDiagnosis')}
<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;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术名称:</strong>${smartField('title')}
</p>
<p style="font-family: SimSun;">
手术开始时间:${smartField('startTime')}
手术终止时间:${smartField('endTime')}
</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;">
手术者:${smartField('surgeon')}
助手:${smartField('assistant')}
</p>
<p style="font-family: SimSun;">
麻醉师:${smartField('anesthesiologist')}
麻醉方式:${smartField('anesthesiaType')}
</p>
</div>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术步骤、术中出现的情况及处理:</strong>
</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各点穿刺置穿刺器,插入相应手术器械。
</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壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
</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处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。
</p>
@@ -87,74 +81,76 @@ export const defaultReportContent = `
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
<tbody><tr>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图A 腹腔镜探查</p>
</td>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图B 胆囊管夹闭与离断</p>
</td>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图C 胆囊动脉夹闭与离断</p>
</td>
</tr>
<tr>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图D 胆囊剥离与床面止血</p>
</td>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图E 胆囊取出与钛夹确认</p>
</td>
<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="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="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图F 止血材料覆盖及检查</p>
</td>
</tr></tbody>
</table>
<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')}
</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')}
</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')}
</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')}
</p>
<p style="font-family: SimSun;">
手术者签名:<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>
<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-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;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
</p>
<p style="text-align: right; font-family: SimSun;">
<p style="margin: 0; padding: 0; line-height: 1.5;">&nbsp;</p>
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0;">
${smartField('reportDate')}
</p>
</div>

View File

@@ -1,4 +1,4 @@
export const printDocument = (htmlContent: string) => {
export const printDocument = (htmlContent: string, docTitle: string = '图文报告') => {
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
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; }
.content { width: 100%; min-height: 277mm; margin: 0 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; }
strong, b { font-weight: 600; }
u { text-decoration: underline; }
@@ -31,7 +31,7 @@ export const printDocument = (htmlContent: string) => {
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.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; }
.template-info-section { position: relative; margin-bottom: 16px; }
.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; }
@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.no-underline { border-bottom: none !important; }
}
</style>
</head>

View 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. 经验记录文档需持续维护,成为项目知识库的核心资产。

View 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` 中严格一致。

View 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不加粗
- 姓名、性别、年龄、科别、床号、住院号,用 `&nbsp;` 间隔
#### 诊断/手术信息(单行加粗)
每项独立 `<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. `&nbsp;` 分隔的基本信息栏在打印时可能换行,需测试实际打印效果。

View File

@@ -0,0 +1,100 @@
# 实现方案 —— 2026-04-18-17-48-59
## 方案目标
修复默认模板排版细节和打印样式问题,提升报告的视觉一致性和打印输出质量。
## 需求 1缩减基本信息栏字段间空格
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将基本信息栏 `<p>` 中字段之间的 `&nbsp;&nbsp;&nbsp;` 替换为单个 `&nbsp;`
**修改前**
```html
姓名:${smartField('patientName')}&nbsp;&nbsp;&nbsp;
性别:${smartField('patientGender')}&nbsp;&nbsp;&nbsp;
年龄:${smartField('patientAge')}&nbsp;&nbsp;&nbsp;
科别:${smartField('department')}&nbsp;&nbsp;&nbsp;
床号:${smartField('bedNumber')}&nbsp;&nbsp;&nbsp;
住院号:${smartField('hospitalId')}
```
**修改后**
```html
姓名:${smartField('patientName')}&nbsp;
性别:${smartField('patientGender')}&nbsp;
年龄:${smartField('patientAge')}&nbsp;
科别:${smartField('department')}&nbsp;
床号:${smartField('bedNumber')}&nbsp;
住院号:${smartField('hospitalId')}
```
## 需求 2Logo 与医院名/标题靠拢并整体居中
### 修改文件
`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 中)不会自动更新。这是预期行为。

View 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 对应 12pt4 对应 14pt5 对应 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` 在签名行可能导致超长内容溢出,但考虑到签名行通常较短,风险可控。

View File

@@ -0,0 +1,91 @@
# 实现方案 —— 2026-04-18-18-36-43
## 方案目标
实现五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
## 需求 1ReportManage 列名修正
### 修改文件
`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; }`
## 需求 3ReportEditor / 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 增加下划线 checkboxinsertSmartField 注入 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 报告),需分别处理。

View 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;">&nbsp;</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和正文中的显示是否正常。

View 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-05Gitea 备份验证(后续真实需求执行时)
**前置条件**:代码修改已完成
**操作步骤**
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 将在后续真实需求中实际执行并验证。

View 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 全部通过,即可确认三项需求均正确实现。

View File

@@ -0,0 +1,155 @@
# 测试方案 —— 2026-04-18-17-27-51
## 测试目标
验证 TemplateManage 静态占位符插入修复、默认模板排版重构、Logo 删除按钮修复。
## 测试用例
### TC-01TemplateManage 插入静态图片占位符
**前置条件**:进入 /template-manage编辑器有焦点
**操作步骤**
1. 点击工具栏「插入图片占位符」
2. 在弹窗中选择「静态图片占位」
3. 输入宽度 200高度 200
4. 点击「确认插入」
**预期结果**
- 编辑器中出现虚线边框的占位符框
- 占位符带有 `class="image-placeholder"``data-mode="manual"`
- 占位符内部显示「插入/点击放置图片」文字
- 占位符右上角显示红色「×」删除按钮
---
### TC-02TemplateManage 插入手术影像占位符
**前置条件**:进入 /template-manage
**操作步骤**
1. 点击工具栏「插入图片占位符」
2. 选择「手术影像占位」
3. 点击「确认插入」
**预期结果**
- 占位符正常显示
- 带有 `data-mode="frame"`
- 可接受关键帧拖拽填充
---
### TC-03TemplateManage 占位符删除按钮
**前置条件**:已插入占位符
**操作步骤**
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-09Logo 占位符删除按钮可点击
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 鼠标悬浮在顶部 Logo 占位符上
2. 点击右上角的红色「×」
**预期结果**
- Logo 占位符被删除
- 可撤销恢复
---
### TC-10Logo 占位符图片上传
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 点击顶部 Logo 占位符
2. 选择本地上传一张图片
**预期结果**
- 图片正确显示在 65×65 区域内
- 图片不溢出占位符
---
### TC-11打印效果验证
**前置条件**:新建报告,填写部分内容
**操作步骤**
1. 点击打印按钮
2. 检查打印预览
**预期结果**
- 抬头排版正确Logo + 医院名 + 标题)
- 基本信息下划线可见
- 双列信息左右对齐
- 无多余虚线边框placeholder 填充后 border 应消失)
---
## 回归测试范围
- 验证 `ReportEditor` 中已有的 `image-placeholder` 点击上传、拖拽填充功能不受影响
- 验证 `TemplateManage` 中智能字段插入、删除、撤销/重做功能正常
- 验证 `smart-field-wrapper` 双向绑定正常工作
## 测试结论
TC-01~TC-11 全部通过,即可确认三项需求均正确实现。

View 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 全部通过,即可确认五项排版优化均正确实现。

View File

@@ -0,0 +1,134 @@
# 测试方案 —— 2026-04-18-18-08-37
## 测试目标
验证编辑器工具栏字号/行距功能、字体选择修复,以及默认模板排版调整。
## 测试用例
### TC-01ReportEditor 字体选择修复
**前置条件**:进入 /report-editor编辑器中有文字
**操作步骤**
1. 选中一段文字
2. 从工具栏字体下拉框选择「微软雅黑」
**预期结果**
- 选中的文字字体变为微软雅黑
- 编辑器未失去焦点
---
### TC-02ReportEditor 字号选择
**前置条件**:进入 /report-editor编辑器中有文字
**操作步骤**
1. 选中一段文字
2. 从工具栏字号下拉框选择「14pt」
**预期结果**
- 选中的文字字号变大
- 编辑器未失去焦点
---
### TC-03ReportEditor 行距选择
**前置条件**:进入 /report-editor编辑器中有多行文字
**操作步骤**
1. 将光标放在某一段落内
2. 从工具栏行距下拉框选择「2.0」
**预期结果**
- 当前段落行距变为 2.0
- 其他段落不受影响
- 草稿自动保存
---
### TC-04TemplateManage 工具栏功能
**前置条件**:进入 /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 全部通过,即可确认所有需求均正确实现。

View File

@@ -0,0 +1,117 @@
# 测试方案 —— 2026-04-18-18-36-43
## 测试目标
验证五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
## 测试用例
### TC-01ReportManage 列名显示
**前置条件**:进入 /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-04ReportEditor 下载按钮
**前置条件**:进入 /report-editor有内容的报告
**操作步骤**
1. 点击顶部下载按钮
2. 在弹窗中选择「导出 PDF」
3. 在弹窗中选择「导出 JSON」
**预期结果**
- 弹窗正常显示两个导出选项
- PDF 导出时浏览器保存对话框的文件名包含「图文报告-{手术名称}-{患者}-{住院号}-{时间}」
- JSON 导出时下载的文件名格式同上,内容包含 reportData
---
### TC-05TemplateManage 下载按钮
**前置条件**:进入 /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 全部通过,即可确认所有需求均正确实现。

View 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` 中的图片占位符同样支持高度自适应。
## 测试通过标准
所有用例均通过,无控制台报错,打印样式正常。

View 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 备份流程
- 无业务代码变更

View 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
## 需求影响范围
- 报告编辑器交互体验
- 右侧基本信息面板渲染逻辑
- 默认报告模板内容

View 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` 顶部排版:
- **抬头**:左侧 Logo65×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 修复)
## 需求影响范围
- 模板管理页面的图片占位符插入功能
- 新建报告时的默认模板视觉效果
- 打印输出时的顶部排版

View File

@@ -0,0 +1,30 @@
# 需求分析 —— 2026-04-18-17-48-59
## 需求来源
用户基于打印预览效果,提出默认模板排版微调和打印样式修复需求。
## 需求概述
### 需求 1缩减基本信息栏字段间空格
当前默认模板中「姓名:性别:年龄:科别:床号:住院号:」之间使用了 `&nbsp;&nbsp;&nbsp;`(三个不间断空格),间距过大。需缩减为单个空格 `&nbsp;`
### 需求 2Logo 与医院名/标题靠拢并整体居中
当前顶部使用 3 列 table20%-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
## 需求影响范围
- 默认报告模板的视觉效果
- 打印输出样式
- 无业务逻辑变更

View 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模板排版修复
## 需求影响范围
- 编辑器工具栏交互
- 默认报告模板视觉效果
- 打印输出样式

View File

@@ -0,0 +1,39 @@
# 需求分析 —— 2026-04-18-18-36-43
## 需求来源
用户提出报告管理列名修正、字段下划线控制、下载功能、右对齐排版修复及默认模板调整等五项改进需求。
## 需求概述
### 需求 1ReportManage 列名"患者号"改为"住院号"
报告管理列表中表头显示为"患者号",但实际数据对应的是住院号字段,需修正列名。
### 需求 2TemplateManage 字段管理增加"下划线"控制
在模板管理的字段管理面板中,每个字段增加"打印时显示下划线"单选框,默认勾选。若取消勾选,则该字段在打印输出时不显示底部下划线。需在 `FormField` 数据结构中增加 `hasUnderline` 属性,并在打印样式中支持 `.no-underline` 类。
### 需求 3ReportEditor / 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`(需求 2FormField 扩展)
- `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
## 需求影响范围
- 报告管理列表展示
- 模板字段配置体系
- 编辑器/模板管理器工具栏交互
- 打印输出样式
- 文件导出功能

View 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>&nbsp;</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
## 需求影响范围
- 默认模板打印样式
- 编辑器交互体验
- 视频分析面板布局
- 图片占位符自适应行为