2026-04-18-16-55-47 - 报告编辑器field-value点击联动、字段动态排序、默认模板手术图片表格替换

This commit is contained in:
Administrator
2026-04-18 17:01:18 +08:00
parent a46ecffadf
commit 67fb2c9080
5 changed files with 261 additions and 18 deletions

View File

@@ -377,6 +377,27 @@ export default function ReportEditor() {
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
// Handle click on field-value: switch to info tab and focus corresponding input
const fieldValue = targetEl.closest('.field-value') as HTMLElement | null;
if (fieldValue) {
const bindKey = fieldValue.getAttribute('data-bind');
if (bindKey) {
setActiveTab('info');
stateRef.current = { ...stateRef.current, activeTab: 'info' };
setTimeout(() => {
const inputEl = document.getElementById(`input-${bindKey}`);
if (inputEl) {
inputEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = inputEl.querySelector('input, select') as HTMLElement | null;
if (focusable) {
focusable.focus();
}
}
}, 100);
}
return;
}
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
@@ -1366,14 +1387,30 @@ export default function ReportEditor() {
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
{formFields.filter(f => f.visibleInForm).map(field => {
{(() => {
const topKeys = ['patientName', 'hospitalId', 'title'];
const contentHtml = contentRef.current || editorRef.current?.innerHTML || '';
return [...formFields.filter(f => f.visibleInForm)].sort((a, b) => {
const aTop = topKeys.indexOf(a.key);
const bTop = topKeys.indexOf(b.key);
if (aTop !== -1 && bTop !== -1) return aTop - bTop;
if (aTop !== -1) return -1;
if (bTop !== -1) return 1;
const aIndex = contentHtml.indexOf(`data-bind="${a.key}"`);
const bIndex = contentHtml.indexOf(`data-bind="${b.key}"`);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
})().map(field => {
const isRequired = field.isSystemLocked;
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} className={field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'}>
<div key={field.key} id={`input-${field.key}`} className={field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'}>
<label className="block text-xs font-bold text-text-main">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
@@ -1393,7 +1430,7 @@ export default function ReportEditor() {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
return (
<div key={field.key} className="space-y-1 select-dropdown-root relative">
<div key={field.key} id={`input-${field.key}`} className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
@@ -1502,7 +1539,7 @@ export default function ReportEditor() {
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
return (
<div key={field.key} className="space-y-1 select-dropdown-root relative">
<div key={field.key} id={`input-${field.key}`} className="space-y-1 select-dropdown-root relative">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
@@ -1583,7 +1620,7 @@ export default function ReportEditor() {
const { h: h12, isPM } = from24h(h24val);
return (
<div key={field.key} className="space-y-1">
<div key={field.key} id={`input-${field.key}`} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
@@ -1640,7 +1677,7 @@ export default function ReportEditor() {
const { h: h12g, isPM: isPMg } = from24h(h24);
return (
<div key={field.key} className="space-y-1">
<div key={field.key} id={`input-${field.key}`} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select

View File

@@ -87,47 +87,47 @@ export const defaultReportContent = `
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
<tbody><tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
</td>
</tr>
<tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
</td>
</tr></tbody>

View File

@@ -0,0 +1,56 @@
# 实现方案 —— 2026-04-18-16-55-47
## 方案目标
实现 report-editor 中正文与侧边栏的点击联动、字段动态排序、以及默认模板的手术图片表格替换。
## 需求 1点击 field-value 联动右侧基本信息
### 实现步骤
1. **修改 `handleEditorClick`**:在 `ReportEditor.tsx``handleEditorClick` 函数中,增加对 `.field-value` 的点击捕获。
- 通过 `e.target.closest('.field-value')` 获取被点击的 field-value 元素。
- 读取其 `data-bind` 属性值(如 `patientName`)。
2. **切换 Tab**:调用 `setActiveTab('info')` 将右侧面板切回「基本信息」。
3. **聚焦与滚动**
- 为右侧表单中的每个输入组件增加 `id={\`input-\${field.key}\`}`。
- 使用 `setTimeout` 等待 React DOM 渲染完成后,通过 `document.getElementById(\`input-\${bindKey}\`)` 获取对应元素。
- 调用 `scrollIntoView({ behavior: 'smooth', block: 'center' })``focus()`
## 需求 2右侧基本信息字段按正文出现顺序动态排序
### 实现步骤
1. **提取正文字段顺序**
- 使用 `contentRef.current`(当前编辑器 HTML 字符串)或 `editorRef.current?.innerHTML`
-`formFields.filter(f => f.visibleInForm)` 中的每个非置顶字段,计算 `data-bind="${field.key}"` 在 HTML 中的首次出现位置(`indexOf`)。
2. **排序策略**
- **置顶组**`const topKeys = ['patientName', 'hospitalId', 'title'];`,按此固定顺序排列。
- **正文组**:非置顶字段,按 `indexOf` 升序排列(越早出现越靠前)。
- **末尾组**:正文中未出现的字段(`indexOf === -1`),统一排在最后,保持原有相对顺序。
3. **渲染表单**:将排序后的字段数组直接用于右侧表单 `.map()` 渲染。
### 性能优化
- 使用 `useMemo` 缓存排序结果,仅在 `formFields` 或编辑器内容变化时重新计算。
- 排序逻辑放在 `useMemo` 中,避免每次渲染重复计算。
## 需求 3替换默认手术图片说明表格
### 实现步骤
1. 定位 `src/utils/defaultContent.ts` 中的 `defaultReportContent`
2. 找到 `<!-- 手术图片说明表格 -->` 注释所在的 `<table>` 区域。
3. 替换为用户提供的 HTML 代码:
- 2 行 × 3 列布局
- 每格包含 `.image-placeholder`(表格内模式:`<div>` 块级容器,`width:100%; height:100%; max-width:200px; max-height:200px;`
- 每格底部含图注图A~图F
- 保留 `data-placeholder="true"``contenteditable="false"`
4. 清理复制时产生的冗余内联样式(如 `background-image: initial` 等),保留功能必需的样式。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/ReportEditor.tsx` | `handleEditorClick` 增加 field-value 点击捕获;表单渲染增加 `id`;右侧字段排序逻辑 |
| `src/utils/defaultContent.ts` | 替换手术图片说明表格 HTML |
## 风险与注意事项
1. `contentRef.current` 在组件首次挂载前可能为空,排序逻辑需做空值保护。
2. `setActiveTab` 后 DOM 切换有短暂延迟,`scrollIntoView` 需包裹在 `setTimeout` 中。
3. 默认模板替换后,需验证新建报告时表格渲染是否正常、占位符点击事件是否生效。
4. 置顶字段的 `key` 名称需与 `DEFAULT_FORM_FIELDS` 中严格一致。

View File

@@ -0,0 +1,123 @@
# 测试方案 —— 2026-04-18-16-55-47
## 测试目标
验证 report-editor 的三项改进是否正确实现field-value 点击联动、右侧字段动态排序、默认模板表格替换。
## 测试用例
### TC-01点击正文 field-value 切换至基本信息 Tab 并聚焦
**前置条件**:进入 /report-editor加载默认模板右侧当前在「视频分析」Tab
**操作步骤**
1. 点击报告正文中「姓名」后的 field-value 方格
2. 观察右侧 Tab 切换
3. 观察页面滚动位置
**预期结果**
- 右侧 Tab 自动从「视频分析」切换为「基本信息」
- 页面平滑滚动至「患者姓名」输入框位置
- 「患者姓名」输入框获得焦点
---
### TC-02点击不同 field-value 聚焦对应不同表单字段
**前置条件**report-editor 已加载模板
**操作步骤**
1. 点击正文中的「住院号」field-value
2. 点击正文中的「手术名称」field-value
3. 点击正文中的「手术日期」field-value
**预期结果**
- 每次点击后右侧均切换至「基本信息」Tab
- 对应字段输入框均被聚焦并滚动至可视区域
---
### TC-03置顶字段顺序验证
**前置条件**report-editor 右侧显示基本信息表单
**操作步骤**
1. 查看右侧表单字段的从上到下顺序
**预期结果**
- 第1个字段为「患者姓名」
- 第2个字段为「住院号」
- 第3个字段为「手术名称」
- 这三个字段始终固定在最上方
---
### TC-04动态排序验证——按正文出现顺序
**前置条件**:默认模板中正文字段有固定出现顺序
**操作步骤**
1. 查看右侧表单第4个及之后的字段顺序
2. 对比正文中 `data-bind` 的首次出现顺序
**预期结果**
- 右侧第4个及之后的字段顺序与正文中 `data-bind` 首次出现的先后顺序一致
- 正文中越靠前的字段,在右侧表单中也越靠前
---
### TC-05动态排序验证——修改正文后排序更新
**前置条件**report-editor 中已加载默认模板
**操作步骤**
1. 将正文中某个靠后的字段(如「术后诊断」)剪切并粘贴到正文开头
2. 观察右侧表单字段顺序变化
**预期结果**
- 「术后诊断」在右侧表单中的位置相应提前
- 排序随正文内容变化实时更新
---
### TC-06默认模板手术图片表格验证
**前置条件**:新建报告或重置系统后进入 report-editor
**操作步骤**
1. 查看编辑器中的「手术图片说明表格」
2. 检查每个单元格内容
**预期结果**
- 表格为 2 行 × 3 列布局
- 每格包含 `image-placeholder` 占位符
- 每格底部有对应图注图A~图F
- 占位符可正常点击上传图片
---
### TC-07表格内占位符图片上传
**前置条件**:默认模板已加载
**操作步骤**
1. 点击表格中某个 `image-placeholder`
2. 在弹窗中选择本地上传一张图片
3. 确认图片正确填充到占位符中
**预期结果**
- 弹窗正常出现(三选一:本地上传/我的签名/系统素材)
- 图片正确显示在占位符内
- 图片不溢出单元格边界
---
### TC-08新建报告默认内容完整性
**前置条件**:退出并重新登录,确保系统使用默认模板
**操作步骤**
1. 进入 /report-editor新建报告
2. 检查整个报告内容
**预期结果**
- 报告头部 Logo 和标题正常
- 基本信息段落正常
- 手术步骤段落正常
- 手术图片说明表格为新模板
- 手术后情况段落正常
- 底部撰写时间字段正常
---
## 回归测试范围
- 验证 `image-placeholder` 的拖拽填充、点击上传、删除功能不受影响
- 验证右侧 Tab 手动切换(「基本信息」↔「视频分析」)正常
- 验证 `smart-field-wrapper` 的双向绑定(表单→正文、正文→表单)正常
- 验证打印功能中表格和图片正常显示
## 测试结论
以上 TC-01~TC-08 全部通过,即可确认三项需求均正确实现。

View File

@@ -0,0 +1,27 @@
# 需求分析 —— 2026-04-18-16-55-47
## 需求来源
用户直接提出三项 report-editor 相关改进需求。
## 需求概述
### 需求 1点击正文 field-value 联动右侧基本信息
`report-editor` 中,点击报告正文内 `class="field-value"` 的元素时,自动将右侧面板切换至「基本信息」栏目,并聚焦/滚动到该字段对应的表单输入框。
### 需求 2右侧基本信息字段按正文出现顺序动态排序
右侧「基本信息」栏目中:
- **固定置顶**:患者姓名 (`patientName`)、住院号 (`hospitalId`)、手术名称 (`title`) 始终排在最上方,顺序固定。
- **动态排序**:其余字段按其在报告正文 HTML 中 `data-bind` 出现的先后顺序排列。
- **兜底处理**:正文中未出现的字段排在末尾。
### 需求 3替换默认模板中的手术图片说明表格
`src/utils/defaultContent.ts` 中的 `<!-- 手术图片说明表格 -->` 默认模板替换为用户提供的 6 图格 HTML 代码(含腹腔镜探查、胆囊管夹闭与离断、胆囊动脉夹闭与离断、胆囊剥离与床面止血、胆囊取出与钛夹确认、止血材料覆盖及检查)。
## 涉及文件
- `src/pages/ReportEditor.tsx`(需求 1、2
- `src/utils/defaultContent.ts`(需求 3
## 需求影响范围
- 报告编辑器交互体验
- 右侧基本信息面板渲染逻辑
- 默认报告模板内容