From 0c57409c592cc3ffb5c7e6c30952377607f19953 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 17 Apr 2026 18:54:10 +0800 Subject: [PATCH] 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. --- src/pages/ReportEditor.tsx | 80 +++++++- src/pages/TemplateManage.tsx | 226 ++++++++++++++++++--- src/types.ts | 9 +- src/utils/defaultContent.ts | 19 +- 工程分析/实现方案-2026-04-17-18-38-47.md | 238 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-17-18-38-47.md | 81 ++++++++ 工程分析/经验记录.md | 35 ++++ 工程分析/需求分析-2026-04-17-18-38-47.md | 77 ++++++++ 8 files changed, 731 insertions(+), 34 deletions(-) create mode 100644 工程分析/实现方案-2026-04-17-18-38-47.md create mode 100644 工程分析/测试方案-2026-04-17-18-38-47.md create mode 100644 工程分析/需求分析-2026-04-17-18-38-47.md diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 572a895..6527c0e 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -63,6 +63,9 @@ export default function ReportEditor() { const [openDropdown, setOpenDropdown] = useState(null); const [touched, setTouched] = useState>({}); const [formFields, setFormFields] = useState([]); + const [imagePickerOpen, setImagePickerOpen] = useState(false); + const [imagePickerTarget, setImagePickerTarget] = useState(null); + const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]); const editorRef = useRef(null); const videoRef = useRef(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('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 = ` + × + + `; + 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() { + + {imagePickerOpen && imagePickerTarget && ( +
+
+

选择图片来源

+
+ + +
+
系统素材
+
+ {imageAssets.map(asset => ( + + ))} + {imageAssets.length === 0 &&
暂无素材
} +
+
+
+
+ +
+
+
+ )} ); } diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx index 38a2ed5..a86218f 100644 --- a/src/pages/TemplateManage.tsx +++ b/src/pages/TemplateManage.tsx @@ -24,6 +24,12 @@ export default function TemplateManage() { const [formFields, setFormFields] = useState([]); const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType }); const [newFieldOptions, setNewFieldOptions] = useState(''); + const [expandedCategories, setExpandedCategories] = useState(['填空', '单选', '多选', '时间', '图片']); + const [activeFieldKey, setActiveFieldKey] = useState(null); + const [editingFieldKey, setEditingFieldKey] = useState(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('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.

) 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 = ` ×​`; + + let html = ''; + if (field.type === 'image') { + const id = 'ph_' + Date.now(); + html = `

×

插入/点击放置图片

​`; + } else { + html = ` ×​`; + } 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) => { + 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('users', []); let updatedUsers = savedUsers; if (currentUser?.role === 'super') { @@ -730,12 +808,13 @@ export default function TemplateManage() { {catFields.map(field => ( + {expanded && ( +
+ {catFields.map(field => ( +
{ + 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 ? ( +
e.stopPropagation()}> +
+ {field.isSystemLocked ? field.label : ( + setEditFieldLabel(e.target.value)} + className="w-full px-1.5 py-1 text-xs border border-border rounded" + placeholder="字段名称" + /> + )} +
+ {['单选', '多选', '图片'].includes(field.category) && ( + setEditFieldOptions(e.target.value)} + className="w-full px-1.5 py-1 text-xs border border-border rounded" + placeholder="选项,用逗号分隔" + /> + )} +
+ + +
+
+ ) : ( +
+
+
{field.label}
+
{field.category} · {field.type}
+
+
+ + {!field.isSystemLocked && ( + + )} +
+
+ )} +
+ ))} +
+ )} -
- - + ); + })} + + {/* Asset Library */} +
+
素材库
+
+
+ {imageAssets.map(asset => ( +
+ {asset.name} +
+ +
+
+ ))}
+
- ))} +
新增字段
@@ -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() { +
{['单选', '多选'].includes(newFieldForm.category) && ( diff --git a/src/types.ts b/src/types.ts index c2531c3..ffbc851 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 }, ]; diff --git a/src/utils/defaultContent.ts b/src/utils/defaultContent.ts index 0bc9c18..41f7080 100644 --- a/src/utils/defaultContent.ts +++ b/src/utils/defaultContent.ts @@ -3,7 +3,10 @@ const smartField = (key: string) => `

- 医院Logo +

+ × +

插入/点击放置图片

+

@@ -29,11 +32,11 @@ export const defaultReportContent = `

- 术前诊断:术前诊断 + 术前诊断:${smartField('preoperativeDiagnosis')}

- 术后诊断:术后诊断 + 术后诊断:${smartField('postoperativeDiagnosis')}

@@ -132,23 +135,23 @@ export const defaultReportContent = `

- 手术后情况:患者麻醉恢复后安返病房 + 手术后情况:${smartField('postOpCondition')}

- 切除标本描述切除标本描述 + 切除标本描述:${smartField('specimenDescription')}

- 是否送病理检查:是 + 是否送病理检查:${smartField('pathologyCheck')}

- 冰冻病理结果冰冻病理结果 + 冰冻病理结果:${smartField('frozenPathology')}

- 手术者签名:签名 + 手术者签名:${smartField('surgeonSignature')}

diff --git a/工程分析/实现方案-2026-04-17-18-38-47.md b/工程分析/实现方案-2026-04-17-18-38-47.md new file mode 100644 index 0000000..4bea735 --- /dev/null +++ b/工程分析/实现方案-2026-04-17-18-38-47.md @@ -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 +// 术前诊断 +术前诊断:${smartField('preoperativeDiagnosis')} + +// 术后诊断 +术后诊断:${smartField('postoperativeDiagnosis')} + +// 手术后情况 +手术后情况:${smartField('postOpCondition')} + +// 切除标本描述 +切除标本描述:${smartField('specimenDescription')} + +// 是否送病理检查 +是否送病理检查:${smartField('pathologyCheck')} + +// 冰冻病理结果 +冰冻病理结果:${smartField('frozenPathology')} + +// 手术者签名 +手术者签名:${smartField('surgeonSignature')} + +// 医院 Logo 替换为图片字段占位符(使用 image-placeholder 结构但带 data-bind) +// 保留原有居中样式 +``` + +Logo 部分不再硬编码 ``,改为可管理的图片占位符: +```html +

+ × +

插入/点击放置图片

+
+``` + +--- + +## 三、TemplateManage.tsx 修改 + +### 3.1 新增字段表单修复(需求 1) +在 category `onChange` 中: +- 选择"单选" → `type` 强制设为 `single_select` +- 选择"多选" → `type` 强制设为 `multi_select` +- 选择"图片" → `type` 强制设为 `image` + +在 type select 的 options 渲染中,移除单选/多选/图片下的"文本" option: +```tsx + +{newFieldForm.category === '单选' && } +{newFieldForm.category === '多选' && } +{newFieldForm.category === '时间' && <>} +{newFieldForm.category === '图片' && } +``` + +### 3.2 字段管理折叠分组(需求 5) +新增状态: +```tsx +const [expandedCategories, setExpandedCategories] = useState(['填空','单选','多选','时间','图片']); +``` + +将字段管理列表改为按 category 分组渲染。每组一个可点击标题栏,点击时 toggle 该 category 在 `expandedCategories` 中的存在性。展开的组内渲染对应字段列表。 + +### 3.3 字段管理点击编辑选项(需求 3) +新增状态: +```tsx +const [editingFieldKey, setEditingFieldKey] = useState(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(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(null); +const [imageAssets, setImageAssets] = useState<{id: string, name: string, dataUrl: string}[]>([]); +``` + +修改 `triggerPlaceholderUpload` 的调用逻辑:当点击无图片的 `image-placeholder` 时,不再直接 `input.click()`,而是: +```tsx +setImagePickerTarget(placeholder); +setImagePickerOpen(true); +``` + +弹窗 JSX(Modal)包含三个 Tab 按钮: +- **本地上传**:内部隐藏 ``,点击"选择文件"触发,读取后填充 placeholder。 +- **我的签名**:若 `currentUser.signature` 存在,展示签名缩略图,点击后填充。 +- **系统素材**:读取 `imageAssets` 列表,展示缩略图网格,点击后填充。 + +填充函数: +```tsx +const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => { + placeholder.innerHTML = `×`; + 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 = `
×

插入/点击放置图片

`; + // 同样的 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 能力完成。 diff --git a/工程分析/测试方案-2026-04-17-18-38-47.md b/工程分析/测试方案-2026-04-17-18-38-47.md new file mode 100644 index 0000000..7bf69e5 --- /dev/null +++ b/工程分析/测试方案-2026-04-17-18-38-47.md @@ -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. **完成报告**:点击"完成报告",确认弱提示(签名确认弹窗)逻辑仍然生效。 + +--- + +## 判定标准 + +全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 7fbaf3a..2814136 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -575,3 +575,38 @@ ange.insertNode(fragment) 精确插入到 Range 位置; - 在 contentEditable 中实现自定义撤销栈时,必须**同时拦截界面按钮和键盘快捷键**的 undo/redo,否则两套历史机制会互相冲突。 - document.execCommand('insertHTML') 对块级元素边界(尤其是
结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。 - 任何对 contentEditable 的 DOM 修改后,都应同步保存内容(saveTemplateContent),确保 localStorage 中的模板数据与编辑器状态一致。 + + +--- + +## 记录 22:TemplateManage 字段体系升级与双向交互联动 + +**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、图片→image);type 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 时应提供合理的默认值或降级处理。 diff --git a/工程分析/需求分析-2026-04-17-18-38-47.md b/工程分析/需求分析-2026-04-17-18-38-47.md new file mode 100644 index 0000000..bfaea8d --- /dev/null +++ b/工程分析/需求分析-2026-04-17-18-38-47.md @@ -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),对应模板顶部 ``。 +- 建立"素材库"概念:使用 `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` 通过,无编译错误。