2026-04-16-22-23-02 - 新增 TemplateManage 字段库与 ReportEditor 双向数据绑定智能占位方格

This commit is contained in:
2026-04-16 22:29:40 +08:00
parent 915e169bd8
commit f373520fa5
9 changed files with 907 additions and 48 deletions

View File

@@ -95,6 +95,31 @@
.manual-frame-badge {
@apply absolute top-1 left-1 px-1.5 py-0.5 bg-yellow-400 text-yellow-900 text-[9px] font-bold rounded shadow-sm pointer-events-none;
}
/* Smart Field Bindable Controls */
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 4px;
vertical-align: middle;
}
.smart-field-wrapper .field-label {
color: #64748b;
user-select: none;
}
.smart-field-wrapper .field-value {
min-width: 60px;
padding: 0 4px;
border: 1px solid #cbd5e1;
border-radius: 4px;
display: inline-block;
background: #fff;
color: #0f172a;
outline: none;
}
.smart-field-wrapper .field-value:empty::before {
content: '\200b';
}
}
@media print {
@@ -124,4 +149,11 @@
.print-content .image-placeholder:not(.has-image) {
display: none !important;
}
.print-content .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;
}
}

View File

@@ -873,6 +873,48 @@ export default function ReportEditor() {
if (status === 'completed') navigate('/report-manage');
};
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
}
updatePageHeight();
saveDraftToStorage();
const target = e.target as HTMLElement;
if (target && target.hasAttribute('data-bind')) {
const fieldKey = target.getAttribute('data-bind')!;
const newValue = target.innerText;
setReportData((prev) => {
const next = { ...prev, [fieldKey]: newValue };
stateRef.current = { ...stateRef.current, reportData: next };
return next;
});
}
};
// Sync form state -> rich text field values
useEffect(() => {
if (!editorRef.current) return;
const bindNodes = editorRef.current.querySelectorAll('[data-bind]');
bindNodes.forEach((node) => {
const el = node as HTMLElement;
const fieldKey = el.getAttribute('data-bind')!;
const rawValue = (reportData as any)[fieldKey];
let newValue = '';
if (Array.isArray(rawValue)) {
newValue = rawValue.join(', ');
} else if (rawValue !== undefined && rawValue !== null) {
newValue = String(rawValue);
}
if (el.innerText !== newValue) {
el.innerText = newValue;
}
});
}, [reportData]);
if (!currentUser) return null;
const hasVisibleTemplates = templates.length > 0;
@@ -1006,7 +1048,7 @@ export default function ReportEditor() {
<div
ref={editorRef}
contentEditable
onInput={() => { contentRef.current = editorRef.current?.innerHTML || ''; updatePageHeight(); saveDraftToStorage(); }}
onInput={handleEditorInput}
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
className="editor-content print-content"
>

View File

@@ -2,7 +2,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 { User, Template } from '../types';
import { User, Template, BINDABLE_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
@@ -156,6 +156,22 @@ export default function TemplateManage() {
editorRef.current?.focus();
};
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
editorRef.current?.focus();
const html = `
<span class="smart-field-wrapper" contenteditable="false">
<span class="field-label">${field.label}</span>
<span class="field-value"
data-bind="${field.key}"
contenteditable="true"
style="min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a;">
</span>
</span>&nbsp;
`;
document.execCommand('insertHTML', false, html);
editorRef.current?.focus();
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
@@ -396,58 +412,87 @@ export default function TemplateManage() {
</div>
</header>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onChange={(e) => execCmd('foreColor', e.target.value)}
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
title="文字颜色"
/>
<div className="flex-1 flex overflow-hidden">
{/* Editor Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
>
<option value=""></option>
<option value="SimSun"></option>
<option value="Microsoft YaHei"></option>
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
<div className="relative flex items-center">
<input
type="color"
onChange={(e) => execCmd('foreColor', e.target.value)}
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
title="文字颜色"
/>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
</div>
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
</div>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
</div>
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
{/* Editor Area */}
<div className="editor-content-wrapper print-wrapper">
<div
ref={editorRef}
contentEditable
className="editor-content print-content"
>
</div>
</div>
</div>
{/* Editor Area */}
<div className="editor-content-wrapper print-wrapper">
<div
ref={editorRef}
contentEditable
className="editor-content print-content"
>
{/* Right: Field Library */}
<aside className="w-[220px] bg-sidebar-bg border-l border-border flex flex-col shrink-0 overflow-hidden">
<div className="p-4 border-b border-border">
<span className="text-sm font-bold text-text-main uppercase tracking-wider"></span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div className="card-minimal p-3">
<h3 className="text-xs font-semibold text-primary mb-2"></h3>
<div className="flex flex-wrap gap-1.5">
{BINDABLE_FIELDS.map((field) => (
<button
key={field.key}
type="button"
onClick={() => insertSmartField(field)}
className="px-2 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
title={`插入 ${field.label}`}
>
{field.label}
</button>
))}
</div>
<p className="text-[10px] text-slate-400 mt-2 leading-tight">Label Value </p>
</div>
</div>
</aside>
</div>
</div>

View File

@@ -79,3 +79,25 @@ export interface SystemSettings {
autoInsertFrameIndices?: number[];
autoInsertDelay?: number;
}
export interface BindableField {
key: string;
label: string;
}
export const BINDABLE_FIELDS: BindableField[] = [
{ key: 'patientName', label: '姓名' },
{ key: 'gender', label: '性别' },
{ key: 'age', label: '年龄' },
{ key: 'hospitalId', label: '住院号' },
{ key: 'bedNumber', label: '床号' },
{ key: 'surgeryDate', label: '手术日期' },
{ key: 'surgeryType', label: '手术类型' },
{ key: 'surgeon', label: '手术者' },
{ key: 'assistant', label: '助手' },
{ key: 'anesthesiologist', label: '麻醉师' },
{ key: 'anesthesiaType', label: '麻醉方式' },
{ key: 'preoperativeDiagnosis', label: '术前诊断' },
{ key: 'intraoperativeDiagnosis', label: '术中诊断' },
{ key: 'surgicalProcedure', label: '手术经过' },
];

View File

@@ -34,6 +34,12 @@ export const printDocument = (htmlContent: string) => {
.image-placeholder .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 4px; vertical-align: middle; }
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
.smart-field-wrapper .field-value { min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a; outline: none; }
@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; }
}
</style>
</head>
<body>