2026-04-16-22-23-02 - 新增 TemplateManage 字段库与 ReportEditor 双向数据绑定智能占位方格
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
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>
|
||||
|
||||
|
||||
22
src/types.ts
22
src/types.ts
@@ -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: '手术经过' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user