feat: TemplateManage field system upgrade and bidirectional navigation

- Fix new field type linkage (remove text option under single/multi/image).
- Add system fields: pre/post-op diagnosis, pathology checks, etc.
- Replace placeholder text in default template with smart fields.
- Accordion grouping and inline option editing in field management.
- Add image field type, asset library with logo preloading.
- Image source picker modal in ReportEditor (local/signature/asset).
- Editor-to-sidebar highlight and scroll navigation on smart field click.
This commit is contained in:
2026-04-17 18:54:10 +08:00
parent b155dd42d6
commit 0c57409c59
8 changed files with 731 additions and 34 deletions

View File

@@ -63,6 +63,9 @@ export default function ReportEditor() {
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [formFields, setFormFields] = useState<FormField[]>([]);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]);
const editorRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
@@ -118,6 +121,9 @@ export default function ReportEditor() {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []);
setImageAssets(savedAssets);
const allTemplates = storage.get<Template[]>('templates', []);
const visibleTplIds = Array.isArray(user.visibleTemplates) ? user.visibleTemplates : allTemplates.map(t => t.id);
const filteredTemplates = allTemplates.filter(t => visibleTplIds.includes(t.id));
@@ -320,7 +326,8 @@ export default function ReportEditor() {
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
triggerPlaceholderUpload(placeholder);
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
};
@@ -374,6 +381,16 @@ export default function ReportEditor() {
return () => editor.removeEventListener('keydown', handleKeyDown);
}, []);
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const execCmd = (command: string, value: string | undefined = undefined) => {
editorRef.current?.focus();
document.execCommand(command, false, value);
@@ -1498,6 +1515,67 @@ export default function ReportEditor() {
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
fillPlaceholderSrc(imagePickerTarget, src);
setImagePickerOpen(false);
setImagePickerTarget(null);
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded text-sm font-semibold"
></button>
<button
onClick={() => {
if (currentUser?.signature) {
fillPlaceholderSrc(imagePickerTarget, currentUser.signature);
}
setImagePickerOpen(false);
setImagePickerTarget(null);
}}
disabled={!currentUser?.signature}
className={`w-full py-2 rounded text-sm font-semibold ${currentUser?.signature ? 'bg-slate-100 hover:bg-slate-200 text-slate-700' : 'bg-slate-50 text-slate-400 cursor-not-allowed'}`}
> {!currentUser?.signature && '(未上传)'}</button>
<div>
<div className="text-xs text-slate-500 mb-2"></div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{imageAssets.map(asset => (
<button
key={asset.id}
onClick={() => { fillPlaceholderSrc(imagePickerTarget, asset.dataUrl); setImagePickerOpen(false); setImagePickerTarget(null); }}
className="w-16 h-16 border rounded overflow-hidden hover:ring-2 ring-accent"
>
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
</button>
))}
{imageAssets.length === 0 && <div className="text-xs text-slate-400"></div>}
</div>
</div>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => { setImagePickerOpen(false); setImagePickerTarget(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -24,6 +24,12 @@ export default function TemplateManage() {
const [formFields, setFormFields] = useState<FormField[]>([]);
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
const [newFieldOptions, setNewFieldOptions] = useState('');
const [expandedCategories, setExpandedCategories] = useState<string[]>(['填空', '单选', '多选', '时间', '图片']);
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
const [editingFieldKey, setEditingFieldKey] = useState<string | null>(null);
const [editFieldLabel, setEditFieldLabel] = useState('');
const [editFieldOptions, setEditFieldOptions] = useState('');
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const updatePageHeight = () => {
if (!editorRef.current) return;
@@ -50,6 +56,25 @@ export default function TemplateManage() {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{ id: string; name: string; dataUrl: string }[]>('imageAssets', []);
if (savedAssets.length > 0) {
setImageAssets(savedAssets);
} else {
fetch('/logo_square.png')
.then((res) => res.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const initialAssets = [{ id: 'asset_logo', name: '医院Logo', dataUrl }];
setImageAssets(initialAssets);
storage.set('imageAssets', initialAssets);
};
reader.readAsDataURL(blob);
})
.catch(() => setImageAssets([]));
}
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedTemplates.length === 0) {
const initial: Template = {
@@ -117,7 +142,6 @@ export default function TemplateManage() {
// Handle image placeholder and smart field delete interactions via click capture
useEffect(() => {
const handleEditorClick = (e: MouseEvent) => {
// e.target may be a text node; safely resolve to an Element
let node: Node | null = e.target as Node;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
const targetEl = node as HTMLElement | null;
@@ -138,6 +162,23 @@ export default function TemplateManage() {
return;
}
if (smartField) {
const valueSpan = smartField.querySelector('.field-value');
const fieldKey = valueSpan?.getAttribute('data-bind') || smartField.getAttribute('data-bind');
if (fieldKey) {
setActiveFieldKey(fieldKey);
const field = formFields.find(f => f.key === fieldKey);
if (field) {
setExpandedCategories(prev => prev.includes(field.category) ? prev : [...prev, field.category]);
setTimeout(() => {
const el = document.getElementById(`sidebar-field-${fieldKey}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
}
}
return;
}
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
@@ -178,7 +219,7 @@ export default function TemplateManage() {
editor.removeEventListener('click', handleEditorClick, true);
}
};
}, [currentTemplateId, currentUser]);
}, [currentTemplateId, currentUser, formFields]);
// Intercept Backspace/Delete next to smart fields to avoid whole-line deletion
useEffect(() => {
@@ -222,7 +263,6 @@ export default function TemplateManage() {
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Cursor is directly inside a block element (e.g. <p>) at a boundary
const el = node as Element;
if (e.key === 'Backspace' && offset > 0) {
const prev = el.childNodes[offset - 1];
@@ -318,12 +358,19 @@ export default function TemplateManage() {
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
restoreSelection();
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
if (field.type !== 'image' && editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
return;
}
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;`;
let html = '';
if (field.type === 'image') {
const id = 'ph_' + Date.now();
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" data-bind="${field.key}" contenteditable="false" style="display:inline-block;vertical-align:middle;"><span class="delete-btn" contenteditable="false">×</span><p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p></div>&#8203;`;
} else {
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 sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
@@ -377,6 +424,23 @@ export default function TemplateManage() {
storage.set('formFieldsConfig', updated);
};
const saveFieldEdit = (key: string) => {
const updated = formFields.map(f => {
if (f.key !== key) return f;
const next: FormField = { ...f };
if (!f.isSystemLocked) {
next.label = editFieldLabel.trim() || f.label;
}
if (['单选', '多选', '图片'].includes(f.category)) {
next.options = editFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean);
}
return next;
});
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setEditingFieldKey(null);
};
const addField = () => {
if (!newFieldForm.label.trim()) return;
const key = 'custom_' + Date.now();
@@ -398,6 +462,21 @@ export default function TemplateManage() {
setNewFieldOptions('');
};
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
const asset = { id: 'asset_' + Date.now(), name: file.name, dataUrl };
const updated = [...imageAssets, asset];
setImageAssets(updated);
storage.set('imageAssets', updated);
};
reader.readAsDataURL(file);
e.target.value = '';
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
@@ -504,7 +583,6 @@ export default function TemplateManage() {
storage.set('templates', updated);
setCurrentTemplateId(newTpl.id);
// Sync user permissions
const savedUsers = storage.get<User[]>('users', []);
let updatedUsers = savedUsers;
if (currentUser?.role === 'super') {
@@ -730,12 +808,13 @@ export default function TemplateManage() {
{catFields.map(field => (
<button
key={field.key}
id={`sidebar-field-${field.key}`}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertSmartField(field)}
onMouseEnter={() => highlightField(field.key, true)}
onMouseLeave={() => highlightField(field.key, false)}
className="px-2 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
className={`px-2 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors ${activeFieldKey === field.key ? 'ring-2 ring-accent bg-blue-50 border-accent' : ''}`}
title={`插入 ${field.label}`}
>
{field.label}
@@ -750,25 +829,121 @@ export default function TemplateManage() {
{fieldLibTab === 'manage' && (
<div className="space-y-3">
{formFields.filter(f => !f.isSystemLocked).map(field => (
<div key={field.key} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-200">
<div className="text-xs">
<div className="font-medium text-text-main">{field.label}</div>
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
{['填空', '单选', '多选', '时间', '图片'].map(cat => {
const catFields = formFields.filter(f => f.category === cat);
if (catFields.length === 0) return null;
const expanded = expandedCategories.includes(cat);
return (
<div key={cat} className="border border-slate-200 rounded overflow-hidden">
<button
onClick={() => setExpandedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat])}
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 text-xs font-semibold text-slate-700 hover:bg-slate-100"
>
<span>{cat}</span>
<span>{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="p-2 space-y-2 bg-white">
{catFields.map(field => (
<div
key={field.key}
id={`sidebar-field-${field.key}`}
onClick={() => {
setEditingFieldKey(field.key);
setEditFieldLabel(field.label);
setEditFieldOptions((field.options || []).join(', '));
}}
className={`cursor-pointer rounded border p-2 transition-all ${activeFieldKey === field.key ? 'border-accent bg-blue-50 ring-1 ring-accent' : 'border-slate-100 bg-slate-50 hover:border-slate-200'}`}
>
{editingFieldKey === field.key ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<div className="text-xs font-medium text-text-main">
{field.isSystemLocked ? field.label : (
<input
type="text"
value={editFieldLabel}
onChange={(e) => setEditFieldLabel(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="字段名称"
/>
)}
</div>
{['单选', '多选', '图片'].includes(field.category) && (
<input
type="text"
value={editFieldOptions}
onChange={(e) => setEditFieldOptions(e.target.value)}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="选项,用逗号分隔"
/>
)}
<div className="flex gap-2">
<button
onClick={() => saveFieldEdit(field.key)}
className="px-2 py-1 bg-accent text-white text-[10px] rounded"
></button>
<button
onClick={() => setEditingFieldKey(null)}
className="px-2 py-1 bg-slate-200 text-slate-700 text-[10px] rounded"
></button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-text-main text-xs">{field.label}</div>
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={field.visibleInForm}
onChange={() => toggleFieldVisible(field.key)}
/>
</label>
{!field.isSystemLocked && (
<button onClick={(e) => { e.stopPropagation(); deleteField(field.key); }} className="text-red-500 text-[10px] hover:underline"></button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={field.visibleInForm}
onChange={() => toggleFieldVisible(field.key)}
/>
</label>
<button onClick={() => deleteField(field.key)} className="text-red-500 text-[10px] hover:underline"></button>
);
})}
{/* Asset Library */}
<div className="border border-slate-200 rounded overflow-hidden mt-4">
<div className="px-3 py-2 bg-slate-50 text-xs font-semibold text-slate-700"></div>
<div className="p-2 space-y-2 bg-white">
<div className="flex flex-wrap gap-2">
{imageAssets.map(asset => (
<div key={asset.id} className="relative w-14 h-14 border rounded overflow-hidden group">
<img src={asset.dataUrl} alt={asset.name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
<button
onClick={() => {
const updated = imageAssets.filter(a => a.id !== asset.id);
setImageAssets(updated);
storage.set('imageAssets', updated);
}}
className="text-white text-[10px]"
></button>
</div>
</div>
))}
</div>
<label className="block w-full py-1.5 bg-accent text-white text-xs font-semibold rounded text-center cursor-pointer hover:opacity-90">
<input type="file" accept="image/*" className="hidden" onChange={handleAssetUpload} />
</label>
</div>
))}
</div>
<div className="pt-3 border-t border-slate-200 space-y-2">
<div className="text-xs font-semibold text-text-main"></div>
@@ -788,6 +963,7 @@ export default function TemplateManage() {
if (cat === '单选') t = 'single_select';
else if (cat === '多选') t = 'multi_select';
else if (cat === '时间') t = 'date';
else if (cat === '图片') t = 'image';
setNewFieldForm({ ...newFieldForm, category: cat, type: t });
}}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
@@ -796,16 +972,18 @@ export default function TemplateManage() {
<option value="单选"></option>
<option value="多选"></option>
<option value="时间"></option>
<option value="图片"></option>
</select>
<select
value={newFieldForm.type}
onChange={(e) => setNewFieldForm({ ...newFieldForm, type: e.target.value as FieldType })}
className="flex-1 px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent bg-white"
>
<option value="text"></option>
{newFieldForm.category === '填空' && <option value="text"></option>}
{newFieldForm.category === '单选' && <option value="single_select"></option>}
{newFieldForm.category === '多选' && <option value="multi_select"></option>}
{newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>}
{newFieldForm.category === '图片' && <option value="image"></option>}
</select>
</div>
{['单选', '多选'].includes(newFieldForm.category) && (

View File

@@ -103,7 +103,7 @@ export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'anesthesiaType', label: '麻醉方式' },
];
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature';
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature' | 'image';
export interface FormField {
key: string;
@@ -130,6 +130,13 @@ export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['赵医生', '钱医生', '孙医生'] },
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['周医生', '吴医生', '郑医生'] },
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
{ key: 'preoperativeDiagnosis', label: '术前诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postoperativeDiagnosis', label: '术后诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postOpCondition', label: '手术后情况', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['患者麻醉恢复后安返病房'] },
{ key: 'specimenDescription', label: '切除标本描述', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊一枚壁厚约0.3cm,内含数枚结石'] },
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] },
{ key: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: true, isSystemLocked: false },
{ key: 'hospitalLogo', label: '医院Logo', category: '图片', type: 'image', visibleInForm: true, isSystemLocked: true },
];

View File

@@ -3,7 +3,10 @@ const smartField = (key: string) => `<span class="smart-field-wrapper" contented
export const defaultReportContent = `
<!-- 医院Logo -->
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
<img src="/logo_square.png" alt="医院Logo" style="width: 65px; height: auto; display: block; margin: 0 auto;">
<div class="image-placeholder" data-placeholder="true" data-bind="hospitalLogo" contenteditable="false" style="width: 65px; margin: 0 auto;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
</p>
<!-- 医院名称 -->
@@ -29,11 +32,11 @@ export const defaultReportContent = `
</p>
<p style="font-family: SimSun;">
<strong>术前诊断:</strong><span style="color: #bdbdbd;">术前诊断</span>
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
</p>
<p style="font-family: SimSun;">
<strong>术后诊断:</strong><span style="color: #bdbdbd;">术后诊断</span>
<strong>术后诊断:</strong>${smartField('postoperativeDiagnosis')}
</p>
<p style="font-family: SimSun;">
@@ -132,23 +135,23 @@ export const defaultReportContent = `
<div class="template-info-section">
<p style="font-family: SimSun;">
<strong>手术后情况</strong>患者麻醉恢复后安返病房
<strong>手术后情况</strong>${smartField('postOpCondition')}
</p>
<p style="font-family: SimSun;">
<strong>切除标本描述</strong><span style="color: #bdbdbd;">切除标本描述</span>
<strong>切除标本描述</strong>${smartField('specimenDescription')}
</p>
<p style="font-family: SimSun;">
<strong>是否送病理检查</strong>
<strong>是否送病理检查</strong>${smartField('pathologyCheck')}
</p>
<p style="font-family: SimSun;">
<strong>冰冻病理结果</strong><span style="color: #bdbdbd;">冰冻病理结果</span>
<strong>冰冻病理结果</strong>${smartField('frozenPathology')}
</p>
<p style="font-family: SimSun;">
手术者签名:<span style="color: #bdbdbd;">签名</span>
手术者签名:${smartField('surgeonSignature')}
</p>
<p style="text-align: right; font-family: SimSun; color: #bdbdbd;">

View File

@@ -0,0 +1,238 @@
# 实现方案 — 2026-04-17-18-38-47
## 变更文件
1. `src/types.ts`
2. `src/utils/defaultContent.ts`
3. `src/pages/TemplateManage.tsx`
4. `src/pages/ReportEditor.tsx`
5. `src/index.css`
---
## 一、types.ts 修改
### 1.1 扩展 FieldType
```typescript
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature' | 'image';
```
### 1.2 更新 DEFAULT_FORM_FIELDS
在现有字段基础上追加/修改:
- `preoperativeDiagnosis`(术前诊断,单选)
- `postoperativeDiagnosis`(术后诊断,单选)
- `postOpCondition`(手术后情况,单选,默认选项含"患者麻醉恢复后安返病房"
- `pathologyCheck`(是否送病理检查,单选,选项["是","否"]
- `frozenPathology`(冰冻病理结果,单选)
- `specimenDescription`(切除标本描述,单选)
- `hospitalLogo`医院Logo图片type: 'image',对应默认模板顶部 logo
所有新增诊断类字段默认 `visibleInForm: true, isSystemLocked: true`
---
## 二、defaultContent.ts 修改
将模板 HTML 中的静态占位文本替换为 `smartField(...)`
```javascript
// 术前诊断
<strong>术前诊断</strong>${smartField('preoperativeDiagnosis')}
// 术后诊断
<strong>术后诊断</strong>${smartField('postoperativeDiagnosis')}
// 手术后情况
<strong>手术后情况</strong>${smartField('postOpCondition')}
// 切除标本描述
<strong>切除标本描述</strong>${smartField('specimenDescription')}
// 是否送病理检查
<strong>是否送病理检查</strong>${smartField('pathologyCheck')}
// 冰冻病理结果
<strong>冰冻病理结果</strong>${smartField('frozenPathology')}
// 手术者签名
手术者签名${smartField('surgeonSignature')}
// 医院 Logo 替换为图片字段占位符(使用 image-placeholder 结构但带 data-bind
// 保留原有居中样式
```
Logo 部分不再硬编码 `<img src="/logo_square.png">`,改为可管理的图片占位符:
```html
<div class="image-placeholder" data-placeholder="true" data-bind="hospitalLogo" contenteditable="false">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
</div>
```
---
## 三、TemplateManage.tsx 修改
### 3.1 新增字段表单修复(需求 1
在 category `onChange` 中:
- 选择"单选" → `type` 强制设为 `single_select`
- 选择"多选" → `type` 强制设为 `multi_select`
- 选择"图片" → `type` 强制设为 `image`
在 type select 的 options 渲染中,移除单选/多选/图片下的"文本" option
```tsx
<option value="text"></option>
{newFieldForm.category === '单选' && <option value="single_select"></option>}
{newFieldForm.category === '多选' && <option value="multi_select"></option>}
{newFieldForm.category === '时间' && <><option value="date"></option><option value="time"></option></>}
{newFieldForm.category === '图片' && <option value="image"></option>}
```
### 3.2 字段管理折叠分组(需求 5
新增状态:
```tsx
const [expandedCategories, setExpandedCategories] = useState<string[]>(['填空','单选','多选','时间','图片']);
```
将字段管理列表改为按 category 分组渲染。每组一个可点击标题栏,点击时 toggle 该 category 在 `expandedCategories` 中的存在性。展开的组内渲染对应字段列表。
### 3.3 字段管理点击编辑选项(需求 3
新增状态:
```tsx
const [editingFieldKey, setEditingFieldKey] = useState<string | null>(null);
const [editFieldOptions, setEditFieldOptions] = useState('');
const [editFieldLabel, setEditFieldLabel] = useState('');
```
在字段分组列表中,每个字段行增加 `onClick`
```tsx
onClick={() => { setEditingFieldKey(field.key); setEditFieldOptions((field.options || []).join(', ')); setEditFieldLabel(field.label); }}
```
`editingFieldKey === field.key` 时,将该行替换为编辑表单:
- 显示字段名 input仅非系统锁定字段可改 label系统字段只读展示
- 选项 input逗号分隔
- "保存"/"取消"按钮。
保存函数:
```tsx
const saveFieldEdit = (key: string) => {
const updated = formFields.map(f => {
if (f.key !== key) return f;
const next = { ...f, options: ['单选','多选','图片'].includes(f.category) ? editFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean) : f.options };
if (!f.isSystemLocked) next.label = editFieldLabel.trim() || f.label;
return next;
});
setFormFields(updated);
storage.set('formFieldsConfig', updated);
setEditingFieldKey(null);
};
```
### 3.4 编辑器点击联动侧边栏(需求 6、7
在现有的 `handleEditorClick` 事件监听中(已存在于 `useEffect`),增加非 delete-btn 的 `smart-field-wrapper` 点击处理:
```typescript
const smartField = targetEl.closest('.smart-field-wrapper') as HTMLElement | null;
if (smartField) {
const valueSpan = smartField.querySelector('.field-value');
const fieldKey = valueSpan?.getAttribute('data-bind') || smartField.getAttribute('data-bind');
if (fieldKey) {
setActiveFieldKey(fieldKey);
const field = formFields.find(f => f.key === fieldKey);
if (field) {
// 展开对应分组
setExpandedCategories(prev => prev.includes(field.category) ? prev : [...prev, field.category]);
// 滚动到可视区域
setTimeout(() => {
const el = document.getElementById(`sidebar-field-${fieldKey}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
}
}
}
```
新增状态 `const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);`
在"插入字段"Tab 的按钮上增加 `id={`sidebar-field-${field.key}`}` 和动态高亮类:
```tsx
className={... + (activeFieldKey === field.key ? ' ring-2 ring-accent bg-blue-50 border-accent' : '')}
```
在"字段管理"Tab 的字段卡片上同样增加 `id` 和高亮边框。
### 3.5 素材管理(需求 4 的一部分)
在"字段管理"Tab 底部新增"素材库"折叠面板(或放在图片分组下方)。
新增状态:
```tsx
const [imageAssets, setImageAssets] = useState<{id: string, name: string, dataUrl: string}[]>([]);
```
初始化时从 `storage.get('imageAssets', [])` 读取。若为空且存在 `/logo_square.png`,则通过 `fetch('/logo_square.png') -> blob -> FileReader` 将其转为 Base64 并作为默认素材 `hospital-logo` 存入。
提供本地上传按钮:选择图片后用 Canvas 压缩max 500px转 Base64追加到 `imageAssets` 并保存 `storage.set('imageAssets', ...)`
---
## 四、ReportEditor.tsx 修改
### 4.1 图片来源选择弹窗(需求 4
新增状态:
```tsx
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
const [imageAssets, setImageAssets] = useState<{id: string, name: string, dataUrl: string}[]>([]);
```
修改 `triggerPlaceholderUpload` 的调用逻辑:当点击无图片的 `image-placeholder` 时,不再直接 `input.click()`,而是:
```tsx
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
```
弹窗 JSXModal包含三个 Tab 按钮:
- **本地上传**:内部隐藏 `<input type="file" accept="image/*">`,点击"选择文件"触发,读取后填充 placeholder。
- **我的签名**:若 `currentUser.signature` 存在,展示签名缩略图,点击后填充。
- **系统素材**:读取 `imageAssets` 列表,展示缩略图网格,点击后填充。
填充函数:
```tsx
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
placeholder.innerHTML = `<span class="delete-btn" contenteditable="false">×</span><img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">`;
placeholder.classList.add('has-image');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
```
### 4.2 图片字段在 TemplateManage 中的插入
`TemplateManage.tsx``insertSmartField` 中,对 `type === 'image'` 的字段,不再插入 `span.smart-field-wrapper`,而是插入 `image-placeholder`
```tsx
if (field.type === 'image') {
const id = 'ph_' + Date.now();
const html = `<div id="${id}" class="image-placeholder" data-placeholder="true" data-bind="${field.key}" contenteditable="false"><span class="delete-btn" contenteditable="false">×</span><p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p></div>`;
// 同样的 Range.insertNode 逻辑插入 html
}
```
---
## 五、index.css 修改
新增/微调以下样式:
1. `.accordion-header`:字段管理分组标题样式(可复用现有按钮类)。
2. `.accordion-body`:分组内容过渡动画(可选)。
3. `.sidebar-field-active`:高亮边框/背景色。
4. 图片选择弹窗遮罩与内容卡片样式(可复用现有 Modal 样式)。
---
## 回滚策略
修改前 `git` 仓库已处于干净状态(最新提交 `b155dd4`)。若验证失败,可执行 `git reset --hard b155dd4` 回滚。
## 无新增 npm 依赖
所有改动均利用现有 React + Tailwind 能力完成。

View File

@@ -0,0 +1,81 @@
# 测试方案 — 2026-04-17-18-38-47
## 测试目标
验证 7 项需求全部正确实现,且不影响现有报告编辑、保存、打印等核心流程。
---
## 测试步骤
### 1. 编译检查
```bash
npm run lint
```
- **预期**`tsc --noEmit` 通过0 errors。
---
### 2. 默认模板与字段初始化(需求 2
1. 清空浏览器 `localStorage`(或打开无痕窗口),重新登录进入 `/template-manage`
2. 检查默认模板内容:
- "术前诊断"、"术后诊断"、"手术后情况"、"切除标本描述"、"是否送病理检查"、"冰冻病理结果"、"手术者签名"、"医院Logo" 均显示为可交互的智能字段/图片占位符,不再显示灰色静态文字。
3. 进入右侧"字段管理"Tab确认新增的系统字段术前诊断、术后诊断…已存在且带默认选项。
---
### 3. 新增字段表单联动(需求 1
1. 在"字段管理 → 新增字段"中:
- 选择分类"单选",确认类型下拉只有"下拉单选"。
- 选择分类"多选",确认类型下拉只有"标签多选"。
- 选择分类"图片",确认类型下拉只有"图片"。
- 选择分类"填空",确认类型下拉只有"文本"。
---
### 4. 字段管理折叠与编辑(需求 3、5
1. 在"字段管理"Tab 中,确认字段按"填空/单选/多选/时间/图片"分组折叠显示。
2. 点击某一分组标题,确认该组展开/收起状态切换。
3. 点击"术前诊断"字段行,确认进入编辑模式,出现选项输入框。
4. 在选项输入框中追加一个选项(如"急性胆囊炎"),点击保存,确认字段列表刷新。
5. 刷新页面,确认修改后的选项仍然保留(已持久化到 `localStorage`)。
6. 确认系统锁定字段没有"删除"按钮,但非系统字段仍有"删除"按钮。
---
### 5. 素材管理与图片字段(需求 4
1. 在"字段管理"中确认存在"素材库"区域,默认已包含"医院Logo"素材(由 `/logo_square.png` 自动转换而来)。
2. 点击素材库"上传图片",选择一张本地图片,确认上传后素材列表新增一项。
3. 在"插入字段"Tab 中,点击"医院Logo"插入到编辑器,确认插入的是图片占位符。
4. 切换到 `/report-editor`(新建报告),确认模板顶部 Logo 显示为图片占位符。
5. 点击该 Logo 占位符,确认弹出"图片来源选择器"弹窗。
6. 在弹窗中分别测试:
- 选择"本地上传"并上传新图,确认占位符被替换为新图。
- 删除后重新点击,选择"系统素材"中的医院 Logo确认替换为 Logo。
- 删除后重新点击,选择"我的签名"(需确保当前用户已上传签名),确认替换为签名图。
---
### 6. 编辑器与侧边栏双向联动(需求 6、7
1.`/template-manage` 中,切换到"插入字段"Tab。
2. 点击编辑器正文中的"术前诊断"智能字段,确认右侧"插入字段"中"术前诊断"按钮出现高亮边框,并自动滚动到可视区域。
3. 切换到"字段管理"Tab点击编辑器正文中的"手术名称"智能字段,确认:
- 右侧"手术名称"字段卡片出现高亮边框;
- 若该字段所在分组原本被折叠,则自动展开;
- 自动滚动到可视区域。
4. 点击编辑器空白处,确认高亮消失(`activeFieldKey` 重置为 null
---
### 7. 回归测试
1. **保存模板**:修改模板后点击"保存模板",刷新页面,内容不丢失。
2. **打印预览**:点击打印预览,确认所有智能字段、图片占位符渲染正常。
3. **撤销重做**:删除一个字段后按 `Ctrl+Z`,确认字段恢复。
4. **报告编辑**:在 `/report-editor` 中填写表单,确认双向同步(表单 → 正文、正文 → 表单)仍然正常。
5. **完成报告**:点击"完成报告",确认弱提示(签名确认弹窗)逻辑仍然生效。
---
## 判定标准
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。

View File

@@ -575,3 +575,38 @@ ange.insertNode(fragment) 精确插入到 Range 位置;
- 在 contentEditable 中实现自定义撤销栈时,必须**同时拦截界面按钮和键盘快捷键**的 undo/redo否则两套历史机制会互相冲突。
- document.execCommand('insertHTML') 对块级元素边界(尤其是 <br> 结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。
- 任何对 contentEditable 的 DOM 修改后都应同步保存内容saveTemplateContent确保 localStorage 中的模板数据与编辑器状态一致。
---
## 记录 22TemplateManage 字段体系升级与双向交互联动
**A. 具体问题**
1. 新增字段时单选/多选分类仍显示"文本"选项,联动逻辑错误。
2. 默认模板中存在大量静态灰色占位文本(术前诊断、术后诊断等),无法与右侧表单双向绑定。
3. 字段管理列表平铺展示,无分组折叠,系统字段选项不可修改。
4. 图片占位符只能通过本地上传填充,无法使用签名图或系统素材。
5. 编辑器中的智能字段与右侧侧边栏完全无联动。
**B. 问题产生原因**
1. `newFieldForm.category` onChange 时未正确过滤 type select 的 options。
2. `DEFAULT_FORM_FIELDS` 缺少术前/术后诊断等临床字段,导致 `defaultContent.ts` 只能写死占位文本。
3. 字段管理 UI 未按 category 分组,也未提供编辑系统字段选项的入口。
4. `ReportEditor.tsx` 中图片占位符点击后直接调用 `input.click()`,缺少多渠道选择机制。
5. `TemplateManage.tsx` 的 `handleEditorClick` 仅处理了删除逻辑,未处理点击高亮/导航。
**C. 解决问题方法**
1. **类型联动修复**category onChange 时强制设置对应 type单选→single_select、多选→multi_select、图片→imagetype select 使用条件渲染,只显示当前 category 支持的选项。
2. **扩展默认字段**:在 `types.ts` 追加 `preoperativeDiagnosis`、`postoperativeDiagnosis`、`postOpCondition`、`specimenDescription`、`pathologyCheck`、`frozenPathology`、`hospitalLogo` 等系统字段,全部 `isSystemLocked: true`。
3. **替换模板占位文本**:在 `defaultContent.ts` 中将所有灰色占位文本替换为 `smartField(...)`Logo 替换为带 `data-bind="hospitalLogo"` 的 `image-placeholder`。
4. **字段管理折叠与编辑**:新增 `expandedCategories` 状态实现折叠面板;新增 `editingFieldKey` 等状态实现点击编辑(系统字段 label 只读、选项可编辑)。
5. **素材库与图片字段**`FieldType` 扩展 `'image'`;初始化时自动将 Logo 转 Base64 存入 `imageAssets``insertSmartField` 对图片类型插入 `image-placeholder`。
6. **图片来源选择弹窗**`ReportEditor.tsx` 点击图片占位符弹出 Modal支持本地上传、我的签名、系统素材三选一。
7. **编辑器-侧边栏双向联动**:点击 `smart-field-wrapper` 时读取 `data-bind`,高亮并滚动定位到右侧对应字段,自动展开分组。
**D. 经验与教训总结**
- category→type 的联动应在 state 变更层强制收敛,而不是仅依赖 JSX 条件渲染。
- 升级静态占位文本为字段时,必须同步修改 `DEFAULT_FORM_FIELDS`、`defaultContent.ts` 和 `formFieldsConfig`。
- 图片字段与普通文本字段的 DOM 结构差异大,插入逻辑需要按 type 分支。
- 编辑器与侧边栏联动建议使用 `scrollIntoView` + 临时 CSS 类,避免复杂的状态同步。
- 新增 localStorage key 时应提供合理的默认值或降级处理。

View File

@@ -0,0 +1,77 @@
# 需求分析 — 2026-04-17-18-38-47
## 用户反馈的 7 项需求
### 1. 新增字段类型联动修复
`template-manage` 的"字段管理 → 新增字段"中,当"分类"选择"单选"或"多选"时,右侧"类型"下拉框里依然保留了"文本"选项。用户认为单选/多选分类下不应再出现"文本"类型。
### 2. 新增默认系统字段并替换模板占位文本
需要新增以下系统字段(默认可见、锁定):
- 术前诊断(单选)
- 术后诊断(单选)
- 手术后情况(单选,默认选项含"患者麻醉恢复后安返病房"
- 是否送病理检查(单选,默认选项"是"/"否"
- 冰冻病理结果(单选)
- 切除标本描述(单选)
同时将 `defaultContent.ts` 中的静态占位文字(灰色提示文本)替换为对应的智能字段绑定,包括:
- "术前诊断" → `preoperativeDiagnosis`
- "术后诊断" → `postoperativeDiagnosis`
- "患者麻醉恢复后安返病房" → `postOpCondition`
- "切除标本描述" → `specimenDescription`
- "是"(是否送病理检查) → `pathologyCheck`
- "冰冻病理结果" → `frozenPathology`
- "签名"(手术者签名处) → `surgeonSignature`(已存在字段)
### 3. 字段管理支持点击编辑选项
在"字段管理"列表中,点击任意字段行(包括系统锁定字段)可进入编辑模式,修改其默认选项(用逗号分隔)。保存后同步更新 `formFieldsConfig`
### 4. 新增"图片"字段类型与素材管理
- `FieldType` 扩展 `'image'` 类型。
- 新增字段表单中"分类"增加"图片""类型"增加"图片"。
- 新增系统字段 `hospitalLogo`医院Logo对应模板顶部 `<img src="/logo_square.png">`
- 建立"素材库"概念:使用 `localStorage``imageAssets` key 存储 `{id, name, dataUrl}` 数组。
- 在模板管理的字段管理/系统设置中提供素材上传入口,将现有 Logo 预置为素材。
-`ReportEditor` 中,点击图片占位符时弹出"图片来源选择器",支持三种渠道:
1. 本地上传FileReader
2. 用户签名图片(`currentUser.signature`
3. 系统素材库(`imageAssets`
### 5. 字段管理按类型折叠分组
"字段管理"Tab 中的字段列表不再平铺,而是按 `category`填空、单选、多选、时间、图片分组采用可折叠的下拉面版Accordion支持展开/收起。
### 6. 编辑器 → 字段管理 自动导航
当用户处于"字段管理"Tab 时,点击编辑器正文中的某个 `smart-field-wrapper`,右侧自动:
- 展开该字段所属的分组;
- 滚动并将该字段卡片高亮。
### 7. 编辑器 → 插入字段 自动高亮
当用户处于"插入字段"Tab 时,点击编辑器正文中的某个 `smart-field-wrapper`,右侧自动将对应字段按钮高亮(边框/背景色变化),并滚动到可视区域。
---
## 影响范围
- `src/types.ts`:扩展 `FieldType`,更新 `DEFAULT_FORM_FIELDS`
- `src/utils/defaultContent.ts`:将占位文本替换为 `smartField(...)`
- `src/pages/TemplateManage.tsx`
- 新增字段表单联动修复;
- 字段管理列表增加折叠分组与点击编辑;
- 编辑器点击事件与侧边栏高亮/导航联动;
- 素材管理 UI。
- `src/pages/ReportEditor.tsx`
- 图片占位符触发逻辑改为弹窗选择器;
- 支持素材库/签名/本地上传三种来源。
- `src/index.css`:新增折叠面板、高亮、弹窗等样式。
---
## 验收标准
1. 新增字段时,单选/多选分类不再出现"文本"选项。
2. 默认模板中所有占位灰字均已替换为可绑定的智能字段。
3. 字段管理列表支持按分类折叠,点击字段可编辑选项(包括系统字段)。
4. 可新增"图片"类型字段;素材库可上传/查看图片Logo 已预置为素材。
5. `ReportEditor` 点击图片占位符可弹出三选一图片来源弹窗。
6. 点击编辑器中任意智能字段,右侧"插入字段"或"字段管理"Tab 能自动高亮并滚动定位到对应字段。
7. `npm run lint` 通过,无编译错误。