Merge remote V1.2.0 and add local V1.2.1
This commit is contained in:
229
.agents/skills/medical-report-dev-workflow/SKILL.md
Normal file
229
.agents/skills/medical-report-dev-workflow/SKILL.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: medical-report-dev-workflow
|
||||
description: |
|
||||
手术图文病历报告系统(Gemini-图文报告系统)的强制性项目修改工作流。
|
||||
当用户提出任何与该项目相关的代码修改、功能开发、Bug 修复、性能优化、UI 调整、
|
||||
需求变更等任务时,**必须严格按此工作流逐步执行,严禁跳过或合并步骤**。
|
||||
不适用于纯问答、查询信息、阅读文档或不涉及代码/文件修改的任务。
|
||||
---
|
||||
|
||||
# 手术图文病历报告系统 — 项目修改工作流(v2.0)
|
||||
|
||||
> 本文档是 AI 编码代理执行项目修改需求时的**强制性规范**。任何涉及代码变更的任务必须按以下 7 个步骤逐一执行,每一步有明确的停止/确认点,严禁跳过。
|
||||
|
||||
---
|
||||
|
||||
## 前置检查
|
||||
|
||||
- **确认任务类型**:如果用户请求不涉及代码/配置/文件修改(如纯提问、查资料、解释概念),则**不启用**此工作流。
|
||||
- **确认当前工作目录**:所有路径均相对于项目根目录 `C:\Users\Administrator\Downloads\Gemini-图文报告系统-V1.2`。
|
||||
|
||||
---
|
||||
|
||||
## Step 0:记录时间戳
|
||||
|
||||
**任务开始后的第一件事**:获取当前系统时间并格式化为 `YYYY-MM-DD-HH-MM-SS`(示例:`2026-04-17-22-53-01`)。
|
||||
|
||||
- 将该时间戳保存到工作上下文中(变量名建议:`workflowTimestamp`)。
|
||||
- **所有后续产出的文档文件名必须以此时间戳结尾**。
|
||||
|
||||
---
|
||||
|
||||
## Step 1:工程分析目录
|
||||
|
||||
1. 检查并确认项目根目录下存在文件夹:
|
||||
```
|
||||
.\工程分析\
|
||||
```
|
||||
2. 若不存在,则立即创建。
|
||||
3. 同时检查该目录下是否存在 `经验记录.md`;若不存在,创建一个空文件并在顶部写入 `# 经验记录`。
|
||||
|
||||
---
|
||||
|
||||
## Step 2:需求分析文档
|
||||
|
||||
1. 仔细阅读用户提出的原始需求,将其拆解为:
|
||||
- **功能点**:具体要实现什么
|
||||
- **非功能点**:性能、兼容性、向后兼容、安全性等要求
|
||||
- **影响范围**:预估需要修改的模块/文件及影响程度
|
||||
- **待确认问题**:如有需求模糊之处,列出请用户澄清
|
||||
2. 将分析结果写入文件:
|
||||
```
|
||||
.\工程分析\需求分析-{workflowTimestamp}.md
|
||||
```
|
||||
3. 文档模板:
|
||||
```markdown
|
||||
# 需求分析 — {workflowTimestamp}
|
||||
|
||||
## 原始需求摘要
|
||||
(用户原话或提炼后的核心诉求)
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- F1:...
|
||||
- F2:...
|
||||
|
||||
### 非功能点
|
||||
-
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| | | |
|
||||
|
||||
## 待确认问题
|
||||
(如有,列出需要用户确认的内容)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3:实现方案文档
|
||||
|
||||
1. 基于需求分析,设计详细的实现方案:
|
||||
- **根因分析**(如果是 Bug 修复,必须说明根因)
|
||||
- **修改文件清单**(精确到文件路径)
|
||||
- **具体代码变更**(含关键代码的前后对比或伪代码)
|
||||
- **风险点与应对措施**
|
||||
- **回滚策略**
|
||||
2. 将方案写入文件:
|
||||
```
|
||||
.\工程分析\实现方案-{workflowTimestamp}.md
|
||||
```
|
||||
3. **🛑 强制停止点**:向用户展示实现方案文档内容,并请求人工审核确认。
|
||||
- 标准提示语:
|
||||
> 实现方案已完成,请审核 `.\工程分析\实现方案-{workflowTimestamp}.md`。确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。
|
||||
- **在得到用户明确确认(如回复"确认"、"同意"、"OK")之前,禁止进入下一步。**
|
||||
|
||||
---
|
||||
|
||||
## Step 4:测试方案文档
|
||||
|
||||
1. 基于已审核通过的实现方案,设计测试方案:
|
||||
- **测试目标**
|
||||
- **测试环境**
|
||||
- **测试用例**(步骤、操作、预期结果,以表格形式呈现)
|
||||
- **验收标准**(勾选列表 `\- [ ]` 形式)
|
||||
- **测试方式**(手工 / 自动化,本项目目前无自动化测试框架,通常为手工验证)
|
||||
2. 将方案写入文件:
|
||||
```
|
||||
.\工程分析\测试方案-{workflowTimestamp}.md
|
||||
```
|
||||
3. **🛑 强制停止点**:向用户展示测试方案文档内容,并请求人工审核确认。
|
||||
- 标准提示语:
|
||||
> 测试方案已完成,请审核 `.\工程分析\测试方案-{workflowTimestamp}.md`。确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。
|
||||
- **在得到用户明确确认之前,禁止进入下一步。**
|
||||
|
||||
---
|
||||
|
||||
## Step 5:执行修改 & 经验记录
|
||||
|
||||
### 5.1 执行前必读
|
||||
|
||||
在动手修改任何代码之前,**必须阅读**:
|
||||
```
|
||||
.\工程分析\经验记录.md
|
||||
```
|
||||
|
||||
- 将该文档中的关键经验教训纳入本次执行的注意事项。
|
||||
- 尤其关注与本次修改相关的历史踩坑点,防止重复犯错。
|
||||
|
||||
### 5.2 执行修改
|
||||
|
||||
1. 严格按照已审核通过的实现方案修改代码。
|
||||
2. 每次修改后应进行必要的验证(如 `npm run lint`、本地编译检查)。
|
||||
3. 如果在执行过程中遇到**任何未在实现方案中预料到的问题**(包括修改范围扩大、发现新的 Bug、环境异常、方案假设不成立等),必须:
|
||||
- 先解决问题
|
||||
- 然后将问题及解决方案记录到 `经验记录.md` 中(见 5.3)
|
||||
|
||||
### 5.3 更新经验记录
|
||||
|
||||
执行完成后,检查本次过程中是否出现了值得记录的关键问题。如果有,在 `.\工程分析\经验记录.md` 中**追加**记录,使用**统一的四段式格式**:
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
## 记录 N:标题(简短概括)
|
||||
|
||||
**A. 具体问题**
|
||||
(清晰描述现象,包括复现步骤)
|
||||
|
||||
**B. 产生问题原因**
|
||||
(根因分析,可多条,避免表面归因)
|
||||
|
||||
**C. 解决问题方案**
|
||||
(具体修改了哪些文件、哪些代码、什么逻辑)
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
(可执行的建议,供未来需求参考)
|
||||
```
|
||||
|
||||
- 记录编号按顺序递增(如记录 1、记录 2……)。
|
||||
- 如果 `经验记录.md` 原本为空或不存在,创建后顶部加 `# 经验记录`,然后写入第一条记录。
|
||||
|
||||
---
|
||||
|
||||
## Step 6:Gitea 备份
|
||||
|
||||
### 6.1 首次备份(仓库未初始化时)
|
||||
|
||||
如果项目根目录下**没有 `.git` 目录**或没有配置 remote,执行:
|
||||
```bash
|
||||
git init
|
||||
git checkout -b main
|
||||
git add README.md
|
||||
# 若 README.md 不存在,先创建:echo "# Medical Sur Report" > README.md
|
||||
git commit -m "first commit"
|
||||
git remote add origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 6.2 日常备份(已有仓库时)
|
||||
|
||||
执行以下 Git 操作,将本次工作流产出的文档(工程分析目录)备份到 Gitea:
|
||||
```bash
|
||||
git add .\工程分析\
|
||||
git commit -m "{workflowTimestamp} - {本次修改的简要描述}"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- **简要描述**应概括本次修改的核心内容(如 "修复路由切换后关键帧丢失问题"、"新增自动帧插入非阻塞优化" 等),控制在 50 字以内。
|
||||
- 如果同时修改了源代码,也应将源码变更一并 `git add` 并提交。
|
||||
|
||||
### 6.3 备份完成提醒
|
||||
|
||||
备份完成后,**必须向用户明确提醒**:
|
||||
> 本次工作流相关文档(及代码修改)已备份到 Gitea,commit 信息为:`{workflowTimestamp} - {简要描述}`。
|
||||
|
||||
---
|
||||
|
||||
## 禁忌清单(严禁事项)
|
||||
|
||||
- ❌ **严禁跳过实现方案审核直接进入代码修改。**
|
||||
- ❌ **严禁跳过测试方案审核直接执行测试或部署。**
|
||||
- ❌ **严禁未阅读 `经验记录.md` 就动手改代码。**
|
||||
- ❌ **严禁执行完成后不更新 `经验记录.md`(如有新问题)。**
|
||||
- ❌ **严禁执行完成后不进行 Gitea 备份。**
|
||||
- ❌ **严禁在需求分析、实现方案、测试方案文档中使用不明确的描述**,应具体到文件路径、函数名、CSS 类名。
|
||||
- ❌ **严禁在 Step 3 / Step 4 的用户确认阶段擅自推进**,必须等待用户明确回复。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考:常用命令
|
||||
|
||||
```bash
|
||||
# 类型检查(修改后必须执行)
|
||||
npm run lint
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
|
||||
# 启动预览服务
|
||||
npm run preview -- --host 0.0.0.0
|
||||
|
||||
# Gitea 日常备份
|
||||
git add .\工程分析\
|
||||
git commit -m "{timestamp} - {描述}"
|
||||
git push origin main
|
||||
```
|
||||
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` 的新增分支,保留原有草稿保存逻辑即可恢复。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
86
工程分析/实现方案-2026-04-16-22-35-38.md
Normal file
86
工程分析/实现方案-2026-04-16-22-35-38.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 实现方案 — 2026-04-16-22-35-38
|
||||
|
||||
## 根因分析
|
||||
|
||||
上一版本虽然实现了 `smart-field-wrapper` 的插入逻辑和双向绑定,但存在两个问题:
|
||||
1. **`BINDABLE_FIELDS` key 与 `Report` 接口不一致**:例如 `gender`、`age` 在 `Report` 接口中实际为 `patientGender`、`patientAge`,导致 `ReportEditor` 中 `reportData[fieldKey]` 读取到 `undefined`,双向绑定不生效。
|
||||
2. **默认模板未使用智能控件**:`defaultContent.ts` 中仍使用红色纯文本占位符(`*姓名*` 等),新建报告时即使字段映射正确,模板里也没有 `data-bind` 节点,联动能力无法被激活。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 修改 | 修正 `BINDABLE_FIELDS` key,移除不存在的字段 |
|
||||
| `src/utils/defaultContent.ts` | 修改 | 将所有占位符替换为 `smart-field-wrapper` HTML |
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:`src/types.ts` — 修正并精简 `BINDABLE_FIELDS`
|
||||
|
||||
**替换为:**
|
||||
|
||||
```typescript
|
||||
export const BINDABLE_FIELDS: BindableField[] = [
|
||||
{ key: 'patientName', label: '姓名' },
|
||||
{ key: 'patientGender', label: '性别' },
|
||||
{ key: 'patientAge', label: '年龄' },
|
||||
{ key: 'department', label: '科别' },
|
||||
{ key: 'bedNumber', label: '床号' },
|
||||
{ key: 'hospitalId', label: '住院号' },
|
||||
{ key: 'surgeryDate', label: '手术日期' },
|
||||
{ key: 'title', label: '手术名称' },
|
||||
{ key: 'surgeon', label: '手术者' },
|
||||
{ key: 'assistant', label: '助手' },
|
||||
{ key: 'anesthesiologist', label: '麻醉师' },
|
||||
{ key: 'anesthesiaType', label: '麻醉方式' },
|
||||
];
|
||||
```
|
||||
|
||||
### 变更 2:`src/utils/defaultContent.ts` — 占位符替换为智能控件
|
||||
|
||||
定义一个辅助函数(仅用于生成字符串),将占位符替换为智能控件 HTML:
|
||||
|
||||
```typescript
|
||||
const smartField = (key: string, label: string) => `
|
||||
<span class="smart-field-wrapper" contenteditable="false">
|
||||
<span class="field-label">${label}:</span>
|
||||
<span class="field-value" data-bind="${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>
|
||||
`;
|
||||
```
|
||||
|
||||
**将默认模板中的以下占位符替换:**
|
||||
|
||||
| 原占位符 | 替换为 |
|
||||
|---------|--------|
|
||||
| `姓名:<span style="color: #ff0000;">*姓名*</span>` | `smartField('patientName', '姓名')`(去掉冒号,因为标签自带冒号) |
|
||||
| `性别: <span style="color: #ff0000;">*性别*</span>` | `smartField('patientGender', '性别')` |
|
||||
| `年龄:<span style="color: #ff0000;">*年龄*</span>` | `smartField('patientAge', '年龄')` |
|
||||
| `科别:<span style="color: #ff0000;">*科室*</span>` | `smartField('department', '科别')` |
|
||||
| `床号:<span style="color: #ff0000;">*床号*</span>` | `smartField('bedNumber', '床号')` |
|
||||
| `住院号:<span style="color: #ff0000;">*住院号*</span>` | `smartField('hospitalId', '住院号')` |
|
||||
| `<strong>手术日期:</strong><span style="color: #bdbdbd;">年 月 日</span>` | `<strong>手术日期:</strong>${smartField('surgeryDate', '')}` |
|
||||
| `<strong>手术名称:</strong>腹腔镜胆囊切除术` | `<strong>手术名称:</strong>${smartField('title', '')}` |
|
||||
| `手术者: <span style="color: #bdbdbd;">手术者</span>` | `手术者:${smartField('surgeon', '')}` |
|
||||
| `助手: <span style="color: #bdbdbd;">助手</span>` | `助手:${smartField('assistant', '')}` |
|
||||
| `麻醉师:<span style="color: #bdbdbd;">麻醉师</span>` | `麻醉师:${smartField('anesthesiologist', '')}` |
|
||||
| `麻醉方式: 全麻` | `麻醉方式:${smartField('anesthesiaType', '')}` |
|
||||
|
||||
> 注:当 `label` 为空字符串时,只生成 `data-bind` 的方格,不生成前置标签。对于已有中文前缀(如"手术日期:")的场景,使用空 label 更自然。
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 现有用户自定义模板仍使用旧占位符,不会自动升级 | 低 | 属于预期行为;只有新建报告时的默认模板会使用新控件 |
|
||||
| 某些字段(如 `title`)在模板加载后可能被模板名称覆盖 | 低 | `apply template` 逻辑本身就会重置 `title`,这是现有行为,不影响 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
本次修改仅涉及常量定义和默认 HTML 字符串,可随时通过 `git revert` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
446
工程分析/实现方案-2026-04-17-00-13-09.md
Normal file
446
工程分析/实现方案-2026-04-17-00-13-09.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# 实现方案 — 2026-04-17-00-13-09
|
||||
|
||||
## 根因分析
|
||||
|
||||
当前系统存在三个核心问题:
|
||||
1. **时间字段未联动**:`defaultContent.ts` 中手术开始/终止时间是纯文本占位符,无 `data-bind`,导致右侧表单与正文内容无法同步。
|
||||
2. **表单硬编码不可扩展**:`ReportEditor.tsx` 右侧的基本信息表单是写死的 JSX,每新增一个字段都需要改代码;`TemplateManage.tsx` 的字段库也是静态数组,无法按医院实际需求自定义。
|
||||
3. **方格 UI 破坏排版**:`field-value` 使用了较大的 `min-width` 和上下 `padding`,在 `inline-block` 布局下撑大了行高,导致段落行间距明显变大。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 修改 | 新增 `FieldType`、`FormField`、`FormFieldsConfig` 类型 |
|
||||
| `src/utils/defaultContent.ts` | 修改 | 手术时间替换为 `startTime`/`endTime` 智能方格 |
|
||||
| `src/index.css` | 修改 | 优化 `.field-value` 紧凑样式 |
|
||||
| `src/utils/print.ts` | 修改 | 同步打印样式 |
|
||||
| `src/pages/TemplateManage.tsx` | 修改 | 字段库重构为 Tab 结构,支持分类、新增、显隐控制 |
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | 右侧表单动态渲染 + 时间解析拼接双向转换 |
|
||||
| `src/pages/Login.tsx` | 修改 | 首次登录时初始化默认字段配置到 localStorage |
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:`src/types.ts` — 动态字段类型定义
|
||||
|
||||
**在 `BINDABLE_FIELDS` 之后追加:**
|
||||
|
||||
```typescript
|
||||
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date';
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string; // 如 '填空'、'单选'、'多选'、'时间'
|
||||
type: FieldType;
|
||||
visibleInForm: boolean;
|
||||
isSystemLocked: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_FORM_FIELDS: FormField[] = [
|
||||
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
||||
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
|
||||
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
||||
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'department', label: '科别', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'bedNumber', label: '床号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'surgeryDate', label: '手术日期', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: false, options: ['张医生', '李医生', '王医生'] },
|
||||
{ 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: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
|
||||
];
|
||||
```
|
||||
|
||||
### 变更 2:`src/utils/defaultContent.ts` — 手术时间方框化
|
||||
|
||||
**替换手术时间相关段落:**
|
||||
|
||||
```typescript
|
||||
<p style="font-family: SimSun;">
|
||||
手术开始时间:${smartField('startTime')}
|
||||
手术终止时间:${smartField('endTime')}
|
||||
</p>
|
||||
```
|
||||
|
||||
> 注意:同时需要把 `smartField` 函数的样式字符串更新为紧凑版本(见变更 4)。
|
||||
|
||||
### 变更 3:`src/utils/defaultContent.ts` — 更新 `smartField` 紧凑样式
|
||||
|
||||
**替换现有的 `smartField` 函数:**
|
||||
|
||||
```typescript
|
||||
const smartField = (key: string) => `
|
||||
<span class="smart-field-wrapper" contenteditable="false">
|
||||
<span class="field-value"
|
||||
data-bind="${key}"
|
||||
contenteditable="true"
|
||||
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
```
|
||||
|
||||
### 变更 4:`src/index.css` — 同步优化 `.field-value` 样式
|
||||
|
||||
**在 `.smart-field-wrapper` 相关样式区块中更新 `.field-value`:**
|
||||
|
||||
```css
|
||||
.smart-field-wrapper .field-value {
|
||||
min-width: 32px;
|
||||
padding: 0 4px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
font-size: inherit;
|
||||
vertical-align: text-bottom;
|
||||
box-sizing: border-box;
|
||||
min-height: 1.2em;
|
||||
outline: none;
|
||||
}
|
||||
```
|
||||
|
||||
**打印样式同步更新:**
|
||||
|
||||
```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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 变更 5:`src/utils/print.ts` — 同步打印样式
|
||||
|
||||
在 iframe 内联 `<style>` 中,将 `.smart-field-wrapper .field-value` 的默认样式更新为紧凑版本,并保留 `@media print` 下划线样式。
|
||||
|
||||
### 变更 6:`src/pages/TemplateManage.tsx` — 字段库重构
|
||||
|
||||
**新增状态:**
|
||||
|
||||
```tsx
|
||||
const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert');
|
||||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||||
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
|
||||
const [newFieldOptions, setNewFieldOptions] = useState('');
|
||||
```
|
||||
|
||||
**初始化(在 `useEffect` 中读取/初始化配置):**
|
||||
|
||||
```tsx
|
||||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
||||
if (savedFields.length > 0) {
|
||||
setFormFields(savedFields);
|
||||
} else {
|
||||
setFormFields(DEFAULT_FORM_FIELDS);
|
||||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||||
}
|
||||
```
|
||||
|
||||
**插入字段 Tab UI:**
|
||||
|
||||
```tsx
|
||||
<div className="space-y-4">
|
||||
{['填空', '单选', '多选', '时间'].map(cat => {
|
||||
const catFields = formFields.filter(f => f.category === cat);
|
||||
if (catFields.length === 0) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="text-[10px] text-slate-400 mb-1">{cat}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catFields.map(field => (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => insertSmartField(field)}
|
||||
className="..."
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
```
|
||||
|
||||
> `insertSmartField` 函数的参数改为 `FormField`,使用 `field.key` 和 `field.label` 生成 HTML。
|
||||
|
||||
**字段管理 Tab UI:**
|
||||
|
||||
```tsx
|
||||
<div className="space-y-3">
|
||||
{formFields.filter(f => !f.isSystemLocked).map(field => (
|
||||
<div key={field.key} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-200">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-text-main">{field.label}</div>
|
||||
<div className="text-[10px] text-slate-400">{field.category} · {field.type}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-[10px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.visibleInForm}
|
||||
onChange={() => toggleFieldVisible(field.key)}
|
||||
/>
|
||||
显示
|
||||
</label>
|
||||
<button onClick={() => deleteField(field.key)} className="text-red-500 text-[10px]">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<div className="text-xs font-semibold mb-2">新增字段</div>
|
||||
<input ... />
|
||||
<select ... />
|
||||
<button onClick={addField}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**关键操作函数:**
|
||||
|
||||
```tsx
|
||||
const toggleFieldVisible = (key: string) => {
|
||||
const updated = formFields.map(f => f.key === key ? { ...f, visibleInForm: !f.visibleInForm } : f);
|
||||
setFormFields(updated);
|
||||
storage.set('formFieldsConfig', updated);
|
||||
};
|
||||
|
||||
const deleteField = (key: string) => {
|
||||
const updated = formFields.filter(f => f.key !== key);
|
||||
setFormFields(updated);
|
||||
storage.set('formFieldsConfig', updated);
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
if (!newFieldForm.label.trim()) return;
|
||||
const key = 'custom_' + Date.now();
|
||||
const newField: FormField = {
|
||||
key,
|
||||
label: newFieldForm.label.trim(),
|
||||
category: newFieldForm.category,
|
||||
type: newFieldForm.type,
|
||||
visibleInForm: true,
|
||||
isSystemLocked: false,
|
||||
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
|
||||
? newFieldOptions.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||||
: undefined
|
||||
};
|
||||
const updated = [...formFields, newField];
|
||||
setFormFields(updated);
|
||||
storage.set('formFieldsConfig', updated);
|
||||
setNewFieldForm({ label: '', category: '填空', type: 'text' });
|
||||
setNewFieldOptions('');
|
||||
};
|
||||
```
|
||||
|
||||
### 变更 7:`src/pages/ReportEditor.tsx` — 动态渲染右侧表单 + 时间联动
|
||||
|
||||
**初始化字段配置(在 `useEffect` 中):**
|
||||
|
||||
```tsx
|
||||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||||
// ...
|
||||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
||||
if (savedFields.length > 0) {
|
||||
setFormFields(savedFields);
|
||||
} else {
|
||||
setFormFields(DEFAULT_FORM_FIELDS);
|
||||
}
|
||||
```
|
||||
|
||||
**时间解析/拼接辅助函数:**
|
||||
|
||||
```tsx
|
||||
const formatTimeValue = (hour?: string, minute?: string) => {
|
||||
if (!hour && !minute) return '';
|
||||
return `${hour || ''}:${minute || ''}`;
|
||||
};
|
||||
|
||||
const parseTimeValue = (value: string) => {
|
||||
const parts = value.split(':');
|
||||
return { hour: parts[0] || '', minute: parts[1] || '' };
|
||||
};
|
||||
```
|
||||
|
||||
**表单 → 方格的时间同步(在 `reportData` 的 `useEffect` 中):**
|
||||
|
||||
```tsx
|
||||
// 对时间字段做特殊拼接
|
||||
let newValue = '';
|
||||
if (fieldKey === 'startTime') {
|
||||
newValue = formatTimeValue(reportData.startHour, reportData.startMinute);
|
||||
} else if (fieldKey === 'endTime') {
|
||||
newValue = formatTimeValue(reportData.endHour, reportData.endMinute);
|
||||
} else {
|
||||
const rawValue = (reportData as any)[fieldKey];
|
||||
if (Array.isArray(rawValue)) newValue = rawValue.join(', ');
|
||||
else if (rawValue !== undefined && rawValue !== null) newValue = String(rawValue);
|
||||
}
|
||||
```
|
||||
|
||||
**方格 → 表单的时间同步(在 `handleEditorInput` 中):**
|
||||
|
||||
```tsx
|
||||
if (target && target.hasAttribute('data-bind')) {
|
||||
const fieldKey = target.getAttribute('data-bind')!;
|
||||
const newValue = target.innerText;
|
||||
|
||||
if (fieldKey === 'startTime') {
|
||||
const { hour, minute } = parseTimeValue(newValue);
|
||||
setReportData(prev => {
|
||||
const next = { ...prev, startHour: hour, startMinute: minute };
|
||||
stateRef.current = { ...stateRef.current, reportData: next };
|
||||
return next;
|
||||
});
|
||||
} else if (fieldKey === 'endTime') {
|
||||
const { hour, minute } = parseTimeValue(newValue);
|
||||
setReportData(prev => {
|
||||
const next = { ...prev, endHour: hour, endMinute: minute };
|
||||
stateRef.current = { ...stateRef.current, reportData: next };
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setReportData(prev => {
|
||||
const next = { ...prev, [fieldKey]: newValue };
|
||||
stateRef.current = { ...stateRef.current, reportData: next };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**动态渲染右侧表单(替换现有的硬编码表单区域):**
|
||||
|
||||
将现有的 `activeTab === 'info'` 下的 `<div className="report-info-form space-y-4">...` 整体替换为:
|
||||
|
||||
```tsx
|
||||
{activeTab === 'info' && (
|
||||
<div className="report-info-form space-y-4">
|
||||
{formFields.filter(f => f.visibleInForm).map(field => {
|
||||
if (field.type === 'text' || field.type === 'date') {
|
||||
const inputType = field.type === 'date' ? 'date' : 'text';
|
||||
return (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||||
<input
|
||||
type={inputType}
|
||||
value={(reportData as any)[field.key] || ''}
|
||||
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||||
className="input-minimal"
|
||||
placeholder={field.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'single_select') {
|
||||
return (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<label className="block text-xs font-bold text-text-main">{field.label}</label>
|
||||
<select
|
||||
value={(reportData as any)[field.key] || ''}
|
||||
onChange={(e) => { const next = { ...reportData, [field.key]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||||
className="input-minimal bg-white"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{(field.options || []).map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'multi_select') {
|
||||
const isOpen = openDropdown === field.key;
|
||||
return (
|
||||
<div key={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="..." onClick={() => setOpenDropdown(field.key)}>
|
||||
{/* 复用现有的多选标签渲染逻辑,字段名用 field.key */}
|
||||
</div>
|
||||
{/* 下拉选项弹窗 ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'time') {
|
||||
const hourKey = field.key === 'startTime' ? 'startHour' : 'endHour';
|
||||
const minuteKey = field.key === 'startTime' ? 'startMinute' : 'endMinute';
|
||||
return (
|
||||
<div key={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
|
||||
value={(reportData as any)[hourKey] || ''}
|
||||
onChange={(e) => { const next = { ...reportData, [hourKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||||
className="input-minimal bg-white flex-1"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{hourOptions.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
<span className="text-text-muted">:</span>
|
||||
<select
|
||||
value={(reportData as any)[minuteKey] || ''}
|
||||
onChange={(e) => { const next = { ...reportData, [minuteKey]: e.target.value }; setReportData(next); stateRef.current = { ...stateRef.current, reportData: next }; saveDraftToStorage(); }}
|
||||
className="input-minimal bg-white flex-1"
|
||||
>
|
||||
<option value="">--</option>
|
||||
{minuteOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
> 对于 `multi_select`,可以完全复用现有的 `surgeon`/`assistant`/`anesthesiologist` 的多选组件逻辑,只需将硬编码的字段名替换为 `field.key`,并将 `multiSelectOptions` 的读取逻辑泛化为从 `field.options` 读取。
|
||||
|
||||
### 变更 8:`src/pages/Login.tsx` — 首次登录初始化字段配置
|
||||
|
||||
在 Login 页面初始化默认数据时(与其他 `storage.set` 一起),增加:
|
||||
|
||||
```tsx
|
||||
if (!storage.get<FormField[]>('formFieldsConfig', null)) {
|
||||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
||||
}
|
||||
```
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 老用户的 `localStorage` 中没有 `formFieldsConfig`,首次进入可能显示空白表单 | 中 | `ReportEditor` 和 `TemplateManage` 中都做 fallback:若不存在则使用 `DEFAULT_FORM_FIELDS` 并自动写入 localStorage |
|
||||
| `ReportEditor` 动态渲染多选字段时,现有 `multiSelectOptions` 状态与新字段体系冲突 | 中 | 多选字段的选项统一从 `field.options` 读取,不再依赖独立的 `multiSelectOptions` 状态(或做兼容映射) |
|
||||
| 时间方格输入非标准格式(如"930"而非"09:30")导致解析失败 | 低 | `parseTimeValue` 使用简单 `split(':')`,若格式不对则 `hour`/`minute` 保持原样或空字符串,不影响系统稳定性 |
|
||||
| 删除自定义字段后,老报告中仍包含该 `data-bind` 节点 | 低 | 老报告中的 orphan 节点只是普通可编辑方格,右侧表单不显示对应输入项,属于预期行为 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
本次改动涉及数据结构和多处 UI 渲染。如出现异常,可:
|
||||
1. `git revert` 回滚代码;
|
||||
2. 手动在浏览器控制台执行 `localStorage.removeItem('formFieldsConfig')` 恢复默认字段配置。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
162
工程分析/实现方案-2026-04-17-09-36-07.md
Normal file
162
工程分析/实现方案-2026-04-17-09-36-07.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 实现方案 — 2026-04-17-09-36-07
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **多余空格**:`TemplateManage.tsx` 的 `insertSmartField` 函数在 HTML 字符串末尾追加了 ` `,这是导致字段后跟随大量空白的主要原因。
|
||||
2. **异常换行**:`inline-block` 元素默认会在边界处根据容器宽度自动换行;`contenteditable="false"` 节点在行尾时,浏览器可能将其视为独立的渲染单元进行换行。
|
||||
3. **Backspace 误删整行**:当光标位于 `contenteditable="false"` 的内联元素之后时,Webkit/Blink 内核的默认行为无法正确删除该节点,而是向上寻找到父级 `<p>` 并将其删除。这是 `contentEditable` 的经典 Bug。
|
||||
4. **默认模板未预置**:`defaultContent.ts` 中的第一行仍使用红色纯文本占位符,没有使用 `smartField()` 函数生成智能控件。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 修改 | 优化 `insertSmartField` HTML(移除 ` `、压缩为一行);增加 `keydown` 事件拦截,保护 `.smart-field-wrapper` 不被 Backspace 误删 |
|
||||
| `src/utils/defaultContent.ts` | 修改 | 将默认模板第一行的红色占位符替换为预置的智能字段控件 |
|
||||
| `src/index.css` | 修改(可选) | 给 `.smart-field-wrapper` 增加 `white-space: nowrap` |
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:`src/pages/TemplateManage.tsx` — 优化插入 HTML
|
||||
|
||||
**当前代码(约第 159-173 行):**
|
||||
```tsx
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
const html = `
|
||||
<span class="smart-field-wrapper" contenteditable="false">
|
||||
<span class="field-value"
|
||||
data-bind="${field.key}"
|
||||
contenteditable="true"
|
||||
style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em;">
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
```
|
||||
|
||||
**修改为:**
|
||||
```tsx
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
const html = `<span class="smart-field-wrapper" contenteditable="false"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em; white-space: nowrap;"></span></span>`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
```
|
||||
|
||||
**改动点**:
|
||||
- 移除末尾的 ` `。
|
||||
- 将多行模板字符串压缩为一行,消除源码换行被渲染为空格的问题。
|
||||
- 在 `field-value` 的内联样式中增加 `white-space: nowrap;`。
|
||||
|
||||
### 变更 2:`src/pages/TemplateManage.tsx` — 拦截 Backspace/Delete 防止误删整行
|
||||
|
||||
在现有的 `useEffect`(用于监听编辑器 click 事件)附近,新增一个 `useEffect` 监听 `keydown`:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!range.collapsed) return;
|
||||
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Find the node immediately before the cursor
|
||||
let prevNode: Node | null = null;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
if (offset === 0) {
|
||||
prevNode = container.previousSibling;
|
||||
}
|
||||
} else if (container.nodeType === Node.ELEMENT_NODE) {
|
||||
prevNode = (container as Element).childNodes[offset - 1] || null;
|
||||
}
|
||||
|
||||
if (!prevNode) return;
|
||||
|
||||
// If the previous node is our smart field wrapper, remove it manually
|
||||
const fieldWrapper = prevNode.nodeType === Node.ELEMENT_NODE
|
||||
? (prevNode as Element).closest('.smart-field-wrapper')
|
||||
: prevNode.parentElement?.closest('.smart-field-wrapper');
|
||||
|
||||
if (fieldWrapper && editorRef.current?.contains(fieldWrapper)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fieldWrapper.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => {
|
||||
if (editor) {
|
||||
editor.removeEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
};
|
||||
}, [currentTemplateId]);
|
||||
```
|
||||
|
||||
> 注意:此逻辑与 `ReportEditor.tsx` 中保护 `.image-placeholder` 不被误删的 `handleKeyDown` 思路一致。
|
||||
|
||||
### 变更 3:`src/utils/defaultContent.ts` — 默认模板预置字段控件
|
||||
|
||||
**当前第一行(约第 15-23 行):**
|
||||
```html
|
||||
<div class="template-info-section">
|
||||
<p style="font-family: SimSun;">
|
||||
姓名:${smartField('patientName')}
|
||||
性别:${smartField('patientGender')}
|
||||
年龄:${smartField('patientAge')}
|
||||
科别:${smartField('department')}
|
||||
床号:${smartField('bedNumber')}
|
||||
住院号:${smartField('hospitalId')}
|
||||
</p>
|
||||
```
|
||||
|
||||
这部分已经在上一版中被替换为 `smartField()`,但需要确认是否末尾有空格问题。由于 `smartField()` 函数本身返回的 HTML 不带 ` `(且是压缩的一行),`defaultContent.ts` 中的这段代码本身没有问题。
|
||||
|
||||
**但**:如果之前的 `smartField()` 定义末尾带有 ` `,则需要一并修正。当前 `defaultContent.ts` 中的 `smartField` 定义已经在上一版中被修正为压缩的一行且不带 ` `,所以默认模板本身已经符合要求。
|
||||
|
||||
**确认结果**:`defaultContent.ts` 中的第一行在上一版(`2026-04-17-00-13-09`)中已经替换为 `smartField('patientName')` 等智能控件。**本次只需确保 `smartField` 辅助函数的定义与变更 1 保持一致(移除 ` `、压缩为一行、增加 `white-space: nowrap`)即可。**
|
||||
|
||||
### 变更 4:`src/index.css` — 增加 `white-space: nowrap`
|
||||
|
||||
在 `.smart-field-wrapper` 的样式中增加:
|
||||
|
||||
```css
|
||||
.smart-field-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 2px;
|
||||
vertical-align: text-bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
> 由于 `field-value` 已经通过内联样式设置了 `white-space: nowrap`,给外层 `.smart-field-wrapper` 增加此属性可作为双重保险。
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 移除 ` ` 后,字段与前/后文本之间没有间隔,显得拥挤 | 低 | `margin: 0 2px` 已经提供了 2px 的左右间距,视觉上足够紧凑 |
|
||||
| `keydown` 拦截可能影响编辑器其他正常删除操作 | 低 | 拦截逻辑严格限定在光标前一个节点为 `.smart-field-wrapper` 时才生效,其他情况正常放行 |
|
||||
| 老模板中已插入的字段仍带有 ` ` | 低 | 老模板中的字段只是带有一个额外的空格,不影响功能;用户可手动删除重插 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
本次修改范围极小,仅调整 `insertSmartField` 的 HTML 输出和增加一个 `keydown` 事件监听。如出现异常,可直接 `git revert` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
138
工程分析/实现方案-2026-04-17-10-21-18.md
Normal file
138
工程分析/实现方案-2026-04-17-10-21-18.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 实现方案 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18)
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
1. `src/pages/TemplateManage.tsx` — 字段唯一性校验 + 删除按钮 + 键盘删除增强
|
||||
2. `src/utils/defaultContent.ts` — `smartField()` 增加删除按钮 HTML
|
||||
3. `src/index.css` — 智能字段删除按钮样式
|
||||
4. `src/pages/ReportManage.tsx` — 复选框、批量操作栏、导出功能
|
||||
|
||||
## 二、详细改动
|
||||
|
||||
### 2.1 `src/pages/TemplateManage.tsx`
|
||||
|
||||
#### A. `insertSmartField` 增加唯一性校验
|
||||
```ts
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
|
||||
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
|
||||
return;
|
||||
}
|
||||
// ... 原有 insertHTML 逻辑,同时给 wrapper 增加 delete-btn
|
||||
};
|
||||
```
|
||||
|
||||
#### B. 给 `smart-field-wrapper` 增加删除按钮
|
||||
插入的 HTML 改为:
|
||||
```html
|
||||
<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="field-value" data-bind="..." ...> </span>
|
||||
</span>
|
||||
```
|
||||
|
||||
#### C. 点击删除按钮事件(复用或扩展已有的 `handleEditorClick` capture 事件)
|
||||
在已有的 `handleEditorClick` 中,除了处理 `.image-placeholder`,再增加对 `.delete-btn` 在 `.smart-field-wrapper` 内的判断:
|
||||
```ts
|
||||
const wrapper = targetEl.closest('.smart-field-wrapper') as HTMLElement | null;
|
||||
if (targetEl.closest('.delete-btn') && wrapper) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
wrapper.remove();
|
||||
// 同步保存模板内容到 localStorage
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### D. 增强 `keydown` 删除逻辑
|
||||
当前逻辑只处理 "光标在文本节点内且 offset 为 0/末尾" 的情况。需要额外处理:
|
||||
- 光标直接在字段后面(`range.startContainer` 是 `<p>`,`range.startOffset` 指向字段节点位置)时按 Backspace。
|
||||
- 光标直接在字段前面时按 Delete。
|
||||
- 选区 collapsed 且紧邻字段的各种边界情况。
|
||||
|
||||
实现策略:在 `keydown` 中统一写一个 `findAdjacentSmartField(range, direction)` 辅助函数,先尝试从文本节点 sibling 找,若找不到则从父级块节点的 childNodes 中按 offset 找。
|
||||
|
||||
### 2.2 `src/utils/defaultContent.ts`
|
||||
|
||||
修改 `smartField(key)` 辅助函数,使其输出包含 `<span class="delete-btn" contenteditable="false">×</span>` 的单行 HTML。
|
||||
|
||||
### 2.3 `src/index.css`
|
||||
|
||||
新增 `.smart-field-wrapper .delete-btn` 的样式:
|
||||
```css
|
||||
.smart-field-wrapper .delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 2px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.smart-field-wrapper .delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
@media print {
|
||||
.smart-field-wrapper .delete-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 `src/pages/ReportManage.tsx`
|
||||
|
||||
#### A. 状态扩展
|
||||
```ts
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportTarget, setExportTarget] = useState<Report | null>(null);
|
||||
```
|
||||
|
||||
#### B. 复选框与全选逻辑
|
||||
- 表头 `<th>` 增加 Checkbox,状态为 `selectedIds.length === filteredReports.length && filteredReports.length > 0`。
|
||||
- 每行 `<td>` 最左侧增加 Checkbox,checked 状态为 `selectedIds.includes(report.id)`,onChange 时切换选中状态。
|
||||
- 当 `selectedIds.length > 0` 时,在搜索栏下方显示批量操作栏(flex row),包含:
|
||||
- `已选择 N 项` 文本
|
||||
- **批量删除** 按钮(红色)
|
||||
- **批量导出 PDF** 按钮
|
||||
- **批量导出 JSON** 按钮
|
||||
- **取消选择** 按钮
|
||||
|
||||
#### C. 单报告导出
|
||||
- **PDF**:调用 `printDocument(report.content)`。
|
||||
- **JSON**:构建对象:
|
||||
```ts
|
||||
const exportData = {
|
||||
meta: { id: report.id, title: report.title, createdAt: report.createdAt, updatedAt: report.updatedAt, author: report.author, authorName: report.authorName, status: report.status },
|
||||
fields: { /* 所有 DEFAULT_FORM_FIELDS 的 key 对应的值 */ }
|
||||
};
|
||||
```
|
||||
通过 `Blob` + `URL.createObjectURL` + `<a download>` 触发下载。
|
||||
|
||||
#### D. 批量导出
|
||||
- **批量 PDF**:`const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>'); printDocument(mergedHTML);`
|
||||
- **批量 JSON**:`const exportData = selectedReports.map(r => ({ meta: ..., fields: ... }));` 同样通过 Blob 下载为 `reports_export_时间戳.json`。
|
||||
|
||||
#### E. 批量删除
|
||||
```ts
|
||||
const handleBulkDelete = () => {
|
||||
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return;
|
||||
const updated = reports.filter(r => !selectedIds.includes(r.id));
|
||||
setReports(updated);
|
||||
storage.set('reports', updated);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
```
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- **风险**:修改 `defaultContent.ts` 中的 `smartField` HTML 结构后,旧模板中已存在的 `smart-field-wrapper` 没有删除按钮。这属于正常行为(旧数据不 retroactive),新建报告时的新模板会带删除按钮。
|
||||
- **风险**:`keydown` 删除逻辑改动较大,可能在不同浏览器下对边界 selection 的解析有差异,需要手工测试。
|
||||
- **回滚**:如出现问题,可回退 `TemplateManage.tsx`、`defaultContent.ts`、`index.css`、`ReportManage.tsx` 的修改。
|
||||
111
工程分析/实现方案-2026-04-17-11-14-28.md
Normal file
111
工程分析/实现方案-2026-04-17-11-14-28.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 实现方案 — 字段聚焦高亮、删除按钮显隐控制与 .map Bug 修复(2026-04-17-11-14-28)
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
1. `src/pages/TemplateManage.tsx` — 调整 `insertSmartField` HTML 结构;给编辑器增加 `template-editor-mode` class
|
||||
2. `src/utils/defaultContent.ts` — 同步调整 `smartField()` HTML 结构
|
||||
3. `src/index.css` — 聚焦高亮样式 + 删除按钮绝对定位 + 显隐控制
|
||||
4. `src/pages/ReportEditor.tsx` — 修复 `multi_select` 的 `.map` 类型安全
|
||||
|
||||
## 二、详细改动
|
||||
|
||||
### 2.1 `src/pages/TemplateManage.tsx`
|
||||
|
||||
#### A. `insertSmartField` HTML 结构调整
|
||||
将 `delete-btn` 放到 `.field-value` **之后**,并给 `.smart-field-wrapper` 增加 `position:relative`:
|
||||
```html
|
||||
<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;">
|
||||
<span class="field-value" data-bind="..." contenteditable="true" style="..."> </span>
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
#### B. 编辑器容器增加专属 class
|
||||
在渲染编辑器的 `<div ref={editorRef} ...>` 上增加 `template-editor-mode`:
|
||||
```tsx
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="editor-content print-content template-editor-mode"
|
||||
>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2.2 `src/utils/defaultContent.ts`
|
||||
|
||||
同步修改 `smartField(key)` 的 HTML 结构,与 `insertSmartField` 完全一致。
|
||||
|
||||
### 2.3 `src/index.css`
|
||||
|
||||
#### A. field-value 聚焦高亮
|
||||
```css
|
||||
.smart-field-wrapper .field-value:focus {
|
||||
background-color: #e2e8f0;
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
```
|
||||
|
||||
#### B. 删除按钮样式(默认隐藏)
|
||||
```css
|
||||
.smart-field-wrapper .delete-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.smart-field-wrapper .delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 显隐控制(仅在 TemplateManage 显示)
|
||||
```css
|
||||
.template-editor-mode .smart-field-wrapper:hover .delete-btn,
|
||||
.template-editor-mode .smart-field-wrapper:focus-within .delete-btn {
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
#### D. 打印隐藏
|
||||
保留已有的 `.print-content .smart-field-wrapper .delete-btn { display: none !important; }`。
|
||||
|
||||
### 2.4 `src/pages/ReportEditor.tsx`
|
||||
|
||||
修复 `multi_select` 渲染处的 `.map` 调用:
|
||||
|
||||
```tsx
|
||||
if (field.type === 'multi_select') {
|
||||
const isOpen = openDropdown === field.key;
|
||||
const opts = field.options || multiSelectOptions[field.key] || [];
|
||||
const rawValue = (reportData as any)[field.key];
|
||||
const tags = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
|
||||
return (
|
||||
...
|
||||
{tags.map((tag: string) => (
|
||||
<span key={tag} ...>
|
||||
...
|
||||
</span>
|
||||
))}
|
||||
...
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- **风险**:修改 `smartField` HTML 结构和 CSS 后,旧报告中已存在的智能字段没有删除按钮。这是预期行为(旧数据不回溯)。
|
||||
- **风险**:`delete-btn` 的 `display: none` 默认隐藏 + `.template-editor-mode` 控制显示,ReportEditor 中由于容器没有 `template-editor-mode` class,删除按钮不会显示。
|
||||
- **回滚**:如出现问题,可回退上述 4 个文件的修改。
|
||||
161
工程分析/实现方案-2026-04-17-11-34-24.md
Normal file
161
工程分析/实现方案-2026-04-17-11-34-24.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 实现方案 — 字段悬浮高亮、电子签上传与手术者签名联动(2026-04-17-11-34-24)
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
1. `src/types.ts` — 扩展 `User` / `FieldType` / `DEFAULT_FORM_FIELDS`
|
||||
2. `src/pages/UserManage.tsx` — 电子签上传组件 + 前端压缩逻辑
|
||||
3. `src/pages/TemplateManage.tsx` — 悬浮高亮 + 图片分类 + 手术者签名插入
|
||||
4. `src/pages/ReportEditor.tsx` — `surgeonSignature` 特殊同步逻辑
|
||||
5. `src/index.css` — 签名图片排版样式 + 打印样式
|
||||
6. `src/utils/print.ts` — 打印样式中增加签名图片规则
|
||||
|
||||
## 二、详细改动
|
||||
|
||||
### 2.1 `src/types.ts`
|
||||
|
||||
- `User` 接口追加 `signature?: string`。
|
||||
- `FieldType` 扩展为 `'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature'`。
|
||||
- `DEFAULT_FORM_FIELDS` 末尾追加:
|
||||
```ts
|
||||
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: false, isSystemLocked: true }
|
||||
```
|
||||
|
||||
### 2.2 `src/pages/UserManage.tsx`
|
||||
|
||||
#### A. 前端压缩工具函数
|
||||
```ts
|
||||
const compressImage = (file: File, maxSize: number = 500): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.src = e.target?.result as string;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let { width, height } = img;
|
||||
if (width > height && width > maxSize) {
|
||||
height = Math.round((height * maxSize) / width);
|
||||
width = maxSize;
|
||||
} else if (height > maxSize) {
|
||||
width = Math.round((width * maxSize) / height);
|
||||
height = maxSize;
|
||||
}
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
}
|
||||
resolve(canvas.toDataURL('image/jpeg', 0.8));
|
||||
};
|
||||
img.onerror = reject;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### B. 上传组件与保存逻辑
|
||||
- 在模态框表单中("状态"选择器下方或底部按钮上方)增加一个区块:
|
||||
- 标签:"电子签名"
|
||||
- 若 `formData.signature` 有值,显示压缩后的预览图(高度限制 64px)。
|
||||
- "上传签名" 按钮(`type="button"`),触发隐藏的 `<input type="file" accept="image/*">`。
|
||||
- "清除签名" 按钮(有值时显示)。
|
||||
- `handleSubmit` 中保存 `signature` 字段到用户对象。
|
||||
- 编辑当前登录用户时,同步更新 `storage.set('currentUser', currentCached)`,确保 ReportEditor 能立即读取到最新签名。
|
||||
|
||||
### 2.3 `src/pages/TemplateManage.tsx`
|
||||
|
||||
#### A. 悬浮高亮
|
||||
在字段库按钮上增加 `onMouseEnter` 和 `onMouseLeave`:
|
||||
```ts
|
||||
const highlightField = (key: string, active: boolean) => {
|
||||
if (!editorRef.current) return;
|
||||
const el = editorRef.current.querySelector(`[data-bind="${key}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
if (active) {
|
||||
el.style.transition = 'all 0.2s';
|
||||
el.style.boxShadow = '0 0 0 2px #3b82f6';
|
||||
el.style.backgroundColor = '#e0f2fe';
|
||||
} else {
|
||||
el.style.boxShadow = '';
|
||||
el.style.backgroundColor = '';
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### B. 图片分类与手术者签名
|
||||
- 插入字段分类数组从 `['填空', '单选', '多选', '时间']` 改为 `['填空', '单选', '多选', '时间', '图片']`。
|
||||
- `surgeonSignature` 字段会自动出现在"图片"分类下,按钮点击逻辑复用 `insertSmartField`(已支持唯一性校验)。
|
||||
|
||||
### 2.4 `src/pages/ReportEditor.tsx`
|
||||
|
||||
在"Sync form state -> rich text field values"的 `useEffect` 中,对 `fieldKey === 'surgeonSignature'` 做特殊分支:
|
||||
```ts
|
||||
if (fieldKey === 'surgeonSignature') {
|
||||
const signatureData = currentUser?.signature;
|
||||
if (signatureData) {
|
||||
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
|
||||
if (el.innerHTML !== imgHtml) {
|
||||
el.innerHTML = imgHtml;
|
||||
el.style.border = 'none';
|
||||
el.style.backgroundColor = 'transparent';
|
||||
}
|
||||
} else {
|
||||
if (el.innerText !== '【请上传电子签】') {
|
||||
el.innerText = '【请上传电子签】';
|
||||
el.style.border = '';
|
||||
el.style.backgroundColor = '';
|
||||
}
|
||||
}
|
||||
return; // 跳过常规文本同步
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 `src/index.css`
|
||||
|
||||
增加签名图片样式:
|
||||
```css
|
||||
.report-signature-img {
|
||||
height: 2.4em;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin: -0.3em 0;
|
||||
}
|
||||
```
|
||||
|
||||
在 `@media print` 中同步增加:
|
||||
```css
|
||||
@media print {
|
||||
.report-signature-img {
|
||||
height: 2.4em !important;
|
||||
width: auto !important;
|
||||
vertical-align: middle !important;
|
||||
display: inline-block !important;
|
||||
margin: -0.3em 0 !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 `src/utils/print.ts`
|
||||
|
||||
在打印 iframe 的 `<style>` 标签内,`.smart-field-wrapper` 规则之后追加:
|
||||
```css
|
||||
.report-signature-img {
|
||||
height: 2.4em;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin: -0.3em 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- **风险**:`localStorage` 容量有限,压缩后的签名图片通常在 10~50KB,单用户存储安全。
|
||||
- **风险**:旧用户的 `User` 对象没有 `signature` 字段,读取时为 `undefined`,代码中已通过可选链和默认值处理。
|
||||
- **回滚**:如出现问题,可回退 6 个文件的修改。
|
||||
157
工程分析/实现方案-2026-04-17-12-34-56.md
Normal file
157
工程分析/实现方案-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 实现方案 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
1. `src/pages/TemplateManage.tsx` — 删除逻辑改用 `execCommand('delete')`;插入 HTML 增加零宽空格防换行
|
||||
2. `src/types.ts` — 修改 `surgeonSignature` 显隐属性;新增 `isSigned` 字段
|
||||
3. `src/pages/ReportEditor.tsx` — 初始值增加 `isSigned`;签名同步逻辑重构;完成报告签名校验
|
||||
4. `src/index.css` — 签名图片尺寸约束
|
||||
5. `src/utils/print.ts` — 打印样式同步签名尺寸约束
|
||||
|
||||
## 二、详细改动
|
||||
|
||||
### 2.1 `src/pages/TemplateManage.tsx`
|
||||
|
||||
#### A. 点击红 X 删除改用 `execCommand('delete')`
|
||||
```ts
|
||||
if (smartField && targetEl.closest('.delete-btn')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNode(smartField);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('delete');
|
||||
saveTemplateContent();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### B. 键盘 Backspace/Delete 改用 `execCommand('delete')`
|
||||
在 `handleKeyDown` 中,当定位到 `smart-field-wrapper` 目标后:
|
||||
```ts
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNode(target);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('delete');
|
||||
saveTemplateContent();
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 插入 HTML 防换行
|
||||
在 `insertSmartField` 的 HTML 字符串末尾增加 `​`(零宽空格),作为行内锚点,防止浏览器将字段挤到新行:
|
||||
```html
|
||||
<span class="smart-field-wrapper" ...>...</span>​
|
||||
```
|
||||
|
||||
### 2.2 `src/types.ts`
|
||||
|
||||
- 将 `surgeonSignature` 改为:
|
||||
```ts
|
||||
{ key: 'surgeonSignature', label: '手术者签名', category: '图片', type: 'signature', visibleInForm: true, isSystemLocked: false }
|
||||
```
|
||||
- 在 `DEFAULT_FORM_FIELDS` 末尾追加(放在 `surgeonSignature` 之前或之后均可):
|
||||
```ts
|
||||
{ key: 'isSigned', label: '手术者签名确认', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['已签字', '未签字'] },
|
||||
```
|
||||
|
||||
### 2.3 `src/pages/ReportEditor.tsx`
|
||||
|
||||
#### A. 初始 `reportData` 增加 `isSigned`
|
||||
```ts
|
||||
const [reportData, setReportData] = useState<Partial<Report>>({
|
||||
// ... 其他字段
|
||||
isSigned: '未签字',
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
#### B. 签名同步逻辑重构
|
||||
将 `surgeonSignature` 的特殊处理从 `useEffect` 移到更前面的位置,逻辑改为:
|
||||
```ts
|
||||
if (fieldKey === 'surgeonSignature') {
|
||||
const isSigned = (reportData as any).isSigned === '已签字';
|
||||
const signatureData = currentUser?.signature;
|
||||
if (isSigned && signatureData) {
|
||||
const imgHtml = `<img src="${signatureData}" class="report-signature-img" alt="签名" draggable="false" />`;
|
||||
if (el.innerHTML !== imgHtml) {
|
||||
el.innerHTML = imgHtml;
|
||||
el.style.border = 'none';
|
||||
el.style.backgroundColor = 'transparent';
|
||||
}
|
||||
} else {
|
||||
const placeholder = isSigned ? '【请上传电子签】' : '【未签字】';
|
||||
if (el.innerText !== placeholder) {
|
||||
el.innerText = placeholder;
|
||||
el.style.border = '';
|
||||
el.style.backgroundColor = '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 完成报告签名校验
|
||||
在 `saveReport` 的 `status === 'completed'` 分支中,在现有患者信息校验之后追加:
|
||||
```ts
|
||||
const hasSignatureField = editorRef.current?.querySelector('[data-bind="surgeonSignature"]');
|
||||
if (hasSignatureField) {
|
||||
const isSigned = reportData.isSigned === '已签字';
|
||||
const hasSignatureImage = !!currentUser?.signature;
|
||||
if (!isSigned) {
|
||||
const proceed = window.confirm('提示:模板中包含【手术者签名】字段,但您在基本信息中未选择"已签字"。是否继续完成报告?');
|
||||
if (!proceed) return;
|
||||
} else if (!hasSignatureImage) {
|
||||
const proceed = window.confirm('提示:您选择了"已签字",但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?');
|
||||
if (!proceed) return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 `src/index.css`
|
||||
|
||||
修改 `.report-signature-img`:
|
||||
```css
|
||||
.report-signature-img {
|
||||
max-width: 120px;
|
||||
max-height: 40px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
```
|
||||
|
||||
在 `@media print` 中同步:
|
||||
```css
|
||||
@media print {
|
||||
.report-signature-img {
|
||||
max-width: 120px !important;
|
||||
max-height: 40px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
vertical-align: middle !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 `src/utils/print.ts`
|
||||
|
||||
在 iframe 的 `<style>` 中,`.smart-field-wrapper` 规则之后追加:
|
||||
```css
|
||||
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||
```
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- **风险**:改用 `execCommand('delete')` 后,少数旧版浏览器可能行为不一致,但现代 Chromium/Edge 支持良好。
|
||||
- **风险**:`​` 零宽空格在极少数场景下可能导致光标异常,但其为无形字符,影响极小。
|
||||
- **回滚**:如出现问题,可回退上述 5 个文件的修改。
|
||||
123
工程分析/实现方案-2026-04-17-12-51-47.md
Normal file
123
工程分析/实现方案-2026-04-17-12-51-47.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 实现方案 — TemplateManage 撤销/重做修复与插入字段光标定位(2026-04-17-12-51-47)
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
1. `src/pages/TemplateManage.tsx` — 核心改动:自定义 undo/redo 栈 + 光标保存恢复 + 阻止焦点流失
|
||||
|
||||
## 二、详细改动
|
||||
|
||||
### 2.1 自定义 Undo/Redo 栈
|
||||
|
||||
#### A. 新增 Refs
|
||||
在组件顶部增加:
|
||||
```ts
|
||||
const undoStack = useRef<string[]>([]);
|
||||
const redoStack = useRef<string[]>([]);
|
||||
```
|
||||
|
||||
#### B. `pushHistory` 函数
|
||||
在执行任何会改变编辑器内容的操作前调用,将当前 `innerHTML` 推入 undo 栈,并清空 redo 栈:
|
||||
```ts
|
||||
const pushHistory = () => {
|
||||
if (!editorRef.current) return;
|
||||
undoStack.current.push(editorRef.current.innerHTML);
|
||||
redoStack.current = [];
|
||||
};
|
||||
```
|
||||
|
||||
#### C. `handleUndo` / `handleRedo`
|
||||
替换原来调用的 `execCmd('undo')` / `execCmd('redo')`:
|
||||
```ts
|
||||
const handleUndo = () => {
|
||||
if (undoStack.current.length === 0 || !editorRef.current) return;
|
||||
redoStack.current.push(editorRef.current.innerHTML);
|
||||
const prev = undoStack.current.pop();
|
||||
if (prev !== undefined) {
|
||||
editorRef.current.innerHTML = prev;
|
||||
saveTemplateContent();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
if (redoStack.current.length === 0 || !editorRef.current) return;
|
||||
undoStack.current.push(editorRef.current.innerHTML);
|
||||
const next = redoStack.current.pop();
|
||||
if (next !== undefined) {
|
||||
editorRef.current.innerHTML = next;
|
||||
saveTemplateContent();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### D. 埋点位置
|
||||
在以下操作**执行前**调用 `pushHistory()`:
|
||||
- `handleEditorClick` 中删除 smart field 之前
|
||||
- `handleKeyDown` 中删除 smart field 之前
|
||||
- `insertSmartField` 执行 `insertHTML` 之前
|
||||
- `insertTable` 执行 `insertHTML` 之前
|
||||
- `insertImage` 执行 `insertHTML` 之前
|
||||
- 工具栏按钮(粗体/斜体/下划线/对齐/颜色/字体等)操作前
|
||||
|
||||
**注意**:为了不过度累积历史记录,键盘输入不需要每次按键都 pushHistory,浏览器原生 undo 可以处理普通文本输入。我们的自定义栈主要负责保护“结构性变更”(插入/删除字段、表格、图片等)。
|
||||
|
||||
### 2.2 阻止焦点流失 + 恢复光标位置
|
||||
|
||||
#### A. 阻止焦点流失
|
||||
在字段库按钮和工具栏按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,这是最简洁有效的办法:
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertSmartField(field)}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
#### B. 保存/恢复光标位置
|
||||
利用已有的 `savedRangeRef`:
|
||||
```ts
|
||||
const saveSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
savedRangeRef.current = sel.getRangeAt(0);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreSelection = () => {
|
||||
if (!savedRangeRef.current) return;
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRangeRef.current);
|
||||
};
|
||||
```
|
||||
|
||||
在编辑器 `<div>` 上绑定事件:
|
||||
```tsx
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="..."
|
||||
onBlur={saveSelection}
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
>
|
||||
```
|
||||
|
||||
在 `insertSmartField` 中恢复光标:
|
||||
```ts
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
// ... 唯一性校验 + insertHTML
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 工具栏按钮改造
|
||||
|
||||
所有工具栏按钮(undo/redo 除外)增加 `onMouseDown={(e) => e.preventDefault()}`,并在 `onClick` 中先 `pushHistory()` 再执行命令。Undo/Redo 按钮不需要 `preventDefault`,因为它们本身不需要保持编辑器焦点,但也可以加上统一处理。
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- **风险**:自定义 undo/redo 栈会占用少量内存(存储 HTML 字符串),但模板内容通常不大,几十步历史记录的内存开销可忽略。
|
||||
- **风险**:`onMouseDown={e => e.preventDefault()}` 会阻止按钮的默认按下行为,但不会影响 `onClick` 的触发,这是 React 中阻止焦点流失的标准做法。
|
||||
- **回滚**:如出现问题,可回退 `TemplateManage.tsx` 的修改。
|
||||
115
工程分析/实现方案-2026-04-17-13-32-07.md
Normal file
115
工程分析/实现方案-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 实现方案 — 2026-04-17-13-32-07
|
||||
|
||||
## 目标
|
||||
|
||||
修复 `TemplateManage.tsx` 中两个编辑器交互问题:
|
||||
1. `Ctrl+Z` 快捷键无法撤销自定义删除行为。
|
||||
2. 在特定 HTML 结构(段落以 `<br>` 结尾)下插入 `smart-field-wrapper` 会导致换行错位。
|
||||
|
||||
---
|
||||
|
||||
## 变更文件
|
||||
|
||||
- `src/pages/TemplateManage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 具体改动
|
||||
|
||||
### 改动 A:拦截键盘 Undo/Redo 快捷键
|
||||
|
||||
**位置**:`TemplateManage.tsx` 中现有的 `keydown` 事件监听 `useEffect`(第 184~243 行附近)。
|
||||
|
||||
**做法**:
|
||||
在 `handleKeyDown` 函数的最开头,增加对 `Ctrl+Z` / `Cmd+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` / `Cmd+Y` 的拦截:
|
||||
|
||||
```typescript
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
handleRedo();
|
||||
} else {
|
||||
handleUndo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`handleUndo` / `handleRedo` 在该组件内是稳定函数(通过 ref 访问 DOM 状态),因此可直接在原生事件监听器闭包中调用,无需额外依赖。
|
||||
|
||||
---
|
||||
|
||||
### 改动 B:替换 `insertSmartField` 的插入方式
|
||||
|
||||
**位置**:`insertSmartField` 函数(第 304~315 行附近)。
|
||||
|
||||
**原逻辑**:
|
||||
```typescript
|
||||
document.execCommand('insertHTML', false, html);
|
||||
```
|
||||
|
||||
**新逻辑**:
|
||||
使用 `Range.insertNode()` 精确插入,避免 `execCommand` 在 `<br>` 边界处的标签逃逸。
|
||||
|
||||
```typescript
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
|
||||
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
|
||||
return;
|
||||
}
|
||||
pushHistory();
|
||||
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
const fragment = document.createDocumentFragment();
|
||||
while (wrapper.firstChild) {
|
||||
fragment.appendChild(wrapper.firstChild);
|
||||
}
|
||||
range.insertNode(fragment);
|
||||
|
||||
// 将光标移动到插入内容末尾
|
||||
const lastNode = fragment.lastChild;
|
||||
if (lastNode) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStartAfter(lastNode);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
editorRef.current?.focus();
|
||||
saveTemplateContent();
|
||||
};
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `range.deleteContents()` 对 collapsed 的光标无实际影响,安全。
|
||||
- `fragment.lastChild` 引用的是已被移入文档的具体节点,`setStartAfter` 可正确定位光标。
|
||||
- 末尾的 `​`(零宽空格)作为 `TextNode` 被一同插入,依然起到防止字段被意外吞并的作用。
|
||||
|
||||
---
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 修改前 `git` 仓库已处于干净状态(最新提交 `b822bb1`)。
|
||||
- 若验证失败,可直接 `git checkout -- src/pages/TemplateManage.tsx` 回滚到上一版本,重新分析。
|
||||
|
||||
---
|
||||
|
||||
## 无其他依赖变更
|
||||
|
||||
- 不新增 npm 依赖。
|
||||
- 不修改 `defaultContent.ts` 或 `index.css`。
|
||||
238
工程分析/实现方案-2026-04-17-18-38-47.md
Normal file
238
工程分析/实现方案-2026-04-17-18-38-47.md
Normal file
@@ -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
|
||||
// 术前诊断
|
||||
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
|
||||
|
||||
// 术后诊断
|
||||
<strong>术后诊断:</strong>${smartField('postoperativeDiagnosis')}
|
||||
|
||||
// 手术后情况
|
||||
<strong>手术后情况</strong>:${smartField('postOpCondition')}
|
||||
|
||||
// 切除标本描述
|
||||
<strong>切除标本描述</strong>:${smartField('specimenDescription')}
|
||||
|
||||
// 是否送病理检查
|
||||
<strong>是否送病理检查</strong>:${smartField('pathologyCheck')}
|
||||
|
||||
// 冰冻病理结果
|
||||
<strong>冰冻病理结果</strong>:${smartField('frozenPathology')}
|
||||
|
||||
// 手术者签名
|
||||
手术者签名:${smartField('surgeonSignature')}
|
||||
|
||||
// 医院 Logo 替换为图片字段占位符(使用 image-placeholder 结构但带 data-bind)
|
||||
// 保留原有居中样式
|
||||
```
|
||||
|
||||
Logo 部分不再硬编码 `<img src="/logo_square.png">`,改为可管理的图片占位符:
|
||||
```html
|
||||
<div class="image-placeholder" data-placeholder="true" data-bind="hospitalLogo" contenteditable="false">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、TemplateManage.tsx 修改
|
||||
|
||||
### 3.1 新增字段表单修复(需求 1)
|
||||
在 category `onChange` 中:
|
||||
- 选择"单选" → `type` 强制设为 `single_select`
|
||||
- 选择"多选" → `type` 强制设为 `multi_select`
|
||||
- 选择"图片" → `type` 强制设为 `image`
|
||||
|
||||
在 type select 的 options 渲染中,移除单选/多选/图片下的"文本" option:
|
||||
```tsx
|
||||
<option value="text">文本</option>
|
||||
{newFieldForm.category === '单选' && <option value="single_select">下拉单选</option>}
|
||||
{newFieldForm.category === '多选' && <option value="multi_select">标签多选</option>}
|
||||
{newFieldForm.category === '时间' && <><option value="date">日期</option><option value="time">时分</option></>}
|
||||
{newFieldForm.category === '图片' && <option value="image">图片</option>}
|
||||
```
|
||||
|
||||
### 3.2 字段管理折叠分组(需求 5)
|
||||
新增状态:
|
||||
```tsx
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>(['填空','单选','多选','时间','图片']);
|
||||
```
|
||||
|
||||
将字段管理列表改为按 category 分组渲染。每组一个可点击标题栏,点击时 toggle 该 category 在 `expandedCategories` 中的存在性。展开的组内渲染对应字段列表。
|
||||
|
||||
### 3.3 字段管理点击编辑选项(需求 3)
|
||||
新增状态:
|
||||
```tsx
|
||||
const [editingFieldKey, setEditingFieldKey] = useState<string | null>(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<string | null>(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<HTMLElement | null>(null);
|
||||
const [imageAssets, setImageAssets] = useState<{id: string, name: string, dataUrl: string}[]>([]);
|
||||
```
|
||||
|
||||
修改 `triggerPlaceholderUpload` 的调用逻辑:当点击无图片的 `image-placeholder` 时,不再直接 `input.click()`,而是:
|
||||
```tsx
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
```
|
||||
|
||||
弹窗 JSX(Modal)包含三个 Tab 按钮:
|
||||
- **本地上传**:内部隐藏 `<input type="file" accept="image/*">`,点击"选择文件"触发,读取后填充 placeholder。
|
||||
- **我的签名**:若 `currentUser.signature` 存在,展示签名缩略图,点击后填充。
|
||||
- **系统素材**:读取 `imageAssets` 列表,展示缩略图网格,点击后填充。
|
||||
|
||||
填充函数:
|
||||
```tsx
|
||||
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
|
||||
placeholder.innerHTML = `<span class="delete-btn" contenteditable="false">×</span><img src="${src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">`;
|
||||
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 = `<div id="${id}" class="image-placeholder" data-placeholder="true" data-bind="${field.key}" contenteditable="false"><span class="delete-btn" contenteditable="false">×</span><p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p></div>`;
|
||||
// 同样的 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 能力完成。
|
||||
154
工程分析/实现方案-2026-04-17-19-26-17.md
Normal file
154
工程分析/实现方案-2026-04-17-19-26-17.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 实现方案 — 2026-04-17-19-26-17
|
||||
|
||||
## 变更文件
|
||||
|
||||
1. `src/types.ts`
|
||||
2. `src/utils/defaultContent.ts`
|
||||
3. `src/pages/TemplateManage.tsx`
|
||||
4. `src/pages/ReportEditor.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 一、types.ts 修改
|
||||
|
||||
从 `DEFAULT_FORM_FIELDS` 中移除以下两个字段:
|
||||
- `surgeonSignature`
|
||||
- `hospitalLogo`
|
||||
|
||||
同时从 `FieldType` 中移除 `'image'`(因为图片不再作为可插入的字段类型,仅作为占位符存在)。若移除 `'image'` 会导致大量类型错误,也可保留类型但不在 UI 中暴露。为最小侵入,保留 `'image'` 类型但不再在 `DEFAULT_FORM_FIELDS` 中使用。
|
||||
|
||||
实际执行:删除 `DEFAULT_FORM_FIELDS` 中的最后两项(`surgeonSignature` 和 `hospitalLogo`)。
|
||||
|
||||
---
|
||||
|
||||
## 二、defaultContent.ts 修改
|
||||
|
||||
### 2.1 医院Logo
|
||||
将原有的 `div.image-placeholder` 替换为 `span.image-placeholder`(保持居中):
|
||||
```html
|
||||
<!-- 医院Logo -->
|
||||
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;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>
|
||||
</p>
|
||||
```
|
||||
|
||||
### 2.2 手术者签名
|
||||
将 `手术者签名:${smartField('surgeonSignature')}` 替换为:
|
||||
```html
|
||||
<p style="font-family: SimSun;">
|
||||
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;min-width:80px;min-height:24px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;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>
|
||||
</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、TemplateManage.tsx 修改
|
||||
|
||||
### 3.1 移除"图片"分类暴露
|
||||
- `expandedCategories` 初始值从 `['填空','单选','多选','时间','图片']` 改为 `['填空','单选','多选','时间']`。
|
||||
- "插入字段"Tab 的遍历数组从 `['填空','单选','多选','时间','图片']` 改为 `['填空','单选','多选','时间']`。
|
||||
- "字段管理"Tab 的遍历数组同样移除 `'图片'`。
|
||||
- 新增字段表单的 category select 中移除 `<option value="图片">图片</option>`。
|
||||
- type select 的条件渲染中移除图片相关的 option。
|
||||
|
||||
### 3.2 改造 insertImage()
|
||||
将现有的 `insertImage()` 替换为:
|
||||
```typescript
|
||||
const insertImage = () => {
|
||||
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
|
||||
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
|
||||
const width = parseInt(widthStr || '0');
|
||||
const height = parseInt(heightStr || '0');
|
||||
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += ` max-width:${width}px;`;
|
||||
if (height > 0) styleStr += ` max-height:${height}px;`;
|
||||
if (!width && !height) styleStr += ' padding:8px 16px;';
|
||||
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
|
||||
|
||||
const id = 'ph_' + Date.now();
|
||||
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>​`;
|
||||
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 统一图片源选择弹窗
|
||||
从 `ReportEditor.tsx` 复用以下逻辑到 `TemplateManage.tsx`:
|
||||
- 新增状态:`imagePickerOpen`、`imagePickerTarget`、`imageAssets`(已存在)。
|
||||
- 新增 `fillPlaceholderSrc` 函数。
|
||||
- 修改 `handleEditorClick` 中的 placeholder 点击逻辑:
|
||||
```typescript
|
||||
if (!placeholder.classList.contains('has-image')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
}
|
||||
```
|
||||
- 删除原有的 `triggerPlaceholderUpload` 函数及其直接调用。
|
||||
- 在 JSX 底部(`isModalOpen` 弹窗之后)新增 `imagePickerOpen` 弹窗组件(与 ReportEditor 完全一致)。
|
||||
|
||||
### 3.4 清理删除后的重置逻辑
|
||||
当 placeholder 被删除(点击 × 后)时,重置为:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
同时保留原有内联样式(避免把 `inline-flex` 等样式清掉)。
|
||||
|
||||
---
|
||||
|
||||
## 四、ReportEditor.tsx 修改
|
||||
|
||||
### 4.1 改造 insertImage()
|
||||
与 TemplateManage 保持一致:
|
||||
```typescript
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
const widthStr = prompt('请输入占位符最大宽度 (px),留空无限制:\n(提示:正文一行文字高度约为 20 像素左右)', '');
|
||||
const heightStr = prompt('请输入占位符最大高度 (px),留空无限制:', '');
|
||||
const width = parseInt(widthStr || '0');
|
||||
const height = parseInt(heightStr || '0');
|
||||
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += ` max-width:${width}px;`;
|
||||
if (height > 0) styleStr += ` max-height:${height}px;`;
|
||||
if (!width && !height) styleStr += ' padding:8px 16px;';
|
||||
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
|
||||
|
||||
const id = 'ph_' + Date.now();
|
||||
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${hintText}</span></span>​`;
|
||||
execCmd('insertHTML', html);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 删除后重置逻辑
|
||||
在 `handleEditorClick` 中,placeholder 删除后重置的 HTML 改为使用 `<span>` 结构并保留内联样式:
|
||||
```typescript
|
||||
placeholder.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
```
|
||||
|
||||
### 4.3 fillPlaceholderSrc 保持兼容
|
||||
已有 `fillPlaceholderSrc` 可继续使用,但建议填充的图片增加 `max-width: 100%; max-height: 100%; object-fit: contain;`。
|
||||
|
||||
---
|
||||
|
||||
## 回滚策略
|
||||
|
||||
修改前最新提交为 `0c57409`。若失败可 `git reset --hard 0c57409`。
|
||||
|
||||
## 无新增 npm 依赖
|
||||
210
工程分析/实现方案-2026-04-17-21-32-27.md
Normal file
210
工程分析/实现方案-2026-04-17-21-32-27.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 实现方案 — 2026-04-17-21-32-27
|
||||
|
||||
## 根因分析
|
||||
|
||||
当前系统的时间/日期字段为「硬编码」形态:
|
||||
- `date` 类型固定使用浏览器原生 `<input type="date">`,smart field 中直接显示原始值。
|
||||
- `time` 类型仅对 `startTime/endTime` 有表单渲染(hour+minute select),且固定为 24 小时制;smart field 中直接拼接 `HH:MM`。
|
||||
- 没有「当前时间自动填充」机制,也没有「显示格式切换」能力。
|
||||
- 模板底部「年 月 日」是写死文本,无法自动关联系统时间。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/types.ts` | `FormField` 增加 `timeFormat`/`timeDefault`;更新 `DEFAULT_FORM_FIELDS`;新增 `reportDate` |
|
||||
| `src/utils/defaultContent.ts` | 底部「年 月 日」→「撰写时间:${smartField('reportDate')}」 |
|
||||
| `src/pages/TemplateManage.tsx` | 新增字段/编辑面板增加时间配置 UI;保存逻辑扩展 |
|
||||
| `src/pages/ReportEditor.tsx` | date/time 表单渲染增强;smart field 同步增加格式转换;初始化自动填充 |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. types.ts
|
||||
|
||||
```ts
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
type: FieldType;
|
||||
visibleInForm: boolean;
|
||||
isSystemLocked: boolean;
|
||||
options?: string[];
|
||||
timeFormat?: string; // NEW
|
||||
timeDefault?: 'current' | 'specific'; // NEW
|
||||
}
|
||||
```
|
||||
|
||||
`DEFAULT_FORM_FIELDS` 更新:
|
||||
- `surgeryDate` 增加 `timeFormat: 'YYYY-MM-DD', timeDefault: 'specific'`
|
||||
- `startTime` 增加 `timeFormat: '24h', timeDefault: 'specific'`
|
||||
- `endTime` 增加 `timeFormat: '24h', timeDefault: 'specific'`
|
||||
- 新增:`reportDate`(date, `YYYY年MM月DD日`, `current`, systemLocked)
|
||||
|
||||
### 2. defaultContent.ts
|
||||
|
||||
尾部修改:
|
||||
```html
|
||||
<!-- 删除旧的 "年 月 日" 段落 -->
|
||||
<p style="text-align: right; font-family: SimSun;">
|
||||
撰写时间:${smartField('reportDate')}
|
||||
</p>
|
||||
```
|
||||
|
||||
### 3. TemplateManage.tsx
|
||||
|
||||
#### 新增状态
|
||||
```ts
|
||||
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY-MM-DD');
|
||||
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
|
||||
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||
```
|
||||
|
||||
#### 点击字段进入编辑时
|
||||
```ts
|
||||
setEditFieldTimeFormat(field.timeFormat || '');
|
||||
setEditFieldTimeDefault(field.timeDefault || 'specific');
|
||||
```
|
||||
|
||||
#### 编辑面板(editingFieldKey === field.key)
|
||||
在「选项输入框」之后,增加条件渲染:
|
||||
```tsx
|
||||
{field.category === '时间' && (
|
||||
<div className="space-y-1">
|
||||
<select value={editFieldTimeDefault} onChange={...}>
|
||||
<option value="specific">手动选择</option>
|
||||
<option value="current">当前时间</option>
|
||||
</select>
|
||||
<select value={editFieldTimeFormat} onChange={...}>
|
||||
{field.type === 'date' && <><option value="YYYY-MM-DD">YYYY-MM-DD</option><option value="YYYY年MM月DD日">YYYY年MM月DD日</option></>}
|
||||
{field.type === 'time' && <><option value="24h">24小时制</option><option value="12h">12小时制</option></>}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### saveFieldEdit
|
||||
```ts
|
||||
if (field.category === '时间') {
|
||||
next.timeFormat = editFieldTimeFormat;
|
||||
next.timeDefault = editFieldTimeDefault;
|
||||
}
|
||||
```
|
||||
|
||||
#### 新增字段表单
|
||||
在 category === '时间' 条件下,增加「默认值」和「显示格式」两个 select。
|
||||
|
||||
#### addField
|
||||
```ts
|
||||
if (newFieldForm.category === '时间') {
|
||||
newField.timeFormat = newFieldTimeFormat;
|
||||
newField.timeDefault = newFieldTimeDefault;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ReportEditor.tsx
|
||||
|
||||
#### 新增辅助函数(组件内)
|
||||
```ts
|
||||
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
|
||||
if (!isoDate) return '';
|
||||
if (fmt === 'YYYY年MM月DD日') {
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
if (y && m && d) return `${y}年${m}月${d}日`;
|
||||
}
|
||||
return isoDate;
|
||||
};
|
||||
|
||||
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
|
||||
if (!timeStr) return '';
|
||||
if (fmt === '12h') {
|
||||
const [hStr, mStr] = timeStr.split(':');
|
||||
let h = parseInt(hStr);
|
||||
const ampm = h >= 12 ? '下午' : '上午';
|
||||
h = h % 12;
|
||||
if (h === 0) h = 12;
|
||||
return `${String(h).padStart(2, '0')}:${mStr} ${ampm}`;
|
||||
}
|
||||
return timeStr;
|
||||
};
|
||||
```
|
||||
|
||||
#### date 字段表单渲染
|
||||
保持 `<input type="date">` 不变,值仍为 `YYYY-MM-DD`。
|
||||
|
||||
#### time 字段表单渲染
|
||||
重构为支持通用 time 字段:
|
||||
|
||||
**startTime/endTime(向后兼容)**:
|
||||
- `timeFormat === '24h'`:保持现有 hour(00-23) + minute select
|
||||
- `timeFormat === '12h'`:hour(01-12) + minute + AM/PM select
|
||||
- 存储转换:`to24h(hour12, isPM)` → 写入 startHour/endHour
|
||||
|
||||
**通用 time 字段(非 startTime/endTime)**:
|
||||
- 解析 reportData[field.key](格式 `HH:MM`)→ hour + minute
|
||||
- `timeFormat === '24h'`:hour(00-23) + minute
|
||||
- `timeFormat === '12h'`:hour(01-12) + minute + AM/PM
|
||||
- onChange 时拼接为 `HH:MM` 存入 reportData[field.key]
|
||||
|
||||
#### smart field 同步(useEffect)
|
||||
在拼接/取值后,增加格式转换:
|
||||
```ts
|
||||
if (fieldKey === 'startTime' || fieldKey === 'endTime') {
|
||||
// ... 拼接 HH:MM
|
||||
const fieldDef = formFields.find(f => f.key === fieldKey);
|
||||
newValue = formatTimeDisplay(newValue, fieldDef?.timeFormat);
|
||||
} else {
|
||||
const rawValue = (reportData as any)[fieldKey];
|
||||
// ... 处理 array/string
|
||||
const fieldDef = formFields.find(f => f.key === fieldKey);
|
||||
if (fieldDef?.type === 'date') {
|
||||
newValue = formatDateDisplay(newValue, fieldDef.timeFormat);
|
||||
} else if (fieldDef?.type === 'time') {
|
||||
newValue = formatTimeDisplay(newValue, fieldDef.timeFormat);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 初始化自动填充
|
||||
在 `useEffect` 初始化数据后,遍历 `formFields`:
|
||||
```ts
|
||||
formFields.forEach(field => {
|
||||
if (field.timeDefault !== 'current') return;
|
||||
if (field.type === 'date') {
|
||||
const current = new Date().toISOString().split('T')[0];
|
||||
if (!(reportData as any)[field.key]) {
|
||||
setReportData(prev => ({ ...prev, [field.key]: current }));
|
||||
}
|
||||
} else if (field.type === 'time') {
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const current = `${hh}:${mm}`;
|
||||
if (field.key === 'startTime') {
|
||||
if (!reportData.startHour) setReportData(prev => ({ ...prev, startHour: hh, startMinute: mm }));
|
||||
} else if (field.key === 'endTime') {
|
||||
if (!reportData.endHour) setReportData(prev => ({ ...prev, endHour: hh, endMinute: mm }));
|
||||
} else {
|
||||
if (!(reportData as any)[field.key]) {
|
||||
setReportData(prev => ({ ...prev, [field.key]: current }));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 现有用户已保存的 `formFieldsConfig` 缺少新字段,导致 `timeFormat` 为 undefined | 代码中统一使用 `field.timeFormat || 默认值` 做回退 |
|
||||
| 12h 表单与 24h 存储转换出错 | 增加边界单元测试(12AM→00, 12PM→12, 1PM→13 等) |
|
||||
| startTime/endTime 的 hour/minute 存储结构改动影响历史报告 | 保持存储结构不变,仅改动渲染和显示 |
|
||||
| 自动填充当前时间在编辑已有报告时覆盖用户值 | 仅当字段值为空时才填充 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- `types.ts` 中新增的属性为 optional,回滚时删除即可,不影响已有数据结构。
|
||||
- `defaultContent.ts` 的修改可通过 Git revert 恢复。
|
||||
- TemplateManage/ReportEditor 的 UI 改动为增量添加,回滚时移除条件渲染块即可。
|
||||
111
工程分析/实现方案-2026-04-17-23-12-52.md
Normal file
111
工程分析/实现方案-2026-04-17-23-12-52.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 实现方案 — 2026-04-17-23-12-52
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题1:格式选项缺失与旧值残留
|
||||
- `types.ts` 的 `DEFAULT_FORM_FIELDS` 中,`startTime` 和 `endTime` 的 `timeFormat` 仍被硬编码为 `'24h'`(历史遗留)。
|
||||
- `TemplateManage.tsx` 中的 `defaultFormats` 虽然已包含 `HH:mm` 和 `hh:mm A`,但 `customTimeFormats` 的 datalist 未按字段类型过滤,导致 date/time 格式混在一起显示,用户体验混乱。
|
||||
- 用户的 `localStorage` 中 `customTimeFormats` 可能还残留旧值 `'24h'`、`'12h'`。
|
||||
|
||||
### 问题2:报告编辑器显示 "24h"
|
||||
- `formatTimeDisplay('14:30', '24h')` 中,格式字符串 `'24h'` 不包含 `HH`/`hh`/`mm`/`A` 等任何替换 token,函数直接原样返回 `'24h'`。
|
||||
- 这是 F1 根因的直接后果:`formFieldsConfig` 中的 `timeFormat = '24h'` 被传入格式化函数。
|
||||
|
||||
### 问题3:输入框点击失效
|
||||
- `TemplateManage.tsx` 字段管理列表位于一个 `overflow-y-auto` 的滚动容器中。
|
||||
- 点击字段卡片后,卡片内部展开编辑表单,高度瞬间增加。如果卡片原本位于可视区域底部,展开后的输入框可能刚好处于容器边缘之外。
|
||||
- 浏览器的 hit-testing 在布局突变时可能无法正确将点击事件路由到新出现的输入框上,导致需要手动滚动后才能点击。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/types.ts` | `DEFAULT_FORM_FIELDS` 中 `startTime`/`endTime` 的 `timeFormat` 从 `'24h'` 改为 `'HH:mm'` |
|
||||
| `src/pages/ReportEditor.tsx` | `formatTimeDisplay` 开头增加 `if (fmt === '24h') fmt = 'HH:mm';` 兼容兜底 |
|
||||
| `src/pages/TemplateManage.tsx` | ① `defaultFormats` 初始化时过滤掉旧脏数据 `'24h'`/`'12h'`;② datalist 渲染时按字段类型(date/time)过滤选项;③ 字段编辑卡片 `onClick` 中增加 `scrollIntoView` |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. types.ts
|
||||
|
||||
```ts
|
||||
// 修改前
|
||||
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: '24h', timeDefault: 'specific' },
|
||||
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: '24h', timeDefault: 'specific' },
|
||||
|
||||
// 修改后
|
||||
{ key: 'startTime', label: '手术开始时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
|
||||
{ key: 'endTime', label: '手术终止时间', category: '时间', type: 'time', visibleInForm: true, isSystemLocked: true, timeFormat: 'HH:mm', timeDefault: 'specific' },
|
||||
```
|
||||
|
||||
### 2. ReportEditor.tsx
|
||||
|
||||
```ts
|
||||
// 在 formatTimeDisplay 函数开头增加一行兼容兜底
|
||||
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
|
||||
if (!timeStr || !fmt) return timeStr || '';
|
||||
if (fmt === '24h') fmt = 'HH:mm'; // 兼容旧脏数据
|
||||
// ... 后续代码不变
|
||||
};
|
||||
```
|
||||
|
||||
### 3. TemplateManage.tsx
|
||||
|
||||
**3.1 清理旧脏数据(初始化时)**
|
||||
```ts
|
||||
// 修改前
|
||||
const savedFormats = storage.get<string[]>('customTimeFormats', []);
|
||||
const defaultFormats = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
|
||||
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...savedFormats])));
|
||||
|
||||
// 修改后
|
||||
const savedFormats = storage.get<string[]>('customTimeFormats', []);
|
||||
const defaultFormats = ['YYYY-MM-DD', 'YYYY年MM月DD日', 'MM-DD', 'MM月DD日', 'HH:mm', 'hh:mm A'];
|
||||
// 过滤掉历史遗留的无效旧格式
|
||||
const cleanedSaved = savedFormats.filter(f => f !== '24h' && f !== '12h');
|
||||
setCustomTimeFormats(Array.from(new Set([...defaultFormats, ...cleanedSaved])));
|
||||
```
|
||||
|
||||
**3.2 按字段类型过滤 datalist**
|
||||
在编辑字段和新增字段的 format `<datalist>` 渲染处,增加按 `field.type` / `newFieldForm.type` 过滤:
|
||||
```tsx
|
||||
<datalist id={`edit-format-list-${field.key}`}>
|
||||
{customTimeFormats
|
||||
.filter(fmt => {
|
||||
const isDateFormat = /YYYY|MM|DD/.test(fmt);
|
||||
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
|
||||
if (field.type === 'date') return isDateFormat;
|
||||
if (field.type === 'time') return isTimeFormat;
|
||||
return true;
|
||||
})
|
||||
.map(fmt => <option key={fmt} value={fmt} />)}
|
||||
</datalist>
|
||||
```
|
||||
新增字段的 datalist 同理。
|
||||
|
||||
**3.3 编辑卡片点击后自动滚动对齐**
|
||||
```tsx
|
||||
onClick={(e) => {
|
||||
setEditingFieldKey(field.key);
|
||||
// ... 其他 setState
|
||||
|
||||
const target = e.currentTarget;
|
||||
setTimeout(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}}
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 清理 `customTimeFormats` 中的 `'24h'`/`'12h'` 可能误删用户真正想保留的自定义格式 | 仅过滤精确匹配 `'24h'` 和 `'12h'` 两个字符串,不影响其他自定义格式 |
|
||||
| 按类型过滤 datalist 可能误过滤 | 使用正则 `/YYYY|MM|DD/` 识别日期格式,`/HH|hh|mm|A/` 识别时间格式;若格式同时满足两者(理论上不应发生)则同时显示 |
|
||||
| `scrollIntoView` 在极端短容器中频繁触发 | 使用 `block: 'nearest'` 而非 `'start'`,减少不必要的滚动;50ms 延迟确保 DOM 已撑开 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- `types.ts` 的修改可直接回退两个字段的 `timeFormat` 字符串。
|
||||
- `ReportEditor.tsx` 的修改仅增加一行,删除即可。
|
||||
- `TemplateManage.tsx` 的修改均为增量逻辑,回滚时移除条件块即可。
|
||||
196
工程分析/实现方案-2026-04-17-23-38-34.md
Normal file
196
工程分析/实现方案-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 实现方案 — 2026-04-17-23-38-34
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题1:原生 datalist 交互体验差
|
||||
- 原生 `<input list>` + `<datalist>` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。
|
||||
- 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。
|
||||
|
||||
### 问题2:execCommand('insertHTML') 在表格中破坏结构
|
||||
- 当 `insertImage` 在 `<td>` 内执行 `execCommand('insertHTML', ...)` 时,WebKit/Blink 会将复杂的 `inline-flex` 嵌套 `<span>` 结构自动"拍平"或重新排列。
|
||||
- 外层 `<span class="image-placeholder">` 被浏览器移除,仅剩内部的 `.delete-btn` 和 `.placeholder-text` 散落为 `<td>` 的直接子元素。
|
||||
- 表格单元格本身就是块级上下文,使用块级 `<div>` 作为占位符容器更符合浏览器预期,不会被强制修正。
|
||||
|
||||
### 问题3:@page margin 与 body padding 的分页失效
|
||||
- `@page { margin: 0 }` 将物理纸张边距设为 0。
|
||||
- `body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。
|
||||
- 当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding,导致第二页顶部和底部紧贴纸张边缘。
|
||||
- 正确做法是将边距交给 `@page` 规则,让打印引擎为每一张物理纸张独立留出边距。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/pages/TemplateManage.tsx` | ① 引入 `formatDropdownOpen` / `newFormatDropdownOpen` 状态;② 将编辑/新增字段的格式 `input[list]+datalist` 替换为自定义下拉组件;③ `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
|
||||
| `src/pages/ReportEditor.tsx` | `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
|
||||
| `src/utils/print.ts` | `@page margin` 与 `body padding` 调整,`.content` width 改为 `100%` |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. TemplateManage.tsx
|
||||
|
||||
#### 1.1 新增状态(组件顶部)
|
||||
|
||||
```tsx
|
||||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||||
```
|
||||
|
||||
#### 1.2 编辑字段格式输入替换为自定义下拉
|
||||
|
||||
将原 `input[list]` + `datalist` 替换为:
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={editFieldTimeFormat}
|
||||
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
|
||||
onFocus={() => setFormatDropdownOpen(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setFormatDropdownOpen(false), 200);
|
||||
const val = editFieldTimeFormat.trim();
|
||||
if (val && !customTimeFormats.includes(val)) {
|
||||
const next = [...customTimeFormats, val];
|
||||
setCustomTimeFormats(next);
|
||||
storage.set('customTimeFormats', next);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = editFieldTimeFormat.trim();
|
||||
if (val && !customTimeFormats.includes(val)) {
|
||||
const next = [...customTimeFormats, val];
|
||||
setCustomTimeFormats(next);
|
||||
storage.set('customTimeFormats', next);
|
||||
}
|
||||
setFormatDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
className="w-full px-1.5 py-1 text-xs border border-border rounded"
|
||||
placeholder="输入格式或下拉选择"
|
||||
/>
|
||||
{formatDropdownOpen && (
|
||||
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
|
||||
{customTimeFormats
|
||||
.filter(fmt => {
|
||||
const isDateFormat = /YYYY|MM|DD/.test(fmt);
|
||||
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
|
||||
if (field.type === 'date') return isDateFormat;
|
||||
if (field.type === 'time') return isTimeFormat;
|
||||
return true;
|
||||
})
|
||||
.map(fmt => (
|
||||
<div
|
||||
key={fmt}
|
||||
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setEditFieldTimeFormat(fmt);
|
||||
setFormatDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{fmt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 1.3 新增字段格式输入同理替换
|
||||
|
||||
使用 `newFormatDropdownOpen` 状态,结构同上,过滤条件改为 `newFieldForm.type`。
|
||||
|
||||
#### 1.4 insertImage 增加表格检测
|
||||
|
||||
```tsx
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
|
||||
// 检测是否在表格单元格内
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
while (node) {
|
||||
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
|
||||
inTable = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
let width = 200;
|
||||
let height = 200;
|
||||
if (!inTable) {
|
||||
while (true) {
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
if (input === null) return;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') break;
|
||||
const parts = trimmed.split('*').map(s => s.trim());
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
||||
width = parseInt(parts[0]) || 0;
|
||||
height = parseInt(parts[1]) || 0;
|
||||
break;
|
||||
}
|
||||
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
|
||||
}
|
||||
}
|
||||
|
||||
const id = 'ph_' + Date.now();
|
||||
const hintText = '插入/点击放置图片';
|
||||
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
// 表格内使用 div 块级容器,自适应单元格
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
// 普通文本中保持行内 span
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插图' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><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;">${text}</span></span>​`;
|
||||
}
|
||||
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. ReportEditor.tsx
|
||||
|
||||
`insertImage` 同理增加表格检测分支,与 TemplateManage 保持一致(去除 `restoreSelection()` 和 `pushHistory()` 调用)。
|
||||
|
||||
### 3. print.ts
|
||||
|
||||
```css
|
||||
/* 修改前 */
|
||||
@page { size: A4; margin: 0; }
|
||||
body { margin: 0; padding: 10mm; ... }
|
||||
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
|
||||
|
||||
/* 修改后 */
|
||||
@page { size: A4; margin: 15mm 10mm; }
|
||||
body { margin: 0; padding: 0; ... }
|
||||
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 自定义下拉组件在滚动容器内可能被裁切 | 父容器设置 `relative`,下拉层设置 `absolute z-10`,并确保外层有适当的 overflow-visible 或足够空间 |
|
||||
| 表格检测 `while (node)` 循环在编辑器外部可能遍历到 body/html | 以 `node.nodeName === 'TD' \|\| node.nodeName === 'TH'` 为终止条件,安全 |
|
||||
| 表格内使用 div 后,fillPlaceholderSrc 需要兼容 | fillPlaceholderSrc 通过 `querySelector('.image-placeholder')` 匹配 class,不受标签名影响,已验证兼容 |
|
||||
| @page margin 增加后 .content width 190mm 会溢出 | 改为 `width: 100%`,让内容自然撑满可用区域 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- TemplateManage.tsx 的修改:删除新增状态和替换的 JSX 条件块,恢复原有的 `input[list]` + `datalist`。
|
||||
- ReportEditor.tsx 的修改:删除 insertImage 中的表格检测分支。
|
||||
- print.ts 的修改:恢复原始的 `@page`、`body`、`content` 样式。
|
||||
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 实现方案 — 2026-04-18-00-02-08
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题1:拖拽插入后边框不消失
|
||||
- `fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`。
|
||||
- `fillPlaceholder`(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。
|
||||
- 同时 `fillPlaceholder` 中图片 style 缺少 `max-height:100%;object-fit:contain;`,图片可能溢出占位符。
|
||||
|
||||
### 问题2:prompt 弹窗体验差 + 自动帧插入无区分
|
||||
- `insertImage` 使用浏览器原生 `prompt` 询问宽高,交互体验不佳。
|
||||
- 所有 `.image-placeholder` 一视同仁,`autoCaptureFrames` 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
|
||||
- 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
|
||||
|
||||
### 问题3:insertTable 使用 prompt
|
||||
- 与 insertImage 同理,原生 `prompt` 弹窗用户体验差,应替换为与项目风格一致的自定义 Modal。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/pages/ReportEditor.tsx` | ① fillPlaceholder 补齐样式清除和图片约束;② insertImage 改为 placeholderModal;③ insertTable 改为 tableModal;④ autoCaptureFrames/insertFrameToPlaceholder 选择器增加 `:not([data-mode="manual"])`;⑤ handleDrop 拦截 manual 模式;⑥ JSX 底部新增 2 个 Modal |
|
||||
| `src/pages/TemplateManage.tsx` | ① insertImage 改为 placeholderModal;② insertTable 改为 tableModal;③ JSX 底部新增 2 个 Modal |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. ReportEditor.tsx
|
||||
|
||||
#### 1.1 fillPlaceholder 修复
|
||||
|
||||
```ts
|
||||
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
|
||||
placeholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
||||
`;
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.2 新增状态
|
||||
|
||||
```ts
|
||||
const [placeholderModal, setPlaceholderModal] = useState({
|
||||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||||
});
|
||||
const [tableModal, setTableModal] = useState({
|
||||
isOpen: false, rows: '2', cols: '3'
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.3 insertImage 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.4 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.5 autoCaptureFrames 中选择器修改
|
||||
|
||||
将 `setTimeout` 回调内的:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
```
|
||||
改为:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.6 insertFrameToPlaceholder 选择器修改
|
||||
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.7 handleDrop 拦截 manual 模式
|
||||
|
||||
```ts
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
if (placeholder.getAttribute('data-mode') === 'manual') {
|
||||
alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) {
|
||||
fillPlaceholder(placeholder, frame);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.8 JSX 底部新增 Modal
|
||||
|
||||
**Placeholder Insert Modal**(在 `</div>` 关闭之前,与现有 `imagePickerOpen` Modal 并列):
|
||||
|
||||
```tsx
|
||||
{placeholderModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">宽度(px)</label>
|
||||
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">高度(px)</label>
|
||||
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1">占位符类型</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>手术影像占位<br/><span className="text-[10px] opacity-80">(支持自动/拖拽插入)</span></button>
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>静态图片占位<br/><span className="text-[10px] opacity-80">(仅支持点击插入)</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={() => {
|
||||
const w = parseInt(placeholderModal.width) || 200;
|
||||
const h = parseInt(placeholderModal.height) || 200;
|
||||
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
|
||||
const showShortText = w > 0 && w < 80;
|
||||
const text = showShortText ? '插图' : '插入/点击放置图片';
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
styleStr += `width:${w}px;height:${h}px;`;
|
||||
const id = 'ph_' + Date.now();
|
||||
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><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;">${text}</span></span>​`;
|
||||
execCmd('insertHTML', html);
|
||||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Table Insert Modal**:
|
||||
|
||||
```tsx
|
||||
{tableModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">行数</label>
|
||||
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">列数</label>
|
||||
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={() => {
|
||||
const rows = parseInt(tableModal.rows);
|
||||
const cols = parseInt(tableModal.cols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
|
||||
setTableModal({...tableModal, isOpen: false});
|
||||
return;
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModal({...tableModal, isOpen: false});
|
||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2. TemplateManage.tsx
|
||||
|
||||
结构与 ReportEditor.tsx 类似,但 `insertImage` 的 Modal 中也需要表格检测逻辑(已在上一轮修改中实现)。
|
||||
|
||||
#### 2.1 新增状态
|
||||
|
||||
```ts
|
||||
const [placeholderModal, setPlaceholderModal] = useState({
|
||||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||||
});
|
||||
const [tableModal, setTableModal] = useState({
|
||||
isOpen: false, rows: '2', cols: '3'
|
||||
});
|
||||
```
|
||||
|
||||
#### 2.2 insertImage 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.3 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
pushHistory();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.4 JSX 底部新增 Modal
|
||||
|
||||
与 ReportEditor.tsx 的 Modal 结构一致。TemplateManage.tsx 的 `insertImage` Modal 中,确认按钮需要执行表格检测(沿用上一轮修改的逻辑),然后调用 `execCmd('insertHTML', html)` 和 `pushHistory()`。
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| `data-mode="manual"` 的选择器 `:not([data-mode="manual"])` 可能不兼容旧浏览器 | 项目使用 Chrome/Edge,完全支持属性选择器 |
|
||||
| 新增 Modal 与现有 `imagePickerOpen` Modal 的 z-index 冲突 | 两者都使用 `z-50`,在同一时刻不会同时打开 |
|
||||
| TemplateManage.tsx 的 insertImage 中 pushHistory() 调用位置 | 确认按钮中在 `execCmd` 之前调用 `pushHistory()` |
|
||||
| 表格内的 insertImage(上一轮修改)与本次 Modal 的冲突 | 确认按钮中保留表格检测逻辑,在表格内时不使用 Modal 中的宽高值 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 删除新增的状态和 Modal JSX,恢复 `insertImage` 和 `insertTable` 中的 `prompt` 弹窗逻辑。
|
||||
- 恢复 `fillPlaceholder` 到修改前状态。
|
||||
- 恢复 `autoCaptureFrames`、`insertFrameToPlaceholder`、`handleDrop` 中的选择器和拦截逻辑。
|
||||
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 实现方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **拖拽后边框残留**:`ReportEditor.tsx` 中存在两个填充函数:
|
||||
- `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。
|
||||
- `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class,未清除内联样式,导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`。
|
||||
- 同理,`autoCaptureFrames` 的 `setTimeout` 回调中也直接操作 DOM,未清除内联样式。
|
||||
|
||||
2. **原生 `prompt()` 体验差**:`insertTable` 和 `insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。
|
||||
|
||||
3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
- `src/pages/ReportEditor.tsx`
|
||||
- `src/pages/TemplateManage.tsx`
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 一、ReportEditor.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [savedRange, setSavedRange] = useState<Range | null>(null);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 修复 `fillPlaceholder`(F1)
|
||||
|
||||
在 `fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式:
|
||||
|
||||
```typescript
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 3. 修复 `autoCaptureFrames` 中的自动插入(F2)
|
||||
|
||||
在 `setTimeout` 回调内,`classList.add('has-image')` 之后增加:
|
||||
|
||||
```typescript
|
||||
emptyPlaceholder.style.border = 'none';
|
||||
emptyPlaceholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 4. 替换 `insertTable` 为弹窗驱动(F3)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setTableRows('2');
|
||||
setTableCols('3');
|
||||
setTableModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertTable = () => {
|
||||
const rows = parseInt(tableRows);
|
||||
const cols = parseInt(tableCols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
|
||||
setTableModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。
|
||||
|
||||
#### 5. 替换 `insertImage` 为弹窗驱动(F4 / F5)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openImageModal = () => {
|
||||
editorRef.current?.focus();
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
while (node) {
|
||||
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
|
||||
inTable = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertImage = () => {
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
|
||||
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
|
||||
const allowSource = imageModalAllowSource;
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
|
||||
let html: string;
|
||||
if (imageModalInTable) {
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插入图片' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><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;">${text}</span></span>​`;
|
||||
}
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。
|
||||
|
||||
#### 6. 拦截拖拽(F6)
|
||||
|
||||
修改 `handleDrop`:
|
||||
|
||||
```typescript
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) fillPlaceholder(placeholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 7. 拦截点击空占位符(F7)
|
||||
|
||||
修改 `handleEditorClick` 中点击空占位符的分支:
|
||||
|
||||
```typescript
|
||||
if (!placeholder.classList.contains('has-image')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'frame') {
|
||||
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
|
||||
return;
|
||||
}
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. 拦截一键插入关键帧(F8)
|
||||
|
||||
修改 `insertFrameToPlaceholder`:
|
||||
|
||||
```typescript
|
||||
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
|
||||
if (!editorRef.current) {
|
||||
alert('编辑器未准备好');
|
||||
return;
|
||||
}
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (!emptyPlaceholder) {
|
||||
alert('没有可插入图片的空位');
|
||||
return;
|
||||
}
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
|
||||
return;
|
||||
}
|
||||
fillPlaceholder(emptyPlaceholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 9. 拦截自动帧插入(F9)
|
||||
|
||||
在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。
|
||||
|
||||
#### 10. 新增 JSX 弹窗(位于组件底部)
|
||||
|
||||
**Table Modal**:
|
||||
- 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm`
|
||||
- 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl`
|
||||
- 输入:行数、列数(`<input type="number">`)
|
||||
- 按钮:确认(`btn-accent` 样式)、取消
|
||||
|
||||
**Image Placeholder Modal**:
|
||||
- 同样遮罩层和卡片布局
|
||||
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
|
||||
- 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200),提示文字「正文一行文字高度约为 20 像素左右」。
|
||||
- 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
|
||||
- 按钮:确认、取消
|
||||
|
||||
### 二、TemplateManage.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标:
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 替换 `insertTable`
|
||||
|
||||
- `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。
|
||||
|
||||
#### 3. 替换 `insertImage`
|
||||
|
||||
- `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。
|
||||
- HTML 中增加 `data-allow-source` 属性。
|
||||
|
||||
#### 4. 新增 JSX 弹窗
|
||||
|
||||
结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 |
|
||||
| `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 |
|
||||
| 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 |
|
||||
| TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在,ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。
|
||||
- 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。
|
||||
74
工程分析/实现方案-2026-04-18-00-43-19.md
Normal file
74
工程分析/实现方案-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 实现方案 — 2026-04-18-00-43-19
|
||||
|
||||
## 根因分析
|
||||
|
||||
此前对「插入图片占位符」进行了弹窗改造,生成的占位符 HTML 新增了 `data-mode="frame|manual"` 属性,用于区分手术影像占位(允许拖拽/自动插入关键帧)和静态图片占位(仅允许点击上传/签名/素材)。
|
||||
|
||||
但 `defaultContent.ts` 中的默认模板仍使用旧版 `image-placeholder` 结构,**缺少 `data-mode` 属性**。这导致:
|
||||
- 默认模板中的签名、Logo 等静态占位符在新建报告时,可被关键帧拖拽误填充。
|
||||
- `autoCaptureFrames`、`insertFrameToPlaceholder` 等逻辑通过 `:not([data-mode="manual"])` 选择器过滤时,无该属性的占位符会被错误地当作手术影像占位处理。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
- `src/utils/defaultContent.ts`
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 一、医院 Logo 占位符(line 6)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;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>
|
||||
```
|
||||
|
||||
新结构(仅添加 `data-mode="manual"`,宽高及布局不变):
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;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>
|
||||
```
|
||||
|
||||
### 二、表格内术中影像占位符(lines 90/97/104/113/120/127,共 6 处)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" 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;">
|
||||
<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>
|
||||
```
|
||||
|
||||
新结构(仅添加 `data-mode="frame"`,宽高及布局不变):
|
||||
```html
|
||||
<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;">
|
||||
<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>
|
||||
```
|
||||
|
||||
### 三、手术者签名占位符(line 154)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;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>
|
||||
```
|
||||
|
||||
新结构(添加 `data-mode="manual"`,并将提示文本改为「插入/点击放置图片」,因为 width=200px ≥ 80px):
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;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>
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 修改默认模板后,新建报告的布局发生偏移 | 仅添加 `data-mode` 属性并修改文本,保持 `style` 中 `width/height/margin/display` 等所有布局属性绝对不变。 |
|
||||
| 默认模板中占位符是 `<span>`,而新弹窗在表格内生成 `<div>` | 用户明确要求「只保存当前框的大小不变」,因此不改动标签类型,保持 `<span>` 以避免表格布局被破坏。 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 仅修改单个文件 `src/utils/defaultContent.ts`,回滚时直接还原该文件即可。
|
||||
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` 属性。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
80
工程分析/测试方案-2026-04-16-22-35-38.md
Normal file
80
工程分析/测试方案-2026-04-16-22-35-38.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 测试方案 — 2026-04-16-22-35-38
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证默认模板中的红色占位符已正确替换为智能占位方格,且双向绑定字段 key 与右侧【基本信息】表单字段名完全一致,确保新建报告时联动功能直接生效。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge
|
||||
- 前置条件:已登录系统(建议使用 `admin` 账号)
|
||||
- 测试页面:`/report-editor`、`/template-manage`
|
||||
|
||||
---
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:新建报告 — 默认模板自动加载智能控件
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/report-editor`(新建报告) | 编辑器加载默认模板,姓名、性别、年龄、科别、床号、住院号等位置显示为带边框的可填方格 |
|
||||
| 1.2 | 右键检查第一个姓名方格的 DOM | HTML 结构为 `<span class="smart-field-wrapper" ...><span class="field-label">姓名:</span><span class="field-value" data-bind="patientName" ...></span></span>` |
|
||||
| 1.3 | 检查性别方格的 `data-bind` | 属性值为 `patientGender` |
|
||||
| 1.4 | 检查年龄方格的 `data-bind` | 属性值为 `patientAge` |
|
||||
|
||||
### 用例 2:报告编辑端 — 姓名方格输入联动表单
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 在编辑器"姓名"方格内输入"张三" | 右侧【基本信息】"患者姓名"字段实时显示"张三" |
|
||||
| 2.2 | 在编辑器"性别"方格内输入"男" | 右侧"患者性别"字段实时显示"男" |
|
||||
| 2.3 | 在编辑器"年龄"方格内输入"45" | 右侧"患者年龄"字段实时显示"45" |
|
||||
|
||||
### 用例 3:报告编辑端 — 表单修改联动方格
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 在右侧表单"患者姓名"中修改为"李四" | 编辑器内"姓名"方格内容同步变为"李四" |
|
||||
| 3.2 | 在右侧表单"科别"中输入"普外科" | 编辑器内"科别"方格同步变为"普外科" |
|
||||
| 3.3 | 在右侧表单"手术名称"中修改为"阑尾切除术" | 编辑器内"手术名称"方格同步变为"阑尾切除术" |
|
||||
|
||||
### 用例 4:模板管理端 — 字段库按钮 key 正确性
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 进入 `/template-manage` | 右侧字段库面板正常显示 |
|
||||
| 4.2 | 点击"性别"按钮,在编辑器中插入控件 | 插入的控件 `data-bind="patientGender"` |
|
||||
| 4.3 | 点击"年龄"按钮,在编辑器中插入控件 | 插入的控件 `data-bind="patientAge"` |
|
||||
| 4.4 | 点击"科别"按钮,在编辑器中插入控件 | 插入的控件 `data-bind="department"` |
|
||||
|
||||
### 用例 5:老数据兼容
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 5.1 | 编辑一个之前保存的、含有旧红色占位符的老模板 | 页面正常加载,不报错;旧红色文本仍然显示为普通文字 |
|
||||
|
||||
### 用例 6:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 6.1 | 在项目根目录执行 `npm run lint` | 无 TypeScript 编译错误 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 新建报告时,默认模板中的姓名、性别、年龄、科别、床号、住院号已变为可填方格。
|
||||
- [ ] 方格的 `data-bind` 属性与右侧表单字段名完全一致。
|
||||
- [ ] 在方格中输入,右侧表单实时同步更新。
|
||||
- [ ] 在表单中修改,方格内容实时同步更新。
|
||||
- [ ] `TemplateManage` 字段库按钮插入的控件 key 正确。
|
||||
- [ ] `npm run lint` 通过。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证 + DevTools 检查 DOM + `npm run lint` 类型检查。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
121
工程分析/测试方案-2026-04-17-00-13-09.md
Normal file
121
工程分析/测试方案-2026-04-17-00-13-09.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 测试方案 — 2026-04-17-00-13-09
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证手术时间方框联动、动态字段分类管理体系、字段显隐控制、自定义字段新增删除、以及 UI 紧凑化优化的正确性与稳定性。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge
|
||||
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
|
||||
- 测试页面:`/template-manage`、`/report-editor`
|
||||
|
||||
---
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:手术时间方框联动
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/report-editor`(新建报告) | 默认模板中"手术开始时间:"和"手术终止时间:"后各有一个可填方格 |
|
||||
| 1.2 | 检查方格的 `data-bind` 属性 | 分别为 `startTime` 和 `endTime` |
|
||||
| 1.3 | 在右侧【基本信息】选择手术开始时间 "09" 时 "30" 分 | 编辑器内"手术开始时间"方格自动显示 "09:30" |
|
||||
| 1.4 | 在编辑器"手术终止时间"方格内输入 "14:45" | 右侧表单"手术终止时间"下拉框自动变为 "14" 时 "45" 分 |
|
||||
| 1.5 | 删除方格内内容 | 右侧时/分下拉框恢复为空("--") |
|
||||
|
||||
### 用例 2:字段库分类展示与插入
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 进入 `/template-manage` | 右侧字段库面板显示两个 Tab:"插入字段"和"字段管理",默认在"插入字段" |
|
||||
| 2.2 | 观察"插入字段"Tab | 字段按"填空"、"单选"、"多选"、"时间"四组分类展示 |
|
||||
| 2.3 | 点击"时间"分类下的"手术开始时间" | 编辑器光标处插入带有 `data-bind="startTime"` 的方格 |
|
||||
| 2.4 | 点击"多选"分类下的"手术者" | 编辑器中插入 `data-bind="surgeon"` 的方格 |
|
||||
|
||||
### 用例 3:字段显隐控制
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 在 `/template-manage` 切换到"字段管理"Tab | 显示所有非系统锁定字段列表 |
|
||||
| 3.2 | 找到"患者性别"字段,取消其"显示"勾选 | 该字段右侧出现未勾选状态 |
|
||||
| 3.3 | 进入 `/report-editor` | 右侧【基本信息】中**不再出现**"患者性别"输入项 |
|
||||
| 3.4 | 返回 `/template-manage`,重新勾选"患者性别"的"显示" | — |
|
||||
| 3.5 | 再次进入 `/report-editor` | "患者性别"重新出现在右侧表单中 |
|
||||
|
||||
### 用例 4:系统锁定字段保护
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 在 `/template-manage` 的"字段管理"中查看"患者姓名"和"住院号" | 这两个字段**不在列表中**(或显示为灰色不可操作状态) |
|
||||
| 4.2 | 尝试在 ReportEditor 中隐藏"患者姓名" | 无法操作,因为 TemplateManage 中没有提供隐藏开关 |
|
||||
|
||||
### 用例 5:自定义字段新增与使用
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 5.1 | 在 `/template-manage` "字段管理"Tab 中,输入新字段名"术中失血量",选择分类"填空",类型"text",点击添加 | 列表中出现"术中失血量"字段,且"显示"已默认勾选 |
|
||||
| 5.2 | 切换到"插入字段"Tab | "填空"分类下出现"术中失血量"按钮 |
|
||||
| 5.3 | 点击"术中失血量"按钮插入编辑器 | 编辑器中出现 `data-bind="custom_xxxx"` 的方格 |
|
||||
| 5.4 | 进入 `/report-editor` | 右侧【基本信息】中出现"术中失血量"文本输入框 |
|
||||
| 5.5 | 在编辑器方格中输入"200ml" | 右侧表单同步显示"200ml" |
|
||||
|
||||
### 用例 6:自定义字段删除
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 6.1 | 在 `/template-manage` "字段管理"中,点击"术中失血量"的"删除"按钮 | 该字段从列表中消失 |
|
||||
| 6.2 | 切换到"插入字段"Tab | "术中失血量"按钮已不在"填空"分类中 |
|
||||
| 6.3 | 进入 `/report-editor` | 右侧表单中不再显示"术中失血量" |
|
||||
|
||||
### 用例 7:自定义单选/多选字段
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 7.1 | 在字段管理中新增字段"手术体位",分类"单选",类型"single_select",选项填入"仰卧位, 侧卧位, 俯卧位" | 新增成功 |
|
||||
| 7.2 | 在 `/report-editor` 中查看右侧表单 | "手术体位"显示为下拉选择框,包含三个选项 |
|
||||
| 7.3 | 选择"侧卧位" | 编辑器内对应方格显示"侧卧位" |
|
||||
|
||||
### 用例 8:UI 紧凑化验证
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 8.1 | 在 `/report-editor` 中观察姓名方格 | 方格宽度明显变小(约 32px 起),不再撑大行间距 |
|
||||
| 8.2 | 对比方格与周围普通文字的行高 | 行高基本一致,段落排版紧凑自然 |
|
||||
| 8.3 | 进入打印预览 | 方格边框消失,变为细下划线,不破坏打印版面 |
|
||||
|
||||
### 用例 9:老数据兼容
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 9.1 | 编辑一份之前保存的老报告(不含新自定义字段) | 页面正常加载,老字段正常显示和编辑 |
|
||||
| 9.2 | 检查 `localStorage` 中 `formFieldsConfig` | 首次访问后自动生成默认配置 |
|
||||
|
||||
### 用例 10:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 10.1 | 执行 `npm run lint` | 无 TypeScript 编译错误 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 手术开始/终止时间在模板中以方格呈现,且与右侧时/分下拉框双向联动。
|
||||
- [ ] `TemplateManage` 字段库支持按"填空/单选/多选/时间"分类展示。
|
||||
- [ ] `TemplateManage` 支持新增自定义字段,新增后可在 ReportEditor 表单和字段库中正常使用。
|
||||
- [ ] `TemplateManage` 支持删除自定义字段,删除后 ReportEditor 表单和字段库同步移除。
|
||||
- [ ] 字段显隐开关可控制 ReportEditor 右侧表单是否显示该字段。
|
||||
- [ ] "患者姓名"和"住院号"为系统锁定字段,不可删除、不可隐藏。
|
||||
- [ ] `field-value` 方格宽度缩小、行间距恢复正常、排版紧凑。
|
||||
- [ ] 打印时方格变为下划线风格。
|
||||
- [ ] 老报告和新用户首次登录均能正常加载,无报错。
|
||||
- [ ] `npm run lint` 通过。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证为主,结合 DevTools 观察 DOM 结构和 `localStorage` 配置。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
77
工程分析/测试方案-2026-04-17-09-36-07.md
Normal file
77
工程分析/测试方案-2026-04-17-09-36-07.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 测试方案 — 2026-04-17-09-36-07
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 `TemplateManage` 中插入智能字段后的空格消除、Backspace 删除保护、异常换行修复,以及默认模板预置字段控件的正确性。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge
|
||||
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
|
||||
- 测试页面:`/template-manage`
|
||||
|
||||
---
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:插入字段后无多余空格
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/template-manage` | 默认模板加载,第一行已预置姓名/性别/年龄等智能字段方格 |
|
||||
| 1.2 | 将光标定位到编辑器任意位置,点击字段库中的「手术名称」按钮 | 编辑器中插入一个蓝色边框的方格,**方格与后方文字之间没有明显的大片空白** |
|
||||
| 1.3 | 右键检查插入元素的 DOM | HTML 中没有 ` `,`smart-field-wrapper` 与前后文本节点紧密相连 |
|
||||
|
||||
### 用例 2:行尾插入字段不异常换行
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 2.1 | 在第一行"住院号:"的方格后点击,使光标位于行尾 | — |
|
||||
| 2.2 | 点击字段库插入「手术日期」 | 新插入的方格**紧跟在住院号方格后面**,不会跳到下一行(只要一行空间足够) |
|
||||
|
||||
### 用例 3:Backspace 删除字段不误删整行
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 3.1 | 在编辑器中插入一个「手术名称」字段 | 方格正常插入 |
|
||||
| 3.2 | 将光标定位到该方格的**紧右侧**(点击方格后方的文字前) | 光标闪烁在方格之后 |
|
||||
| 3.3 | 按下键盘 **Backspace** 键 | **仅删除该「手术名称」方格**,方格前方的文字(如"手术名称:")和整行 `<p>` **完好保留** |
|
||||
| 3.4 | 再次按 Backspace | 正常删除方格前方的文字字符(如冒号或文字),不会删行 |
|
||||
|
||||
### 用例 4:Delete 键同样受保护
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 4.1 | 将光标定位到「手术名称」方格的**紧左侧**(点击方格前方的文字后) | 光标闪烁在方格之前 |
|
||||
| 4.2 | 按下键盘 **Delete** 键 | **仅删除该「手术名称」方格**,整行内容保留 |
|
||||
|
||||
### 用例 5:默认模板预置字段验证
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 5.1 | 进入 `/template-manage`,观察默认模板第一行 | 姓名、性别、年龄、科别、床号、住院号后面**直接就是可填写的蓝色边框方格**,没有红色 `*姓名*` 纯文本占位符 |
|
||||
| 5.2 | 新建一个模板 | 新模板内容中也包含第一行的预置智能字段 |
|
||||
|
||||
### 用例 6:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|--------- |
|
||||
| 6.1 | 在项目根目录执行 `npm run lint` | 无 TypeScript 编译错误 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 插入字段后,方格与前后文字之间没有多余空格。
|
||||
- [ ] 行尾插入字段时,空间足够则不会异常跳到下一行。
|
||||
- [ ] 按 Backspace/Delete 删除字段时,仅删除该字段节点,不会误删整行。
|
||||
- [ ] 默认模板第一行已预置姓名、性别、年龄等智能字段方格。
|
||||
- [ ] `npm run lint` 通过。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证,结合 DevTools 观察 DOM 结构和键盘事件响应。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
47
工程分析/测试方案-2026-04-17-10-21-18.md
Normal file
47
工程分析/测试方案-2026-04-17-10-21-18.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18)
|
||||
|
||||
## 一、编译检查
|
||||
|
||||
- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。
|
||||
|
||||
## 二、功能验证步骤
|
||||
|
||||
### 测试 1:TemplateManage 字段唯一性
|
||||
1. 进入【模板管理】,选择一个模板。
|
||||
2. 在右侧字段库点击"姓名"(`patientName`),确认成功插入智能字段方框。
|
||||
3. 再次点击"姓名",确认弹出提示 `"姓名" 已存在,请勿重复插入。`,且没有再次插入方框。
|
||||
|
||||
### 测试 2:TemplateManage 字段删除(点击删除按钮)
|
||||
1. 在模板编辑器中,鼠标悬停在任意智能字段方框上,确认左上角出现红色小圆 ×。
|
||||
2. 点击该 ×,确认字段方框被移除,模板内容自动保存。
|
||||
3. 刷新页面,确认该字段确实已被删除。
|
||||
|
||||
### 测试 3:TemplateManage 字段删除(键盘 Backspace/Delete)
|
||||
1. 将光标定位在智能字段方框**正后方**(字段与后续文字之间),按 Backspace,确认字段被删除。
|
||||
2. 将光标定位在智能字段方框**正前方**(段落开头,字段前面),按 Delete,确认字段被删除。
|
||||
3. 尝试在段落中间的其他位置按 Backspace/Delete,确认不影响正常文本编辑。
|
||||
|
||||
### 测试 4:ReportManage 单报告导出
|
||||
1. 进入【报告管理】,确保列表中至少有一份已完成的报告。
|
||||
2. 点击某报告操作列的"导出"按钮,弹出导出选项弹窗。
|
||||
3. 选择 **PDF**:确认调用 `printDocument` 弹出浏览器打印窗口(可选择"另存为 PDF")。
|
||||
4. 再次点击"导出",选择 **JSON**:确认浏览器下载了一个 `.json` 文件。
|
||||
5. 打开该 JSON 文件,确认结构包含 `meta`(id、title、createdAt 等)和 `fields`(patientName、hospitalId 等字段值)。
|
||||
|
||||
### 测试 5:ReportManage 复选框与批量删除
|
||||
1. 在报告列表中,点击多行的左侧复选框,确认 `selectedIds` 状态更新,顶部出现批量操作栏并显示"已选择 N 项"。
|
||||
2. 点击表头全选 Checkbox,确认所有行被选中;再次点击,确认全部取消。
|
||||
3. 选中 2 份报告,点击批量操作栏的"批量删除",在确认弹窗中点击"取消",确认报告未被删除。
|
||||
4. 再次点击"批量删除"并确认,确认选中的报告从列表和 localStorage 中移除,批量操作栏消失。
|
||||
|
||||
### 测试 6:ReportManage 批量导出
|
||||
1. 选中 2 份报告,点击"批量导出 JSON",确认下载的 JSON 文件中包含一个数组,数组长度为 2,每个元素结构同单报告导出。
|
||||
2. 选中 2 份报告,点击"批量导出 PDF",确认弹出浏览器打印窗口,打印内容中两份报告之间有明显的分页(或分页符空白)。
|
||||
|
||||
## 三、预期结果
|
||||
|
||||
- `npm run lint` 0 错误。
|
||||
- 模板字段唯一性校验生效,重复插入被阻止。
|
||||
- 模板字段可通过点击 × 或键盘 Backspace/Delete 删除。
|
||||
- 报告管理支持单报告 PDF/JSON 导出。
|
||||
- 报告管理支持复选框全选、批量删除、批量 PDF/JSON 导出。
|
||||
56
工程分析/测试方案-2026-04-17-11-14-28.md
Normal file
56
工程分析/测试方案-2026-04-17-11-14-28.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 测试方案 — 字段聚焦高亮、删除按钮显隐控制与 .map Bug 修复(2026-04-17-11-14-28)
|
||||
|
||||
## 一、编译检查
|
||||
|
||||
- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。
|
||||
|
||||
## 二、功能验证步骤
|
||||
|
||||
### 测试 1:TemplateManage 字段聚焦高亮
|
||||
1. 进入【模板管理】,选择默认模板。
|
||||
2. 点击模板中的任意智能字段方框(如"姓名"),观察背景色是否明显变深(如 `#e2e8f0`),边框是否变深,是否有蓝色外发光。
|
||||
3. 点击另一个字段方框,确认高亮状态随焦点切换。
|
||||
|
||||
### 测试 2:TemplateManage 删除按钮位置与显隐
|
||||
1. 鼠标悬浮在智能字段方框上(不点击),确认字段**右上角**出现红色圆形 ×。
|
||||
2. 鼠标移开字段区域,确认红色 × 消失。
|
||||
3. 点击字段方框使其获得焦点,确认红色 × 保持显示。
|
||||
4. 点击红色 ×,确认该字段被删除,模板自动保存。
|
||||
|
||||
### 测试 3:ReportEditor 中不显示删除按钮
|
||||
1. 进入【新建报告】或编辑已有报告。
|
||||
2. 观察编辑器中的所有智能字段方框,确认**没有任何红色 × 显示**(无论悬浮还是聚焦)。
|
||||
3. 在 ReportEditor 的 field-value 中输入文字,确认编辑器正常工作。
|
||||
|
||||
### 测试 4:ReportEditor multi_select 脏数据兼容性
|
||||
1. DevTools Console 执行以下代码,手动构造一份脏数据报告(`surgeon` 为字符串而非数组):
|
||||
```js
|
||||
const dirtyReport = {
|
||||
id: 'test_dirty_001',
|
||||
title: '测试脏数据报告',
|
||||
patientName: '张三',
|
||||
hospitalId: 'H0001',
|
||||
author: 'admin',
|
||||
authorName: '管理员',
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'draft',
|
||||
content: '<p>测试内容</p>',
|
||||
surgeon: '张医生', // 脏数据:应该是数组
|
||||
assistant: [],
|
||||
anesthesiologist: ['周医生']
|
||||
};
|
||||
const reports = JSON.parse(localStorage.getItem('reports') || '[]');
|
||||
reports.push(dirtyReport);
|
||||
localStorage.setItem('reports', JSON.stringify(reports));
|
||||
```
|
||||
2. 刷新页面,进入【报告管理】,点击编辑该测试报告。
|
||||
3. 确认页面**没有白屏崩溃**,右侧【基本信息】中的"手术者"字段正确显示为单标签 `张医生`,且可以正常添加/删除标签。
|
||||
4. 删除该测试报告,避免污染数据。
|
||||
|
||||
## 三、预期结果
|
||||
|
||||
- `npm run lint` 0 错误。
|
||||
- TemplateManage 中字段聚焦时有明显高亮效果。
|
||||
- TemplateManage 中删除按钮仅悬浮/聚焦时显示在右上角,点击可删除字段。
|
||||
- ReportEditor 中完全看不到删除按钮。
|
||||
- ReportEditor 能兼容非数组类型的 multi_select 脏数据,不崩溃。
|
||||
49
工程分析/测试方案-2026-04-17-11-34-24.md
Normal file
49
工程分析/测试方案-2026-04-17-11-34-24.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 测试方案 — 字段悬浮高亮、电子签上传与手术者签名联动(2026-04-17-11-34-24)
|
||||
|
||||
## 一、编译检查
|
||||
|
||||
- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。
|
||||
|
||||
## 二、功能验证步骤
|
||||
|
||||
### 测试 1:TemplateManage 字段悬浮高亮
|
||||
1. 进入【模板管理】,选择默认模板。
|
||||
2. 将鼠标悬浮在右侧字段库中的"姓名"按钮上(不点击)。
|
||||
3. 观察编辑器中"姓名"对应的智能字段方框,确认出现蓝色外发光/背景变浅蓝色高亮。
|
||||
4. 鼠标移开"姓名"按钮,确认高亮效果消失,字段框恢复原样。
|
||||
5. 尝试悬浮其他字段按钮(如"手术名称"、"手术日期"),确认高亮定位准确。
|
||||
|
||||
### 测试 2:UserManage 电子签上传与压缩
|
||||
1. 进入【用户管理】,点击任意医生用户的"编辑"按钮。
|
||||
2. 在编辑弹窗中找到"电子签名"区域,点击"上传签名"。
|
||||
3. 选择一张大于 500×500 像素的本地图片(如 1200×800 的 PNG/JPG)。
|
||||
4. 确认上传后预览图显示正常,且图片已被等比例压缩(宽或高最大不超过 500px)。
|
||||
5. 点击"保存用户",刷新页面后再次编辑该用户,确认签名图片仍然保留。
|
||||
6. 点击"清除签名",确认预览图消失;保存后刷新,确认签名已清除。
|
||||
|
||||
### 测试 3:TemplateManage 新增"手术者签名"字段
|
||||
1. 进入【模板管理】,查看右侧【插入字段】面板。
|
||||
2. 确认分类列表中新增"图片"分类,下方有"手术者签名"按钮。
|
||||
3. 点击"手术者签名"按钮,确认模板中插入一个智能字段方框(`data-bind="surgeonSignature"`)。
|
||||
4. 再次点击"手术者签名",确认弹出"已存在,请勿重复插入"的提示。
|
||||
|
||||
### 测试 4:ReportEditor 签名自动填充
|
||||
1. 确保当前登录用户(如 admin)已通过测试 2 上传了电子签。
|
||||
2. 进入【新建报告】或编辑已有报告,观察模板中的"手术者签名"方框。
|
||||
3. 确认方框中自动显示了当前登录用户的签名图片(高度约 2 行文字)。
|
||||
4. 在 UserManage 中清除当前用户的签名,返回 ReportEditor 刷新页面。
|
||||
5. 确认"手术者签名"方框显示文本"【请上传电子签】"。
|
||||
|
||||
### 测试 5:签名图片排版与打印效果
|
||||
1. 在 ReportEditor 中确认签名图片与周围文字行高协调,没有明显撑大段落间距。
|
||||
2. 点击报告页面的"打印"按钮,在浏览器打印预览中观察签名图片。
|
||||
3. 确认打印输出中签名图片高度仍然保持约 2 行文字,排版正常。
|
||||
|
||||
## 三、预期结果
|
||||
|
||||
- `npm run lint` 0 错误。
|
||||
- TemplateManage 悬浮高亮响应迅速,定位准确。
|
||||
- UserManage 电子签上传、压缩、清除、持久化均正常。
|
||||
- TemplateManage 可插入"手术者签名"字段,且唯一性校验生效。
|
||||
- ReportEditor 能自动根据当前用户签名状态填充图片或提示文本。
|
||||
- 签名图片在编辑态和打印态均保持约 2 行文字高度,排版美观。
|
||||
56
工程分析/测试方案-2026-04-17-12-34-56.md
Normal file
56
工程分析/测试方案-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 测试方案 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||
|
||||
## 一、编译检查
|
||||
|
||||
- 执行 `npm run lint`(`tsc --noEmit`),确保全量 TypeScript 无编译错误。
|
||||
|
||||
## 二、功能验证步骤
|
||||
|
||||
### 测试 1:TemplateManage 撤销功能恢复
|
||||
1. 进入【模板管理】,选择默认模板。
|
||||
2. 点击某智能字段(如"手术日期")右上角的红色 × 删除该字段。
|
||||
3. 点击编辑器工具栏的"撤销"按钮(↶)。
|
||||
4. 确认被删除的字段重新出现,撤销功能正常。
|
||||
|
||||
### 测试 2:TemplateManage 插入字段不强制换行
|
||||
1. 在模板编辑器中,将光标定位到一行文字中间(如"手术名称:"后面)。
|
||||
2. 点击右侧字段库插入"手术日期"。
|
||||
3. 确认"手术日期"字段框紧跟在光标位置,没有跳到下一行。
|
||||
4. 再次插入"手术者签名",确认同样保持在当前行。
|
||||
|
||||
### 测试 3:TemplateManage Backspace/Delete 精准删除
|
||||
1. 将光标定位在某智能字段框**正后方**,按 Backspace。
|
||||
2. 确认仅该字段被删除,前面和后面的文本不受影响。
|
||||
3. 将光标定位在某智能字段框**正前方**,按 Delete。
|
||||
4. 确认仅该字段被删除,其他文本完好无损。
|
||||
5. 删除后点击"撤销",确认字段恢复。
|
||||
|
||||
### 测试 4:签名图片尺寸约束
|
||||
1. 进入【用户管理】,给当前登录用户上传一张较大的电子签图片。
|
||||
2. 进入【新建报告】,在右侧【基本信息】中将"手术者签名确认"选择为"已签字"。
|
||||
3. 观察模板中的签名图片,确认其宽度不超过 120px,高度不超过 40px,且等比例缩放未变形。
|
||||
|
||||
### 测试 5:签名字段显隐与表单联动
|
||||
1. 进入【模板管理】,查看右侧【字段管理】,确认"手术者签名"和"手术者签名确认"字段存在且可切换"显示/隐藏"。
|
||||
2. 进入【新建报告】,确认右侧【基本信息】表单中出现了"手术者签名确认"下拉框(默认"未签字")。
|
||||
3. 选择"未签字",确认模板中的签名方框显示"【未签字】"。
|
||||
4. 选择"已签字",确认模板中的签名方框显示签名图片。
|
||||
5. 在 UserManage 中清除当前用户签名,返回 ReportEditor 将"手术者签名确认"选为"已签字"。
|
||||
6. 确认签名方框显示"【请上传电子签】"。
|
||||
|
||||
### 测试 6:完成报告签名校验提示
|
||||
1. 确保模板中存在"手术者签名"字段,且 ReportEditor 中"手术者签名确认"为"未签字"。
|
||||
2. 点击"完成报告",确认弹出提示:"模板中包含【手术者签名】字段,但您在基本信息中未选择'已签字'。是否继续完成报告?"
|
||||
3. 点击"取消",确认报告未被保存,停留在编辑页。
|
||||
4. 将"手术者签名确认"改为"已签字",清除当前用户的电子签图片。
|
||||
5. 再次点击"完成报告",确认弹出提示:"您选择了'已签字',但您的账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?"
|
||||
6. 点击"确定",确认报告正常保存并跳转至报告管理页。
|
||||
|
||||
## 三、预期结果
|
||||
|
||||
- `npm run lint` 0 错误。
|
||||
- TemplateManage 中删除字段后撤销正常。
|
||||
- 插入字段不强制换行;Backspace/Delete 精准删除单个字段。
|
||||
- 签名图片受 120×40px 约束,等比例缩放。
|
||||
- 表单联动正常:已签字→显示图片,未签字→显示"【未签字】",无签名图→显示"【请上传电子签】"。
|
||||
- 完成报告时签名异常给出弱阻断提示,用户可取消或继续。
|
||||
82
工程分析/测试方案-2026-04-17-13-32-07.md
Normal file
82
工程分析/测试方案-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 测试方案 — 2026-04-17-13-32-07
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 `TemplateManage.tsx` 中以下两项修复是否生效且无副作用:
|
||||
1. `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` 快捷键正确调用自定义 Undo/Redo。
|
||||
2. 在段落末尾(含 `<br>`)插入 `smart-field-wrapper` 不再换行错位。
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 编译检查
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
- **预期结果**:`tsc --noEmit` 通过,0 errors。
|
||||
|
||||
---
|
||||
|
||||
### 2. 快捷键 Undo/Redo 测试
|
||||
|
||||
**前置条件**:登录后进入 `/template-manage`,选中任意模板。
|
||||
|
||||
#### 2.1 删除字段后撤销
|
||||
1. 在编辑器中点击一个已有的 `smart-field-wrapper` 的 × 按钮(或通过 Backspace/Delete 删除字段)。
|
||||
2. **立刻按下 `Ctrl+Z`**。
|
||||
- **预期结果**:被删除的字段完整恢复,内容与样式均正常。
|
||||
|
||||
#### 2.2 撤销后再重做
|
||||
1. 完成步骤 2.1 后,**按下 `Ctrl+Y`**(或 `Ctrl+Shift+Z`)。
|
||||
- **预期结果**:刚刚恢复的字段再次被删除。
|
||||
|
||||
#### 2.3 插入字段后撤销
|
||||
1. 从右侧字段库点击任意字段插入到编辑器。
|
||||
2. **按下 `Ctrl+Z`**。
|
||||
- **预期结果**:刚插入的字段消失,编辑器恢复到插入前的状态。
|
||||
|
||||
#### 2.4 多次撤销
|
||||
1. 连续进行多次编辑操作(插入字段、删除字段、输入文字)。
|
||||
2. 连续多次按 `Ctrl+Z`。
|
||||
- **预期结果**:每次按 `Ctrl+Z` 都按自定义历史栈顺序回退一步;不会触发浏览器原生 undo 造成状态混乱。
|
||||
|
||||
---
|
||||
|
||||
### 3. 插入字段排版测试
|
||||
|
||||
**前置条件**:编辑器中存在一个以 `<br>` 结尾的 `<p>` 标签,例如:
|
||||
```html
|
||||
<p style="font-family: SimSun;">
|
||||
<strong>手术日期:</strong><br>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### 3.1 在 `<br>` 后插入字段
|
||||
1. 将光标放到 `<strong>手术日期:</strong>` 后面的空白处(即 `<br>` 附近)。
|
||||
2. 从右侧字段库点击 `手术日期` 字段插入。
|
||||
- **预期结果**:`smart-field-wrapper` 出现在 `<p>` 标签内部,与 `<strong>手术日期:</strong>` 保持在同一行,**不会**跑到 `<p>` 外面形成新段落。
|
||||
|
||||
#### 3.2 段中插入字段
|
||||
1. 在正常段落(如 `姓名:xxx`)中间插入字段。
|
||||
- **预期结果**:字段与前后文字保持在同一行,排版正常。
|
||||
|
||||
#### 3.3 已有字段附近插入
|
||||
1. 在已有的 `smart-field-wrapper` 前后插入新字段(只要字段 key 不同,允许插入)。
|
||||
- **预期结果**:新字段正确插入,不会与已有字段重叠或被挤到下一行。
|
||||
|
||||
---
|
||||
|
||||
### 4. 回归测试
|
||||
|
||||
1. **保存模板**:进行任意编辑后点击「保存模板」,刷新页面,确认内容已持久化。
|
||||
2. **打印预览**:点击打印预览按钮,确认字段显示正常。
|
||||
3. **工具栏撤销/重做按钮**:确认点击工具栏的撤销/重做按钮依然工作正常。
|
||||
4. **Backspace/Delete 边界拦截**:确认光标紧邻字段时按 Backspace/Delete 仍然只删除字段本身,不会误删整段。
|
||||
|
||||
---
|
||||
|
||||
## 判定标准
|
||||
|
||||
- 所有编译检查和手工测试均通过,方可认为任务完成。
|
||||
- 若任一测试失败,回滚修改并重新分析根因。
|
||||
81
工程分析/测试方案-2026-04-17-18-38-47.md
Normal file
81
工程分析/测试方案-2026-04-17-18-38-47.md
Normal file
@@ -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. **完成报告**:点击"完成报告",确认弱提示(签名确认弹窗)逻辑仍然生效。
|
||||
|
||||
---
|
||||
|
||||
## 判定标准
|
||||
|
||||
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。
|
||||
71
工程分析/测试方案-2026-04-17-19-26-17.md
Normal file
71
工程分析/测试方案-2026-04-17-19-26-17.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 测试方案 — 2026-04-17-19-26-17
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 6 项需求全部正确实现,且不破坏现有编辑、保存、打印功能。
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 编译检查
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
- **预期**:`tsc --noEmit` 通过,0 errors。
|
||||
|
||||
---
|
||||
|
||||
### 2. 字段体系清理(需求 1)
|
||||
1. 进入 `/template-manage`。
|
||||
2. 在"插入字段"Tab 中,确认分类列表只有"填空、单选、多选、时间",**没有"图片"**。
|
||||
3. 在"字段管理"Tab 中,确认同样没有"图片"分组。
|
||||
4. 在"字段管理 → 新增字段"中,确认 category select 没有"图片"选项,type select 也不会出现"图片"。
|
||||
|
||||
---
|
||||
|
||||
### 3. 默认模板占位符替换(需求 4)
|
||||
1. 重新加载默认模板(或清空 localStorage 后重新登录)。
|
||||
2. 确认模板顶部 Logo 处显示为一个虚线框占位符(而非直接显示医院 Logo 图片)。
|
||||
3. 确认"手术者签名:"后方显示为一个虚线框占位符,而非 `smart-field-wrapper` 文本框。
|
||||
|
||||
---
|
||||
|
||||
### 4. 插入图片占位符同行与尺寸设置(需求 2、5)
|
||||
1. 在 `/template-manage` 编辑器中,将光标放在一段文字中间,点击工具栏"插入图片占位符"。
|
||||
2. 在 prompt 中输入宽度 `120`,高度 `60`。
|
||||
3. 确认占位符插入后与前后文字**保持在同一行**,没有换行。
|
||||
4. 使用浏览器 DevTools 检查该占位符,确认 `style` 中包含 `display:inline-flex` 和 `max-width:120px; max-height:60px;`。
|
||||
5. 在 `/report-editor` 中重复上述操作,确认行为一致。
|
||||
6. 测试留空宽高:确认插入的占位符没有 `max-width/max-height`,但有默认的 `padding: 8px 16px;`。
|
||||
|
||||
---
|
||||
|
||||
### 5. 占位符文字自适应(需求 3)
|
||||
1. 插入一个宽度为 `60px` 的图片占位符。
|
||||
2. 确认占位符内显示的文字是**"插入图片"**(而非"插入/点击放置图片")。
|
||||
3. 插入一个宽度为 `120px` 的占位符,确认显示"插入/点击放置图片"。
|
||||
|
||||
---
|
||||
|
||||
### 6. 图片来源选择弹窗统一(需求 6)
|
||||
1. 在 `/template-manage` 中,点击任意无图片的 `image-placeholder`。
|
||||
2. 确认弹出"选择图片来源"弹窗,包含"本地上传"、"我的签名"、"系统素材"三个选项。
|
||||
3. 选择"系统素材"中的医院 Logo,确认占位符被替换为 Logo 图片。
|
||||
4. 在 `/report-editor` 中点击占位符,确认弹窗行为与 `/template-manage` 完全一致。
|
||||
5. 测试弹窗中的"取消"按钮,确认点击后弹窗关闭且占位符未被修改。
|
||||
|
||||
---
|
||||
|
||||
### 7. 回归测试
|
||||
1. **保存模板**:修改模板后点击保存,刷新页面确认内容不丢失。
|
||||
2. **保存报告**:在 `/report-editor` 中填写表单并保存草稿,确认内容持久化。
|
||||
3. **打印预览**:确认图片占位符(已填充和未填充)在打印预览中显示正常。
|
||||
4. **撤销重做**:插入占位符后按 `Ctrl+Z`,确认占位符被正确撤销。
|
||||
5. **拖拽/自动插入关键帧**:确认 `/report-editor` 中的视频关键帧仍能正常插入到图片占位符中。
|
||||
|
||||
---
|
||||
|
||||
## 判定标准
|
||||
|
||||
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。
|
||||
96
工程分析/测试方案-2026-04-17-21-32-27.md
Normal file
96
工程分析/测试方案-2026-04-17-21-32-27.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 测试方案 — 2026-04-17-21-32-27
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证时间/日期字段的格式配置、默认值策略、以及模板底部「撰写时间」动态字段的正确性。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:TemplateManage 新增时间字段配置
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 登录 admin,进入「模板管理」 |
|
||||
| 2 | 点击「新增字段」,category 选「时间」,type 选「日期」 | 下方出现「默认值」select(手动选择/当前时间)和「显示格式」select(YYYY-MM-DD / YYYY年MM月DD日) |
|
||||
| 3 | 默认值选「当前时间」,格式选「YYYY年MM月DD日」,填写标签「出院日期」,点击「添加字段」 | 字段列表中出现「出院日期」,category 显示「时间 · date」 |
|
||||
| 4 | 新增字段 category 选「时间」,type 选「时分」 | 显示格式 select 出现「24小时制 / 12小时制」 |
|
||||
|
||||
### TC-2:TemplateManage 编辑已有时间字段配置
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在字段列表中点击「手术日期」 | 进入编辑模式 |
|
||||
| 2 | 修改显示格式为「YYYY年MM月DD日」,保存 | 字段信息更新 |
|
||||
| 3 | 点击「手术开始时间」 | 编辑模式中出现 24h/12h 选项 |
|
||||
|
||||
### TC-3:ReportEditor 日期格式同步到富文本
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 新建报告,加载默认模板 | 基本信息中出现「撰写时间」字段 |
|
||||
| 2 | 在 TemplateManage 中将「手术日期」格式设为「YYYY年MM月DD日」 | — |
|
||||
| 3 | 回到 ReportEditor,手术日期选「2026-04-17」 | 编辑器中「手术日期」smart field 显示为「2026年04月17日」 |
|
||||
| 4 | 将格式改回「YYYY-MM-DD」 | 编辑器中显示为「2026-04-17」 |
|
||||
|
||||
### TC-4:ReportEditor 时间 12h/24h 格式
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在 TemplateManage 中将「手术开始时间」格式设为「12小时制」 |
|
||||
| 2 | 在 ReportEditor 中选择 09:30 AM | 编辑器中显示「09:30 上午」 |
|
||||
| 3 | 切换为 02:30 PM | 编辑器中显示「02:30 下午」;reportData.startHour = "14" |
|
||||
| 4 | 将格式改回「24小时制」 | 表单变为 hour(00-23)+minute;编辑器显示「14:30」 |
|
||||
|
||||
### TC-5:自动填充当前时间
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 确保「撰写时间」的 timeDefault 为「当前时间」 | — |
|
||||
| 2 | 新建报告 | 「撰写时间」字段自动填充为当天日期(如 2026-04-17) |
|
||||
| 3 | 确保「手术开始时间」的 timeDefault 为「当前时间」 | — |
|
||||
| 4 | 新建报告 | 「手术开始时间」自动填充为当前时分 |
|
||||
| 5 | 编辑已有报告(已有值的报告) | 已有值不被覆盖 |
|
||||
|
||||
### TC-6:模板底部「撰写时间」
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 新建报告,加载默认模板 | 模板底部出现「撰写时间:2026年04月17日」(或当天日期) |
|
||||
| 2 | 在基本信息中修改「撰写时间」 | 编辑器底部同步更新 |
|
||||
| 3 | 预览/打印报告 | 底部显示正确的撰写时间 |
|
||||
|
||||
### TC-7:通用 time 字段(非 startTime/endTime)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在 TemplateManage 新增一个 time 字段「麻醉开始时间」 |
|
||||
| 2 | 在 ReportEditor 中新建报告 | 基本信息中出现「麻醉开始时间」,可正常选择时分 |
|
||||
| 3 | 选择 08:15 | 编辑器中对应 smart field 显示「08:15」(24h)或「08:15 上午」(12h) |
|
||||
|
||||
### TC-8:向后兼容
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 清除 localStorage 中 `formFieldsConfig`,重新登录 | 系统加载默认字段,所有时间字段正常工作 |
|
||||
| 2 | 不配置 timeFormat/timeDefault 的自定义字段 | 按默认行为工作(date 显示 YYYY-MM-DD,time 显示 24h) |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] TemplateManage 中时间字段可正常新增、编辑、保存配置
|
||||
- [ ] ReportEditor 中 date 字段可根据格式正确显示在富文本中
|
||||
- [ ] ReportEditor 中 time 字段 12h/24h 切换正常,存储值正确
|
||||
- [ ] 自动填充当前时间仅在值为空时触发
|
||||
- [ ] 模板底部「撰写时间」动态显示且可编辑
|
||||
- [ ] 通用 time 字段有表单渲染并能正确同步到富文本
|
||||
- [ ] 现有报告和历史数据不受本次改动影响
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
77
工程分析/测试方案-2026-04-17-23-12-52.md
Normal file
77
工程分析/测试方案-2026-04-17-23-12-52.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 测试方案 — 2026-04-17-23-12-52
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证时间字段格式选项补全、报告编辑器 "24h" 脏数据显示修复、以及字段管理编辑面板点击失效问题的修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:默认时间格式修正
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 清除 localStorage 中 `formFieldsConfig`,重新登录 | 系统加载默认字段配置 |
|
||||
| 2 | 进入「模板管理」→「字段管理」 | 字段列表正常显示 |
|
||||
| 3 | 点击「手术开始时间」进入编辑模式 | `timeFormat` 输入框显示为 `HH:mm`,而非 `24h` |
|
||||
| 4 | 点击「手术终止时间」进入编辑模式 | 同上,显示 `HH:mm` |
|
||||
|
||||
### TC-2:报告编辑器 "24h" 脏数据兼容
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」新建报告 | 加载默认模板 |
|
||||
| 2 | 在基本信息中填写手术开始时间为 `09:30` | 编辑器中「手术开始时间」smart field 显示 `09:30`,而非 `24h` |
|
||||
| 3 | 填写手术终止时间为 `14:00` | 编辑器中「手术终止时间」smart field 显示 `14:00`,而非 `24h` |
|
||||
| 4 | (进阶)手动将 `formFieldsConfig` 中某 time 字段的 `timeFormat` 改回 `'24h'`,刷新后再新建报告 | 该时间字段仍能正常显示时间值(通过兼容兜底) |
|
||||
|
||||
### TC-3:格式选项按类型过滤
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」→「字段管理」 | — |
|
||||
| 2 | 点击「手术日期」(date 类型)进入编辑模式,聚焦格式输入框 | datalist 下拉中**只出现日期格式**(如 `YYYY-MM-DD`、`YYYY年MM月DD日`、`MM-DD`、`MM月DD日`),**不出现** `HH:mm`、`hh:mm A` |
|
||||
| 3 | 点击「手术开始时间」(time 类型)进入编辑模式,聚焦格式输入框 | datalist 下拉中**只出现时间格式**(如 `HH:mm`、`hh:mm A`),**不出现** `YYYY-MM-DD` 等日期格式 |
|
||||
| 4 | 新增字段 → category 选「时间」→ type 选「日期」→ 聚焦格式输入框 | 同上,只显示日期格式 |
|
||||
| 5 | type 切换为「时分」→ 聚焦格式输入框 | 只显示时间格式 |
|
||||
|
||||
### TC-4:旧脏数据清理
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在浏览器 DevTools → Application → Local Storage 中,手动将 `customTimeFormats` 设为 `["YYYY-MM-DD", "24h", "12h", "HH:mm"]` | — |
|
||||
| 2 | 刷新页面,进入「模板管理」 | — |
|
||||
| 3 | 聚焦任意时间字段的格式输入框 | datalist 中**不出现** `24h` 和 `12h`,只出现 `YYYY-MM-DD`、`YYYY年MM月DD日`、`HH:mm`、`hh:mm A` 等有效格式 |
|
||||
|
||||
### TC-5:字段编辑面板点击与滚动
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」→「字段管理」 | — |
|
||||
| 2 | 将页面滚动到底部,使最后一个字段(如「撰写时间」)刚好位于可视区域底部边缘 | — |
|
||||
| 3 | 点击该字段卡片进入编辑模式 | 编辑面板展开后,卡片**自动平滑滚动**到可视区域内,所有输入框和下拉框均可正常点击获取焦点,无需手动滚动 |
|
||||
| 4 | 重复测试中间位置的字段 | 展开后同样能正常点击所有控件 |
|
||||
|
||||
### TC-6:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 默认字段 `startTime`/`endTime` 的 `timeFormat` 为 `HH:mm`,不再出现 `24h`
|
||||
- [ ] 报告编辑器中时间字段正常显示时间值,不出现 "24h" 字样
|
||||
- [ ] date 字段的 format datalist 只显示日期格式,time 字段只显示时间格式
|
||||
- [ ] localStorage 中旧脏数据 `24h`/`12h` 被自动清理
|
||||
- [ ] 字段编辑面板展开后自动滚动对齐,底部输入框可正常点击
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
86
工程分析/测试方案-2026-04-17-23-38-34.md
Normal file
86
工程分析/测试方案-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 测试方案 — 2026-04-17-23-38-34
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证时间格式自定义下拉组件、表格内图片占位符插入、以及打印多页页边距三项修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:时间格式自定义下拉(编辑字段)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 登录 admin,进入「模板管理」→「字段管理」 | — |
|
||||
| 2 | 点击「手术日期」进入编辑模式,聚焦格式输入框 | 输入框下方弹出下拉列表,显示 `YYYY-MM-DD`、`YYYY年MM月DD日` 等日期格式 |
|
||||
| 3 | 点击列表中 `YYYY年MM月DD日` | 输入框被填充为 `YYYY年MM月DD日`,下拉列表关闭 |
|
||||
| 4 | 点击「手术开始时间」进入编辑模式,聚焦格式输入框 | 下拉列表只显示时间格式(`HH:mm`、`hh:mm A`),**不出现**日期格式 |
|
||||
| 5 | 在格式输入框中手写输入 `MM-DD HH:mm`,按 Enter | 新格式被保存,下拉列表中新增 `MM-DD HH:mm`;重新聚焦后可在列表中看到 |
|
||||
|
||||
### TC-2:时间格式自定义下拉(新增字段)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在字段管理底部点击「新增字段」 | — |
|
||||
| 2 | category 选「时间」,type 选「日期」,聚焦格式输入框 | 下拉列表只显示日期格式 |
|
||||
| 3 | type 切换为「时分」,聚焦格式输入框 | 下拉列表只显示时间格式 |
|
||||
| 4 | 手写输入新格式 `hh:mm:ss`,按 Enter | 新格式被保存到 `customTimeFormats` 并出现在列表中 |
|
||||
|
||||
### TC-3:表格内插入图片占位符(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」,编辑器中确保存在表格 | — |
|
||||
| 2 | 将光标放入表格的某个 `<td>` 单元格内 | — |
|
||||
| 3 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,占位符直接插入到单元格内 |
|
||||
| 4 | 用 DevTools 检查插入的元素 | 外层标签为 `<div class="image-placeholder">`,style 包含 `width:100%;height:100%;max-width:200px;max-height:200px;`,内部结构完整(delete-btn + placeholder-text) |
|
||||
| 5 | 点击图片占位符填充一张图片 | 图片正常显示,占位符添加 `has-image` class |
|
||||
|
||||
### TC-4:普通文本中插入图片占位符(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 将光标放在表格外的普通文本段落中 | — |
|
||||
| 2 | 点击「插入图片占位符」 | 弹出 prompt 要求输入宽高 |
|
||||
| 3 | 输入 `100*80` | 插入行内 `<span class="image-placeholder">`,style 包含 `width:100px;height:80px;`,与前后文字保持在同一行 |
|
||||
|
||||
### TC-5:表格内插入图片占位符(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」,确保报告内容中有表格 | — |
|
||||
| 2 | 将光标放入表格单元格内,插入图片占位符 | 不弹出 prompt,插入自适应 div 占位符 |
|
||||
| 3 | 从视频分析面板拖拽关键帧到占位符中 | 图片正常填充,结构完整 |
|
||||
|
||||
### TC-6:打印多页页边距
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在「报告编辑器」中创建一份内容较多的报告(或复制粘贴大量文本使内容超过1页A4) | — |
|
||||
| 2 | 点击「预览/打印」 | 弹出打印对话框 |
|
||||
| 3 | 在浏览器打印预览中查看第2页 | 第二页顶部和底部均有约 15mm 的留白,不紧贴纸张边缘;左右留白约 10mm |
|
||||
| 4 | 检查第一页 | 第一页同样有 15mm 上下 / 10mm 左右的均匀留白 |
|
||||
|
||||
### TC-7:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 时间格式输入框支持点击展开下拉列表、选择选项、手写输入并记忆新格式
|
||||
- [ ] date 字段下拉只显示日期格式,time 字段只显示时间格式
|
||||
- [ ] 表格内插入图片占位符不弹 prompt,结构完整,使用 div 块级容器
|
||||
- [ ] 普通文本中插入图片占位符仍弹 prompt,使用 span 行内容器
|
||||
- [ ] 打印多页时每一页上下均有约 15mm 留白,左右约 10mm
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 测试方案 — 2026-04-18-00-02-08
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证拖拽关键帧插入样式修复、图片占位符自定义弹窗与分类隔离、表格插入自定义弹窗三项修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:拖拽关键帧后边框消失 + 图片约束
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」,上传视频并自动摘取关键帧 | 右侧视频分析面板显示关键帧缩略图 |
|
||||
| 2 | 编辑器中插入一个图片占位符 | 显示虚线框占位符 |
|
||||
| 3 | 从右侧拖拽关键帧到占位符中 | 图片正常显示,**虚线边框和灰色背景消失**;图片不溢出占位符边界 |
|
||||
| 4 | 用 DevTools 检查 `<img>` 元素 | style 包含 `max-width:100%;max-height:100%;object-fit:contain;` |
|
||||
|
||||
### TC-2:图片占位符插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,而是弹出居中的自定义 Modal |
|
||||
| 3 | Modal 中显示默认宽度 200、高度 200 | — |
|
||||
| 4 | 修改宽度为 120,高度为 80 | 输入框值正常变化 |
|
||||
| 5 | 选择「静态图片占位」模式 | 模式按钮高亮切换 |
|
||||
| 6 | 点击「确认插入」 | Modal 关闭,编辑器中插入行内 `<span>` 占位符,带有 `data-mode="manual"` 属性 |
|
||||
| 7 | 用 DevTools 检查插入的元素 | `data-mode="manual"` 存在,style 包含 `width:120px;height:80px;` |
|
||||
|
||||
### TC-3:图片占位符插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | 弹出自定义 Modal |
|
||||
| 3 | 确认插入 | 占位符正常插入,结构完整 |
|
||||
|
||||
### TC-4:自动帧插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在编辑器中插入两个占位符:第一个是 frame 模式,第二个是 manual 模式 | — |
|
||||
| 2 | 上传视频,开启「自动帧插入」,点击「自动关键帧摘取」 | — |
|
||||
| 3 | 观察占位符填充情况 | 只有**第一个 frame 模式**的占位符被自动填入关键帧;第二个 manual 占位符**保持空白** |
|
||||
|
||||
### TC-5:一键插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中先插入一个 manual 占位符,再插入一个 frame 占位符 | — |
|
||||
| 2 | 右侧关键帧卡片点击「插入」按钮 | 关键帧填入**第二个 frame 占位符**;manual 占位符不受影响 |
|
||||
|
||||
### TC-6:拖拽拦截 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中插入一个 manual 占位符 | — |
|
||||
| 2 | 从右侧拖拽关键帧到该 manual 占位符上 | 弹出提示「此处为静态图片占位符,仅支持点击插入...」;占位符**不被填充** |
|
||||
|
||||
### TC-7:表格插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | **不弹出 prompt**,弹出自定义 Modal |
|
||||
| 3 | Modal 中显示默认行数 2、列数 3 | — |
|
||||
| 4 | 修改行数为 4,列数为 2 | 输入框值正常变化 |
|
||||
| 5 | 点击「确认插入」 | Modal 关闭,编辑器中插入 4 行 2 列的表格 |
|
||||
|
||||
### TC-8:表格插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | 弹出自定义 Modal |
|
||||
| 3 | 设置行数 3,列数 3,确认插入 | 表格正常插入 |
|
||||
|
||||
### TC-9:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 拖拽关键帧后占位符边框和背景消失,图片不溢出
|
||||
- [ ] 点击「插入图片占位符」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持选择占位符模式(frame/manual)
|
||||
- [ ] manual 占位符带有 `data-mode="manual"` 属性
|
||||
- [ ] 自动帧插入和一键插入跳过 manual 占位符
|
||||
- [ ] 拖拽到 manual 占位符被拦截并提示
|
||||
- [ ] 点击「插入表格」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持输入行数/列数并正常插入表格
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证以下 3 个需求是否按预期工作:
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,占位符内联边框和背景被彻底清除。
|
||||
2. 「插入表格」和「插入图片占位符」改为自定义居中弹窗,且插入位置准确。
|
||||
3. 图片占位符支持「图片来源限制」,各限制类型在拖拽、点击、一键插入、自动插入场景下均被正确拦截或放行。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome / Edge(推荐)
|
||||
- 测试账号:任意账号(建议 `admin` / `123456`)
|
||||
|
||||
## 测试用例
|
||||
|
||||
| 编号 | 场景 | 操作步骤 | 预期结果 |
|
||||
|------|------|---------|---------|
|
||||
| TC-01 | 拖拽关键帧后边框清除 | 1. 进入「报告编辑」页。<br>2. 上传视频并自动/手动截取关键帧。<br>3. 插入一个图片占位符。<br>4. 从右侧视频分析面板拖拽关键帧到占位符。 | 占位符内的虚线框和浅灰背景完全消失,图片正常显示,无残留边框。 |
|
||||
| TC-02 | 自动帧插入后边框清除 | 1. 开启「自动帧插入」。<br>2. 上传新视频触发自动摘帧。<br>3. 观察自动插入到占位符的关键帧。 | 自动插入的图片同样无残留边框和背景。 |
|
||||
| TC-03 | ReportEditor 插入表格弹窗 | 1. 点击工具栏「表格」按钮。<br>2. 在弹窗中输入 3 行 4 列,点击确认。 | 页面中央弹出模态框;确认后表格正确插入到光标所在位置。 |
|
||||
| TC-04 | ReportEditor 插入图片占位符弹窗 | 1. 点击工具栏「插入图片占位符」按钮。<br>2. 在弹窗中输入宽 150、高 100,选择「所有来源」,点击确认。 | 页面中央弹出模态框;确认后行内占位符(150×100)插入到光标位置,且可正常点击上传图片。 |
|
||||
| TC-05 | TemplateManage 插入表格弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「表格」按钮,输入 2 行 2 列确认。 | 弹窗正常弹出,表格插入位置准确。 |
|
||||
| TC-06 | TemplateManage 插入图片占位符弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「插入图片占位符」,输入宽 80、高 80,确认。 | 弹窗正常弹出,占位符插入后显示「插图」缩写文本。 |
|
||||
| TC-07 | 表格内插入占位符隐藏尺寸 | 1. 在表格单元格内点击。<br>2. 点击「插入图片占位符」。 | 弹窗中提示「表格内占位符将自动填满单元格,无需设置尺寸」,不显示宽高输入框。 |
|
||||
| TC-08 | 仅限关键帧占位符 | 1. 插入占位符时选择「仅限关键帧」。<br>2. 点击该空占位符。 | 弹出提示「此区域仅限插入关键帧图片...」,不打开图片选择器。 |
|
||||
| TC-09 | 仅限关键帧-拖拽放行 | 1. 对「仅限关键帧」占位符,从右侧拖拽关键帧放入。 | 关键帧正常插入,无报错。 |
|
||||
| TC-10 | 仅限关键帧-上传拦截 | 1. 对「仅限关键帧」占位符,尝试点击打开图片选择器。 | 被拦截并提示。 |
|
||||
| TC-11 | 仅限上传类占位符 | 1. 插入占位符时选择「仅限本地上传/签名/素材」。<br>2. 点击该空占位符。 | 正常弹出「本地上传/签名/素材」三选一弹窗。 |
|
||||
| TC-12 | 仅限上传类-拖拽拦截 | 1. 对「仅限上传类」占位符,从右侧拖拽关键帧放入。 | 弹出提示「此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。」,拒绝插入。 |
|
||||
| TC-13 | 一键插入拦截 | 1. 插入一个「仅限上传类」占位符作为第一个空位。<br>2. 在右侧关键帧卡片点击「插入」按钮。 | 弹出提示,拒绝插入。 |
|
||||
| TC-14 | 自动帧插入跳过受限占位符 | 1. 插入一个「仅限上传类」占位符。<br>2. 开启自动帧插入,上传视频触发自动摘帧。 | 第一个空占位符因限制为 upload 而跳过,不插入关键帧。 |
|
||||
| TC-15 | 向后兼容 | 1. 打开一份旧报告(无 `data-allow-source` 的占位符)。<br>2. 拖拽关键帧和点击上传。 | 旧占位符行为不变,两种操作均可正常执行。 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] TC-01 ~ TC-02:拖拽/自动插入后占位符内联边框和背景完全清除。
|
||||
- [ ] TC-03 ~ TC-07:ReportEditor 和 TemplateManage 的表格/图片占位符弹窗正常工作,焦点恢复无误。
|
||||
- [ ] TC-08 ~ TC-10:「仅限关键帧」占位符正确拦截上传类操作,放行关键帧操作。
|
||||
- [ ] TC-11 ~ TC-14:「仅限上传类」占位符正确拦截关键帧操作,放行上传类操作。
|
||||
- [ ] TC-15:旧数据无 `data-allow-source` 时默认行为不受影响。
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工验证。本项目无自动化测试框架,所有用例通过浏览器交互逐项确认。
|
||||
32
工程分析/测试方案-2026-04-18-00-43-19.md
Normal file
32
工程分析/测试方案-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 测试方案 — 2026-04-18-00-43-19
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证默认模板 `defaultContent.ts` 中的全部 `.image-placeholder` 已正确添加 `data-mode` 属性,且尺寸、布局与原有模板保持一致。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome / Edge
|
||||
- 测试账号:`admin` / `123456`
|
||||
|
||||
## 测试用例
|
||||
|
||||
| 编号 | 场景 | 操作步骤 | 预期结果 |
|
||||
|------|------|---------|---------|
|
||||
| TC-01 | 默认模板 Logo 占位符 | 1. 登录后新建报告(不选择任何模板,加载默认模板)。<br>2. 查看编辑器顶部的医院 Logo 占位符。 | 占位符尺寸仍为 65×65px;DOM 中可见 `data-mode="manual"`;从右侧视频分析面板拖拽关键帧到 Logo 占位符时,弹出提示「此处为静态图片占位符...」并拒绝插入。 |
|
||||
| TC-02 | 默认模板签名占位符 | 1. 新建报告,滚动到底部「手术者签名」处。<br>2. 查看占位符 DOM。 | 占位符尺寸仍为 200×40px;DOM 中可见 `data-mode="manual"`;提示文本为「插入/点击放置图片」;拖拽关键帧到签名区域时被拦截。 |
|
||||
| TC-03 | 默认模板表格内影像占位符 | 1. 新建报告,查看「手术图片说明表格」中的 6 个占位符。<br>2. 检查 DOM。 | 每个占位符尺寸仍为 100%×150px;DOM 中可见 `data-mode="frame"`;从右侧拖拽关键帧到表格占位符时,可正常插入。 |
|
||||
| TC-04 | 自动帧插入过滤 | 1. 新建报告,确保表格内和签名/Logo 占位符均为空。<br>2. 上传视频并开启「自动帧插入」。<br>3. 观察自动插入行为。 | 自动插入的关键帧只会填充表格内 `data-mode="frame"` 的占位符;不会填充 `data-mode="manual"` 的 Logo 和签名占位符。 |
|
||||
| TC-05 | 布局无偏移 | 1. 对比修改前后的默认模板预览效果(或打印预览)。 | 所有占位符的位置、大小、边框、背景色与修改前完全一致,无可见差异。 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] TC-01 ~ TC-03:默认模板中 8 个占位符均已正确添加 `data-mode`,尺寸未改变。
|
||||
- [ ] TC-04:自动帧插入和拖拽逻辑对 `manual` / `frame` 的隔离生效。
|
||||
- [ ] TC-05:视觉和排版与修改前完全一致。
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工验证。通过浏览器 DevTools 检查 DOM 属性,并通过拖拽/自动插入验证隔离逻辑。
|
||||
944
工程分析/经验记录.md
Normal file
944
工程分析/经验记录.md
Normal file
@@ -0,0 +1,944 @@
|
||||
# 经验记录
|
||||
|
||||
> 本文档为项目统一知识库,记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读,防止重复踩坑。
|
||||
> 记录格式:A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题
|
||||
|
||||
---
|
||||
|
||||
## 记录 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 恢复已有报告、draf t 恢复新建报告)中,将 `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(', ')` 转换为字符串,保持显示一致性。
|
||||
|
||||
---
|
||||
|
||||
## 记录 13:手术时间方框化、动态字段分类体系与 UI 紧凑化
|
||||
|
||||
**A. 具体问题**
|
||||
1. 手术开始/终止时间在模板中是纯文本"时 分",无法与右侧表单联动。
|
||||
2. `TemplateManage` 的字段库是静态列表,无法按医院需求自定义字段;`ReportEditor` 的右侧表单全部硬编码,每新增一个字段就要改代码。
|
||||
3. `field-value` 方格使用了 `min-width: 60px` 和上下 `padding`,导致行间距被撑大,排版松散。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 时间字段在 `defaultContent.ts` 中没有使用 `data-bind` 智能控件,且右侧表单将时间拆分为 `startHour`/`startMinute` 两个独立字段,缺少与方格的双向转换层。
|
||||
2. 早期设计采用了"硬编码表单"思路,字段名、类型、选项全部写死在 `ReportEditor.tsx` 的 JSX 中,不具备扩展性。
|
||||
3. `inline-block` 元素自带上下 `padding` 和 `border`,超出了默认行高,浏览器不得不增大整行高度以容纳它。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **时间方框联动**:
|
||||
- 在 `defaultContent.ts` 中替换为 `data-bind="startTime"` 和 `data-bind="endTime"` 的方格。
|
||||
- 在 `ReportEditor.tsx` 的 `handleEditorInput` 中,对 `startTime`/`endTime` 使用 `split(':')` 解析,反向更新 `startHour`/`startMinute`;在 `useEffect(reportData)` 中拼接 `HH:mm` 同步回方格。
|
||||
2. **动态字段体系**:
|
||||
- 在 `types.ts` 中新增 `FieldType`、`FormField`、`DEFAULT_FORM_FIELDS`,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。
|
||||
- 使用 `localStorage` 的 `formFieldsConfig` 持久化字段配置。
|
||||
- `TemplateManage.tsx` 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。
|
||||
- `ReportEditor.tsx` 右侧基本信息表单改为遍历 `formFieldsConfig`、按 `type` switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。
|
||||
3. **UI 紧凑化**:
|
||||
- 将 `min-width` 从 `60px` 缩至 `32px`。
|
||||
- 去除上下 `padding`,使用 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`。
|
||||
- 背景色改为 `#f8fafc`(编辑态更明显),打印时恢复透明并只保留下划线。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。
|
||||
- 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"(Config-Driven UI),降低后续维护成本。
|
||||
- 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。
|
||||
|
||||
---
|
||||
|
||||
## 记录 14:智能字段插入间距修复与 Backspace 防误删
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage.tsx` 中使用 `insertSmartField` 插入智能字段后,字段后方会出现一个可见的空格(由 ` ` 和多行模板字符串中的换行/缩进空白引起)。
|
||||
2. 在 `contenteditable` 中,光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace,WebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。
|
||||
3. `defaultContent.ts` 中的 `smartField` 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 ` `,导致插入后字段与后续文字之间总有一个不必要的空格。
|
||||
2. `contenteditable="false"` 的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。
|
||||
3. `defaultContent.ts` 中的 `smartField` 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 `smartField` 调用前后都引入了额外的空白文本节点。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 ` `。
|
||||
2. **防止内部折行**:给 `.smart-field-wrapper` 增加 `white-space: nowrap;`(内联样式 + CSS 类双保险),确保标签和输入框不会在行中间被拆开。
|
||||
3. **拦截 Backspace/Delete**:在 `TemplateManage.tsx` 的编辑器上增加 `keydown` 事件监听(capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace,或光标在文本节点末尾且后一个兄弟节点是 `.smart-field-wrapper` 时按 Delete,主动 `preventDefault()` 并手动移除该字段节点,随后同步更新 `localStorage` 中的模板内容。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。
|
||||
- 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `<p>` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。
|
||||
- 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。
|
||||
|
||||
---
|
||||
|
||||
## 记录 15:5 项交互与默认值优化(占位符尺寸、签名状态、素材预加载)
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 5 个 UI/UX 改进需求:
|
||||
1. 插入图片占位符时两次 `prompt` 弹窗合并为一次,用英文逗号分隔宽高;
|
||||
2. 占位符未指定尺寸时默认显示为 `200×200px`,且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`;
|
||||
3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`;
|
||||
4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列,根据 `user.signature` 显示「已上传」/「未上传」;
|
||||
5. 修复系统重置后 `ReportEditor` 的素材库为空的问题,将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `insertImage()` 在两个编辑器(`TemplateManage`、`ReportEditor`)中均使用两次独立的 `prompt()`,操作冗余且中断感强。
|
||||
2. 旧占位符样式使用 `max-width`/`max-height`,当内容区域大于占位符时,边框和背景不会收缩到指定尺寸,视觉尺寸不可控;且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化,不统一。
|
||||
3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段,导致新系统首次进入 `/system-settings` 时相关开关为空。
|
||||
4. `UserManage.tsx` 表格缺少签名可视化列,管理员无法一眼辨别哪些医生已上传电子签名。
|
||||
5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`,素材库为空,图片选择器无法使用系统默认 logo。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **合并 prompt**:
|
||||
```ts
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
const parts = input.split(',').map(s => s.trim());
|
||||
```
|
||||
按逗号分割,第一部分为宽度,第二部分为高度。留空或单侧留空时,另一侧自动回退到 `200`。
|
||||
2. **固定尺寸样式**:
|
||||
- 移除 `max-width`/`max-height`,改用 `width:${width}px;` / `height:${height}px;`。
|
||||
- 默认值逻辑:`!widthStr && !heightStr` → `200×200`;`widthStr && !heightStr` → 宽自定义、高 `200`;`!widthStr && heightStr` → 宽 `200`、高自定义。
|
||||
3. **默认设置补全**:在 `Login.tsx` 的 `defaultSettings` 中显式加入:
|
||||
```ts
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
|
||||
```
|
||||
4. **签名状态列**:在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中,于「部门」之后、「状态」之前插入签名状态标签。
|
||||
5. **素材预加载前置**:将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中,并增加 `savedAssets.length === 0` 的判空保护,避免覆盖用户后续上传的素材。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于成对的数值输入(如宽高、行列),优先考虑单输入框 + 分隔符,减少弹窗次数;同时做好格式解析和容错(空值、单侧空值、非数字)。
|
||||
- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控,避免 `inline-flex` 内容撑大容器。
|
||||
- 任何需要在多个页面共享的初始化数据(如素材库、默认配置),应放在全局初始化入口(如登录页的 `initData`),而不是分散在各个页面的 `useEffect` 中。
|
||||
- 表格字段变更时,注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致,避免列错位。
|
||||
|
||||
---
|
||||
|
||||
## 记录 16:模板字段唯一性、删除按钮与报告批量导出
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage` 中智能字段可以重复插入多次,导致模板混乱。
|
||||
2. 智能字段在某些边界位置(如段落开头/结尾)无法通过 Backspace/Delete 删除。
|
||||
3. `ReportManage` 缺少报告导出功能和批量操作能力。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `insertSmartField` 没有检测 DOM 中是否已存在相同 `data-bind` 的字段节点。
|
||||
2. 之前的 `keydown` 拦截逻辑只处理了光标在文本节点内的情况,没有处理光标直接在块级父节点边界(`startContainer` 为 `<p>` 等块元素)的场景。
|
||||
3. `ReportManage` 的设计只支持单条查看/编辑/删除,没有设计多选状态和导出逻辑。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **唯一性校验**:在 `insertSmartField` 中通过 `editorRef.current?.querySelector([data-bind="..."])` 预检查,若已存在则 `alert` 并终止插入。
|
||||
2. **删除按钮**:给 `.smart-field-wrapper` 内部增加一个红色圆形的 `<span class="delete-btn">×</span>`,点击即可删除整个字段节点。同时在 `index.css` 和 `print` 媒体查询中分别定义显示/隐藏样式。
|
||||
3. **键盘删除增强**:重写 `keydown` 处理器,同时处理 `startContainer` 为 `TEXT_NODE` 和 `ELEMENT_NODE` 两种情况。当光标位于块级父节点的子节点边界时,通过 `el.childNodes[offset - 1]` 或 `el.childNodes[offset]` 定位字段节点并安全删除。
|
||||
4. **报告批量操作**:
|
||||
- 在 `ReportManage.tsx` 中引入 `selectedIds` 状态,表格每行增加 Checkbox,表头支持全选/反选。
|
||||
- 增加浮动批量操作栏,支持"批量删除"、"批量导出 PDF"、"批量导出 JSON"、"取消选择"。
|
||||
- 单报告操作列增加"导出"按钮,点击弹出模态框选择 PDF 或 JSON。
|
||||
- PDF 导出复用现有的 `printDocument(content)`;JSON 导出通过 `Blob` + `URL.createObjectURL` 实现下载,数据结构包含 `meta`(报告元信息)和 `fields`(所有 `DEFAULT_FORM_FIELDS` 对应值)。
|
||||
- 批量 PDF 将多份报告的 HTML 用 `<div style="page-break-after: always;"></div>` 拼接后统一打印。
|
||||
- 批量 JSON 将多份报告导出为数组形式的单个 `.json` 文件。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在 `contentEditable` 中插入的任何可复用控件,都应考虑增加唯一性校验和明确的删除入口(可视化按钮 + 键盘事件拦截)。
|
||||
- 键盘事件处理不能假设 `startContainer` 一定是文本节点,必须覆盖块级元素边界的情况。
|
||||
- 当列表页需要增加批量操作时,建议将"选择状态"和"批量动作"封装为独立逻辑,保持单条操作按钮的可维护性。
|
||||
- 导出功能应尽量复用现有的 `printDocument` 等工具函数,减少新依赖引入。
|
||||
|
||||
---
|
||||
|
||||
## 记录 17:字段聚焦高亮、删除按钮显隐隔离与 multi_select 脏数据崩溃修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage` 中编辑智能字段时缺少视觉焦点反馈,用户体验不够直观。
|
||||
2. 红色 × 删除按钮始终显示在字段内部左侧,且在任何包含 `smart-field-wrapper` 的页面(包括 `ReportEditor`)都会显示。
|
||||
3. `ReportEditor` 加载某些历史报告时崩溃,报错 `(y[x.key] || []).map is not a function`。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 之前没有为 `.field-value` 定义 `:focus` 状态的 CSS 样式。
|
||||
2. `delete-btn` 使用 `display: inline-flex` 默认常驻显示,且没有针对页面做显隐隔离。
|
||||
3. `multi_select` 字段(如 `surgeon`、`assistant`)的渲染直接对值调用 `.map()`,但旧数据或异常存储可能将其保存为字符串(如 `"张医生"` 而非 `["张医生"]`),导致 `.map` 在字符串上调用时抛出 `TypeError`。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **聚焦高亮**:在 `index.css` 中为 `.smart-field-wrapper .field-value:focus` 增加背景色加深(`#e2e8f0`)、边框变深(`#94a3b8`)和蓝色外发光(`box-shadow: 0 0 0 2px rgba(59,130,246,0.25)`)的样式,配合 `transition` 实现平滑反馈。
|
||||
2. **删除按钮定位与显隐隔离**:
|
||||
- 将 `delete-btn` 从字段内部移到 `.field-value` 之后,并给 `.smart-field-wrapper` 增加 `position:relative`,使 `delete-btn` 可绝对定位到右上角(`top: -8px; right: -8px`)。
|
||||
- 默认 `display: none`;在 `TemplateManage` 的编辑器容器上增加 `template-editor-mode` class,通过 `.template-editor-mode .smart-field-wrapper:hover .delete-btn` 和 `:focus-within .delete-btn` 控制仅在 TemplateManage 中悬浮/聚焦时显示。
|
||||
- `ReportEditor` 的编辑器容器没有 `template-editor-mode`,因此删除按钮不会显示。
|
||||
3. **类型安全修复**:在 `ReportEditor.tsx` 的 `multi_select` 渲染分支中,增加 `Array.isArray` 检查:
|
||||
```ts
|
||||
const rawValue = (reportData as any)[field.key];
|
||||
const tags = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
|
||||
```
|
||||
确保无论旧数据是数组、字符串还是空值,都能安全渲染为标签列表。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 任何需要在不同页面显隐不同的 UI 元素,应通过容器级 class 做样式隔离,而不是依赖全局显示/隐藏。
|
||||
- `contentEditable` 控件的焦点状态必须有明确的视觉反馈(背景/边框/阴影变化),否则用户难以感知当前编辑位置。
|
||||
- 对从持久化存储读取的数组类型数据,在 React 渲染前务必做 `Array.isArray` 校验,防止历史脏数据导致整页崩溃。
|
||||
|
||||
---
|
||||
|
||||
## 记录 18:字段悬浮高亮、电子签上传与手术者签名联动
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage` 中右侧字段库按钮与编辑器中的字段缺乏视觉关联,用户难以快速定位字段位置。
|
||||
2. `UserManage` 缺少电子签名上传功能,无法为医生绑定个人签名图。
|
||||
3. 模板中缺少"手术者签名"字段,报告编辑时无法自动带入医生签名。
|
||||
4. 签名图片若直接放入 `.field-value` 中,容易撑大行高,影响排版和打印效果。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 字段库按钮没有任何与编辑器 DOM 联动的交互反馈机制。
|
||||
2. 早期设计未考虑医疗文书中的电子签需求,`User` 模型和 `DEFAULT_FORM_FIELDS` 均缺少签名相关定义。
|
||||
3. 没有针对签名图片设计专门的 CSS 尺寸约束,导致浏览器按原图尺寸渲染,破坏行高。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **悬浮高亮**:在 `TemplateManage.tsx` 的字段库按钮上增加 `onMouseEnter` / `onMouseLeave`,直接操作编辑器中对应 `data-bind` 的 `.field-value` 的 `style.boxShadow` 和 `style.backgroundColor`,实现蓝色外发光/背景变浅蓝色的即时高亮反馈。
|
||||
2. **电子签上传与压缩**:
|
||||
- 在 `UserManage.tsx` 中增加 `compressImage(file, maxSize=500)` 工具函数,利用 Canvas 等比例缩放并填充白色背景,输出 JPEG base64(质量 0.8)。
|
||||
- 在用户编辑/新增弹窗中增加"电子签名"区块:预览图、上传按钮、清除按钮。
|
||||
- 编辑当前登录用户时同步更新 `storage.set('currentUser', ...)`,确保 ReportEditor 能读取最新签名。
|
||||
3. **手术者签名字段**:
|
||||
- `types.ts` 中 `User` 增加 `signature?: string`;`FieldType` 增加 `'signature'`;`DEFAULT_FORM_FIELDS` 追加 `surgeonSignature`(分类"图片",系统锁定)。
|
||||
- `TemplateManage` 插入字段分类增加"图片",`surgeonSignature` 自动出现在该分类下。
|
||||
- `ReportEditor` 的"表单 → 编辑器"同步 `useEffect` 中,对 `fieldKey === 'surgeonSignature'` 做特殊分支:有签名则填充 `<img class="report-signature-img" src="..." />`,无签名则填充文本"【请上传电子签】"。
|
||||
4. **签名排版优化**:
|
||||
- 在 `index.css` 和 `print.ts` 中定义 `.report-signature-img`:
|
||||
```css
|
||||
.report-signature-img {
|
||||
height: 2.4em;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin: -0.3em 0;
|
||||
}
|
||||
```
|
||||
- 打印媒体查询中同步使用 `!important` 确保打印输出也保持同样尺寸。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当需要在 React 之外直接操作 DOM 样式实现即时反馈时,优先使用原生事件 + inline style(避免触发组件重渲染导致光标丢失)。
|
||||
- 任何新增的持久化字段,应在类型定义(TypeScript interface)、默认值(DEFAULT_xxx)、以及所有相关读写逻辑中同步补齐,防止类型不一致。
|
||||
- 在 `contentEditable` 中插入图片时,务必通过 CSS 对 `height`/`width`/`vertical-align` 做严格约束,避免原图尺寸破坏文本流。
|
||||
- 涉及打印的样式必须在 iframe 打印模板和 `@media print` 中双端同步,防止打印效果与屏幕预览不一致。
|
||||
|
||||
---
|
||||
|
||||
## 记录 19:撤销栈修复、字段删除交互优化与签名字段闭环
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage` 中通过红色 × 或键盘删除智能字段后,浏览器撤销栈(Undo)失效,点击"撤销"按钮无法恢复。
|
||||
2. 插入"手术日期"、"手术者签名"等字段后,字段框有时会跳到下一行。
|
||||
3. Backspace 键无法删除字段;Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。
|
||||
4. 签名图片没有最大尺寸限制;"手术者签名"字段不在 ReportEditor 表单中显示,无法受控管理签字状态。
|
||||
5. 点击"完成报告"时缺少对签名状态的确认提示。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 删除字段时使用了 `target.remove()` 直接操作 DOM,绕过了浏览器的原生撤销栈(`undo stack`)。
|
||||
2. 插入的 `smart-field-wrapper` 是 `inline-block` 元素,但其后缺少行内锚点文本节点,浏览器在特定光标位置插入时容易将其挤到新行。
|
||||
3. `keydown` 拦截逻辑中 `target.remove()` 同样会误删父级块节点(WebKit 在边界处对 `contenteditable="false"` inline 元素的处理缺陷)。
|
||||
4. `surgeonSignature` 字段原先 `visibleInForm: false`,且签名图片样式仅用 `height: 2.4em` 约束,没有 `max-width/max-height` 的硬限制。
|
||||
5. 完成报告逻辑中缺少针对签名字段的业务校验。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **撤销栈修复**:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 `Range.selectNode(target)` + `document.execCommand('delete')`。这样浏览器会将删除操作记录到撤销栈中,`execCommand('undo')` 可以正确恢复。
|
||||
2. **防换行**:在 `insertSmartField` 和 `defaultContent.ts` 的 `smartField()` 生成的 HTML 末尾增加 `​`(零宽空格),作为稳定的行内锚点,防止字段被浏览器排到新行。
|
||||
3. **精准键盘删除**:配合 `Range.selectNode` + `execCommand('delete')`,不再直接 `remove()` DOM 节点,彻底避免误删父级 `<p>` 的问题。
|
||||
4. **签名尺寸与字段管理**:
|
||||
- `types.ts` 中将 `surgeonSignature` 改为 `visibleInForm: true, isSystemLocked: false`,使其出现在字段管理和右侧表单中。
|
||||
- 新增 `isSigned` 字段(单选:已签字 / 未签字,默认"未签字")。
|
||||
- 签名图片样式改为 `max-width: 120px; max-height: 40px; object-fit: contain;`,并在打印样式和 `print.ts` 中同步。
|
||||
5. **签名同步逻辑重构**:`ReportEditor` 中 `surgeonSignature` 的渲染由 `isSigned` 控制:
|
||||
- `已签字` 且 `currentUser.signature` 存在 → 显示签名图片。
|
||||
- `已签字` 但无签名图 → 显示 "【请上传电子签】"。
|
||||
- `未签字` → 显示 "【未签字】"。
|
||||
6. **完成报告签名校验**:`saveReport('completed')` 中,若模板包含 `surgeonSignature`:
|
||||
- 未选择"已签字" → `confirm` 弱阻断提示。
|
||||
- 已选择"已签字"但无签名图 → `confirm` 弱阻断提示。
|
||||
- 用户点击"取消"则中断保存,点击"确定"仍可继续保存。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在 `contentEditable` 中删除元素时,**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`,以确保撤销/重做等原生编辑行为正常工作。
|
||||
- 插入 `inline-block` 或 `inline-flex` 控件时,可在其后追加 `​` 零宽空格,为浏览器提供稳定的行内文本锚点,减少排版异常。
|
||||
- 任何需要从"不可见"改为"可见/可配置"的字段,应在 `DEFAULT_FORM_FIELDS`、`Report 类型`、`reportData 初始值` 三处同步更新,防止表单渲染遗漏。
|
||||
- 对于图片类嵌入内容,应使用 `max-width`/`max-height` + `object-fit: contain` 做硬约束,避免不同来源图片破坏页面布局。
|
||||
|
||||
---
|
||||
|
||||
## 记录 20:TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. `TemplateManage` 中删除智能字段(通过红 × 或 Backspace/Delete)后,点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。
|
||||
2. 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 即使将删除逻辑改为 `execCommand('delete')`,浏览器原生的 undo stack 在 `contentEditable` 中结合 React 状态更新时仍然非常脆弱,容易被清空。
|
||||
2. 点击侧边栏按钮会导致编辑器 `blur`,浏览器内部的光标位置(Selection/Range)丢失;再次 `focus()` 后光标被重置,导致 `insertHTML` 插入位置错误。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **自定义 Undo/Redo 栈**:
|
||||
- 在 `TemplateManage.tsx` 中引入 `undoStack` 和 `redoStack` 两个 `useRef<string[]>([])`。
|
||||
- 实现 `pushHistory()`,在执行任何结构性变更(删除字段、插入字段、插入表格/图片、格式化命令)前将当前 `editorRef.current.innerHTML` 推入 undo 栈并清空 redo 栈。
|
||||
- 实现 `handleUndo()` / `handleRedo()`,直接替换工具栏按钮的 `execCmd('undo')` / `execCmd('redo')` 调用。从栈中取出历史 HTML 字符串并赋值给 `editorRef.current.innerHTML`,再调用 `saveTemplateContent()` 同步到 React state 和 `localStorage`。
|
||||
2. **阻止焦点流失**:
|
||||
- 在所有工具栏按钮和字段库插入按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,阻止 mousedown 默认行为导致编辑器失去焦点。
|
||||
3. **光标位置记忆与恢复**:
|
||||
- 利用已有的 `savedRangeRef`,实现 `saveSelection()` 和 `restoreSelection()`。
|
||||
- 在编辑器 `<div>` 上绑定 `onBlur={saveSelection}`、`onMouseUp={saveSelection}`、`onKeyUp={saveSelection}`,持续记录光标位置。
|
||||
- 在 `insertSmartField` 和 `insertImage` 中,执行 `insertHTML` 前先调用 `restoreSelection()` 恢复光标,确保字段插入到正确的位置。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于 `contentEditable` 编辑器中的结构性变更(插入/删除特殊节点),如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。
|
||||
- 侧边栏/工具栏按钮与编辑器共存时,**必须**通过 `onMouseDown={e => e.preventDefault()}` 或等价手段阻止焦点流失,这是保证光标位置不丢失的最简单有效方案。
|
||||
- 插入操作前恢复 `savedRangeRef` 可以作为焦点流失后的兜底保险,两者结合使用效果最佳。
|
||||
|
||||
---
|
||||
|
||||
## 记录 21:TemplateManage 快捷键 Undo/Redo 与字段插入排版修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. TemplateManage 中删除 smart-field-wrapper 后按键盘 Ctrl+Z 无法撤销,但点击工具栏撤销按钮可以恢复。
|
||||
2. 当目标段落以 `<br>` 结尾时,从字段库插入 smart-field-wrapper 会被拆到下一行(`<span>` 跑到了 `<p>` 外部)。
|
||||
|
||||
**B. 问题产生原因**
|
||||
1. keydown 事件监听器只拦截了 Backspace/Delete,未拦截 Ctrl+Z/Ctrl+Y,导致浏览器原生 undo 与自定义 undoStack/redoStack 完全脱节。
|
||||
2. insertSmartField 使用 document.execCommand('insertHTML'),WebKit/Blink 在块级元素末尾存在 `<br>` 时,会自动将插入的 inline `<span>` 修正到块级元素外部,造成排版错位。
|
||||
|
||||
**C. 解决问题方法**
|
||||
1. **快捷键拦截**:在 keydown 监听的最开头增加 Ctrl+Z / Cmd+Z / Ctrl+Shift+Z / Ctrl+Y 的拦截,调用 e.preventDefault() 后路由到 handleUndo() 或 handleRedo()。
|
||||
2. **精确 Range 插入**:将 insertSmartField 的插入方式从 execCommand('insertHTML') 替换为手动 Range.insertNode():
|
||||
- restoreSelection() 恢复光标;
|
||||
- Range.deleteContents() 清空当前选区;
|
||||
- 将 HTML 字符串转为 DocumentFragment;
|
||||
- Range.insertNode(fragment) 精确插入到 Range 位置;
|
||||
- setStartAfter(lastNode) 把光标移动到插入内容末尾。
|
||||
|
||||
**D. 经验与教训总结**
|
||||
- 在 contentEditable 中实现自定义撤销栈时,必须**同时拦截界面按钮和键盘快捷键**的 undo/redo,否则两套历史机制会互相冲突。
|
||||
- document.execCommand('insertHTML') 对块级元素边界(尤其是 `<br>` 结尾)的自动修正行为不可控;需要精确插入时,应优先使用 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 时应提供合理的默认值或降级处理。
|
||||
|
||||
---
|
||||
|
||||
## 记录 23:图片占位符体系重构与双端统一
|
||||
|
||||
**A. 具体问题**
|
||||
1. `template-manage` 的"插入字段"中仍存在"图片"分类(手术者签名、医院Logo),用户认为不再需要。
|
||||
2. 插入图片占位符时无法自定义默认宽高,且使用 `<div>` 导致强制换行。
|
||||
3. 占位符框太小时"插入/点击放置图片"文字显示不全。
|
||||
4. 默认模板中签名和 Logo 的结构不统一(一个是 `smartField`,一个是 `div.image-placeholder`)。
|
||||
5. `template-manage` 点击图片占位符直接调起本地文件选择器,与 `report-editor` 的三选一弹窗行为不一致。
|
||||
|
||||
**B. 问题产生原因**
|
||||
1. `DEFAULT_FORM_FIELDS` 仍包含 `surgeonSignature` 和 `hospitalLogo`。
|
||||
2. 两端编辑器的 `insertImage()` 使用块级 `<div>` 插入,未提供尺寸 prompt。
|
||||
3. 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。
|
||||
4. `TemplateManage` 的 placeholder 点击事件直接调用 `triggerPlaceholderUpload()`,缺少与 `ReportEditor` 一致的弹窗组件。
|
||||
|
||||
**C. 解决问题方法**
|
||||
1. **清理图片字段**:从 `DEFAULT_FORM_FIELDS` 和 `types.ts` 中移除 `surgeonSignature` 和 `hospitalLogo`;在 `TemplateManage.tsx` 的插入字段/字段管理/新增字段表单中彻底移除"图片"分类。
|
||||
2. **统一默认模板**:在 `defaultContent.ts` 中将 Logo 和签名均替换为 `<span class="image-placeholder" style="display:inline-flex;...">`。
|
||||
3. **改造 insertImage()**:在 `TemplateManage.tsx` 和 `ReportEditor.tsx` 中,插入前通过 `prompt` 获取最大宽度/高度(px),生成带 `max-width/max-height` 的 `<span>` 行内占位符;提示文字中附加"正文一行文字高度约为 20 像素左右"。
|
||||
4. **文本自适应**:根据 prompt 输入的宽度决定提示文字:宽度 < 80px 时显示"插入图片",否则显示"插入/点击放置图片"。
|
||||
5. **统一弹窗行为**:将 `ReportEditor` 的 `imagePickerOpen` / `imagePickerTarget` / `fillPlaceholderSrc` 逻辑完整移植到 `TemplateManage`;删除旧的 `triggerPlaceholderUpload` 直接上传逻辑;两端点击图片占位符均弹出"本地上传 / 我的签名 / 系统素材"三选一弹窗。
|
||||
6. **优化填充样式**:`fillPlaceholderSrc` 中给 `<img>` 增加 `max-width:100%; max-height:100%; object-fit:contain;`,避免撑破设置了固定尺寸的占位符。
|
||||
|
||||
**D. 经验与教训总结**
|
||||
- 当从字段体系中彻底移除某一分类时,需要同时清理:`DEFAULT_FORM_FIELDS`、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑(如编辑字段时显示 options 输入框的条件)。
|
||||
- 在 `contentEditable` 中实现"同行插入"必须使用行内元素(`<span>`)并显式设置 `display:inline-flex` + `vertical-align:middle`;块级 `<div>` 即使通过 CSS 改 display 也可能因浏览器 execCommand 修正导致换行。
|
||||
- 跨页面/跨编辑器的一致交互(如图片选择弹窗)应抽取为可复用逻辑或至少保持代码结构一致,避免用户在不同页面产生认知割裂。
|
||||
- `prompt` 虽不是最优雅的用户交互,但在工具栏快捷操作中是一种零依赖、快速落地的方案;若后续需要更复杂交互,可再替换为 Modal 组件。
|
||||
|
||||
---
|
||||
|
||||
## 记录 24:时间/日期字段格式配置与撰写时间动态字段
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 2 个需求:
|
||||
1. TemplateManage 字段管理中,时间/日期字段增加配置:date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「手动选择」作为默认值策略。
|
||||
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `FormField` 数据结构缺少格式和默认值配置字段。
|
||||
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。
|
||||
3. 模板底部「年 月 日」是纯静态 HTML 文本,没有数据绑定能力。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **扩展数据结构**:`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值(`surgeryDate` → `YYYY-MM-DD`+`specific`,`startTime/endTime` → `24h`+`specific`);新增系统字段 `reportDate`(`YYYY年MM月DD日`+`current`)。
|
||||
2. **TemplateManage UI 增强**:
|
||||
- 新增字段表单:category 为「时间」时显示「默认值」select(手动选择/当前时间)和「显示格式」select(date 提供两种日期格式,time 提供 24h/12h)。
|
||||
- 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。
|
||||
3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。
|
||||
4. **ReportEditor 表单渲染重构**:
|
||||
- `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围(24h: 00-23,12h: 01-12),12h 时额外增加 AM/PM select。存储仍保持 24h(`startHour/startMinute`),转换函数 `to24h`/`from24h` 处理 12h↔24h。
|
||||
- 通用 time 字段(非 startTime/endTime):新增 hour+minute select 渲染,值统一存储为 `HH:MM` 字符串。
|
||||
5. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`,将原始值转换为配置格式后写入编辑器。
|
||||
6. **编辑器反向编辑解析**:`handleEditorInput` 中,当用户直接在编辑器内修改 date/time smart field 时,通过正则解析格式化文本(如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`),转回原始值后存入 `reportData`。
|
||||
7. **默认模板更新**:`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当为字段增加新的配置属性时,务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值,保证向后兼容。
|
||||
- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
|
||||
- 12h/24h 转换要覆盖所有边界情况:12AM→00、12PM→12、1PM→13,建议用独立纯函数(`to24h`/`from24h`)集中处理,避免在 JSX 中内联复杂计算。
|
||||
- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。
|
||||
|
||||
---
|
||||
|
||||
## 记录 25:时间字段增强——自定义格式、固定时间默认值、系统锁定标签
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 4 个改进需求:
|
||||
1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符;
|
||||
2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段;
|
||||
3. 「手动选择」文案歧义,应改为「固定时间」;
|
||||
4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间:${smartField('reportDate')}`,导致编辑器中显示重复文字。
|
||||
2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`,字段库允许修改 label。
|
||||
3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。
|
||||
4. 日期/时间格式仅通过固定 `<select>` 提供预设选项(如 `YYYY-MM-DD`、`24h`),无法覆盖用户自定义需求(如 `YYYY/MM/DD`、`hh:mm A` 等)。
|
||||
5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **删除前缀**:`defaultContent.ts` 中将底部 HTML 从 `撰写时间:${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。
|
||||
2. **系统锁定**:`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。
|
||||
3. **文案修改**:`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。
|
||||
4. **自定义格式输入**:
|
||||
- `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。
|
||||
- `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式:
|
||||
- `formatInputMode: 'select' | 'custom'`,默认 `select`。
|
||||
- 选择「自定义」时显示 `<input>`,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。
|
||||
- 预设候选包含常用格式:`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。
|
||||
- 通用化显示函数:
|
||||
```ts
|
||||
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
|
||||
if (!isoDate || !fmt) return isoDate || '';
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || '');
|
||||
};
|
||||
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
|
||||
if (!timeStr || !fmt) return timeStr || '';
|
||||
const [h24str, mstr] = timeStr.split(':');
|
||||
const h24 = parseInt(h24str) || 0;
|
||||
const isPM = h24 >= 12;
|
||||
let h12 = h24 % 12; if (h12 === 0) h12 = 12;
|
||||
return fmt.replace(/HH/g, String(h24).padStart(2, '0'))
|
||||
.replace(/mm/g, mstr || '00')
|
||||
.replace(/hh/g, String(h12).padStart(2, '0'))
|
||||
.replace(/A/g, isPM ? '下午' : '上午');
|
||||
};
|
||||
```
|
||||
5. **通用化反向解析**:新增 `parseDateFromFormat` / `parseTimeFromFormat`,从格式化文本中通过数字正则提取原始值,确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。
|
||||
6. **固定时间默认值自动填充**:`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支,若字段配置了 `fixedTimeValue` 且当前值为空,则自动填入固定值。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数,否则编辑器双向同步会断裂。
|
||||
- 使用占位符替换(如 `fmt.replace(/YYYY/g, y)`)实现通用格式化时,要确保所有可能的 token 都覆盖到,且替换顺序不会相互干扰。
|
||||
- 当某个字段被标记为 `isSystemLocked: true` 后,需在 UI 层面同时禁用 label 输入框,否则用户会困惑「为何修改无效」。
|
||||
- 时间/日期字段的默认值策略文案应直接体现业务含义(如「固定时间」「当前时间」),避免使用技术词汇(如「手动选择」)。
|
||||
- 对于 `startTime`/`endTime` 这类拆分存储(`startHour`+`startMinute`)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。
|
||||
|
||||
---
|
||||
|
||||
## 记录 26:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换
|
||||
|
||||
**A. 具体问题**
|
||||
用户发现 3 个时间字段配置与报告编辑器的联动断层:
|
||||
1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式 `YYYY年MM月DD日`;新建时间字段时默认格式为不可解析的 `'24h'`。
|
||||
2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
|
||||
3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`(12小时制),报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **默认格式错误**:`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token, `'24h'` 无法被正确解析。
|
||||
2. **固定时间未注入**:`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中,`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过(因为已有值了)。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。
|
||||
3. **12h 判断写死**:`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时,判断失败,表单始终渲染为 24 小时制。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修正默认格式**:
|
||||
- `TemplateManage.tsx` 中新建字段的默认格式改为:
|
||||
```ts
|
||||
setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
|
||||
```
|
||||
- 重置表单时的默认值同步修正。
|
||||
2. **注入固定时间默认值**:
|
||||
- `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。
|
||||
- 切换模板的 `useEffect` 中,在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑:
|
||||
```ts
|
||||
formFields.forEach(field => {
|
||||
if (field.category === '时间') {
|
||||
if (field.timeDefault === 'specific' && field.fixedTimeValue) {
|
||||
// 按 field.type 和 field.key 注入固定值
|
||||
} else if (field.timeDefault === 'current') {
|
||||
// 注入当前系统时间
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!nextReportData.surgeryDate) {
|
||||
nextReportData.surgeryDate = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
```
|
||||
3. **通用化 12h 判断**:
|
||||
- `ReportEditor.tsx` 中:
|
||||
```ts
|
||||
const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
|
||||
```
|
||||
- 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`,只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'`、`'12h'`)作为存储值,除非格式化函数也能识别这些别名。
|
||||
- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口(初始 state、切换模板、重置表单等)中显式遍历字段配置并注入,不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。
|
||||
- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**(`includes`)而非**精确匹配**,以兼容用户自定义格式。如果判断逻辑较为复杂,建议抽离为独立工具函数(如 `is12HourFormat(fmt: string): boolean`)。
|
||||
- 当某个字段在初始化时被赋予了「看似合理的默认值」(如 `surgeryDate: new Date()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 27:DEFAULT_FORM_FIELDS 遗留 '24h' 默认值导致报告显示异常 + 格式选项未分类 + 编辑面板点击失效
|
||||
|
||||
**A. 具体问题**
|
||||
1. `template-manage` 字段管理中,时间字段的格式 datalist 只显示 `YYYY-MM-DD` 和 `24h`,缺少 `YYYY年MM月DD日` 和 `HH:mm`/`hh:mm A`。
|
||||
2. `report-editor` 中手术终止时间 smart field 显示为 "24h" 字样,而非正常时间值。
|
||||
3. `template-manage` 字段管理中,点击底部字段进入编辑模式后,部分输入框/下拉框点击无响应,需手动滚动后才能获取焦点。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **`DEFAULT_FORM_FIELDS` 遗留旧值**:`types.ts` 中 `startTime` 和 `endTime` 的 `timeFormat` 仍被硬编码为 `'24h'`(历史遗留简写别名)。当新用户登录或重置系统时,该值被加载到 `formFieldsConfig` 中。`ReportEditor.tsx` 的 `formatTimeDisplay` 函数用 `'24h'` 作为格式模板进行 token 替换,但 `'24h'` 中不含 `HH`/`hh`/`mm`/`A` 等任何可替换 token,函数直接原样返回 `'24h'`,导致编辑器中显示 "24h"。
|
||||
2. **`customTimeFormats` 未按类型过滤**:`TemplateManage.tsx` 的 datalist 直接渲染了 `customTimeFormats` 数组中的所有格式(日期和时间混在一起)。当用户编辑 time 字段时,会看到 `YYYY-MM-DD` 等日期格式;编辑 date 字段时,会看到 `HH:mm` 等时间格式,选项混乱。
|
||||
3. **布局突变导致点击穿透失效**:字段管理列表位于 `overflow-y-auto` 滚动容器内。点击字段卡片后,内部编辑表单展开,高度瞬间增加。若卡片原本位于可视区域底部边缘,新出现的输入框可能刚好处于容器裁剪区域之外,浏览器 hit-testing 无法将点击事件正确路由到输入框上。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修正默认值**:`types.ts` 中 `startTime`/`endTime` 的 `timeFormat` 从 `'24h'` 改为 `'HH:mm'`。
|
||||
2. **兼容兜底**:`ReportEditor.tsx` 的 `formatTimeDisplay` 开头增加 `if (fmt === '24h') fmt = 'HH:mm';`,防止已有用户的 `formFieldsConfig` 中仍残留 `'24h'` 导致显示异常。
|
||||
3. **清理旧缓存**:`TemplateManage.tsx` 初始化 `customTimeFormats` 时,对 `savedFormats` 增加 `.filter(f => f !== '24h' && f !== '12h')`,自动清理历史遗留的无效旧格式。
|
||||
4. **按类型过滤 datalist**:编辑字段和新增字段的 format `<datalist>` 渲染时,增加 `.filter`:
|
||||
```ts
|
||||
.filter(fmt => {
|
||||
const isDateFormat = /YYYY|MM|DD/.test(fmt);
|
||||
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
|
||||
if (field.type === 'date') return isDateFormat;
|
||||
if (field.type === 'time') return isTimeFormat;
|
||||
return true;
|
||||
})
|
||||
```
|
||||
5. **自动滚动对齐**:字段卡片 `onClick` 中,在设置完编辑状态后增加 `setTimeout(() => { e.currentTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 50);`,确保编辑面板展开后卡片位于可视区域内。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当将格式简写别名(如 `'24h'`)迁移为标准 token 格式(如 `'HH:mm'`)时,必须**全局搜索**所有硬编码默认值(`DEFAULT_FORM_FIELDS`、测试数据、mock 数据等),确保源头不再产生脏数据。
|
||||
- `customTimeFormats` 这类用户可扩展的缓存数组,在初始化时应建立**无效值清理机制**,防止历史版本残留的数据污染后续 UI。
|
||||
- `datalist` / `select` 的选项如果存在明显的类型分组(日期 vs 时间),应在渲染层做过滤,而不是将所有选项平铺展示。
|
||||
- 任何在滚动容器内通过点击展开/折叠的交互组件,都应考虑增加 `scrollIntoView` 兜底,防止布局突变导致的点击失效问题。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 28:原生 datalist 交互体验差、表格内 execCommand 插入破坏结构、打印分页边距失效
|
||||
|
||||
**A. 具体问题**
|
||||
1. `template-manage` 字段管理中,时间字段的格式输入使用原生 `<input list>` + `<datalist>`,浏览器下拉体验差,部分浏览器不会自动展示全部选项。
|
||||
2. 在 `template-manage` 编辑器表格中点击"插入图片占位符"后,HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素散落为 `<td>` 的直接子元素。
|
||||
3. `report-editor` / `report-view` 打印多页报告时,第二页及后续页面的上下边距几乎为 0,内容紧贴纸张边缘。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **原生 datalist 局限性**:不同浏览器对 `<datalist>` 的展示逻辑不一致,Edge/Chrome 中聚焦时不会自动展开全部选项,且不支持样式自定义,无法提供一致的下拉选择体验。
|
||||
2. **execCommand 在表格中的自动修正**:`document.execCommand('insertHTML')` 在 `<td>` 内处理复杂的 `inline-flex` 嵌套 `<span>` 时,WebKit/Blink 会将其自动"拍平"或重新排列。外层 `contenteditable="false"` 的 inline 容器被浏览器移除,仅剩内部子元素散落。
|
||||
3. **@page margin 与 body padding 的分页陷阱**:`@page { margin: 0 }` 将物理纸张边距设为 0,`body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding,导致第二页顶部和底部紧贴纸张边缘。`@page` 的 margin 才是为每一张物理纸张独立分配边距的正确方式。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **自定义下拉组件**:放弃原生 `input[list]` + `datalist`,改为手写 input + 绝对定位 div 列表组件:
|
||||
- `onFocus` 时 `setDropdownOpen(true)` 展开列表
|
||||
- `onMouseDown` + `e.preventDefault()` 阻止失焦,实现点击选项填充
|
||||
- `onBlur`(延迟 200ms)时保存手写的新格式到 `customTimeFormats`
|
||||
- 列表项通过 `.filter` 按 `date`/`time` 类型过滤显示
|
||||
2. **表格检测 + 块级容器**:在 `insertImage` 中通过 `window.getSelection().anchorNode` 向上遍历检测是否在 `<td>` / `<th>` 内:
|
||||
- 若在表格内:不弹出 prompt,使用 `<div>` 块级容器 + `width:100%;height:100%;max-width:200px;max-height:200px;`
|
||||
- 若不在表格内:保持现有 `<span>` 行内容器 + prompt 输入自定义宽高
|
||||
3. **打印边距修正**:`print.ts` 中:
|
||||
- `@page { margin: 15mm 10mm; }` 让打印引擎为每一页纸张独立分配上下 15mm / 左右 10mm 边距
|
||||
- `body { padding: 0; }` 清除 body padding
|
||||
- `.content { width: 100%; }` 让内容自然撑满可用区域
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当 `<input list>` + `<datalist>` 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。
|
||||
- `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `<td>` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `<div>`)作为外层容器,减少被浏览器重新排列的风险。
|
||||
- 打印样式的边距控制必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 29:拖拽关键帧样式遗漏、占位符分类隔离与 Modal 弹窗改造
|
||||
|
||||
**A. 具体问题**
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,虚线边框和灰色背景未消失,且图片可能溢出占位符。
|
||||
2. `insertImage` 和 `insertTable` 使用浏览器原生 `prompt` 弹窗,交互体验差。
|
||||
3. 所有占位符一视同仁,自动帧插入和一键插入会把手术关键帧填入 Logo、签名等静态图片位置。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **`fillPlaceholder` 遗漏样式清除**:`fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`,但 `fillPlaceholder`(拖拽路径)遗漏了这两行,且图片 style 缺少 `max-height:100%;object-fit:contain;`。
|
||||
2. **原生 prompt 的限制**:`prompt` 弹窗无法自定义样式,且在不同浏览器中表现不一致,用户体验差。
|
||||
3. **占位符无分类机制**:所有 `.image-placeholder` 都接受关键帧填充,没有区分"接受自动插入"和"不接受自动插入"的占位符。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **补齐 `fillPlaceholder`**:增加 `placeholder.style.border = 'none'`、`placeholder.style.background = 'transparent'`,图片 style 改为 `max-width:100%;max-height:100%;object-fit:contain;`。
|
||||
2. **自定义 Modal 替代 prompt**:
|
||||
- 新增 `placeholderModal` 状态(isOpen, width, height, mode)和 `tableModal` 状态(isOpen, rows, cols)。
|
||||
- `insertImage` 和 `insertTable` 改为打开 Modal。
|
||||
- Modal 使用项目统一的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
|
||||
3. **占位符分类隔离**:
|
||||
- Modal 中增加模式选择:「手术影像占位(frame)」和「静态图片占位(manual)」。
|
||||
- manual 模式生成的 placeholder 带有 `data-mode="manual"` 属性。
|
||||
- `autoCaptureFrames` 和 `insertFrameToPlaceholder` 的选择器增加 `:not([data-mode="manual"])`。
|
||||
- `handleDrop` 中拦截 manual 占位符的拖拽,弹出提示。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。
|
||||
- 原生 `prompt`/`confirm`/`alert` 在现代 Web 应用中应尽量避免使用,优先采用自定义 Modal 组件,以获得一致的视觉体验和更灵活的控制能力。
|
||||
- 当系统中存在"自动填充"机制时,应考虑为被填充的容器增加分类标记(如 `data-mode`),并在自动填充逻辑中通过选择器过滤,防止无关区域被污染。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 30:默认模板中 image-placeholder 缺少 data-mode 导致来源隔离失效
|
||||
|
||||
**A. 具体问题**
|
||||
默认模板 `defaultContent.ts` 中的 8 个 `.image-placeholder`(医院 Logo、6 个表格内术中影像、手术者签名)使用的是旧版 HTML 结构,缺少 `data-mode="frame|manual"` 属性。新建报告加载默认模板后,签名和 Logo 区域可被关键帧拖拽误填充;自动帧插入时也会将术中截图插入签名位置。
|
||||
|
||||
**B. 产生问题原因**
|
||||
此前对「插入图片占位符」进行弹窗改造时,仅在运行时插入逻辑中新增了 `data-mode` 属性,但未同步回刷默认模板 `defaultContent.ts`。导致默认模板产出的占位符与新插入的占位符结构不一致,图片来源隔离机制在默认模板场景下完全失效。
|
||||
|
||||
**C. 解决问题方案**
|
||||
在 `defaultContent.ts` 中对 8 个占位符做最小化修补:
|
||||
1. 医院 Logo(65×65)和手术者签名(200×40)添加 `data-mode="manual"`,标记为静态图片占位。
|
||||
2. 表格内 6 个术中影像占位符(100%×150)添加 `data-mode="frame"`,标记为手术影像占位。
|
||||
3. 签名占位符宽度 200px ≥ 80px,按新弹窗规则将提示文本从「插入图片」更新为「插入/点击放置图片」。
|
||||
4. 所有占位符的 `width/height/margin/display` 等布局属性绝对保持不变。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode`、`data-allow-source`)时,必须同步检索 `defaultContent.ts` 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
|
||||
- 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。
|
||||
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` | 低 | 可选:将默认模板中的部分占位符替换为智能控件 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已提供详细需求描述及技术实现思路,可直接进入实现方案设计阶段。
|
||||
39
工程分析/需求分析-2026-04-16-22-35-38.md
Normal file
39
工程分析/需求分析-2026-04-16-22-35-38.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 需求分析 — 2026-04-16-22-35-38
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
将默认手术报告模板中红色的 `*姓名* *性别* *年龄* *科室* *床号* *住院号*` 等占位符,替换为可填写的智能占位方框。这些方框需与 `ReportEditor` 右侧【基本信息】表单双向绑定,同时也要在 `TemplateManage` 的模板编辑中体现字段绑定关系。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
1. **修正字段映射一致性**
|
||||
- 上一版本中 `BINDABLE_FIELDS` 里的 `gender`、`age` 等 key 与 `Report` 接口中的 `patientGender`、`patientAge` 不一致,导致双向绑定无法命中实际表单字段。
|
||||
- 需要统一 key 命名,使其与 `Report` 接口及右侧表单字段名完全一致。
|
||||
|
||||
2. **更新默认模板内容 (`defaultContent.ts`)**
|
||||
- 将当前纯文本红色占位符(如 `<span style="color: #ff0000;">*姓名*</span>`)替换为 `smart-field-wrapper` 智能控件。
|
||||
- 同步将手术日期、手术名称、手术者、助手、麻醉师、麻醉方式等灰色提示文本也替换为绑定控件,提升默认模板的智能化程度。
|
||||
|
||||
3. **确保双向绑定生效**
|
||||
- 替换后,新建报告时从默认模板加载的内容中已内嵌 `data-bind` 属性,用户在方格中输入即可自动同步右侧表单;右侧表单修改也会同步回方格。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 不引入新的依赖或文件,仅修改现有常量与字段映射。
|
||||
- 保持 `npm run lint` 通过。
|
||||
- 老用户已有的自定义模板不会被强制覆盖,仅默认模板生效。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 中 | 修正 `BINDABLE_FIELDS` 的 key,精简为右侧表单实际存在的字段 |
|
||||
| `src/utils/defaultContent.ts` | 高 | 将占位符替换为智能控件 HTML |
|
||||
| `src/pages/TemplateManage.tsx` | 低 | 字段库按钮随 `BINDABLE_FIELDS` 自动更新 |
|
||||
| `src/pages/ReportEditor.tsx` | 低 | 无需改动,已有双向绑定逻辑直接生效 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。需求明确,修改范围可控。
|
||||
58
工程分析/需求分析-2026-04-17-00-13-09.md
Normal file
58
工程分析/需求分析-2026-04-17-00-13-09.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 需求分析 — 2026-04-17-00-13-09
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **手术时间方框化**:将报告模板中的"手术开始时间"、"手术终止时间"从静态文本改为可联动的智能方格。
|
||||
2. **动态字段体系**:在 `TemplateManage` 中将字段库升级为支持分类(填空、多选、单选、时间)、允许自定义新增字段,并可控制字段是否在 `ReportEditor` 右侧【基本信息】中显示。除"姓名"、"住院号"为系统锁定字段外,其余字段均可调整显隐和删除。
|
||||
3. **UI 紧凑化**:缩小 `field-value` 方格的默认宽度和高度,降低其对行间距的影响,使排版更紧凑自然。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
#### 需求 1:手术开始/终止时间方框联动
|
||||
- 在 `defaultContent.ts` 中将"手术开始时间:时 分"替换为 `data-bind="startTime"` 的智能方格,同理替换"手术终止时间"。
|
||||
- 在 `ReportEditor.tsx` 右侧基本信息中,保留现有的 `startHour`/`startMinute`、`endHour`/`endMinute` 下拉框(或合并为时间输入)。
|
||||
- 建立双向转换:
|
||||
- 表单 → 方格:下拉框变化时拼接为 `HH:mm` 同步到方格。
|
||||
- 方格 → 表单:用户在方格内输入时间文本(如"09:30")时,解析并反向更新 `startHour`/`startMinute`。
|
||||
|
||||
#### 需求 2:动态字段配置体系
|
||||
- **数据结构**:在 `types.ts` 中新增 `FieldType` 和 `FormField` 接口,描述字段的 key、label、分类、类型、显隐状态、是否系统锁定、选项列表等。
|
||||
- **全局配置存储**:在 `localStorage` 中新增 `formFieldsConfig` key,保存字段配置数组。
|
||||
- **TemplateManage 字段库改造**:
|
||||
- 右侧面板增加【插入字段】和【字段管理】两个 Tab。
|
||||
- 【插入字段】按分类(填空、单选、多选、时间)分组展示字段按钮,点击插入对应 `data-bind` 方格。
|
||||
- 【字段管理】展示所有非系统锁定字段,支持新增字段(输入名称、选择类型、选择分类)、编辑选项、删除字段、控制 `visibleInForm` 开关。
|
||||
- **ReportEditor 动态渲染**:右侧【基本信息】表单不再硬编码,而是读取 `formFieldsConfig`,过滤出 `visibleInForm === true` 的字段,根据 `type` 动态渲染对应输入组件(文本框/下拉框/多选标签/时间拆分下拉框)。
|
||||
- **初始化默认配置**:首次加载时若 `formFieldsConfig` 不存在,自动生成一套与当前硬编码表单一致的默认配置。
|
||||
|
||||
#### 需求 3:UI 紧凑化优化
|
||||
- 调整 `field-value` 的内联样式:
|
||||
- `min-width` 从 `60px` 降至 `32px`
|
||||
- 去除上下 `padding`(或仅保留极小的上下间距)
|
||||
- 增加 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`
|
||||
- 背景色微调为 `#f8fafc`(编辑态更明显)
|
||||
- 同步修改 `index.css` 和 `print.ts` 中的对应样式。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 系统锁定字段(`patientName`、`hospitalId`)不可删除、不可隐藏。
|
||||
- 老用户已有的 `localStorage` 数据不因字段配置变化而丢失;新增配置与现有 `Report`/`Template` 数据解耦。
|
||||
- 保持 `npm run lint` 类型检查通过。
|
||||
- 不引入新的第三方依赖。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 高 | 新增 `FieldType`、`FormField`、`FormFieldsConfig` 类型定义 |
|
||||
| `src/utils/defaultContent.ts` | 中 | 手术时间占位符替换为智能方格 |
|
||||
| `src/index.css` | 中 | 优化 `.field-value` 紧凑样式 |
|
||||
| `src/utils/print.ts` | 低 | 同步打印样式 |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 字段库 UI 重构为 Tab + 分类分组 + 字段管理 |
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 右侧基本信息表单从硬编码改为动态渲染,新增时间解析/拼接逻辑 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已提供详细需求和初步技术思路,可直接进入实现方案设计。
|
||||
45
工程分析/需求分析-2026-04-17-09-36-07.md
Normal file
45
工程分析/需求分析-2026-04-17-09-36-07.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求分析 — 2026-04-17-09-36-07
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
全面优化 `TemplateManage` 模板编辑器的交互体验,解决以下三个核心问题:
|
||||
1. 消除从右侧字段库插入智能字段后产生的多余空格与排版松散问题。
|
||||
2. 修复在行尾插入字段时出现的异常换行,以及按 Backspace 删除字段时误删整行的底层 Bug。
|
||||
3. 将常用基本信息字段(姓名、性别、年龄等)直接预置到系统默认模板中,实现开箱即用。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
1. **消除插入字段后的多余空格**
|
||||
- 问题:`insertSmartField` 生成的 HTML 字符串末尾带有 ` `,且可能包含换行符,导致字段后跟随大量不可见空格。
|
||||
- 方案:移除 ` `,将 HTML 压缩为一行;调整 `margin` 为更小的值(如 `0 2px`)。
|
||||
|
||||
2. **修复异常换行与 Backspace 误删整行**
|
||||
- 问题 2a(异常换行):当在"住院号:"等行尾插入 `smart-field-wrapper` 后,即使空间足够,字段也可能被挤到下一行。这与 `inline-block` 的默认换行行为以及 `contenteditable="false"` 节点的边界处理有关。
|
||||
- 问题 2b(Backspace 误删):光标位于 `contenteditable="false"` 的字段节点之后时,浏览器内核(Webkit/Blink)无法正确选中该不可编辑节点,会向上寻址删除其父级 `<p>` 节点,导致整行被删。
|
||||
- 方案:
|
||||
- 给 `smart-field-wrapper` 增加 `white-space: nowrap`。
|
||||
- 在 `TemplateManage.tsx` 中增加 `keydown` 事件监听,拦截 Backspace/Delete,当光标紧挨着 `.smart-field-wrapper` 时,手动选中并删除该节点,阻止默认行为。
|
||||
|
||||
3. **默认模板预置字段控件**
|
||||
- 问题:当前 `defaultContent.ts` 中第一行是红色纯文本占位符(`*姓名* *性别*...`),用户需要手动在 `TemplateManage` 中逐个替换为智能字段。
|
||||
- 方案:修改 `defaultContent.ts`,将第一行的纯文本直接替换为 `smartField('patientName')` 等智能控件,使新建模板时即自带可联动的字段方格。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 不引入新的依赖。
|
||||
- 保持 `npm run lint` 通过。
|
||||
- 保持现有 `ReportEditor` 的双向绑定逻辑不受影响。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertSmartField` HTML 结构、增加 `keydown` 拦截逻辑保护字段节点 |
|
||||
| `src/utils/defaultContent.ts` | 中 | 默认模板第一行替换为预置的智能字段控件 |
|
||||
| `src/index.css` | 低 | 给 `.smart-field-wrapper` 增加 `white-space: nowrap`(可选) |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已提供详细的问题现象和解决方案思路,可直接进入实现方案。
|
||||
56
工程分析/需求分析-2026-04-17-10-21-18.md
Normal file
56
工程分析/需求分析-2026-04-17-10-21-18.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 需求分析 — 模板字段唯一性、删除交互与报告批量导出(2026-04-17-10-21-18)
|
||||
|
||||
## 一、需求来源
|
||||
|
||||
用户反馈 TemplateManage 中智能字段存在无法删除和重复插入的问题,同时要求升级 ReportManage 的报告管理能力,支持单份/多份报告的结构化导出。
|
||||
|
||||
## 二、具体需求拆解
|
||||
|
||||
### 需求 1:模板字段唯一性校验
|
||||
|
||||
**问题**:`TemplateManage` 中点击字段库按钮插入智能字段时,同一个字段(如"姓名")可以被重复插入多次,导致模板混乱。
|
||||
|
||||
**期望**:每个 `data-bind` 对应的智能字段在编辑器中只能存在一份,若已存在则提示用户并阻止插入。
|
||||
|
||||
### 需求 2:模板字段删除交互优化
|
||||
|
||||
**问题**:默认模板中的智能字段以及手动插入的智能字段,按下 Backspace/Delete 时经常无法删除(尤其是字段位于段落开头/结尾时,浏览器默认行为会误删父级 `<p>`)。
|
||||
|
||||
**期望**:
|
||||
- 键盘 Backspace/Delete 能正确删除相邻的智能字段。
|
||||
- 为智能字段增加可视化删除按钮(类似图片占位符的 ×),用户可直接点击删除。
|
||||
|
||||
### 需求 3:ReportManage 单报告导出(PDF / JSON)
|
||||
|
||||
**期望**:在报告管理列表的每行操作列增加"导出"按钮,点击后弹出选项:
|
||||
- **导出 PDF**:调用现有的 `printDocument` 打印报告 HTML 内容,由用户在浏览器打印弹窗中选择"另存为 PDF"。
|
||||
- **导出 JSON**:下载结构化 JSON 文件,文件内包含该报告所有 `data-bind` 对应字段的值( patientName、hospitalId、title、surgeryDate 等 ),以及报告元信息(id、createdAt 等)。
|
||||
|
||||
### 需求 4:ReportManage 批量操作(复选框 + 批量删除 / 批量导出)
|
||||
|
||||
**期望**:
|
||||
- 表格每行最左侧增加复选框,表头增加全选/反选复选框。
|
||||
- 当有报告被选中时,表格上方显示批量操作栏,包含:
|
||||
- **批量删除**:确认后从 `localStorage` 中移除所有选中报告。
|
||||
- **批量导出 PDF**:将选中报告的 `content` 按分页符拼接后调用 `printDocument`,一次性生成多页 PDF。
|
||||
- **批量导出 JSON**:将选中报告数组导出为单个 JSON 文件下载。
|
||||
|
||||
## 三、影响范围分析
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|------|----------|
|
||||
| `src/pages/TemplateManage.tsx` | `insertSmartField` 增加唯一性校验;增强 `keydown` 删除逻辑;给 `smart-field-wrapper` 增加删除按钮及点击事件处理。 |
|
||||
| `src/utils/defaultContent.ts` | 默认模板中预生成的 `smartField()` 需要包含删除按钮 HTML。 |
|
||||
| `src/index.css` | 增加 `.smart-field-wrapper .delete-btn` 的样式。 |
|
||||
| `src/pages/ReportManage.tsx` | 增加 `selectedIds` 状态、复选框列、批量操作栏、导出弹窗/下拉、单报告导出函数、批量导出函数。 |
|
||||
| `src/utils/print.ts` | 可能需要提供 `printDocument` 的复用,无需修改。 |
|
||||
|
||||
## 四、验收标准
|
||||
|
||||
- [ ] `TemplateManage` 中已存在的字段再次插入时弹出提示并被阻止。
|
||||
- [ ] `TemplateManage` 中点击智能字段左上角的 × 可直接删除该字段。
|
||||
- [ ] `TemplateManage` 中按 Backspace/Delete 可正确删除光标相邻的智能字段(包括段落边界场景)。
|
||||
- [ ] `ReportManage` 操作列出现"导出"按钮,支持单报告 PDF/JSON 导出。
|
||||
- [ ] `ReportManage` 表格出现复选框,支持全选/反选。
|
||||
- [ ] 选中报告后显示批量操作栏,支持批量删除、批量 PDF 导出、批量 JSON 导出。
|
||||
- [ ] 导出 JSON 的文件内容包含所有 `data-bind` 字段值及报告元信息。
|
||||
50
工程分析/需求分析-2026-04-17-11-14-28.md
Normal file
50
工程分析/需求分析-2026-04-17-11-14-28.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析 — 字段聚焦高亮、删除按钮显隐控制与 .map Bug 修复(2026-04-17-11-14-28)
|
||||
|
||||
## 一、需求来源
|
||||
|
||||
用户反馈 TemplateManage 中字段删除按钮位置和显示时机需要优化,同时要求字段获得焦点时有视觉高亮反馈,并修复 ReportEditor 中多选字段渲染崩溃的 Bug。
|
||||
|
||||
## 二、具体需求拆解
|
||||
|
||||
### 需求 1:field-value 聚焦高亮
|
||||
|
||||
**期望**:当光标落入 `.field-value`(`contenteditable="true"` 的输入框)时,背景色加深、边框色变化,并带一个短暂的过渡动画,让用户明确感知当前正在编辑哪个字段。
|
||||
|
||||
### 需求 2:删除按钮定位与显隐控制
|
||||
|
||||
**期望**:
|
||||
- 红色 × 删除按钮从字段内部左侧移到**右上角**(绝对定位)。
|
||||
- 仅在鼠标**悬浮在 `.field-value` 上**或 `.field-value` 获得焦点时显示删除按钮。
|
||||
- **ReportEditor 中完全不显示**该删除按钮(编辑器中不应有删除字段的入口)。
|
||||
|
||||
### 需求 3:修复 ReportEditor `.map is not a function` 崩溃
|
||||
|
||||
**问题**:
|
||||
```
|
||||
Uncaught TypeError: (y[x.key] || []).map is not a function
|
||||
```
|
||||
发生在 `ReportEditor.tsx` 渲染 `multi_select` 类型字段时:
|
||||
```tsx
|
||||
{((reportData as any)[field.key] || []).map((tag: string) => ...
|
||||
```
|
||||
|
||||
**原因**:历史脏数据或异常情况下,`surgeon`/`assistant`/`anesthesiologist` 等字段的值不是数组,而是字符串或其他类型,导致 `.map()` 调用崩溃。
|
||||
|
||||
**期望**:在渲染前对值做类型安全检查,非数组时安全转换为空数组或包裹为单元素数组,避免页面白屏。
|
||||
|
||||
## 三、影响范围分析
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|------|----------|
|
||||
| `src/pages/TemplateManage.tsx` | `insertSmartField` 的 HTML 结构调整(wrapper 增加 `position:relative`,delete-btn 改为绝对定位在右上角)。编辑器 div 增加专属 class `template-editor-mode` 用于样式隔离。 |
|
||||
| `src/utils/defaultContent.ts` | `smartField()` 辅助函数同步调整 HTML 结构,与 `insertSmartField` 保持一致。 |
|
||||
| `src/index.css` | 增加 `.field-value:focus` 高亮样式;重写 `.delete-btn` 样式为绝对定位;增加 `.template-editor-mode .smart-field-wrapper:hover .delete-btn` / `:focus-within .delete-btn` 显隐控制;确保 `.print-content .delete-btn` 打印隐藏。 |
|
||||
| `src/pages/ReportEditor.tsx` | 修复 `multi_select` 渲染处的 `.map` 调用,增加 `Array.isArray` 安全转换。 |
|
||||
|
||||
## 四、验收标准
|
||||
|
||||
- [ ] TemplateManage 中点击 field-value,背景色明显加深并有过渡动画。
|
||||
- [ ] TemplateManage 中鼠标悬浮/聚焦 field-value 时,右上角出现红色 ×;移开后隐藏。
|
||||
- [ ] ReportEditor 中所有 smart-field-wrapper 均不显示红色 ×。
|
||||
- [ ] ReportEditor 加载存在脏数据(非数组类型)的报告时,multi_select 字段不再崩溃,页面正常渲染。
|
||||
- [ ] `npm run lint` 无编译错误。
|
||||
54
工程分析/需求分析-2026-04-17-11-34-24.md
Normal file
54
工程分析/需求分析-2026-04-17-11-34-24.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 需求分析 — 字段悬浮高亮、电子签上传与手术者签名联动(2026-04-17-11-34-24)
|
||||
|
||||
## 一、需求来源
|
||||
|
||||
用户提出四个关联需求:优化 TemplateManage 字段定位体验、增加用户电子签上传功能、在模板中新增手术者签名字段、并优化签名图片在编辑器及打印中的排版表现。
|
||||
|
||||
## 二、具体需求拆解
|
||||
|
||||
### 需求 1:TemplateManage 字段悬浮高亮定位
|
||||
|
||||
**期望**:当鼠标悬浮在右侧字段库中的某个字段按钮上时,编辑器中已插入的对应 `data-bind` 字段的 `.field-value` 框会有明显的视觉高亮(如边框发光、背景色变化),帮助用户快速定位该字段在模板中的位置。
|
||||
|
||||
### 需求 2:UserManage 电子签上传与前端压缩
|
||||
|
||||
**期望**:
|
||||
- 在【用户管理】的用户编辑/新增弹窗中增加"电子签名"上传区域。
|
||||
- 支持从本地选择图片文件(PNG/JPG 等)。
|
||||
- 上传后利用 Canvas 在前端自动等比例压缩,使图片的长、宽最大不超过 500 像素。
|
||||
- 压缩后的图片以 Base64(JPEG,质量 0.8,白色背景填充透明 PNG)形式存储在用户对象的 `signature` 字段中,并持久化到 `localStorage`。
|
||||
|
||||
### 需求 3:TemplateManage 新增"手术者签名"字段
|
||||
|
||||
**期望**:
|
||||
- 在 `DEFAULT_FORM_FIELDS` 中新增一个系统锁定字段 `surgeonSignature`,分类为"图片",类型为 `signature`。
|
||||
- `TemplateManage` 的【插入字段】侧边栏中增加"图片"分类,包含"手术者签名"按钮。
|
||||
- `ReportEditor` 中,当渲染到 `data-bind="surgeonSignature"` 的智能字段时,自动从 `currentUser.signature` 读取电子签图片并填充到 `.field-value` 中;若当前用户没有上传签名,则显示提示文本"【请上传电子签】"。
|
||||
|
||||
### 需求 4:签名图片在模板中的排版优化
|
||||
|
||||
**期望**:
|
||||
- 签名图片在 `.field-value` 中显示时,高度应恰好约等于 2 行文字(约 `2.4em`),宽度等比例自适应。
|
||||
- 图片垂直对齐方式需与周围文字协调,避免把行高撑得过大。
|
||||
- 打印输出时(`printDocument` 及 `@media print`),签名图片保持同样的高度约束和排版效果。
|
||||
|
||||
## 三、影响范围分析
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|------|----------|
|
||||
| `src/types.ts` | `User` 接口增加 `signature?: string`;`FieldType` 增加 `'signature'`;`DEFAULT_FORM_FIELDS` 追加 `surgeonSignature` 字段。 |
|
||||
| `src/pages/UserManage.tsx` | 用户编辑弹窗增加电子签上传组件;增加 `compressImage` 前端压缩工具函数;保存时将 `signature` 写入用户对象。 |
|
||||
| `src/pages/TemplateManage.tsx` | 插入字段分类增加"图片";字段按钮增加 `onMouseEnter` / `onMouseLeave` 事件实现悬浮高亮;`insertSmartField` 增加 `surgeonSignature` 的 HTML 输出(与普通字段一致但 `data-bind="surgeonSignature"`)。 |
|
||||
| `src/pages/ReportEditor.tsx` | 在"表单 → 编辑器"同步的 `useEffect` 中,对 `surgeonSignature` 做特殊处理:填充 `<img>` 或提示文本。 |
|
||||
| `src/index.css` | 增加 `.report-signature-img` 的样式规则(高度 `2.4em`、宽度 `auto`、`vertical-align: middle` 等);增加打印媒体查询中的签名图片样式。 |
|
||||
| `src/utils/print.ts` | 在打印 iframe 的 `<style>` 中增加 `.report-signature-img` 的样式规则。 |
|
||||
|
||||
## 四、验收标准
|
||||
|
||||
- [ ] 鼠标悬浮在 TemplateManage 右侧字段按钮上时,编辑器中对应字段框出现高亮边框/背景变化;移开后恢复。
|
||||
- [ ] UserManage 中可上传电子签图片,上传后预览显示压缩后的图片。
|
||||
- [ ] 压缩后的图片宽/高均不超过 500px,文件体积显著减小。
|
||||
- [ ] TemplateManage 的插入字段列表中出现"图片"分类及"手术者签名"按钮,可正常插入。
|
||||
- [ ] ReportEditor 中,`surgeonSignature` 字段自动显示当前登录用户的电子签图片;无签名时显示"【请上传电子签】"。
|
||||
- [ ] 签名图片在编辑器中高度约 2 行文字,不破坏行高排版;打印输出效果一致。
|
||||
- [ ] `npm run lint` 无编译错误。
|
||||
67
工程分析/需求分析-2026-04-17-12-34-56.md
Normal file
67
工程分析/需求分析-2026-04-17-12-34-56.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 需求分析 — 撤销栈修复、字段删除交互优化与签名字段闭环(2026-04-17-12-34-56)
|
||||
|
||||
## 一、需求来源
|
||||
|
||||
用户反馈 TemplateManage 中删除字段后撤销失效、插入字段导致非预期换行、Backspace/Delete 误删父级内容;同时希望优化手术者签名的业务逻辑闭环,包括尺寸约束、表单显隐控制及完成报告时的弱阻断提示。
|
||||
|
||||
## 二、具体需求拆解
|
||||
|
||||
### 需求 1:修复撤销(Undo)失效
|
||||
|
||||
**问题**:在 TemplateManage 中点击红色 × 或通过键盘删除智能字段后,点击工具栏的"撤销"按钮无法恢复被删除的字段。
|
||||
|
||||
**原因**:当前代码使用 `smartField.remove()` 和 `target.remove()` 直接操作 DOM,绕过了浏览器的原生撤销栈(undo stack)。
|
||||
|
||||
**期望**:删除字段的操作能被浏览器的 `execCommand('undo')` 正确撤销。
|
||||
|
||||
### 需求 2:修复插入字段换行与 Backspace/Delete 误删
|
||||
|
||||
**问题**:
|
||||
- 在 TemplateManage 中插入"手术日期"或"手术者签名"后,字段框有时会直接跳到下一行。
|
||||
- Backspace 键无法删除字段(无反应)。
|
||||
- Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。
|
||||
|
||||
**期望**:
|
||||
- 插入的字段保持在当前行内,不强制换行。
|
||||
- Backspace/Delete 键能精准删除光标相邻的单个字段节点,不影响周围文本。
|
||||
|
||||
### 需求 3:签名图片尺寸约束与字段管理
|
||||
|
||||
**问题**:
|
||||
- 当前签名图片没有最大尺寸限制,可能过大。
|
||||
- "手术者签名"字段不在 ReportEditor 的右侧基本信息表单中显示,无法控制是否签字。
|
||||
|
||||
**期望**:
|
||||
- 签名图片设置最大宽度 `120px`、最大高度 `40px`,等比例缩放(`object-fit: contain`)。
|
||||
- 将 `surgeonSignature` 字段改为 `visibleInForm: true`、`isSystemLocked: false`,使其可在字段管理中显隐控制。
|
||||
- 在 ReportEditor 右侧表单中增加"手术者签名确认"(`isSigned`)单选下拉框,选项为"已签字"、"未签字",默认"未签字"。
|
||||
- 模板中的 `surgeonSignature` 字段根据 `isSigned` 的值渲染:
|
||||
- `isSigned === '已签字'` 且用户有签名图 → 显示等比例缩放后的签名图片。
|
||||
- 其他情况 → 显示"【未签字】"。
|
||||
|
||||
### 需求 4:完成报告时的签名校验提示
|
||||
|
||||
**期望**:点击"完成报告"时:
|
||||
- 若模板中存在 `data-bind="surgeonSignature"` 的字段,但 `isSigned !== '已签字'`,弹出 `confirm` 提示"模板中包含【手术者签名】字段,但您未选择'已签字'。是否继续完成报告?",用户选择"取消"则中断保存,选择"确定"则继续保存。
|
||||
- 若 `isSigned === '已签字'` 但当前用户未上传电子签名图片,弹出 `confirm` 提示"您选择了'已签字',但账号尚未上传电子签名图片。报告中将不显示签名图片,是否继续完成?",同样弱阻断(可继续保存)。
|
||||
|
||||
## 三、影响范围分析
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|------|----------|
|
||||
| `src/pages/TemplateManage.tsx` | 点击删除和键盘删除逻辑全部改用 `Selection + Range + execCommand('delete')`;`insertSmartField` 的 HTML 末尾增加 `​`(零宽空格)防止强制换行。 |
|
||||
| `src/types.ts` | `surgeonSignature` 改为 `visibleInForm: true, isSystemLocked: false`;新增 `isSigned` 字段到 `DEFAULT_FORM_FIELDS`。 |
|
||||
| `src/pages/ReportEditor.tsx` | `reportData` 初始值增加 `isSigned: '未签字'`;签名同步逻辑改为基于 `isSigned` 判断;`saveReport('completed')` 增加签名校验提示。 |
|
||||
| `src/index.css` | 调整 `.report-signature-img` 为 `max-width: 120px; max-height: 40px; object-fit: contain;`。 |
|
||||
| `src/utils/print.ts` | 打印样式中同步签名图片尺寸约束。 |
|
||||
|
||||
## 四、验收标准
|
||||
|
||||
- [ ] TemplateManage 中删除字段后,点击"撤销"按钮能正确恢复字段。
|
||||
- [ ] TemplateManage 中插入字段后保持在当前行,不跳到下一行。
|
||||
- [ ] Backspace/Delete 键能精准删除单个字段节点,不误删周围文本。
|
||||
- [ ] ReportEditor 中签名图片最大 120×40px,等比例缩放。
|
||||
- [ ] ReportEditor 右侧表单出现"手术者签名确认"下拉框(已签字/未签字)。
|
||||
- [ ] 选择"已签字"且有签名图时,模板中显示签名图片;选择"未签字"或无签名图时显示"【未签字】"。
|
||||
- [ ] 点击"完成报告"时,签名状态异常会弹出弱阻断提示,用户可取消或继续保存。
|
||||
- [ ] `npm run lint` 无编译错误。
|
||||
41
工程分析/需求分析-2026-04-17-12-51-47.md
Normal file
41
工程分析/需求分析-2026-04-17-12-51-47.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 需求分析 — TemplateManage 撤销/重做修复与插入字段光标定位(2026-04-17-12-51-47)
|
||||
|
||||
## 一、需求来源
|
||||
|
||||
用户反馈 TemplateManage 中存在两个严重的交互体验问题:
|
||||
1. 删除智能字段(包括默认模板自带的和手动插入的)后,撤销/重做按钮完全失效。
|
||||
2. 点击右侧字段库插入字段时,字段经常跳到下一行或错误位置。
|
||||
|
||||
## 二、具体需求拆解
|
||||
|
||||
### 需求 1:撤销/重做功能修复
|
||||
|
||||
**问题**:即使已经将删除逻辑改为 `execCommand('delete')`,撤销/重做按钮仍然无法恢复被删除的字段。
|
||||
|
||||
**原因**:浏览器原生的 `undo stack` 在 `contentEditable` 中结合 React 状态更新和强制 Range 操作时非常脆弱,容易被清空或打断。
|
||||
|
||||
**期望**:实现一个自定义的 undo/redo 历史栈,完全接管撤销/重做逻辑,确保任何内容变更(键盘输入、插入字段、删除字段)都能被正确撤销和恢复。
|
||||
|
||||
### 需求 2:插入字段光标定位修复
|
||||
|
||||
**问题**:点击右侧字段库按钮时,编辑器失去焦点(blur),浏览器内部光标位置丢失。再次 `focus()` 后,光标往往被重置到文档开头/末尾或新块级位置,导致 `insertHTML` 插入的字段跳到下一行。
|
||||
|
||||
**期望**:
|
||||
- 点击字段库按钮时不让编辑器失去焦点。
|
||||
- 若焦点仍丢失,则在插入前恢复上一次保存的光标位置(Range)。
|
||||
- 插入的字段必须紧跟在插入前的光标位置,不强制换行。
|
||||
|
||||
## 三、影响范围分析
|
||||
|
||||
| 文件 | 改动说明 |
|
||||
|------|----------|
|
||||
| `src/pages/TemplateManage.tsx` | 新增 `undoStack` / `redoStack` refs;重写 `handleUndo` / `handleRedo`;替换 `execCmd('undo')` / `execCmd('redo')` 的调用;在关键操作(删除、插入)前增加 `pushHistory`;增加 `saveSelection` 和 `restoreSelection`;字段按钮增加 `onMouseDown={(e) => e.preventDefault()}` 阻止焦点流失。 |
|
||||
|
||||
## 四、验收标准
|
||||
|
||||
- [ ] 在模板中删除任意智能字段后,点击"撤销"按钮能立即恢复该字段。
|
||||
- [ ] 撤销恢复后,点击"重做"按钮能再次删除该字段。
|
||||
- [ ] 连续输入文字、插入字段、删除字段后,撤销/重做能按正确的历史顺序回退/前进。
|
||||
- [ ] 在文字中间点击插入字段,字段紧跟光标位置,不跳到下一行或文档末尾。
|
||||
- [ ] 多次在不同位置插入字段,每次都能准确定位。
|
||||
- [ ] `npm run lint` 无编译错误。
|
||||
73
工程分析/需求分析-2026-04-17-13-32-07.md
Normal file
73
工程分析/需求分析-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 需求分析 — 2026-04-17-13-32-07
|
||||
|
||||
## 用户反馈
|
||||
|
||||
1. **Ctrl+Z 快捷键无法撤销对 `smart-field-wrapper` 的删除**,但点击工具栏的撤销按钮可以正常撤销。
|
||||
2. **插入 `smart-field-wrapper` 时仍会分成两行**。用户怀疑是原本文本结构的问题,并提供了出现问题的 HTML 片段:
|
||||
```html
|
||||
<p style="font-family: SimSun;">
|
||||
<strong>手术日期:</strong><br>
|
||||
</p>
|
||||
<span class="smart-field-wrapper" ...>...</span>
|
||||
```
|
||||
用户补充:把字段插到下面的内容中(即上述 HTML 中已有字段的位置)则没有问题。
|
||||
|
||||
---
|
||||
|
||||
## 问题 1:Ctrl+Z 快捷键撤销失效
|
||||
|
||||
### 现状
|
||||
- `TemplateManage.tsx` 已实现了自定义的 `undoStack` / `redoStack` 以及 `handleUndo()` / `handleRedo()`。
|
||||
- 工具栏的撤销/重做按钮调用的是自定义函数,能够正确恢复 `innerHTML` 历史快照。
|
||||
- 键盘按下 `Ctrl+Z` 时,浏览器会触发**原生 `undo`**(`document.execCommand('undo')`),它与自定义历史栈完全脱节,因此:
|
||||
- 若原生栈为空(或已耗尽),则没有任何反应;
|
||||
- 若原生栈有记录,可能恢复出意料之外的状态。
|
||||
|
||||
### 根因
|
||||
- 当前只在编辑器上拦截了 `Backspace` / `Delete` 键(用于防止误删整段),**没有拦截 `Ctrl+Z` / `Ctrl+Y` 快捷键**。
|
||||
|
||||
### 需求
|
||||
- 在 `TemplateManage.tsx` 的编辑器 `keydown` 事件中,拦截以下快捷键并路由到自定义 Undo/Redo 逻辑:
|
||||
- `Ctrl+Z` / `Cmd+Z` → `handleUndo()`
|
||||
- `Ctrl+Shift+Z` / `Cmd+Shift+Z` → `handleRedo()`
|
||||
- `Ctrl+Y` / `Cmd+Y` → `handleRedo()`
|
||||
- 拦截后调用 `e.preventDefault()` 阻止浏览器原生行为。
|
||||
|
||||
---
|
||||
|
||||
## 问题 2:插入 smart-field-wrapper 分成两行
|
||||
|
||||
### 现状
|
||||
- `insertSmartField()` 使用 `document.execCommand('insertHTML', false, html)` 插入字段。
|
||||
- 当光标位于一个以 `<br>` 结尾的 `<p>` 标签末尾时,WebKit/Blink 内核的 `insertHTML` 会把 `<span>` 插到 `<p>` **外部**,导致字段独自占据新行。
|
||||
|
||||
### 根因
|
||||
- 用户提供的 HTML 明确显示了这一现象:`<span>` 跑到了 `</p>` 之后。
|
||||
- `execCommand('insertHTML')` 对块级元素边界(尤其是末尾存在 `<br>` 时)的自动修正行为不可控。
|
||||
|
||||
### 需求
|
||||
- 将 `insertSmartField()` 的插入方式从 `execCommand('insertHTML')` 替换为**精确的 `Range.insertNode()` 手动插入**:
|
||||
1. `restoreSelection()` 恢复光标;
|
||||
2. 获取当前 `Selection` 的 `Range`;
|
||||
3. `range.deleteContents()`(对 collapsed 光标无实际删除);
|
||||
4. 将 HTML 字符串转为 `DocumentFragment`;
|
||||
5. `range.insertNode(fragment)` 精确插入到 Range 位置;
|
||||
6. 把光标移动到插入内容的末尾(最后一个节点之后),保持编辑连贯性。
|
||||
- 该方式不依赖浏览器的 `execCommand` 自动修正,可避免 `<span>` 被抛到 `<p>` 外部。
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
- **仅 `src/pages/TemplateManage.tsx`** 需要修改:
|
||||
- `insertSmartField()` 函数(替换插入逻辑)。
|
||||
- `keydown` 事件监听 `useEffect`(增加快捷键拦截)。
|
||||
- 其他页面(`ReportEditor.tsx`、`ReportManage.tsx` 等)不受影响。
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 在 `TemplateManage` 编辑器中删除一个 `smart-field-wrapper`(通过点击 × 或 Backspace/Delete)后,立即按 `Ctrl+Z`,字段能够完整恢复;按 `Ctrl+Y`(或 `Ctrl+Shift+Z`)能够重做删除。
|
||||
2. 在 `<p>` 标签末尾(尤其是存在 `<br>` 的情况下)插入 `smart-field-wrapper`,字段与段落保持在同一行,不再被拆成两行。
|
||||
3. `npm run lint` 通过,无 TypeScript 编译错误。
|
||||
77
工程分析/需求分析-2026-04-17-18-38-47.md
Normal file
77
工程分析/需求分析-2026-04-17-18-38-47.md
Normal file
@@ -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),对应模板顶部 `<img src="/logo_square.png">`。
|
||||
- 建立"素材库"概念:使用 `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` 通过,无编译错误。
|
||||
62
工程分析/需求分析-2026-04-17-19-26-17.md
Normal file
62
工程分析/需求分析-2026-04-17-19-26-17.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 需求分析 — 2026-04-17-19-26-17
|
||||
|
||||
## 用户反馈的 6 项需求
|
||||
|
||||
### 1. 移除插入字段中的"图片"类型字段
|
||||
在 `template-manage` 的"插入字段"Tab 中,"图片"分类(包含手术者签名、医院Logo)不再需要。需要:
|
||||
- 从插入字段的分类列表中移除"图片";
|
||||
- 从 `DEFAULT_FORM_FIELDS` 中移除 `surgeonSignature` 和 `hospitalLogo`;
|
||||
- 清理 `TemplateManage.tsx` 中与图片类型字段相关的分类渲染逻辑(如折叠分组、新增字段表单中的"图片"选项等)。
|
||||
|
||||
### 2. 细化"插入图片占位符"功能(支持自定义默认宽高)
|
||||
在 `template-manage` 和 `report-editor` 中,点击工具栏的"插入图片占位符"按钮时:
|
||||
- 弹出提示框让用户输入默认最大宽度(px)和最大高度(px),留空则表示无限制;
|
||||
- 提示文字中附加说明:"一个文字高度约为 20 像素左右";
|
||||
- 将用户输入的宽高写入占位符的 `style` 属性中(`max-width` / `max-height`)。
|
||||
|
||||
### 3. 占位符文字自适应缩写
|
||||
`class="image-placeholder"` 中的提示文字,如果占位符框太小(通过宽度 < 80px 判断),则将 "插入/点击放置图片" 缩写为 "插入图片"。
|
||||
|
||||
### 4. 手术者签名、医院Logo 改用图片占位符
|
||||
在 `defaultContent.ts` 中:
|
||||
- 将 `smartField('surgeonSignature')` 替换为行内 `<span class="image-placeholder">`;
|
||||
- 将顶部的 `hospitalLogo` 占位符替换为标准的 `<span class="image-placeholder">`(保持居中)。
|
||||
|
||||
### 5. 插入图片占位符时保持同行
|
||||
当前使用 `<div>` 插入图片占位符会导致强制换行。需要:
|
||||
- 将占位符的容器标签从 `<div>` 改为 `<span>`;
|
||||
- 设置 `display: inline-flex` + `vertical-align: middle`;
|
||||
- 确保插入后与前后文字保持在同一行。
|
||||
|
||||
### 6. 统一两个编辑器的图片占位符点击行为
|
||||
`template-manage` 中点击图片占位符时目前直接调起本地文件选择器。需要将其改为与 `report-editor` 一致的行为:
|
||||
- 弹出"图片来源选择器"弹窗;
|
||||
- 支持"本地上传"、"我的签名"、"系统素材"三种来源;
|
||||
- 将 `ReportEditor` 中的弹窗逻辑复用到 `TemplateManage` 中。
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `src/types.ts`:移除 `surgeonSignature` 和 `hospitalLogo` 字段(或将其从字段配置中移除)。
|
||||
- `src/utils/defaultContent.ts`:签名和 Logo 位置替换为 `image-placeholder`。
|
||||
- `src/pages/TemplateManage.tsx`:
|
||||
- 移除"图片"分类相关渲染和新增字段表单选项;
|
||||
- 改造 `insertImage()` 支持 prompt 输入宽高;
|
||||
- 新增图片来源选择弹窗状态和填充逻辑;
|
||||
- 移除 `triggerPlaceholderUpload` 的直接调用。
|
||||
- `src/pages/ReportEditor.tsx`:
|
||||
- 改造 `insertImage()` 支持 prompt 输入宽高;
|
||||
- 保持现有的图片来源选择弹窗逻辑不变。
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `template-manage` 的"插入字段"和"字段管理"中不再出现"图片"分类和相关字段。
|
||||
2. `defaultContent.ts` 中签名和 Logo 均为 `image-placeholder`。
|
||||
3. 点击"插入图片占位符"时弹出宽高 prompt,正确应用 `max-width` / `max-height`。
|
||||
4. 图片占位符使用 `<span>` + `display: inline-flex`,插入后与文字同行。
|
||||
5. 宽度 < 80px 的占位符显示"插入图片",否则显示"插入/点击放置图片"。
|
||||
6. `template-manage` 和 `report-editor` 点击图片占位符均弹出三选一图片来源弹窗。
|
||||
7. `npm run lint` 通过。
|
||||
43
工程分析/需求分析-2026-04-17-21-32-27.md
Normal file
43
工程分析/需求分析-2026-04-17-21-32-27.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 需求分析 — 2026-04-17-21-32-27
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. TemplateManage 字段管理中,时间/日期字段增加配置选项:
|
||||
- date/time 均可选择默认值策略:「当前时间」或「手动选择(特定时间)」
|
||||
- date 可选择显示格式:`YYYY-MM-DD` 或 `YYYY年MM月DD日`
|
||||
- time 可选择显示格式:24小时制 或 12小时制(AM/PM)
|
||||
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
| # | 功能点 | 说明 |
|
||||
|---|--------|------|
|
||||
| 1 | 扩展 `FormField` 数据结构 | 新增 `timeFormat?: string` 和 `timeDefault?: 'current' \| 'specific'` |
|
||||
| 2 | TemplateManage 新增字段表单增强 | 当 category === '时间' 时,显示「默认值」和「显示格式」两个配置项 |
|
||||
| 3 | TemplateManage 字段编辑面板增强 | 已有时间字段点击编辑时,可修改默认值策略和显示格式 |
|
||||
| 4 | 默认字段配置更新 | `surgeryDate/startTime/endTime` 加上合理的默认配置;新增 `reportDate` 字段 |
|
||||
| 5 | 默认模板底部「撰写时间」 | `defaultContent.ts` 底部静态文本替换为 `${smartField('reportDate')}` |
|
||||
| 6 | ReportEditor date 字段格式同步 | smart field 同步时根据 `timeFormat` 转换日期显示格式 |
|
||||
| 7 | ReportEditor time 字段格式同步 | smart field 同步时根据 `timeFormat` 转换时间显示格式(12h/24h) |
|
||||
| 8 | ReportEditor time 字段 12h 表单渲染 | time 字段表单增加 AM/PM 选择,与 hour/minute 联动 |
|
||||
| 9 | ReportEditor 自动填充当前时间 | 组件初始化时,对 `timeDefault === 'current'` 且值为空的字段自动填充 |
|
||||
| 10 | 通用 time 字段表单渲染 | 非 `startTime/endTime` 的 time 字段新增 hour+minute select 渲染 |
|
||||
|
||||
### 非功能点
|
||||
- 向后兼容:未配置 `timeFormat/timeDefault` 的现有字段按原有行为工作
|
||||
- 最小侵入:不改动现有数据存储结构(date 仍存 `YYYY-MM-DD`,time 仍存 `HH:MM` 或 `startHour:startMinute`)
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 高 | `FormField` 接口扩展 + `DEFAULT_FORM_FIELDS` 新增/更新 |
|
||||
| `src/utils/defaultContent.ts` | 中 | 底部静态文本替换为 smartField |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 新增字段表单、字段编辑面板、保存逻辑均需改动 |
|
||||
| `src/pages/ReportEditor.tsx` | 高 | date/time 表单渲染、smart field 同步、初始化自动填充 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求)。
|
||||
33
工程分析/需求分析-2026-04-17-23-12-52.md
Normal file
33
工程分析/需求分析-2026-04-17-23-12-52.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 需求分析 — 2026-04-17-23-12-52
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. `template-manage` 字段管理 → 时间字段 → 格式第三栏中,当前只显示 `YYYY-MM-DD` 和 `24h`,缺少 `YYYY年MM月DD日` 和 `HH:mm` / `hh:mm A` 等选项;希望将 `24h` 改为 `HH:mm(24H)` 的友好显示。
|
||||
2. `report-editor` 中手术终止时间 smart field 显示为 "24h" 字样,而不是正常的时间值。
|
||||
3. `template-manage` 字段管理中,点击字段进入编辑模式后,部分输入框/下拉框在特定位置(如滚动容器底部边缘)点击无响应,需要滚动屏幕后才能正常获取焦点。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:修正 `types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `startTime`/`endTime` 的 `timeFormat` 默认值,从 `'24h'` 改为 `'HH:mm'`。
|
||||
- **F2**:在 `ReportEditor.tsx` 的 `formatTimeDisplay` 函数中增加对旧脏数据 `'24h'` 的兼容兜底,自动映射为 `'HH:mm'`。
|
||||
- **F3**:在 `TemplateManage.tsx` 中按字段类型(date/time)过滤 `customTimeFormats` 的 datalist 显示,避免 date 字段看到 time 格式、time 字段看到 date 格式。
|
||||
- **F4**:在 `TemplateManage.tsx` 的字段编辑卡片 `onClick` 中增加 `scrollIntoView`,解决编辑面板撑开后底部输入框被滚动容器裁切导致的点击失效问题。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已有用户的 `formFieldsConfig` 中可能仍存在 `'24h'`,通过 F2 的兼容处理确保不影响现有报告。
|
||||
- 不引入新的 localStorage key,复用现有的 `customTimeFormats` 缓存。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/types.ts` | 中 | `DEFAULT_FORM_FIELDS` 中 `startTime`/`endTime` 的 `timeFormat` 默认值修改 |
|
||||
| `src/pages/ReportEditor.tsx` | 低 | `formatTimeDisplay` 增加一行兼容兜底 |
|
||||
| `src/pages/TemplateManage.tsx` | 中 | datalist 过滤逻辑 + 编辑卡片滚动对齐 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
35
工程分析/需求分析-2026-04-17-23-38-34.md
Normal file
35
工程分析/需求分析-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 需求分析 — 2026-04-17-23-38-34
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **时间格式输入框改造**:`template-manage` 字段管理中,时间字段的格式输入当前使用原生 `<input list="...">` + `<datalist>`,浏览器兼容性和交互体验不佳。希望改造为类似单选下拉框的自定义组件,既能下拉选择已有格式,又能手写输入并自动记忆新格式。
|
||||
|
||||
2. **表格内插入图片占位符修复**:在 `template-manage` 编辑器表格中点击"插入图片占位符"后,HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素被分散到 `<td>` 中。且表格内占位符应默认自适应单元格大小(最大不超过 200×200px)。
|
||||
|
||||
3. **打印第二页页边距太小**:`report-editor` / `report-view` 点击打印时,第二页及后续页面的上下边距几乎为 0,内容紧贴纸张边缘。当前 `@page { margin: 0 }` + `body { padding: 10mm }` 的组合仅在文档首尾生效一次,分页后无padding。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:`TemplateManage.tsx` 中编辑字段和新增字段的时间格式输入,从原生 `input[list]` + `datalist` 改造为自定义下拉组件(input + 绝对定位 ul 列表)。支持点击展开下拉、点击选项填充、手写输入、blur/Enter 时自动保存新格式到 `customTimeFormats`。
|
||||
- **F2**:`TemplateManage.tsx` 和 `ReportEditor.tsx` 的 `insertImage` 函数,在插入前检测当前光标是否位于 `<td>` / `<th>` 内。若在表格内,使用块级 `<div>` 作为外层容器(避免浏览器 execCommand 修正破坏结构),并设置 `width:100%;height:100%;max-width:200px;max-height:200px;` 实现自适应。不在表格内时保持现有 `<span>` 行内结构。
|
||||
- **F3**:`src/utils/print.ts` 中,将 `@page { margin: 0 }` + `body { padding: 10mm }` 改为 `@page { margin: 15mm 10mm }` + `body { padding: 0 }`,使每一页物理纸张都有独立的上下 15mm / 左右 10mm 留白。同步调整 `.content` 的 width 为 `100%`。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已保存的模板/报告中已有的 `image-placeholder` 结构不受影响。
|
||||
- 下拉组件的 `z-index` 需确保覆盖在滚动容器之上。
|
||||
- 打印样式调整应同时兼顾 `.image-placeholder` 在打印时的隐藏逻辑。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 时间格式自定义下拉组件(编辑+新增两处);insertImage 表格检测与分支逻辑 |
|
||||
| `src/pages/ReportEditor.tsx` | 中 | insertImage 表格检测与分支逻辑 |
|
||||
| `src/utils/print.ts` | 低 | @page margin 与 body padding 调整 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求分析 — 2026-04-18-00-02-08
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **修复拖拽关键帧插入兼容性**:拖拽关键帧到 `.image-placeholder` 后,虚线边框和背景色未消失,且图片缺少 `max-height:100%;object-fit:contain;` 约束,可能溢出占位符。
|
||||
|
||||
2. **图片占位符插入改为自定义弹窗 + 分类隔离**:
|
||||
- 替换 `insertImage` 中的 `prompt` 弹窗为自定义 React Modal。
|
||||
- 占位符分为两类:
|
||||
- **手术影像占位(frame 模式)**:支持自动帧插入、一键插入、拖拽插入。
|
||||
- **静态图片占位(manual 模式)**:仅支持点击后从弹窗选择图片来源(本地上传/签名/素材),防止系统自动将手术关键帧填入 Logo 或签名位置。
|
||||
- 自动帧插入和一键插入逻辑需跳过 `data-mode="manual"` 的占位符。
|
||||
- 拖拽到 manual 占位符时需拦截并提示。
|
||||
|
||||
3. **表格插入改为自定义弹窗**:替换 `insertTable` 中的 `prompt` 弹窗为自定义 React Modal,中间弹出子窗口选择行数和列数。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:`ReportEditor.tsx` 的 `fillPlaceholder` 函数补齐 `border='none'`、`background='transparent'`,图片 style 增加 `max-height:100%;object-fit:contain;`。
|
||||
- **F2**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertImage` 改为打开自定义 Modal(替代 `prompt`)。Modal 包含:
|
||||
- 宽高输入框(默认 200*200)
|
||||
- 模式选择:手术影像占位(frame)/ 静态图片占位(manual)
|
||||
- 确认/取消按钮
|
||||
- **F3**:生成 placeholder HTML 时,manual 模式添加 `data-mode="manual"` 属性。
|
||||
- **F4**:`ReportEditor.tsx` 的 `autoCaptureFrames`(setTimeout 回调内)、`insertFrameToPlaceholder` 的空占位符选择器,从 `.image-placeholder:not(.has-image)` 改为 `.image-placeholder:not(.has-image):not([data-mode="manual"])`。
|
||||
- **F5**:`ReportEditor.tsx` 的 `handleDrop` 增加拦截:若目标 placeholder 的 `data-mode === 'manual'`,弹出提示并阻止填充。
|
||||
- **F6**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertTable` 改为打开自定义 Modal(替代 `prompt`),包含行数/列数输入和确认/取消按钮。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已有报告中已有的 placeholder 结构不受影响(没有 `data-mode` 属性的占位符默认为 frame 模式)。
|
||||
- Modal 样式复用现有的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | fillPlaceholder 修复;insertImage 改为 Modal;insertTable 改为 Modal;autoCaptureFrames 选择器;insertFrameToPlaceholder 选择器;handleDrop 拦截;新增 3 个 Modal 的 JSX |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | insertImage 改为 Modal;insertTable 改为 Modal;新增 2 个 Modal 的 JSX |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析 — 2026-04-18-00-23-14
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户提出 3 个关于富文本编辑器(ReportEditor / TemplateManage)的优化需求:
|
||||
|
||||
1. **拖拽关键帧后占位符边框残留**:通过拖拽将视频关键帧放入 `.image-placeholder` 后,占位符原有的虚线框和背景色未完全清除,视觉兼容性差。
|
||||
2. **废弃原生 `prompt()`,改为居中 UI 弹窗**:点击「插入表格」和「插入图片占位符」时,当前使用浏览器原生 `prompt()` 弹窗,希望改为屏幕中央的自定义 React 弹窗,以确认表格行列数或占位符最大长宽。
|
||||
3. **占位符图片来源隔离与保护**:创建图片占位符时,可选择允许的图片来源类型(关键帧图片 / 本地上传/签名/素材),从而保护签名等特定区域不被误拖入术中截图。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:修复 `fillPlaceholder`(拖拽填充)未清除内联 `border` 和 `background` 的问题,使图片完全撑满占位符。
|
||||
- **F2**:修复 `autoCaptureFrames` 中自动插入关键帧时同样未清除内联样式的问题。
|
||||
- **F3**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertTable` 的原生 `prompt()` 替换为自定义居中弹窗(输入行数、列数)。
|
||||
- **F4**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertImage` 的原生 `prompt()` 替换为自定义居中弹窗(输入宽度、高度;表格内自动隐藏尺寸输入)。
|
||||
- **F5**:在弹窗中增加「图片来源限制」下拉选项(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成占位符时写入 `data-allow-source` 属性。
|
||||
- **F6**:在 `handleDrop`(拖拽关键帧)中拦截:若占位符限制为 `upload`,拒绝拖入并提示。
|
||||
- **F7**:在 `handleEditorClick`(点击空占位符)中拦截:若占位符限制为 `frame`,拒绝打开图片选择器并提示。
|
||||
- **F8**:在 `insertFrameToPlaceholder`(一键插入关键帧)中拦截:若目标占位符限制为 `upload`,拒绝插入并提示。
|
||||
- **F9**:在 `autoCaptureFrames` 的自动帧插入 `setTimeout` 中拦截:若第一个空置占位符限制为 `upload`,跳过该帧不插入。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:未设置 `data-allow-source` 的旧占位符默认行为不变(视为 `all`)。
|
||||
- 焦点管理:打开弹窗前保存当前 Selection/Range,确认后恢复光标位置再执行 `insertHTML`,确保插入位置正确。
|
||||
- 视觉一致性:弹窗样式与现有 `imagePickerOpen` 弹窗保持一致(固定遮罩 + 白色圆角卡片 + 居中布局)。
|
||||
- 零新依赖:不引入第三方 UI 库,继续使用原生 React state + Tailwind CSS 实现。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 修改 `fillPlaceholder`、`insertTable`、`insertImage`、`handleDrop`、`handleEditorClick`、`insertFrameToPlaceholder`、`autoCaptureFrames`;新增弹窗 state 与 JSX。 |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertTable`、`insertImage`;新增弹窗 state 与 JSX;复用 `savedRangeRef` 做光标恢复。 |
|
||||
| `src/index.css` | 低 | 无需修改,`.image-placeholder.has-image` 的 Tailwind 样式已正确,只需在 JS 中清除内联样式。 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已明确要求本次不经过人工二次确认直接执行。
|
||||
32
工程分析/需求分析-2026-04-18-00-43-19.md
Normal file
32
工程分析/需求分析-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 需求分析 — 2026-04-18-00-43-19
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户反馈默认模板里的 `class="image-placeholder"` 有问题,要求将默认模板中全部 `image-placeholder` 替换为「按动插入图片占位符之后的状态」,且**只保留当前框的大小不变**。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:分析默认模板 `defaultContent.ts` 中所有 `.image-placeholder` 的当前结构与新弹窗插入逻辑生成结构的差异。
|
||||
- **F2**:为默认模板中所有 `.image-placeholder` 补充 `data-mode` 属性,使其与新的图片来源隔离机制兼容:
|
||||
- 医院 Logo、手术者签名 → `data-mode="manual"`(静态图片占位,仅支持点击插入,禁止拖入关键帧)
|
||||
- 表格内术中影像占位符 → `data-mode="frame"`(手术影像占位,支持拖拽/自动关键帧插入)
|
||||
- **F3**:更新签名占位符的提示文本,使其符合新弹窗的宽度阈值规则(width ≥ 80 时显示「插入/点击放置图片」)。
|
||||
- **F4**:保持所有占位符的现有 `width`、`height` 及外围布局(标签类型、margin、容器结构)绝对不变。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:默认模板仅影响新建报告,已有报告不受影响。
|
||||
- 最小侵入:仅修改 `defaultContent.ts`,不动任何 TSX/JS 逻辑。
|
||||
- `npm run lint` 零错误。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/utils/defaultContent.ts` | 高 | 修改 8 个 `image-placeholder` 的 HTML 结构,补充 `data-mode` 及文本。 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已明确无需人工二次确认。
|
||||
116
过往经验/实现方案-2026-04-16-20-24-11.md
Normal file
116
过往经验/实现方案-2026-04-16-20-24-11.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 实现方案 — 2026-04-16-20-24-11
|
||||
|
||||
## 根因分析
|
||||
|
||||
当前问题由两个核心缺陷共同导致:
|
||||
|
||||
### 1. `saveDraftToStorage` 的闭包陷阱 + DOM 引用失效
|
||||
|
||||
此前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 重构为直接从 React state 读取:
|
||||
```tsx
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
storage.set(key, {
|
||||
content: editorRef.current?.innerHTML || '',
|
||||
reportData,
|
||||
videos,
|
||||
capturedFrames,
|
||||
...
|
||||
});
|
||||
}, [reportData, videos, capturedFrames, ...]);
|
||||
```
|
||||
|
||||
这引入了新的问题:
|
||||
- **闭包陷阱**:用户操作后常见写法是 `setCapturedFrames(nextFrames); saveDraftToStorage();`。由于 `setState` 异步,`saveDraftToStorage` 闭包中读取到的 `capturedFrames` 仍是旧值(空数组),导致旧值覆盖 localStorage。
|
||||
- **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空,导致 `content: editorRef.current?.innerHTML || ''` 保存了空字符串,覆盖了已有的报告内容。
|
||||
|
||||
### 2. `contentRef` 更新遗漏
|
||||
|
||||
代码中部分修改编辑器 DOM 的路径没有同步更新 `contentRef.current`。例如 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef`。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 补齐 `contentRef` 遗漏点 |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:重构 `saveDraftToStorage` 从 Ref 读取
|
||||
|
||||
**修改为:**
|
||||
```tsx
|
||||
const saveDraftToStorage = React.useCallback(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
const key = user ? `reportEditorDraft_${user.username}` : '';
|
||||
if (key) {
|
||||
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
|
||||
storage.set(key, {
|
||||
content: currentContent,
|
||||
draftReportId: reportId || null,
|
||||
reportData: stateRef.current.reportData,
|
||||
videos: stateRef.current.videos,
|
||||
capturedFrames: stateRef.current.capturedFrames,
|
||||
activeTab: stateRef.current.activeTab,
|
||||
loadedTemplateId: stateRef.current.loadedTemplateId
|
||||
});
|
||||
}
|
||||
}, [reportId]);
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 数据源回归 `stateRef` 和 `contentRef`,彻底摆脱 React state 的闭包陷阱。
|
||||
- `content` 优先读取 `contentRef.current`(在内存中稳定存在),回退到 `editorRef.current?.innerHTML`(兼容某些未更新 contentRef 的旧路径),兜底为空字符串。即使组件卸载时 DOM 已被销毁,`contentRef.current` 仍保存着最新的编辑器 HTML。
|
||||
|
||||
### 变更 2:补齐 `contentRef` 更新遗漏
|
||||
|
||||
在 `handleEditorClick` 的 `document.execCommand('delete')` 分支中,增加 `contentRef.current` 的同步:
|
||||
|
||||
**当前代码(约第 296-304 行):**
|
||||
```tsx
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNode(placeholder);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('delete');
|
||||
saveDraftToStorage();
|
||||
}
|
||||
```
|
||||
|
||||
**修改为:**
|
||||
```tsx
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNode(placeholder);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('delete');
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
```
|
||||
|
||||
### 变更 3:确保自动保存 effect 绑定最新 `saveDraftToStorage`
|
||||
|
||||
当前自动保存 effect 已经绑定 `[saveDraftToStorage]`,由于 `saveDraftToStorage` 的 dependency 现在只有 `[reportId]`,effect 不会因为 state 变化而频繁重新注册,但 cleanup 中仍然指向最新的保存函数。
|
||||
|
||||
### 变更 4:初始化恢复时 `contentRef` 同步(已有,确认无误)
|
||||
|
||||
在 `useEffect` 和 `useLayoutEffect` 的各恢复分支中,设置 `editorRef.current.innerHTML = draft.content`(或 `found.content`)时,代码已经同步设置了 `contentRef.current = ...`,这部分无需修改。
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 仍有未被发现的 `contentRef` 更新遗漏点 | 低 | 已全面搜索 `innerHTML` 修改点和 `saveDraftToStorage` 调用点,仅发现 1 处遗漏 |
|
||||
| `stateRef` 在某些新功能中再次不同步 | 低 | `saveDraftToStorage` 从 `stateRef` 读取,后续新增功能只要保持「setState 后立即同步 stateRef」的习惯即可 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
本次修改仅调整 `saveDraftToStorage` 的数据源和补齐一处 `contentRef` 更新,不改变数据结构和接口。如出现异常,可直接 `git revert` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
90
过往经验/实现方案-2026-04-16-20-33-12.md
Normal file
90
过往经验/实现方案-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 实现方案 — 2026-04-16-20-33-12
|
||||
|
||||
## 根因分析
|
||||
|
||||
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了:
|
||||
```tsx
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||
}
|
||||
```
|
||||
|
||||
`await` 会暂停整个 `for` 循环的执行,导致:
|
||||
1. 关键帧摘取被强制暂停,等待延迟结束;
|
||||
2. 所有帧必须一张一张串行处理,整体耗时 = 摘取时间 + 插入延迟 × 插入帧数;
|
||||
3. 用户体验上感觉"卡顿"或"慢"。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | `autoCaptureFrames` 中自动插入逻辑改为 `setTimeout` 非阻塞执行 |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更:`autoCaptureFrames` 中的插入逻辑(约第 523-535 行)
|
||||
|
||||
**当前代码:**
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i) && editorRef.current) {
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||
}
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
emptyPlaceholder.innerHTML = `...`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修改为:**
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
|
||||
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
|
||||
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
|
||||
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!editorRef.current) return;
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
}, actualDelay);
|
||||
}
|
||||
```
|
||||
|
||||
**效果:**
|
||||
- `for` 循环全速运行,不再被插入延迟阻塞;
|
||||
- 每张需要插入的帧按顺序延迟(第 1 张 delay,第 2 张 2×delay...),避免同时插入;
|
||||
- `setTimeout` 回调中实时查询 DOM 获取最新的空 placeholder;
|
||||
- 插入后同步 `contentRef.current` 并保存草稿。
|
||||
|
||||
### 附加变更:移除循环后的批量 `contentRef` 更新
|
||||
|
||||
当前代码在循环结束后:
|
||||
```tsx
|
||||
if (settings.autoInsertFrames && editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
```
|
||||
|
||||
由于每个 `setTimeout` 回调内部已经单独更新 `contentRef` 和保存草稿,且循环结束时可能 `setTimeout` 尚未执行,这句批量更新既不及时也可能遗漏。建议**移除或保留不影响功能**。为简化逻辑,选择保留但无实质影响,因为非阻塞的 `setTimeout` 回调会各自负责自己的保存。
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| `setTimeout` 回调执行时 placeholder 已被用户手动填充 | 低 | 回调中实时查询 `.image-placeholder:not(.has-image)`,找不到则跳过,不会覆盖用户内容 |
|
||||
| 多张图片按顺序延迟插入时,用户快速离开页面 | 低 | 每次插入后都调用 `saveDraftToStorage()`,已插入的图片会被保存;未执行的 `setTimeout` 自然丢弃 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
修改范围极小,仅涉及 `autoCaptureFrames` 中的几行代码。如有异常可直接 revert。
|
||||
105
过往经验/实现方案-2026-04-16-20-46-50.md
Normal file
105
过往经验/实现方案-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 实现方案 — 2026-04-16-20-46-50
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 1. LocalStorage 容量超限(QuotaExceededError)
|
||||
|
||||
浏览器对单个域名的 `localStorage` 通常有 **5MB** 的严格容量限制。
|
||||
|
||||
当前代码在抽帧时使用了视频的原始分辨率:
|
||||
```tsx
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
```
|
||||
|
||||
如果上传的是 1080p 甚至 4K 视频:
|
||||
- 单张 0.9 质量 JPEG Base64 图片可能达到 **300KB ~ 1MB**;
|
||||
- 自动提取 12 张关键帧 + 手动截图若干张,总数据量可能达到 **5MB ~ 10MB**;
|
||||
- 直接超过 `localStorage` 的 5MB 上限。
|
||||
|
||||
### 2. 静默失败
|
||||
|
||||
`src/utils/storage.ts` 中的 `set` 方法:
|
||||
```typescript
|
||||
set<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// ignore quota exceeded
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当数据量超过 5MB 时,`localStorage.setItem` 抛出 `QuotaExceededError`,但被 `catch` 静默吞掉。
|
||||
|
||||
**实际发生的过程:**
|
||||
1. 用户上传视频 → 此时 `videos` 数组中的 `url` 是 `blob:http://...`(短字符串),数据量很小,**保存成功**;
|
||||
2. 系统开始自动抽帧,生成巨大的 Base64 `dataUrl` 数组;
|
||||
3. 调用 `saveDraftToStorage()` 尝试保存时,`localStorage.setItem` 触发超限报错;
|
||||
4. 异常被 `catch` 忽略,**draft 没有被更新**(或更新失败);
|
||||
5. 当用户离开页面再返回时,localStorage 中读到的 draft 仍然停留在"仅有视频、没有关键帧"的状态。
|
||||
|
||||
这就是为什么:编辑器内容保留了,视频保留了,但**关键帧全部消失**。
|
||||
|
||||
## 修改方向
|
||||
|
||||
### 方向一:压缩关键帧分辨率与质量(快速修复,推荐优先执行)
|
||||
|
||||
关键帧只是用于插入报告的缩略图,通常不需要 4K 原画质。可以:
|
||||
1. 设定最大宽度(如 800px),等比缩放 Canvas;
|
||||
2. 将 JPEG 导出质量从 `0.9` 降到 `0.5 ~ 0.6`;
|
||||
3. 这样单张图片体积可从 500KB 压缩到 30KB~80KB,十几张关键帧总计不到 1MB,远低于 5MB 限制。
|
||||
|
||||
**修改点:**
|
||||
- `captureFrame()`(手动截图)
|
||||
- `autoCaptureFrames()`(自动抽帧)
|
||||
|
||||
### 方向二:增加存储超限的可见性
|
||||
|
||||
在 `storage.ts` 中不再静默吞掉异常,而是至少输出 `console.error`,甚至可以在 UI 层捕获后提示用户:
|
||||
"报告数据过大,请降低视频截图质量或删除部分图片。"
|
||||
|
||||
### 方向三:迁移到 IndexedDB(长期根治)
|
||||
|
||||
`localStorage` 的 5MB 上限对于包含大量 Base64 图片的医疗报告系统来说迟早会不够用。长期方案是:
|
||||
- 引入 `localforage` 或 `idb-keyval` 等轻量库;
|
||||
- 将 `storage.ts` 改造为基于 IndexedDB 的异步存储方案(容量可达数百 MB)。
|
||||
|
||||
**注意:** 方向三涉及全项目的 `storage.get/set` 调用点从同步改为异步,改动面较大,适合作为后续迭代项目。
|
||||
|
||||
## 建议的实施方案
|
||||
|
||||
**本次优先执行方向一 + 方向二**,以最快速度解决关键帧丢失问题,并让用户感知到存储异常:
|
||||
|
||||
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;
|
||||
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
||||
```
|
||||
|
||||
2. 在 `storage.ts` 中增加超限日志:
|
||||
```tsx
|
||||
} catch (e) {
|
||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||
}
|
||||
```
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 压缩后图片清晰度下降 | 低 | 800px 宽度 + 0.6 质量对于报告插入足够清晰 |
|
||||
| 仍有个别超长视频压缩后接近 5MB | 极低 | 配合方向二的日志提示,便于后续继续优化 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
仅调整 Canvas 缩放参数和 JPEG 质量,不涉及数据结构和接口变更。如有异常可直接 revert。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
72
过往经验/测试方案-2026-04-16-20-24-11.md
Normal file
72
过往经验/测试方案-2026-04-16-20-24-11.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 测试方案 — 2026-04-16-20-24-11
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证路由切换后,报告编辑器内容(文本、图片、表格等)和视频分析关键帧(自动/手动摘取)均不再丢失。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge
|
||||
- 前置条件:已登录系统
|
||||
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:新建报告 — 编辑器内容 + 基本信息切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/report-editor` | 页面正常加载默认模板 |
|
||||
| 1.2 | 填写患者姓名、住院号 | 输入内容保留 |
|
||||
| 1.3 | 在编辑器中输入文字、插入表格 | 内容正常显示 |
|
||||
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **编辑器内容和基本信息完整保留** |
|
||||
|
||||
### 用例 2:新建报告 — 视频 + 自动/手动关键帧切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 上传视频 | 视频出现在右侧列表 |
|
||||
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧 |
|
||||
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
|
||||
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
|
||||
|
||||
### 用例 3:新建报告 — placeholder 图片 + 删除 placeholder 后切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 拖拽一张关键帧到 `image-placeholder` | placeholder 显示图片 |
|
||||
| 3.2 | 点击 placeholder 的 × 删除图片(保留空 placeholder) | placeholder 恢复为空 |
|
||||
| 3.3 | 再次拖拽一张手动截图到 placeholder | 再次显示图片 |
|
||||
| 3.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
|
||||
|
||||
### 用例 4:编辑已有报告 — 修改后保存并重新编辑
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 编辑已有报告 | 数据正常加载 |
|
||||
| 4.2 | 修改内容并保存草稿 | 提示保存成功 |
|
||||
| 4.3 | 离开并重新进入编辑 | **所有修改完整恢复** |
|
||||
|
||||
### 用例 5:边界 — 多次快速切换
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 5.1 | 完成用例 1+2 的操作 | 数据正常 |
|
||||
| 5.2 | 连续快速切换路由 3 次以上 | **没有任何数据丢失** |
|
||||
| 5.3 | 检查 localStorage draft | `content`、`videos`、`capturedFrames` 均非空 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 编辑器内容在路由切换后 100% 保留
|
||||
- [ ] 基本信息在路由切换后 100% 保留
|
||||
- [ ] 视频和关键帧在路由切换后 100% 保留
|
||||
- [ ] 多次快速切换后数据不丢失
|
||||
- [ ] 编辑已有报告保存后重新编辑数据完整
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
37
过往经验/测试方案-2026-04-16-20-33-12.md
Normal file
37
过往经验/测试方案-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 测试方案 — 2026-04-16-20-33-12
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证自动帧插入改为非阻塞后:
|
||||
1. 关键帧摘取过程不再被插入延迟阻塞;
|
||||
2. 图片按顺序、按延迟时间依次插入 placeholder;
|
||||
3. 插入后草稿正常保存。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 用例 1:非阻塞摘取
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 系统设置开启自动插入,延迟 2 秒,勾选多个帧索引 | — |
|
||||
| 1.2 | 上传视频,点击「自动关键帧摘取」 | 右侧关键帧列表在 1-2 秒内迅速全部出现,不再卡顿等待 |
|
||||
|
||||
### 用例 2:顺序延迟插入
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 确保编辑器中有足够空 placeholder | — |
|
||||
| 2.2 | 点击「自动关键帧摘取」 | 摘取完成后,placeholder 按顺序每隔约 2 秒插入一张,不是同时插入 |
|
||||
|
||||
### 用例 3:插入后内容保存
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 等待自动插入全部完成 | — |
|
||||
| 3.2 | 切换到 `/report-manage` 再返回 | 已插入 placeholder 的图片保留在编辑器中 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 关键帧摘取不受插入延迟阻塞,快速完成
|
||||
- [ ] 图片按顺序依次插入,不堆积
|
||||
- [ ] 插入后的内容能正常保存
|
||||
60
过往经验/测试方案-2026-04-16-20-46-50.md
Normal file
60
过往经验/测试方案-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 测试方案 — 2026-04-16-20-46-50
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证压缩关键帧分辨率/质量后,LocalStorage 不再超限,路由切换后自动/手动关键帧能够正常保留;同时验证存储超限时能在控制台看到错误日志。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge(推荐开启 DevTools 观察 Console 和 Application > Local Storage)
|
||||
- 测试文件:准备一个 1080p 或更高分辨率的 MP4 视频文件(时长 60 秒以上,确保能提取多张关键帧)
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:自动关键帧摘取后切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/report-editor`,上传高清视频 | 视频正常加载 |
|
||||
| 1.2 | 点击「自动关键帧摘取」 | 右侧迅速生成 10+ 张关键帧缩略图 |
|
||||
| 1.3 | 打开 DevTools > Console | 无 `QuotaExceededError` 报错 |
|
||||
| 1.4 | 打开 DevTools > Application > Local Storage | `reportEditorDraft_{username}` 存在且体积明显小于 5MB |
|
||||
| 1.5 | 跳转到 `/report-manage`,再返回 `/report-editor` | **所有自动关键帧缩略图完整保留** |
|
||||
|
||||
### 用例 2:手动截图后切换路由
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 播放视频,在多个时间点点击「手动截图」 | 生成 5 张以上手动截图 |
|
||||
| 2.2 | 切换路由后再返回 | **所有手动截图完整保留** |
|
||||
|
||||
### 用例 3:自动+手动混合场景
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 上传视频,自动摘取 12 张关键帧 | 自动帧正常显示 |
|
||||
| 3.2 | 再手动截取 5 张 | 手动帧正常显示 |
|
||||
| 3.3 | 切换路由后再返回 | **自动帧和手动帧全部保留** |
|
||||
|
||||
### 用例 4:图片清晰度验证
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 拖拽一张压缩后的关键帧到 `image-placeholder` | placeholder 中图片清晰可见,无严重马赛克 |
|
||||
| 4.2 | 打印预览或放大查看 | 图片质量满足报告使用需求 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 高清视频自动摘取 10+ 张关键帧后,LocalStorage 不超限;
|
||||
- [ ] 路由切换后,自动关键帧 100% 保留;
|
||||
- [ ] 路由切换后,手动截图 100% 保留;
|
||||
- [ ] 压缩后的图片清晰度仍满足报告使用;
|
||||
- [ ] `storage.ts` 中存储失败时能在 Console 看到错误日志。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证,结合 DevTools 观察 LocalStorage 容量和 Console 日志。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
583
过往经验/经验记录.md
Normal file
583
过往经验/经验记录.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# 经验记录
|
||||
|
||||
---
|
||||
|
||||
## 记录 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 在延迟期间已被用户修改。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 12:5 项交互与默认值优化(占位符尺寸、签名状态、素材预加载)
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 5 个 UI/UX 改进需求:
|
||||
1. 插入图片占位符时两次 `prompt` 弹窗合并为一次,用英文逗号分隔宽高;
|
||||
2. 占位符未指定尺寸时默认显示为 `200×200px`,且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`;
|
||||
3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`;
|
||||
4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列,根据 `user.signature` 显示「已上传」/「未上传」;
|
||||
5. 修复系统重置后 `ReportEditor` 的素材库为空的问题,将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `insertImage()` 在两个编辑器(`TemplateManage`、`ReportEditor`)中均使用两次独立的 `prompt()`,操作冗余且中断感强。
|
||||
2. 旧占位符样式使用 `max-width`/`max-height`,当内容区域大于占位符时,边框和背景不会收缩到指定尺寸,视觉尺寸不可控;且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化,不统一。
|
||||
3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段,导致新系统首次进入 `/system-settings` 时相关开关为空。
|
||||
4. `UserManage.tsx` 表格缺少签名可视化列,管理员无法一眼辨别哪些医生已上传电子签名。
|
||||
5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`,素材库为空,图片选择器无法使用系统默认 logo。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **合并 prompt**:
|
||||
```ts
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
const parts = input.split(',').map(s => s.trim());
|
||||
```
|
||||
按逗号分割,第一部分为宽度,第二部分为高度。留空或单侧留空时,另一侧自动回退到 `200`。
|
||||
2. **固定尺寸样式**:
|
||||
- 移除 `max-width`/`max-height`,改用 `width:${width}px;` / `height:${height}px;`。
|
||||
- 默认值逻辑:`!widthStr && !heightStr` → `200×200`;`widthStr && !heightStr` → 宽自定义、高 `200`;`!widthStr && heightStr` → 宽 `200`、高自定义。
|
||||
3. **默认设置补全**:在 `Login.tsx` 的 `defaultSettings` 中显式加入:
|
||||
```ts
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
|
||||
```
|
||||
4. **签名状态列**:在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中,于「部门」之后、「状态」之前插入:
|
||||
```tsx
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
||||
user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{user.signature ? '已上传' : '未上传'}
|
||||
</span>
|
||||
```
|
||||
5. **素材预加载前置**:将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中,并增加 `savedAssets.length === 0` 的判空保护,避免覆盖用户后续上传的素材。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于成对的数值输入(如宽高、行列),优先考虑单输入框 + 分隔符,减少弹窗次数;同时做好格式解析和容错(空值、单侧空值、非数字)。
|
||||
- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控,避免 `inline-flex` 内容撑大容器。
|
||||
- 任何需要在多个页面共享的初始化数据(如素材库、默认配置),应放在全局初始化入口(如登录页的 `initData`),而不是分散在各个页面的 `useEffect` 中。
|
||||
- 表格字段变更时,注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致,避免列错位。
|
||||
|
||||
---
|
||||
|
||||
## 记录 13:6 项交互优化(placeholder 虚线框、删除按钮、签名尺寸、多选重构)
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 6 个 UI/UX 改进需求:
|
||||
1. 图片插入占位符后虚线框残留——内联 `border:1px dashed #cbd5e1` 优先级高于 `.has-image` CSS class;
|
||||
2. `insertImage` 生成的 placeholder 中 `overflow:hidden` 裁切了绝对定位的删除按钮(`×`);
|
||||
3. 占位符尺寸输入从逗号分隔改为星号(`*`)分隔,格式错误时提示重新输入;
|
||||
4. 默认模板中「手术者签名」占位符固定为 `200×40px`;
|
||||
5. 删除「手术者签名确认」字段及相关的弱阻断确认弹窗;
|
||||
6. 多选组件从 tag 形态重构为纯文本拼接形态,支持多种标点符号拆分并自动保存新选项。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `fillPlaceholderSrc` 仅添加了 `has-image` class,但内联 `style="border:..."` 的优先级永远高于外部 CSS,导致虚线框无法消除。
|
||||
2. `insertImage` 的 `styleStr` 中硬编码了 `overflow:hidden;`,而删除按钮使用 `position:absolute; top:-8px; right:-8px` 之类的定位,必然被父级裁切。
|
||||
3. 英文逗号分隔容易与用户输入的千位分隔符或中文逗号混淆。
|
||||
4. 默认模板中签名占位符使用 `min-width:80px;min-height:24px`,尺寸过小且不一致。
|
||||
5. `isSigned` 字段与签名图片是两个独立的状态,造成医生需要多点一次确认,流程冗余。
|
||||
6. 原多选使用 tag 胶囊形式,每个 tag 带背景色和删除按钮,占用空间大,且无法直接复制粘贴整段文本。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **清除内联样式**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `fillPlaceholderSrc` 中增加:
|
||||
```ts
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
```
|
||||
同时统一 `defaultContent.ts` 中所有 8 个 placeholder 为 `<span style="display:inline-flex;...">` 格式,表格中的 6 个也统一使用 `width:100%;height:150px;`。
|
||||
2. **移除 overflow:hidden**:从两个 `insertImage` 的 `styleStr` 中删除 `overflow:hidden;`,保留在 `placeholder-text` 子元素上(文字截断仍可用)。
|
||||
3. **星号分隔 + 校验循环**:
|
||||
```ts
|
||||
while (true) {
|
||||
const input = prompt('...用 * 分隔...', '');
|
||||
if (input === null) return;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') break;
|
||||
const parts = trimmed.split('*').map(s => s.trim());
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
||||
width = parseInt(parts[0]); height = parseInt(parts[1]); break;
|
||||
}
|
||||
alert('格式错误...');
|
||||
}
|
||||
```
|
||||
4. **签名占位符尺寸**:`defaultContent.ts` 中改为 `width:200px;height:40px;`。
|
||||
5. **移除 `isSigned`**:
|
||||
- `types.ts` 的 `DEFAULT_FORM_FIELDS` 中删除;
|
||||
- `ReportEditor.tsx` 的初始 `reportData` 中删除;
|
||||
- `saveReport` 的完成确认逻辑中删除 `isSigned` 判断;
|
||||
- smart field 同步逻辑中删除 `isSigned` 判断,只要有 `signatureData` 就直接显示签名图。
|
||||
6. **多选重构为文本拼接**:
|
||||
- `displayText = currentValues.join(', ')`;
|
||||
- input 使用 `value={displayText}` 受控组件;
|
||||
- `onChange` 实时解析并更新 `reportData`:`parseMultiInput(text)` 用 `/[,,;;、]/` 正则拆分、去重;
|
||||
- `onBlur` / `Enter` 时调用 `handleMultiCommit`,将拆分出的新选项保存到 `multiSelectOptions` 和 `formFieldsConfig`;
|
||||
- 下拉选择时追加 `, opt` 到现有文本。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当使用内联样式设置边框/背景时,如需在特定状态下移除,**必须在内联层面重置**(`style.border = 'none'`),不能仅依赖 CSS class 覆盖。
|
||||
- `overflow:hidden` 与绝对定位子元素互斥,若需要裁切文字但保留溢出按钮,应将 `overflow:hidden` 限制在文字子元素上,而非父容器。
|
||||
- 用户输入的格式校验应使用 `while` 循环 + `alert` 重试,避免静默容错导致不可预期的行为。
|
||||
- 删除字段时务必全局搜索(`grep -r 'isSigned'`),确保初始化状态、表单验证、模板绑定等所有引用点都被清理。
|
||||
- 将「标签胶囊」改为「纯文本拼接」时,注意保持 `reportData` 的数据结构仍为数组,UI 层只做 `join/split` 转换。
|
||||
|
||||
---
|
||||
|
||||
## 记录 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 容量瓶颈。
|
||||
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 14:5 项交互修复(虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示)
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 5 个修复需求:
|
||||
1. 删除 `image-placeholder` 中的图片后,虚线框消失——`fillPlaceholderSrc` 中设置了 `border='none'`,但删除图片的代码没有恢复;
|
||||
2. `ReportEditor.tsx` 的 `insertImage` prompt 文案仍显示旧版 "用英文逗号分隔",未同步修改;
|
||||
3. 新生成的 `image-placeholder` 右上角红色 `×` 显示不完全——`ReportEditor.tsx` 的 `insertImage` 中 `overflow:hidden` 未移除;
|
||||
4. 多选框无法输入 `,`、`;`、`,`、`、` 等分隔符——`onChange` 实时调用 `parseMultiInput` + `filter(Boolean)`,末尾的分隔符被瞬间吃掉;
|
||||
5. 多选框 label 缺少 "(可多选)" 提示。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 上批次修改时,`ReportEditor.tsx` 的 `insertImage` 替换未成功匹配(旧字符串与文件实际内容有微小差异),导致该函数保留了旧代码。删除图片逻辑同样缺少 border/background 恢复。
|
||||
2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除,`ReportEditor.tsx` 中仍保留。
|
||||
3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`,每次输入都会触发 `split(/[,,;;、]/)` 和 `filter(Boolean)`。当用户输入一个逗号时,split 产生空字符串,filter 将其过滤,输入框值立即回退到无逗号状态。
|
||||
4. label 渲染时直接使用 `{field.label}`,未追加多选提示。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **恢复虚线框**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的删除图片分支中,增加:
|
||||
```ts
|
||||
placeholder.style.border = '1px dashed #cbd5e1';
|
||||
placeholder.style.background = '#f8fafc';
|
||||
```
|
||||
2. **修正 prompt 文案**:将 `ReportEditor.tsx` 的 `insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑(`*` 分隔 + while 循环校验)。
|
||||
3. **移除 overflow:hidden**:从 `ReportEditor.tsx` 的 `styleStr` 中删除 `overflow:hidden;`。
|
||||
4. **多选输入解耦**:引入本地状态 `multiInputText: Record<string, string>`,
|
||||
- `onChange` 仅更新 `multiInputText`,不触发拆分;
|
||||
- `onBlur` 和 `Enter` 时才调用 `handleMultiCommit` 执行 `split` + `filter(Boolean)` + 保存新选项;
|
||||
- 输入框 `value` 优先读取 `multiInputText[field.key]`,无本地缓存时回退到 `displayText`。
|
||||
5. **label 追加提示**:`{field.label}(可多选)`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 同类型函数在多个文件中存在时,务必逐个文件 grep 确认修改结果,不能假设一次替换就能覆盖所有实例。
|
||||
- 任何 "实时解析输入" 的逻辑都必须警惕 `filter(Boolean)` 对空字符串的过滤效应——如果允许用户输入分隔符,应使用独立状态缓存原始输入,仅在确认时(blur/enter)执行解析。
|
||||
- `StrReplaceFile` 的批量替换若返回 "Applied N edit(s) with M total replacement(s)" 且 M < N,应立即检查未匹配的文件,避免遗漏。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 15:时间/日期字段格式配置与撰写时间动态字段
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 2 个需求:
|
||||
1. TemplateManage 字段管理中,时间/日期字段增加配置:date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「手动选择」作为默认值策略。
|
||||
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `FormField` 数据结构缺少格式和默认值配置字段。
|
||||
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。
|
||||
3. 模板底部「年 月 日」是纯静态 HTML 文本,没有数据绑定能力。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **扩展数据结构**:`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值(`surgeryDate` → `YYYY-MM-DD`+`specific`,`startTime/endTime` → `24h`+`specific`);新增系统字段 `reportDate`(`YYYY年MM月DD日`+`current`)。
|
||||
2. **TemplateManage UI 增强**:
|
||||
- 新增字段表单:category 为「时间」时显示「默认值」select(手动选择/当前时间)和「显示格式」select(date 提供两种日期格式,time 提供 24h/12h)。
|
||||
- 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。
|
||||
3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。
|
||||
4. **ReportEditor 表单渲染重构**:
|
||||
- `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围(24h: 00-23,12h: 01-12),12h 时额外增加 AM/PM select。存储仍保持 24h(`startHour/startMinute`),转换函数 `to24h`/`from24h` 处理 12h↔24h。
|
||||
- 通用 time 字段(非 startTime/endTime):新增 hour+minute select 渲染,值统一存储为 `HH:MM` 字符串。
|
||||
5. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`,将原始值转换为配置格式后写入编辑器。
|
||||
6. **编辑器反向编辑解析**:`handleEditorInput` 中,当用户直接在编辑器内修改 date/time smart field 时,通过正则解析格式化文本(如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`),转回原始值后存入 `reportData`。
|
||||
7. **默认模板更新**:`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当为字段增加新的配置属性时,务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值,保证向后兼容。
|
||||
- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
|
||||
- 12h/24h 转换要覆盖所有边界情况:12AM→00、12PM→12、1PM→13,建议用独立纯函数(`to24h`/`from24h`)集中处理,避免在 JSX 中内联复杂计算。
|
||||
- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 16:时间字段增强——自定义格式、固定时间默认值、系统锁定标签
|
||||
|
||||
**A. 具体问题**
|
||||
用户提出 4 个改进需求:
|
||||
1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符;
|
||||
2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段;
|
||||
3. 「手动选择」文案歧义,应改为「固定时间」;
|
||||
4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间:${smartField('reportDate')}`,导致编辑器中显示重复文字。
|
||||
2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`,字段库允许修改 label。
|
||||
3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。
|
||||
4. 日期/时间格式仅通过固定 `<select>` 提供预设选项(如 `YYYY-MM-DD`、`24h`),无法覆盖用户自定义需求(如 `YYYY/MM/DD`、`hh:mm A` 等)。
|
||||
5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **删除前缀**:`defaultContent.ts` 中将底部 HTML 从 `撰写时间:${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。
|
||||
2. **系统锁定**:`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。
|
||||
3. **文案修改**:`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。
|
||||
4. **自定义格式输入**:
|
||||
- `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。
|
||||
- `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式:
|
||||
- `formatInputMode: 'select' | 'custom'`,默认 `select`。
|
||||
- 选择「自定义」时显示 `<input>`,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。
|
||||
- 预设候选包含常用格式:`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。
|
||||
- 通用化显示函数:
|
||||
```ts
|
||||
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
|
||||
if (!isoDate || !fmt) return isoDate || '';
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || '');
|
||||
};
|
||||
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
|
||||
if (!timeStr || !fmt) return timeStr || '';
|
||||
const [h24str, mstr] = timeStr.split(':');
|
||||
const h24 = parseInt(h24str) || 0;
|
||||
const isPM = h24 >= 12;
|
||||
let h12 = h24 % 12; if (h12 === 0) h12 = 12;
|
||||
return fmt.replace(/HH/g, String(h24).padStart(2, '0'))
|
||||
.replace(/mm/g, mstr || '00')
|
||||
.replace(/hh/g, String(h12).padStart(2, '0'))
|
||||
.replace(/A/g, isPM ? '下午' : '上午');
|
||||
};
|
||||
```
|
||||
5. **通用化反向解析**:新增 `parseDateFromFormat` / `parseTimeFromFormat`,从格式化文本中通过数字正则提取原始值,确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。
|
||||
6. **固定时间默认值自动填充**:`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支,若字段配置了 `fixedTimeValue` 且当前值为空,则自动填入固定值。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数,否则编辑器双向同步会断裂。
|
||||
- 使用占位符替换(如 `fmt.replace(/YYYY/g, y)`)实现通用格式化时,要确保所有可能的 token 都覆盖到,且替换顺序不会相互干扰。
|
||||
- 当某个字段被标记为 `isSystemLocked: true` 后,需在 UI 层面同时禁用 label 输入框,否则用户会困惑「为何修改无效」。
|
||||
- 时间/日期字段的默认值策略文案应直接体现业务含义(如「固定时间」「当前时间」),避免使用技术词汇(如「手动选择」)。
|
||||
- 对于 `startTime`/`endTime` 这类拆分存储(`startHour`+`startMinute`)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。
|
||||
|
||||
---
|
||||
|
||||
## 记录 17:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换
|
||||
|
||||
**A. 具体问题**
|
||||
用户发现 3 个时间字段配置与报告编辑器的联动断层:
|
||||
1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式 `YYYY年MM月DD日`;新建时间字段时默认格式为不可解析的 `'24h'`。
|
||||
2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
|
||||
3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`(12小时制),报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **默认格式错误**:`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token, `'24h'` 无法被正确解析。
|
||||
2. **固定时间未注入**:`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中,`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过(因为已有值了)。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。
|
||||
3. **12h 判断写死**:`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时,判断失败,表单始终渲染为 24 小时制。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修正默认格式**:
|
||||
- `TemplateManage.tsx` 中新建字段的默认格式改为:
|
||||
```ts
|
||||
setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
|
||||
```
|
||||
- 重置表单时的默认值同步修正。
|
||||
2. **注入固定时间默认值**:
|
||||
- `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。
|
||||
- 切换模板的 `useEffect` 中,在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑:
|
||||
```ts
|
||||
formFields.forEach(field => {
|
||||
if (field.category === '时间') {
|
||||
if (field.timeDefault === 'specific' && field.fixedTimeValue) {
|
||||
// 按 field.type 和 field.key 注入固定值
|
||||
} else if (field.timeDefault === 'current') {
|
||||
// 注入当前系统时间
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!nextReportData.surgeryDate) {
|
||||
nextReportData.surgeryDate = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
```
|
||||
3. **通用化 12h 判断**:
|
||||
- `ReportEditor.tsx` 中:
|
||||
```ts
|
||||
const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
|
||||
```
|
||||
- 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`,只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'`、`'12h'`)作为存储值,除非格式化函数也能识别这些别名。
|
||||
- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口(初始 state、切换模板、重置表单等)中显式遍历字段配置并注入,不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。
|
||||
- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**(`includes`)而非**精确匹配**,以兼容用户自定义格式。如果判断逻辑较为复杂,建议抽离为独立工具函数(如 `is12HourFormat(fmt: string): boolean`)。
|
||||
- 当某个字段在初始化时被赋予了「看似合理的默认值」(如 `surgeryDate: new Date()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。
|
||||
34
过往经验/需求分析-2026-04-16-20-24-11.md
Normal file
34
过往经验/需求分析-2026-04-16-20-24-11.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 需求分析 — 2026-04-16-20-24-11
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再次返回 `/report-editor` 时:
|
||||
- `class="editor-content-wrapper print-wrapper"` 中的内容(报告文本、图片、表格等)全部丢失;
|
||||
- 视频分析面板中自动摘取的关键帧、手动摘取的关键帧全部丢失。
|
||||
|
||||
此前三次修复尝试(同步 stateRef 到更多恢复分支、彻底重构 saveDraftToStorage 依赖 React state)未能根治问题。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 修复路由切换后报告编辑器内容丢失的问题;
|
||||
- 修复路由切换后自动/手动关键帧丢失的问题;
|
||||
- 修复 `saveDraftToStorage` 中的闭包陷阱问题;
|
||||
- 修复组件卸载时 `editorRef` 失效导致的 content 丢失问题;
|
||||
- 确保所有修改编辑器 DOM 的操作后都及时更新 `contentRef`。
|
||||
|
||||
### 非功能点
|
||||
- 最小化改动范围,不引入新的状态管理库;
|
||||
- 保持现有 localStorage 草稿机制不变;
|
||||
- 保持用户现有的操作习惯(上传视频、自动摘帧、拖拽插入等)。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | `saveDraftToStorage` 函数重构 + `contentRef` 遗漏点补齐 |
|
||||
| 其他文件 | 无 | 不涉及修改 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。问题现象和分析已明确。
|
||||
26
过往经验/需求分析-2026-04-16-20-33-12.md
Normal file
26
过往经验/需求分析-2026-04-16-20-33-12.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 需求分析 — 2026-04-16-20-33-12
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
`autoCaptureFrames` 中自动插入关键帧到 placeholder 的逻辑使用了 `await new Promise(setTimeout(...))`,这会**阻塞 `for` 循环**,导致必须等待插入延迟结束后才会开始摘取下一帧。期望将其改为**异步非阻塞**,使关键帧摘取全速运行,插入操作在延迟后独立执行,两者互不影响。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 移除 `autoCaptureFrames` 中自动插入逻辑的 `await` 阻塞;
|
||||
- 使用 `setTimeout` 将插入操作推入事件队列异步执行;
|
||||
- 实现延迟叠加(顺序插入),避免多张图片在同一时刻同时插入。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 `flushSync` 实时显示关键帧的效果;
|
||||
- 不破坏现有的 `contentRef` 同步和草稿保存机制。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 中 | 仅修改 `autoCaptureFrames` 中的自动插入逻辑 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。修改方向明确。
|
||||
29
过往经验/需求分析-2026-04-16-20-46-50.md
Normal file
29
过往经验/需求分析-2026-04-16-20-46-50.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 需求分析 — 2026-04-16-20-46-50
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `/report-editor` 页面操作后,切换到 `/report-manage` 再返回 `/report-editor` 时:
|
||||
- 报告编辑器内容(`class="editor-content-wrapper print-wrapper"`)已能正常保留 ✅;
|
||||
- 视频列表也能正常保留 ✅;
|
||||
- **但视频分析中的自动摘取关键帧和手动截图全部丢失** ❌。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 定位关键帧在路由切换后丢失的根因;
|
||||
- 给出可行的修改方向,确保关键帧数据能够持久化并恢复。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 UI 和交互不变;
|
||||
- 尽量减少对存储架构的侵入。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 中 | 抽帧时的 Canvas 尺寸/质量调整 |
|
||||
| `src/utils/storage.ts` | 低 | 增加超限日志或错误提示 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。根因已高度明确,等待用户确认修改方向。
|
||||
Reference in New Issue
Block a user