2026-04-16-22-23-02 - 新增 TemplateManage 字段库与 ReportEditor 双向数据绑定智能占位方格
This commit is contained in:
@@ -95,6 +95,31 @@
|
|||||||
.manual-frame-badge {
|
.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;
|
@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 {
|
@media print {
|
||||||
@@ -124,4 +149,11 @@
|
|||||||
.print-content .image-placeholder:not(.has-image) {
|
.print-content .image-placeholder:not(.has-image) {
|
||||||
display: none !important;
|
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');
|
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;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
const hasVisibleTemplates = templates.length > 0;
|
const hasVisibleTemplates = templates.length > 0;
|
||||||
@@ -1006,7 +1048,7 @@ export default function ReportEditor() {
|
|||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
contentEditable
|
contentEditable
|
||||||
onInput={() => { contentRef.current = editorRef.current?.innerHTML || ''; updatePageHeight(); saveDraftToStorage(); }}
|
onInput={handleEditorInput}
|
||||||
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
|
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
|
||||||
className="editor-content print-content"
|
className="editor-content print-content"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
|
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
|
||||||
import { User, Template } from '../types';
|
import { User, Template, BINDABLE_FIELDS } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { printDocument } from '../utils/print';
|
import { printDocument } from '../utils/print';
|
||||||
import { storage } from '../utils/storage';
|
import { storage } from '../utils/storage';
|
||||||
@@ -156,6 +156,22 @@ export default function TemplateManage() {
|
|||||||
editorRef.current?.focus();
|
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 insertTable = () => {
|
||||||
const rowsStr = prompt('请输入行数:', '2');
|
const rowsStr = prompt('请输入行数:', '2');
|
||||||
const colsStr = prompt('请输入列数:', '3');
|
const colsStr = prompt('请输入列数:', '3');
|
||||||
@@ -396,6 +412,8 @@ export default function TemplateManage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Editor Main */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Toolbar */}
|
{/* 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 items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
|
||||||
@@ -449,6 +467,33 @@ export default function TemplateManage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 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>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
|
|||||||
22
src/types.ts
22
src/types.ts
@@ -79,3 +79,25 @@ export interface SystemSettings {
|
|||||||
autoInsertFrameIndices?: number[];
|
autoInsertFrameIndices?: number[];
|
||||||
autoInsertDelay?: 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 .delete-btn { display: none !important; }
|
||||||
.image-placeholder:not(.has-image) { display: none !important; }
|
.image-placeholder:not(.has-image) { display: none !important; }
|
||||||
.template-info-section { position: relative; margin-bottom: 16px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
249
工程分析/实现方案-2026-04-16-22-23-02.md
Normal file
249
工程分析/实现方案-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# 实现方案 — 2026-04-16-22-23-02
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
当前 `TemplateManage.tsx` 和 `ReportEditor.tsx` 均使用原生 `contentEditable` 实现富文本编辑,但模板中的占位符是纯 HTML(如 `姓名:<span style="color: #ff0000;">*姓名*</span>`),存在以下问题:
|
||||||
|
1. **固定文本无保护**:"姓名:" 等标签与普通文本无异,用户可随意删除或篡改。
|
||||||
|
2. **无双向绑定**:模板中的占位符与右侧表单之间没有数据通道,模板内容不会随表单变化,表单也不会随模板输入自动填充。
|
||||||
|
3. **打印样式混乱**:现有的红色占位文本在打印报告中显得不专业。
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 修改类型 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `src/pages/TemplateManage.tsx` | 修改 | 新增右侧"字段库"面板,支持点击插入智能占位控件 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | 修改 | 新增 `data-bind` DOM 的双向监听与同步逻辑 |
|
||||||
|
| `src/utils/print.ts` | 修改 | 打印样式中增加 `.field-value` 的打印适配 |
|
||||||
|
| `src/index.css` | 修改 | 新增 `.smart-field-wrapper` 系列样式 |
|
||||||
|
| `src/types.ts` | 修改(可选) | 定义字段映射常量数组,供两端复用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 具体代码变更
|
||||||
|
|
||||||
|
### 变更 1:`src/types.ts` — 定义字段库常量
|
||||||
|
|
||||||
|
**新增内容(在文件末尾追加):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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: 'anesthesiaType', label: '麻醉方式' },
|
||||||
|
{ key: 'preoperativeDiagnosis', label: '术前诊断' },
|
||||||
|
{ key: 'intraoperativeDiagnosis', label: '术中诊断' },
|
||||||
|
{ key: 'surgicalProcedure', label: '手术经过' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更 2:`src/index.css` — 智能占位控件样式
|
||||||
|
|
||||||
|
**在 `@layer components` 或文件末尾新增:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.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'; /* zero-width space to keep min-height */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.smart-field-wrapper .field-value {
|
||||||
|
border: none !important;
|
||||||
|
border-bottom: 1px solid #000 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变更 3:`src/pages/TemplateManage.tsx` — 字段库面板与插入逻辑
|
||||||
|
|
||||||
|
**当前结构:** `TemplateManage.tsx` 右侧通常为操作按钮区(如保存、预览)。
|
||||||
|
|
||||||
|
**新增字段库面板(放在保存按钮下方或单独区域):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BINDABLE_FIELDS } from '../types';
|
||||||
|
|
||||||
|
// 在组件内新增辅助函数
|
||||||
|
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 位置(在保存按钮下方新增卡片):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="card-minimal mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-primary mb-2">表单字段库</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BINDABLE_FIELDS.map((field) => (
|
||||||
|
<button
|
||||||
|
key={field.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertSmartField(field)}
|
||||||
|
className="px-2 py-1 text-xs 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">点击字段插入智能占位方格,Label 锁定,Value 可输入。</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:`TemplateManage.tsx` 的具体行号需以实际文件为准,但插入逻辑与 UI 结构如上。
|
||||||
|
|
||||||
|
### 变更 4:`src/pages/ReportEditor.tsx` — 双向绑定逻辑
|
||||||
|
|
||||||
|
#### 4.1 富文本 → 表单(`handleEditorInput` 或 `onInput` 事件)
|
||||||
|
|
||||||
|
**当前代码**中 `ReportEditor.tsx` 的编辑器通常已有 `onInput` 处理(保存草稿)。
|
||||||
|
|
||||||
|
**在原有 `onInput` 处理器中追加:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||||
|
// 1. 原有逻辑:同步 contentRef 并保存草稿
|
||||||
|
if (editorRef.current) {
|
||||||
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
|
}
|
||||||
|
saveDraftToStorage();
|
||||||
|
|
||||||
|
// 2. 新增:双向绑定 — 方格内容变更时更新表单 State
|
||||||
|
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
|
||||||
|
stateRef.current.reportData = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 表单 → 富文本(`useEffect` 监听 `reportData`)
|
||||||
|
|
||||||
|
**在 `ReportEditor.tsx` 中新增一个 `useEffect`:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 处理数组类型(如 surgeon / assistant)
|
||||||
|
let newValue = '';
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
newValue = rawValue.join(', ');
|
||||||
|
} else if (rawValue !== undefined && rawValue !== null) {
|
||||||
|
newValue = String(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在差异时更新 DOM,防止光标跳动
|
||||||
|
if (el.innerText !== newValue) {
|
||||||
|
el.innerText = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [reportData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 光标/焦点保护(边界处理)
|
||||||
|
|
||||||
|
为避免 `useEffect` 在 `reportData` 变化时与用户的输入冲突,上述逻辑已通过 `if (el.innerText !== newValue)` 做短路保护。若用户当前正在该方格内输入(此时 `reportData` 已由 `handleEditorInput` 同步更新),`innerText` 通常等于 `newValue`,不会触发 DOM 重写,因此光标不会跳动。
|
||||||
|
|
||||||
|
### 变更 5:`src/utils/print.ts` — 打印样式适配
|
||||||
|
|
||||||
|
**当前 `print.ts` 会将 HTML 内容包裹后打印。**
|
||||||
|
|
||||||
|
**在注入的 `<style>` 中追加:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若 `print.ts` 本身会读取 `index.css` 中的打印样式,可确认是否重复。保险起见,两边同时维护或仅在 `index.css` 维护即可。此处优先在 `index.css` 中维护,`print.ts` 若已内联样式则同步追加。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
| 风险 | 级别 | 应对措施 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 光标跳动/输入中断 | 中 | `useEffect` 同步时严格判断 `innerText !== newValue`,仅在差异时重写 DOM |
|
||||||
|
| `contenteditable="false"` 外层导致整个控件无法删除 | 低 | 这是预期行为,用户可通过选中整个控件后按 Delete 删除;若需要允许删除,可在外层增加 `tabindex` 或删除按钮 |
|
||||||
|
| 数组字段(surgeon)同步时格式异常 | 低 | 在 `useEffect` 中加入 `Array.isArray(rawValue)` 分支,统一用 `join(', ')` |
|
||||||
|
| 老模板未自动升级,用户反馈"联动不生效" | 低 | 在 `TemplateManage` 页面增加提示文案:"请重新编辑模板并插入字段库控件以激活联动" |
|
||||||
|
|
||||||
|
## 回滚策略
|
||||||
|
|
||||||
|
本次改动仅涉及前端 UI 和 DOM 事件处理,不修改数据结构和存储接口。如出现异常,可直接执行以下任一方式:
|
||||||
|
1. `git revert` 撤销相关提交;
|
||||||
|
2. 手动注释掉 `ReportEditor.tsx` 中的 `useEffect` 双向绑定逻辑和 `handleEditorInput` 的新增分支,保留原有草稿保存逻辑即可恢复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||||
102
工程分析/测试方案-2026-04-16-22-23-02.md
Normal file
102
工程分析/测试方案-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 测试方案 — 2026-04-16-22-23-02
|
||||||
|
|
||||||
|
## 测试目标
|
||||||
|
|
||||||
|
验证 `TemplateManage` 端字段库插入功能、智能占位控件的保护特性,以及 `ReportEditor` 端富文本与基本信息表单之间的双向数据绑定是否稳定可靠。
|
||||||
|
|
||||||
|
## 测试环境
|
||||||
|
|
||||||
|
- 浏览器:Chrome / Edge
|
||||||
|
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
|
||||||
|
- 测试页面:`/template-manage`、`/report-editor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例设计
|
||||||
|
|
||||||
|
### 用例 1:模板管理端 — 字段库插入与标签锁定
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 1.1 | 进入 `/template-manage` | 页面正常加载,右侧出现"表单字段库"卡片 |
|
||||||
|
| 1.2 | 将光标定位到编辑器任意位置,点击字段库中的「姓名」按钮 | 编辑器中插入一个带有"姓名:"标签和空方格的控件 |
|
||||||
|
| 1.3 | 尝试用鼠标单独选中"姓名:"标签并删除 | **无法单独删除或修改**标签文本,只能整体选中或删除整个控件 |
|
||||||
|
| 1.4 | 点击空方格并输入"张三" | 方格内正常显示"张三",光标不跳动 |
|
||||||
|
| 1.5 | 再次点击「住院号」按钮插入第二个控件 | 两个控件在编辑器中并存,互不干扰 |
|
||||||
|
|
||||||
|
### 用例 2:报告编辑端 — 富文本 → 表单单向联动
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 2.1 | 进入 `/report-editor`(新建报告),确保模板已包含至少一个智能占位控件(如"姓名") | 编辑器加载默认模板,智能控件正确渲染 |
|
||||||
|
| 2.2 | 点击"姓名"方格,输入"李四" | 右侧【基本信息】表单的"患者姓名"字段**自动同步为"李四"** |
|
||||||
|
| 2.3 | 继续输入,追加"先生" | 表单字段同步更新为"李四先生",输入过程光标稳定不跳 |
|
||||||
|
| 2.4 | 删除方格内部分文字(如删去"先生") | 表单字段同步回退为"李四" |
|
||||||
|
|
||||||
|
### 用例 3:报告编辑端 — 表单 → 富文本单向联动
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 3.1 | 在右侧【基本信息】表单的"患者姓名"字段中输入"王五" | 编辑器内"姓名"方格的内容**自动同步为"王五"** |
|
||||||
|
| 3.2 | 清空表单中的"患者姓名" | 编辑器内方格内容同步清空,但方格本身保留 |
|
||||||
|
| 3.3 | 修改"住院号"表单字段 | 仅对应"住院号"方格更新,其他控件不受影响 |
|
||||||
|
|
||||||
|
### 用例 4:双向联动并发编辑 — 光标防跳动
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 4.1 | 在方格内快速连续输入一段长文本 | 光标始终位于输入末尾,**不会出现跳到行首或方格外**的现象 |
|
||||||
|
| 4.2 | 同时通过右侧表单修改同一字段的值 | 若当前焦点在方格内,表单修改不应打断当前输入;失焦后方格内容正确更新 |
|
||||||
|
|
||||||
|
### 用例 5:数组类型字段同步
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 5.1 | 在模板中插入「手术者」控件 | 控件正常插入,标签锁定 |
|
||||||
|
| 5.2 | 在右侧表单中选择多个手术者(如"张医生"、"李医生") | 编辑器内"手术者"方格显示为"张医生, 李医生" |
|
||||||
|
| 5.3 | 在右侧表单中取消部分选择 | 方格内容同步更新,逗号分隔格式保持正确 |
|
||||||
|
|
||||||
|
### 用例 6:打印样式适配
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 6.1 | 在编辑器中填充若干智能控件 | 编辑态下方格带有浅灰色边框和圆角 |
|
||||||
|
| 6.2 | 点击"打印"按钮或进入打印预览 | 打印预览中,方格边框消失,变为黑色下划线填空风格 |
|
||||||
|
|
||||||
|
### 用例 7:路由切换与草稿保存
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 7.1 | 在 `/report-editor` 中编辑报告,填写带有智能控件的内容 | — |
|
||||||
|
| 7.2 | 切换到 `/report-manage`,再返回 `/report-editor` | 编辑器内容、基本信息表单值、智能控件内的数据**全部完整保留** |
|
||||||
|
| 7.3 | 检查 localStorage draft | `reportEditorDraft_{username}` 中 HTML 包含完整的 `data-bind` 属性 |
|
||||||
|
|
||||||
|
### 用例 8:老模板兼容性
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 8.1 | 打开一个未使用智能控件的老模板(纯文本占位符) | 报告编辑器正常加载,无报错 |
|
||||||
|
| 8.2 | 在老模板中正常编辑文字 | 文字编辑和表单保存功能不受影响 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] `TemplateManage` 右侧正确显示字段库面板,点击可插入控件。
|
||||||
|
- [ ] 插入控件的 Label 不可单独编辑或删除,Value 方格可正常输入。
|
||||||
|
- [ ] `ReportEditor` 中方格输入时,右侧表单字段实时同步更新。
|
||||||
|
- [ ] 右侧表单字段修改时,编辑器内对应方格实时同步更新。
|
||||||
|
- [ ] 快速输入过程中光标稳定,无跳动或焦点丢失。
|
||||||
|
- [ ] 数组字段(手术者/助手)在方格中正确显示为逗号分隔字符串。
|
||||||
|
- [ ] 打印预览中方格样式变为下划线风格,符合 A4 报告规范。
|
||||||
|
- [ ] 路由切换后,智能控件内的数据不丢失。
|
||||||
|
- [ ] 老模板无智能控件时,现有编辑功能不受影响。
|
||||||
|
- [ ] `npm run lint` 类型检查通过,无编译错误。
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
手工浏览器验证为主,结合 DevTools 观察 DOM 结构和 `data-bind` 属性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||||
311
工程分析/经验记录.md
Normal file
311
工程分析/经验记录.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# 经验记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 1:report-editor 新建报告时显示空白模板
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。
|
||||||
|
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。
|
||||||
|
3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。
|
||||||
|
4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。
|
||||||
|
2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。
|
||||||
|
3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。
|
||||||
|
- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。
|
||||||
|
- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 2:关键帧一键插入占位符功能实现
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。
|
||||||
|
2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。
|
||||||
|
3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。
|
||||||
|
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。
|
||||||
|
- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 3:关键帧 "插入" 按钮位置与样式优化
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。
|
||||||
|
2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。
|
||||||
|
3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。
|
||||||
|
- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。
|
||||||
|
- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部)
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。
|
||||||
|
2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。
|
||||||
|
3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。
|
||||||
|
4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
||||||
|
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
||||||
|
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 5:路由切换后视频分析图片丢失
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。
|
||||||
|
2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。
|
||||||
|
3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。
|
||||||
|
4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。
|
||||||
|
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。
|
||||||
|
- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 6:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
1. 在 `/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`,**报告内容变空、基本信息清空、视频分析数据全部丢失**。
|
||||||
|
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft,覆盖了用户已有的数据。
|
||||||
|
2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。
|
||||||
|
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
|
||||||
|
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。
|
||||||
|
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。
|
||||||
|
- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview)
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI;
|
||||||
|
2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署;
|
||||||
|
3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程;
|
||||||
|
2. 执行 `npm run build` 重新构建生产包;
|
||||||
|
3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器;
|
||||||
|
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本;
|
||||||
|
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱;
|
||||||
|
- 如需固定端口,可在 `package.json` 的 `preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 8:路由切换后所有内容仍然丢失——彻底重构自动保存机制
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
在 `/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。
|
||||||
|
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []`、`capturedFrames: []`、默认 `reportData`)。
|
||||||
|
3. 组件卸载(cleanup)时调用保存,用这个空值**覆盖了 localStorage 中已有的正确 draft**。
|
||||||
|
4. 重新挂载后,系统读取了被清空的 draft,导致所有数据全部丢失。
|
||||||
|
5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current` 和 `stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId`、`reportId`,确保闭包永远绑定当前渲染周期的最新 state。
|
||||||
|
2. **重构自动保存 effect**:将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`,effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载,cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。
|
||||||
|
3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
|
||||||
|
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
|
||||||
|
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
|
||||||
|
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 9:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
在 `/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`:
|
||||||
|
- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失;
|
||||||
|
- 视频分析面板中的自动关键帧和手动截图全部丢失。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state(如 `capturedFrames`、`videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。
|
||||||
|
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。
|
||||||
|
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **重构 `saveDraftToStorage` 从 Ref 读取**:
|
||||||
|
- `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`。
|
||||||
|
- `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。
|
||||||
|
- `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。
|
||||||
|
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 对于需要在异步操作或组件卸载时读取的"最新状态",**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
|
||||||
|
- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。
|
||||||
|
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。
|
||||||
|
- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景(React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`:
|
||||||
|
```tsx
|
||||||
|
if ((settings.autoInsertDelay || 0) > 0) {
|
||||||
|
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。
|
||||||
|
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。
|
||||||
|
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。
|
||||||
|
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
|
||||||
|
- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9:
|
||||||
|
```tsx
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
```
|
||||||
|
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。
|
||||||
|
2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
|
||||||
|
```typescript
|
||||||
|
} catch {
|
||||||
|
// ignore quota exceeded
|
||||||
|
}
|
||||||
|
```
|
||||||
|
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **压缩关键帧分辨率与质量**:
|
||||||
|
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
|
||||||
|
```tsx
|
||||||
|
const MAX_WIDTH = 800;
|
||||||
|
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||||||
|
canvas.width = video.videoWidth * scale;
|
||||||
|
canvas.height = video.videoHeight * scale;
|
||||||
|
```
|
||||||
|
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
|
||||||
|
- 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。
|
||||||
|
|
||||||
|
2. **增加存储错误可见性**:
|
||||||
|
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`:
|
||||||
|
```typescript
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
|
||||||
|
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
||||||
|
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
|
||||||
|
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 12:contentEditable 中实现标签锁定与输入方格的双向绑定
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
需要在 `ReportEditor` 和 `TemplateManage` 的富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧【基本信息】表单双向联动。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。若仅用样式区分的普通 `<span>`,用户仍可通过退格键将"姓名:"删掉一半或改乱。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
采用三层嵌套 HTML 结构:
|
||||||
|
1. **外层** `<span class="smart-field-wrapper" contenteditable="false">`:作为不可编辑的框架,确保整个控件不会被内部逐字删除。
|
||||||
|
2. **标签层** `<span class="field-label">`:显示固定文本如"姓名:",受外层保护。
|
||||||
|
3. **输入层** `<span class="field-value" contenteditable="true" data-bind="patientName">`:允许用户输入,并通过 `data-bind` 属性建立与 `reportData` 的映射关系。
|
||||||
|
|
||||||
|
双向绑定逻辑:
|
||||||
|
- **富文本 → 表单**:在 `handleEditorInput` 中通过 `e.target.hasAttribute('data-bind')` 判断输入源,实时更新 `reportData`。
|
||||||
|
- **表单 → 富文本**:在 `useEffect` 中监听 `reportData` 变化,仅当 `el.innerText !== newValue` 时才重写 DOM,防止光标跳动。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 `<span>`。
|
||||||
|
- 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。
|
||||||
|
- 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。
|
||||||
50
工程分析/需求分析-2026-04-16-22-23-02.md
Normal file
50
工程分析/需求分析-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 需求分析 — 2026-04-16-22-23-02
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
在 `TemplateManage` 模块新增**字段库功能**,实现在手术记录模板中插入具备**"标签锁定、内容可调"**特性的智能占位方格。建立报告正文(`ReportEditor` 富文本编辑器)与右侧"基本信息"表单之间的**双向数据绑定映射**。确保模板固定文本(如"姓名:")在报告编辑端不被误删,同时实现文档内容与结构化表单的同步联动录入。
|
||||||
|
|
||||||
|
## 需求拆解
|
||||||
|
|
||||||
|
### 功能点
|
||||||
|
|
||||||
|
1. **模板编辑端 (`TemplateManage.tsx`)**
|
||||||
|
- 新增右侧"表单字段库"侧边栏,列出所有可映射字段(姓名、性别、年龄、住院号、手术者等)。
|
||||||
|
- 点击字段库中的字段按钮,在编辑器光标处插入一个特殊 HTML 占位控件。
|
||||||
|
- 占位控件结构要求:
|
||||||
|
- `Label`(如"姓名:")**锁定不可编辑**,且不能被单独删除或篡改。
|
||||||
|
- `Value`(方格区域)**允许用户输入**,并与右侧表单字段双向绑定。
|
||||||
|
|
||||||
|
2. **报告编辑端 (`ReportEditor.tsx`)**
|
||||||
|
- **富文本 → 表单**:用户在编辑器占位方格内输入内容时,自动同步更新右侧【基本信息】对应字段的表单值。
|
||||||
|
- **表单 → 富文本**:用户在右侧【基本信息】表单中修改内容时,自动同步更新编辑器内对应占位方格的内容。
|
||||||
|
- 同步时必须处理光标跳动问题,保证输入体验流畅。
|
||||||
|
|
||||||
|
3. **打印适配**
|
||||||
|
- 打印时占位方格的边框样式需要适配 A4 报告风格(如下划线填空或去边框)。
|
||||||
|
|
||||||
|
4. **老数据兼容**
|
||||||
|
- 现有模板中的纯文本占位符(如 `姓名:<span style="color: #ff0000;">*姓名*</span>`)不会被自动转换。
|
||||||
|
- 仅当管理员在 `TemplateManage` 中重新编辑模板并插入新控件后,才激活联动能力。
|
||||||
|
|
||||||
|
### 非功能点
|
||||||
|
|
||||||
|
- 不引入第三方富文本编辑器,继续基于现有 `contentEditable` + `document.execCommand` 方案。
|
||||||
|
- 最小化对 `storage.ts` 数据结构的侵入。
|
||||||
|
- 保持现有 `npm run lint` 类型检查通过。
|
||||||
|
- 多选字段(如 `surgeon` 数组)在方格中展示为逗号分隔字符串。
|
||||||
|
|
||||||
|
## 影响范围预估
|
||||||
|
|
||||||
|
| 模块 | 影响程度 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `src/pages/TemplateManage.tsx` | 高 | 新增字段库侧边栏、插入控件逻辑 |
|
||||||
|
| `src/pages/ReportEditor.tsx` | 高 | 双向绑定监听(`onInput` + `useEffect`)、DOM 同步 |
|
||||||
|
| `src/utils/print.ts` | 中 | 打印样式适配(`@media print` 下 `.field-value` 样式) |
|
||||||
|
| `src/index.css` | 中 | 新增 `.smart-field-wrapper`、`.field-label`、`.field-value` 的编辑态/打印态样式 |
|
||||||
|
| `src/types.ts` | 低 | 可能需要扩展字段映射常量/类型定义 |
|
||||||
|
| `src/utils/defaultContent.ts` | 低 | 可选:将默认模板中的部分占位符替换为智能控件 |
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
无。用户已提供详细需求描述及技术实现思路,可直接进入实现方案设计阶段。
|
||||||
Reference in New Issue
Block a user