55 Commits

Author SHA1 Message Date
Administrator
e6cdfd84d4 2026-04-19-00-33-44 - Dashboard 30天图表稀疏化+Tooltip悬停交互 2026-04-19 00:35:43 +08:00
Administrator
3eb1b489f3 2026-04-19-00-24-02 - Dashboard新增全部报告卡片、图表时间切换、X轴重叠修复 2026-04-19 00:27:33 +08:00
Administrator
9ff2f5923a 2026-04-19-00-13-20 - 打印下划线紧贴文字(line-height: 1) 2026-04-19 00:14:27 +08:00
Administrator
8ccb234a62 2026-04-19-00-01-50 - 高亮样式柔化、点击空白取消、打印高亮隔离、下划线配置同步 2026-04-19 00:07:14 +08:00
Administrator
cfb3cb91f8 2026-04-18-23-39-35 - 追加经验记录37:下划线修复与表单逆向联动 2026-04-18 23:44:57 +08:00
Administrator
d5529a4998 2026-04-18-23-39-35 - 四项修复:下划线默认、PDF文件名、间距缩紧、表单逆向联动 2026-04-18 23:44:17 +08:00
Administrator
7ab8c919e3 2026-04-18-23-19-44 - 追加经验记录36:七项排版与功能优化 2026-04-18 23:24:56 +08:00
Administrator
89bf60b4e1 2026-04-18-23-19-44 - 七项优化:排版对齐、间距微调、PDF文件名、北京时间、模板批量操作 2026-04-18 23:24:12 +08:00
Administrator
888255ae6f 2026-04-18-22-59-10 - 追加经验记录35:字段下划线默认与占位符居中 2026-04-18 23:02:44 +08:00
Administrator
48337c382c 2026-04-18-22-59-10 - 字段默认不下划线、占位符文字居中修复 2026-04-18 23:02:11 +08:00
Administrator
726bbc5bac 2026-04-18-20-03-44 - 追加经验记录34:模板导入导出与Logo占位符 2026-04-18 20:09:04 +08:00
Administrator
c5648077e8 2026-04-18-20-03-44 - 模板导入导出迁移、Logo替换为可交互占位符 2026-04-18 20:08:43 +08:00
Administrator
9c09e6cccc 2026-04-18-19-37-56 - 追加经验记录33:四项编辑器体验优化 2026-04-18 19:43:14 +08:00
Administrator
f98177938f 2026-04-18-19-37-56 - 四项编辑器体验优化:视频按钮位置、占位符文字居中、删除恢复尺寸、安全对齐 2026-04-18 19:42:47 +08:00
Administrator
8ffb9162d3 2026-04-18-19-23-31 - 追加经验记录32:视频空白修复与占位符自适应 2026-04-18 19:27:30 +08:00
Administrator
32f8b2a7ec 2026-04-18-19-23-31 - 修复视频分析模块空白、图片占位符尺寸自适应优化 2026-04-18 19:27:05 +08:00
Administrator
519cc6fc82 2026-04-18-19-08-43 - 追加经验记录31:六项UI优化实施记录 2026-04-18 19:15:00 +08:00
Administrator
4a7051b6db 2026-04-18-19-08-43 - 六项UI优化:基础字段无下划线、field-value联动高亮、视频按钮整合、视频间距紧凑、签名空行、图片占位符自适应高度 2026-04-18 19:14:23 +08:00
Administrator
5f4ae1ff29 2026-04-18-18-36-43 - 报告管理列名修正、字段下划线控制、下载导出功能、右对齐排版修复、签名默认右对齐 2026-04-18 18:48:30 +08:00
Administrator
db1c11f7eb 2026-04-18-18-08-37 - 编辑器工具栏新增字号行距选择、修复字体选择、模板排版间距调整 2026-04-18 18:13:07 +08:00
Administrator
55ce78d898 2026-04-18-17-48-59 - 模板排版微调:字段间距、Flex居中抬头、打印隐藏删除按钮、统一1.5行距、下划线贴底 2026-04-18 17:52:05 +08:00
Administrator
e1dc961ecf 2026-04-18-17-27-51 - 修复TemplateManage静态占位符插入、重构默认模板排版、修复Logo删除按钮交互 2026-04-18 17:33:07 +08:00
Administrator
67fb2c9080 2026-04-18-16-55-47 - 报告编辑器field-value点击联动、字段动态排序、默认模板手术图片表格替换 2026-04-18 17:01:18 +08:00
Administrator
a46ecffadf 2026-04-18-16-45-02 - 建立代码编纂工作流规范(含需求分析、实现方案、测试方案) 2026-04-18 16:50:55 +08:00
Administrator
034575e0a8 Merge remote V1.2.0 and add local V1.2.1 2026-04-18 16:31:38 +08:00
Administrator
4e24ee15a2 V1.2.1 2026-04-18 16:31:09 +08:00
0df27cbc73 2026-04-18-00-43-19 - 默认模板占位符补充data-mode属性与图片来源隔离对齐 2026-04-18 00:46:05 +08:00
1278f7282f 2026-04-18-00-23-14 - 补充自动帧插入时的占位符边框清除 2026-04-18 00:34:46 +08:00
8978b7a2de Merge remote: adopt upstream modal and placeholder isolation changes 2026-04-18 00:33:59 +08:00
6498ef6444 2026-04-18-00-23-14 - 修复拖拽关键帧边框残留、替换prompt为自定义弹窗、增加占位符图片来源隔离 2026-04-18 00:32:28 +08:00
b7a1ea457e 2026-04-18-00-02-08 - 拖拽关键帧样式修复、占位符分类隔离与Modal弹窗改造、表格插入Modal化 2026-04-18 00:09:33 +08:00
d05029838f 2026-04-17-23-38-34 - 时间格式自定义下拉组件、表格内图片占位符自适应、打印多页页边距修复 2026-04-17 23:44:03 +08:00
d45e973255 2026-04-17-23-12-52 - 修复时间字段24h脏数据、格式选项分类过滤、字段管理编辑面板滚动对齐 2026-04-17 23:18:59 +08:00
1a766edb90 2026-04-17-22-53-01 - 新建项目修改工作流Skill并创建统一经验记录 2026-04-17 23:10:57 +08:00
8e7079e6a9 [文档] 更新经验记录:时间字段联动修复 2026-04-17 22:51:42 +08:00
8ea0a9a69e [修复] 时间字段联动:默认中文日期格式、固定时间自动填充、12/24h动态切换 2026-04-17 22:47:18 +08:00
ac6b619549 [第五批] 手术图文病历报告系统 - 时间字段增强:自定义格式、固定时间默认值、系统锁定标签 2026-04-17 22:20:33 +08:00
8beb534abb 2026-04-17-21-32-27 - 时间日期字段格式配置与撰写时间动态字段 2026-04-17 21:42:51 +08:00
8f746c25f3 fix: 5项交互修复 - 删除图片恢复虚线框、prompt文案统一、移除overflow、多选输入解耦、label加可多选 2026-04-17 21:19:20 +08:00
28b913692c feat: 6项交互优化 - placeholder虚线框清除、删除按钮遮挡修复、*分隔输入、签名尺寸固定、移除isSigned、多选文本拼接重构 2026-04-17 20:46:58 +08:00
ee1ac0d637 feat: 5项UX优化 - 合并占位符prompt、默认200x200尺寸、系统默认设置、签名状态列、素材预加载前置 2026-04-17 19:59:19 +08:00
5fee3352c1 refactor: unify image-placeholder across editors and remove image field type
- Remove surgeonSignature and hospitalLogo from DEFAULT_FORM_FIELDS.
- Replace logo and signature in default template with inline image-placeholder spans.
- Enhance insertImage() in both editors with prompt for max-width/height (px).
- Abbreviate placeholder text to '插入图片' when width < 80px.
- Force inline insertion using display:inline-flex + vertical-align:middle.
- Port image-source picker modal from ReportEditor to TemplateManage.
- Remove legacy triggerPlaceholderUpload direct upload logic.
2026-04-17 19:34:03 +08:00
0c57409c59 feat: TemplateManage field system upgrade and bidirectional navigation
- Fix new field type linkage (remove text option under single/multi/image).
- Add system fields: pre/post-op diagnosis, pathology checks, etc.
- Replace placeholder text in default template with smart fields.
- Accordion grouping and inline option editing in field management.
- Add image field type, asset library with logo preloading.
- Image source picker modal in ReportEditor (local/signature/asset).
- Editor-to-sidebar highlight and scroll navigation on smart field click.
2026-04-17 18:54:10 +08:00
b155dd42d6 fix(TemplateManage): Ctrl+Z undo and smart-field insertion layout
- Intercept Ctrl+Z/Y keyboard shortcuts in keydown listener and route
to custom undoStack/redoStack to fix undo inconsistency.
- Replace execCommand('insertHTML') with precise Range.insertNode()
in insertSmartField to prevent <span> escaping out of <p> when
preceded by <br>.
2026-04-17 13:39:16 +08:00
b822bb1b47 fix: custom undo/redo stack and cursor positioning in TemplateManage
- Add undoStack/redoStack refs with pushHistory/handleUndo/handleRedo
- Replace execCmd('undo')/execCmd('redo') with custom stack handlers
- Call pushHistory before structural changes (delete, insert field/table/image, formatting)
- Add onMouseDown preventDefault to toolbar and field library buttons to stop focus loss
- Implement saveSelection/restoreSelection using savedRangeRef
- Bind onBlur/onMouseUp/onKeyUp on editor to persist cursor position
- Restore selection in insertSmartField and insertImage before insertHTML
- Update experience record (#20)
2026-04-17 13:18:54 +08:00
f7c7270053 fix: undo stack, field insertion wrap, backspace/delete precision; feat: signature size limit & isSigned control
- Replace direct DOM remove() with Range+execCommand('delete') in TemplateManage click and keydown handlers to restore undo stack
- Append &#8203; zero-width space to smart-field-wrapper HTML in insertSmartField and defaultContent.ts to prevent unwanted line breaks
- Refactor ReportEditor surgeonSignature rendering to depend on isSigned field
- Add isSigned to DEFAULT_FORM_FIELDS (single_select: 已签字/未签字)
- Change surgeonSignature to visibleInForm=true, isSystemLocked=false
- Constrain signature image with max-width:120px, max-height:40px, object-fit:contain in CSS and print.ts
- Add weak-blocking signature validation prompts in saveReport('completed')
- Update experience record (#19)
2026-04-17 12:41:07 +08:00
424407a17e feat: field hover highlight, e-signature upload, surgeon signature linkage
- Add signature?: string to User type and 'signature' to FieldType
- Add surgeonSignature field to DEFAULT_FORM_FIELDS (category: 图片)
- UserManage: add canvas-based image compression (max 500px) and signature upload UI
- TemplateManage: add hover highlight on field buttons via direct DOM style manipulation
- TemplateManage: add '图片' category to field library for surgeonSignature insertion
- ReportEditor: auto-fill surgeonSignature with currentUser.signature image or placeholder text
- index.css & print.ts: add .report-signature-img styling (height 2.4em, vertical-align middle)
- Update experience record (#18)
2026-04-17 12:04:23 +08:00
0ff1cbe5f0 feat: focus highlight, delete-btn visibility isolation, multi_select crash fix
- Move delete-btn to top-right of smart-field-wrapper via absolute positioning
- Add template-editor-mode class to TemplateManage editor for CSS isolation
- Show delete-btn only on hover/focus-within inside template-editor-mode
- Add .field-value:focus highlight with background darken and blue glow
- Sync defaultContent.ts smartField() HTML structure
- Fix ReportEditor multi_select .map crash with Array.isArray guard
2026-04-17 11:21:32 +08:00
db5df13a05 feat: smart field uniqueness, delete button, bulk export in report manage
- TemplateManage: add uniqueness check for smart fields to prevent duplicate inserts
- Add red circular delete button to smart-field-wrapper (visible on hover via CSS)
- Enhance keydown handler to delete smart fields at block-level boundaries
- Update defaultContent.ts smartField() to include delete-btn
- ReportManage: add per-row checkboxes, select-all, bulk delete
- Add single-report export modal (PDF via printDocument, JSON via Blob)
- Add bulk export actions for PDF and JSON
- Update experience record (#16)
2026-04-17 10:32:07 +08:00
38ff67a6a8 fix: smart field spacing/line-break in TemplateManage and default template
- Compress insertSmartField HTML to single-line, remove trailing &nbsp;
- Compress smartField helper in defaultContent.ts to single-line
- Add white-space: nowrap to .smart-field-wrapper (CSS + inline)
- Add keydown interceptor in TemplateManage to prevent Backspace/Delete
  from removing whole <p> when adjacent to smart-field-wrapper
- Update experience record (#14)
2026-04-17 09:47:21 +08:00
2a4934e7c4 2026-04-17-00-13-09 - 手术时间方框联动、动态字段分类管理体系、字段显隐控制、UI紧凑化优化 2026-04-17 00:30:11 +08:00
952856e8c6 2026-04-16-22-35-38 - 修正 BINDABLE_FIELDS key 与 Report 接口一致,并将默认模板占位符替换为智能绑定方格 2026-04-16 23:55:33 +08:00
f373520fa5 2026-04-16-22-23-02 - 新增 TemplateManage 字段库与 ReportEditor 双向数据绑定智能占位方格 2026-04-16 22:29:40 +08:00
915e169bd8 2026-04-16-22-12-00 - 建立项目修改需求工作流规范(含需求分析、实现方案、测试方案、经验记录、Gitea备份流程) 2026-04-16 22:13:17 +08:00
c55a55a27b release: v1.2.0 手术图文病历报告系统 2026-04-16 21:41:21 +08:00
156 changed files with 13735 additions and 2634 deletions

View File

@@ -1,113 +1,229 @@
---
name: medical-report-dev-workflow
description: Enforce a strict five-step workflow for all code modification requests on the Medical Surgical Report System (Gemini-图文报告系统-V1.1). Use whenever the user asks to change, add, fix, refactor, or update any project code, feature, UI, logic, or configuration. Do NOT use for simple questions, explanations, or read-only research.
description: |
手术图文病历报告系统Gemini-图文报告系统)的强制性项目修改工作流。
当用户提出任何与该项目相关的代码修改、功能开发、Bug 修复、性能优化、UI 调整、
需求变更等任务时,**必须严格按此工作流逐步执行,严禁跳过或合并步骤**。
不适用于纯问答、查询信息、阅读文档或不涉及代码/文件修改的任务。
---
# 手术图文病历报告系统 —— 修改需求五步工作流
# 手术图文病历报告系统 — 项目修改工作流v2.0
当用户提出任何程序修改需求时,必须严格按照以下顺序执行。禁止跳过任何步骤,禁止在方案未经过用户二次人工审核确认前进入下一步
> 本文档是 AI 编码代理执行项目修改需求时的**强制性规范**。任何涉及代码变更的任务必须按以下 7 个步骤逐一执行,每一步有明确的停止/确认点,严禁跳过
---
## Step 0: Gitea 备份 + 记录时间戳
## 前置检查
1. 获取当前时间戳,格式:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`(例如 `2026-04-16-16-39-42`
2. 执行 Gitea 备份:
```bash
git add .
git commit -m "backup before modification at {时间戳}"
git remote set-url origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git
git branch -M main
git push -u origin main
- **确认任务类型**:如果用户请求不涉及代码/配置/文件修改(如纯提问、查资料、解释概念),则**不启用**此工作流
- **确认当前工作目录**:所有路径均相对于项目根目录 `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. 检查并确认项目根目录下存在文件夹:
```
3. 若首次初始化仓库,执行 `git init` 和 `git checkout -b main` 后再提交。
4. 此步骤中所有 git 操作若遇到报错(如 nothing to commit通过调整文件内容或空提交确保能推送成功。
---
## Step 1: 工程分析目录检查
确保以下目录存在(如不存在则创建):
```
.\工程分析\
```
---
## Step 2: 需求分析文档
将用户提出的修改需求整理分析,写入文件:
```
.\工程分析\需求分析-{时间戳}.md
```
文档必须包含:
- 需求背景
- 功能目标(精确描述要实现什么)
- 涉及页面/模块/文件
- 验收标准
---
## Step 3: 实现方案文档(必须经过用户二次人工审核)
基于需求分析,编写详细实现方案,写入文件:
```
.\工程分析\实现方案-{时间戳}.md
```
文档必须包含:
- 技术思路与实现路径
- 需要修改/新增的文件清单
- 关键代码变更说明
- 潜在风险点及应对策略
**重要约束**:写完此文档后,必须立即停止并向用户汇报,等待用户二次人工审核确认。在得到明确的"确认"、"同意"、"可以执行"等回复前,禁止继续进入 Step 4 或开始任何代码修改。
---
## Step 4: 测试方案文档(必须经过用户二次人工审核)
实现方案经用户确认后,编写测试方案,写入文件:
```
.\工程分析\测试方案-{时间戳}.md
```
文档必须包含:
- 测试项清单
- 测试步骤(操作路径)
- 预期结果
- 回归验证范围(确保没有破坏现有功能)
**重要约束**:写完此文档后,必须立即停止并向用户汇报,等待用户二次人工审核确认。在得到明确的"确认"、"同意"、"可以执行"等回复前,禁止继续进入 Step 5。
---
## Step 5: 执行修改 + 经验记录
测试方案经用户确认后,开始执行:
1. 按实现方案修改代码。
2. 运行 `npm run lint` 进行 TypeScript 类型检查,确保零错误。
3. 运行 `npm run build` 验证生产构建是否通过。
4. 修改完成后,在以下文档中追加本次执行过程中遇到的关键问题及解决方案:
.\工程分析\
```
.\工程分析\经验记录.md
```
每条记录必须使用四段式格式:
- **A. 具体问题**
- **B. 产生问题原因**
- **C. 解决问题方案**
- **D. 后续如何避免问题**
5. 向用户汇报最终执行结果和验证情况。
2. 若不存在,则立即创建。
3. 同时检查该目录下是否存在 `经验记录.md`;若不存在,创建一个空文件并在顶部写入 `# 经验记录`
---
## 快速参考
## Step 2需求分析文档
| 项目 | 值 |
|------|-----|
| Gitea 地址 | `http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git` |
| 工程分析目录 | `.\工程分析\` |
| 类型检查 | `npm run lint` |
| 构建验证 | `npm run build` |
| 开发服务器 | `npm run dev` |
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 6Gitea 备份
### 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 备份完成提醒
备份完成后,**必须向用户明确提醒**
> 本次工作流相关文档(及代码修改)已备份到 Giteacommit 信息为:`{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
```

296
AGENTS.md
View File

@@ -1,203 +1,209 @@
# 手术图文病历报告系统 —— AI 代理开发指南
# 手术图文病历报告系统 —— Agent 开发指南
> 本文面向 AI 编码代理。若你正在阅读此文件,说明你对该项目一无所知,请仔细阅读后再修改代码
> 本文面向 AI 编程助手。修改项目结构、构建流程或关键配置后,请同步更新本文档
---
## 1. 项目概
## 项目概
**手术图文病历报告系统**Gemini-图文报告系统-V1.1)是一款基于 **React 19 + TypeScript + Vite + Tailwind CSS 4** 开发的纯前端医疗图文报告管理应用。所有数据持久化在浏览器 `localStorage` 中,无需后端服务即可独立运行
这是一个面向医院场景的**纯前端单页应用SPA**,用于手术记录图文报告的撰写、视频关键帧抽取、模板管理以及基于角色的用户权限控制
### 核心功能
- **图文报告生成**:基于 `contentEditable` 的富文本编辑器,支持插入表格、图片占位符,可从本地或手术视频中截取关键帧插入报告
- **报告管理**:搜索、筛选、查看、编辑、打印、删除报告;支持历史版本回溯。
- **模板管理**:创建和维护报告标准模板,新建报告时自动加载默认模板
- **用户管理**:基于角色的权限控制(超级管理员 / 管理员 / 医生)。
- **系统设置**配置视频自动抽帧百分比、AI API 接口地址、默认模板等全局参数。
### 默认测试账号
| 账号 | 密码 | 角色 |
|---------|--------|------------|
| admin | 123456 | 超级管理员 |
| manager | 123456 | 管理员 |
| 0001 | 123456 | 医生 |
- **名称**:手术图文病历报告生成终端 / 智能图文报告管理系统
- **架构**:纯前端应用,无后端服务。所有数据(用户、报告、模板、设置、图片资源)均持久化在浏览器 `localStorage` / `sessionStorage`
- **技术栈**React 19 + TypeScript 5.8 + Vite 6 + Tailwind CSS 4 + React Router DOM 7 + Lucide React图标
- **语言**:项目界面、注释、文档均为中文
- **运行环境**:现代浏览器(依赖 `localStorage``URL.createObjectURL``contenteditable``MutationObserver` 等 Web API)。
---
## 2. 技术栈与运行时架
### 技术栈
- **框架**React 19函数组件 + Hooks
- **路由**React Router DOM 7`BrowserRouter`
- **构建工具**Vite 6
- **样式**Tailwind CSS 4使用 `@import "tailwindcss"``@theme` 语法)
- **图标**Lucide React
- **动画**Motion
- **语言**TypeScript 5.8`tsconfig.json``jsx: "react-jsx"``moduleResolution: "bundler"`
### 运行时架构
- **纯前端 SPA**:无后端 API所有业务逻辑在浏览器端执行。
- **数据存储**:全部使用 `localStorage`(通过 `src/utils/storage.ts` 封装)和少量 `sessionStorage`(用于版本恢复)。
- **安全模型**:客户端认证授权,密码以**明文**形式保存在 `localStorage` 中。项目设计用于内网或受信任环境,**切勿直接暴露到公网**。
---
## 3. 项目目录结构
## 项目结
```
.
├── docker-compose.yaml # Docker Compose 配置(端口 8080:80
├── docker-compose.qnap.yml # QNAP 专用 Docker Compose
├── Dockerfile # 多阶段构建node:20-alpine -> nginx:alpine
├── nginx.conf # Nginx SPA 回退配置try_files
├── package.json # 依赖与脚本
├── vite.config.ts # Vite 配置(含 GEMINI_API_KEY 注入
├── tsconfig.json # TypeScript 配置paths: "@/*": "./*"
├── index.html # Vite 入口 HTML
├── public/ # 静态资源logo、favicon
├── docker-compose.yaml # Docker Compose 配置(端口 4002:80
├── Dockerfile # 多阶段构建node:20-alpine 构建 → nginx:alpine 运行
├── nginx.conf # Nginx SPA 路由回退配置try_files
├── package.json # 项目依赖与脚本
├── vite.config.ts # Vite 配置(含 Tailwind CSS 插件、路径别名 @
├── tsconfig.json # TypeScript 配置ES2022、react-jsx、paths: {"@/*": ["./*"] }
├── index.html # Vite 入口 HTML
├── public/ # 静态资源logo_square.png、favicon.ico
└── src/
├── App.tsx # 根组件与路由表
├── main.tsx # 应用入口createRoot + StrictMode
├── index.css # 全局样式、Tailwind 主题、打印样式、编辑器专用样式
├── types.ts # 核心 TypeScript 类型定义
├── main.tsx # 应用入口React StrictMode + createRoot
├── App.tsx # 根组件定义所有路由BrowserRouter
├── index.css # 全局样式、Tailwind 主题变量、编辑器/打印专用样式
├── types.ts # TypeScript 类型定义与常量User、Report、Template、SystemSettings、FormField 等)
├── components/
│ └── Sidebar.tsx # 左侧导航栏(角色过滤菜单
│ └── Sidebar.tsx # 侧边导航栏(角色过滤、折叠逻辑、退出登录
├── pages/
│ ├── Login.tsx # 登录页(初始化默认数据
│ ├── Dashboard.tsx # 工作台概览(统计图表 + 快捷入口)
│ ├── ReportEditor.tsx # 图文报告编辑器(最复杂页面)
│ ├── ReportManage.tsx # 报告列表管理
│ ├── ReportView.tsx # 报告查看/打印
│ ├── TemplateManage.tsx # 模板管理
│ ├── UserManage.tsx # 用户管理
│ └── SystemSettings.tsx # 系统设置
│ ├── Login.tsx # 登录页 + 默认数据初始化(用户、模板、表单字段、系统设置
│ ├── Dashboard.tsx # 工作台概览(统计卡片、7 天趋势 SVG 图表、快捷入口)
│ ├── ReportEditor.tsx # 图文报告编辑器(最核心、最复杂页面2000+ 行
│ ├── ReportManage.tsx # 报告管理(搜索、筛选、查看、编辑、删除、历史、导出)
│ ├── ReportView.tsx # 报告只读查看 + 打印
│ ├── TemplateManage.tsx # 模板管理(富文本编辑器、字段库管理、图片占位符)
│ ├── UserManage.tsx # 用户管理CRUD、签名上传、角色/模板权限分配)
│ └── SystemSettings.tsx # 系统设置视频抽帧参数、API 地址、默认模板)
└── utils/
├── storage.ts # localStorage/sessionStorage 封装
├── print.ts # 基于 iframe A4 打印实现
└── defaultContent.ts # 默认手术报告模板 HTML 字符串
├── storage.ts # localStorage / sessionStorage 封装JSON 自动序列化)
├── print.ts # 打印工具:通过隐藏 iframe 渲染 A4 内容并调用 window.print()
└── defaultContent.ts # 默认报告模板 HTML(含智能字段绑定语法与图片占位符)
```
---
## 4. 构建运行与部署
## 构建运行命令
### 本地开发
```bash
# 安装依赖
npm install
# 启动开发服务器(端口 3000监听 0.0.0.0
# 本地开发(端口 3000监听 0.0.0.0
npm run dev
# 生产构建(输出到 dist/
npm run build
# 预览生产构建
npm run preview
# 类型检查(不输出文件)
npm run lint
# 清理构建产物
npm run clean
```
### 可用脚本package.json
| 脚本 | 作用 |
|-----------|-----------------------------------|
| `dev` | `vite --port=3000 --host=0.0.0.0` |
| `build` | `vite build`(输出到 `dist/` |
| `preview` | `vite preview` |
| `lint` | `tsc --noEmit`(类型检查) |
| `clean` | `rm -rf dist` |
### 环境变量
复制 `.env.example``.env.local`(或 `.env`
- `GEMINI_API_KEY`Google Gemini API 密钥(预留 AI 功能Vite 会在构建时通过 `define` 注入为 `process.env.GEMINI_API_KEY`)。
- `APP_URL`:应用部署后的访问地址。
### Docker 部署
```bash
# 构建并启动(访问 http://localhost:8080
# 构建并运行(访问 http://localhost:4002
docker-compose up -d --build
# 停止
docker-compose down
```
- **构建阶段**`node:20-alpine` 执行 `npm ci` + `npm run build`
- **运行阶段**`nginx:alpine` 托管 `dist/` 静态资源
- **SPA 支持**`nginx.conf` 已配置 `try_files $uri $uri/ /index.html;`
构建流程:
1. **构建阶段**`node:20-alpine` 执行 `npm ci` + `npm run build`
2. **运行阶段**`nginx:alpine` 托管 `dist/` 静态文件,端口 80
3. `nginx.conf` 已配置 `try_files` 回退,支持 SPA 前端路由刷新不 404。
---
## 5. 代码组织与开发约定
## 代码组织与模块划分
### 路由结构
所有路由定义在 `src/App.tsx`
- `/` → 登录页
- `/dashboard` → 工作台
- `/report-editor` → 新建报告(`?id=xxx` 为编辑)
- `/report-view/:id` → 查看报告
- `/report-manage` → 报告管理
- `/template-manage` → 模板管理
- `/user-manage` → 用户管理
- `/system-settings` → 系统设置
### 路由结构`src/App.tsx`
### 权限模型
角色分为三种:`super`(超级管理员)、`admin`(管理员)、`user`(医生)。
- 各页面在 `useEffect` 中读取 `storage.get('currentUser')`,未登录则 `navigate('/')`
- `Sidebar.tsx``navItems``roles` 数组过滤菜单。
- `user` 角色只能查看/编辑自己创建的报告;`super`/`admin` 可查看全院报告。
| 路径 | 页面 | 角色权限 |
|------|------|----------|
| `/` | Login | 公开 |
| `/dashboard` | Dashboard | super / admin / user |
| `/report-editor` | ReportEditor | super / admin / user |
| `/report-manage` | ReportManage | super / admin / user |
| `/report-view/:id` | ReportView | super / admin / useruser 只能看自己的) |
| `/template-manage` | TemplateManage | super / admin |
| `/user-manage` | UserManage | super / admin |
| `/system-settings` | SystemSettings | super / admin / user |
### 数据持久化约定
- **禁止直接调用 `localStorage`**,统一使用 `src/utils/storage.ts` 中的 `storage.get / storage.set / storage.remove`
- localStorage 中存储的 key 包括:`users``reports``templates``systemSettings``currentUser``multiSelectOptions``anesthesiaOptions``reportEditorDraft_{username}``restore_{reportId}`sessionStorage
- 报告编辑器会在 `beforeunload``visibilitychange` 时自动保存草稿到 `reportEditorDraft_{username}`
### 核心数据模型(`src/types.ts`
### 样式约定
- 全局使用 Tailwind 工具类;自定义设计变量定义在 `src/index.css``@theme` 中(如 `--color-bg``--color-accent`
- 通用组件类在 `index.css``@layer components` 中定义:`.btn-accent``.card-minimal``.input-minimal`
- **编辑器样式**`.editor-content``.editor-content-wrapper``.image-placeholder`)和 **打印样式**`@media print`)集中在 `index.css` 中维护。
- A4 打印尺寸为 `210mm × 297mm`,打印时通过 `visibility` 控制只显示 `.print-content` 区域。
- `User`:用户(`role: 'super' | 'admin' | 'user'`
- `Report`:报告(含患者信息、手术信息、富文本 `content`、视频数组、抽帧数组 `capturedFrames`、历史记录 `history`
- `Template`:模板(名称、描述、富文本 `content`
- `SystemSettings`:系统设置(抽帧数量/位置、API 端点、默认模板、自动插帧配置)
- `FormField` / `FieldType`:表单字段配置(支持文本、单选、多选、时间、日期、签名、图片)
- `CapturedFrame`视频关键帧含视频索引、时间戳、DataURL
### 编辑器实现细节
- `ReportEditor.tsx` 使用原生 `contentEditable` + `document.execCommand` 实现富文本,而非第三方编辑器。
- 图片通过 `.image-placeholder` 占位符插入,支持两种填充方式:
1. 点击占位符上传本地图片Base64 存入 HTML
2. 从右侧“视频分析”面板拖拽自动抽帧的截图到占位符中。
- 视频中上传后会根据 `systemSettings.framePositions` 自动抽帧(通过 `<video>` + `<canvas>` 实现)。
### 存储层(`src/utils/storage.ts`
### TypeScript 类型
核心类型定义在 `src/types.ts`
- `User`:用户,角色为 `'super' | 'admin' | 'user'`
- `Report`:报告,状态为 `'draft' | 'completed'`,含 `content`HTML 字符串)
- `Template`:模板,结构与报告内容类似
- `SystemSettings`:系统设置,含 `frameCount``framePositions``apiEndpoint`
- `CapturedFrame`:视频抽帧结果
所有业务数据通过 `storage.get<T>(key, fallback)` / `storage.set<T>(key, value)` 读写 `localStorage`(或 `sessionStorage`)。**不存在任何后端 API 调用**。
### 路径别名
- `vite.config.ts``tsconfig.json` 均配置了 `@/` 指向项目根目录(`.`)。
- 源码中导入使用相对路径(如 `../utils/storage`)或 `@/` 均可。
关键存储键:
- `users` — 用户列表
- `currentUser` — 当前登录用户
- `reports` — 报告列表
- `templates` — 模板列表
- `systemSettings` — 系统设置
- `formFieldsConfig` — 表单字段配置
- `multiSelectOptions` / `anesthesiaOptions` — 下拉选项缓存
- `imageAssets` — 图片资源库DataURL
- `reportEditorDraft_${username}` — 报告编辑器自动草稿
- `restore_${reportId}` — 报告恢复临时内容sessionStorage
---
## 6. 测试策略
## 核心功能实现细节
**当前项目没有单元测试或 E2E 测试框架。**
### 1. 图文报告编辑器(`ReportEditor.tsx`
- 唯一可用的质量检查命令是 `npm run lint`,它执行 `tsc --noEmit` 进行全量类型检查
- 在修改代码后,**务必运行 `npm run lint` 确保无 TypeScript 编译错误**
- 若你引入了新依赖或修改了复杂交互逻辑,建议在本地通过 `npm run dev` 进行手工功能验证(可快速使用登录页的“快捷登录测试账号”)。
- **富文本实现**:基于原生 `contenteditable` div通过 `document.execCommand` 实现加粗、斜体、下划线、对齐、插入表格等
- **A4 纸模拟**:编辑器内容区固定宽度 `210mm`,最小高度 `297mm`,通过 `MutationObserver` 动态扩展为多页
- **图片占位符**`.image-placeholder` 元素,支持点击上传、拖入视频关键帧、或自动插入抽帧结果。占位符分为 `data-mode="frame"`(可拖入关键帧)和 `data-mode="manual"`(仅手动上传,如 Logo、签名)。
- **智能字段绑定**:模板中可插入 `<span class="smart-field-wrapper" data-bind="key">`,在报告编辑器左侧表单填写后,通过 DOM 查询同步更新编辑器内对应字段值。
- **视频抽帧**:支持上传本地视频(`URL.createObjectURL`),可手动截图或按百分比位置自动均匀抽帧(`autoCaptureFrames`)。抽帧结果支持拖入编辑器占位符。
- **自动草稿**:在 `beforeunload` / `visibilitychange` / 状态变更时自动保存草稿到 `localStorage`
- **打印**:调用 `printDocument()` 将编辑器 HTML 注入隐藏 iframe 并触发打印;`@media print` 样式隐藏所有非打印元素。
### 2. 模板管理(`TemplateManage.tsx`
- 与报告编辑器共享相似的 `contenteditable` 编辑体验。
- 额外支持**字段库管理**:可插入/编辑/删除表单字段,字段变更会同步到 `formFieldsConfig`
- 模板内容即为 HTML 字符串,存储在 `templates` localStorage 键中。
### 3. 用户与权限(`Login.tsx`、`UserManage.tsx`、`Sidebar.tsx`
- **角色体系**
- `super`(超级管理员):拥有所有权限,可管理所有用户。
- `admin`(管理员):可管理同科室的 `user` 角色用户。
- `user`(医生):只能查看/编辑自己创建的报告。
- **权限控制**:路由跳转前检查 `currentUser.role`Sidebar 根据角色过滤导航项;页面内根据角色隐藏按钮或重定向。
- **默认账号**(首次登录或数据缺失时自动初始化):
- `admin` / `123456` — 超级管理员
- `manager` / `123456` — 管理员
- `0001` / `123456` — 医生
---
## 7. 安全与部署注意事项
## 代码风格指南
### 安全警告(必读)
1. **无后端哈希**:用户密码以明文保存在浏览器 `localStorage`
2. **客户端鉴权**:所有权限判断都在前端执行,易被绕过
3. **因此,该应用仅适合部署在医院内网、受信任的局域网或单人使用的环境中,严禁直接暴露于公网。**
### 部署检查清单
- [ ] `nginx.conf` 中的 `try_files` 确保 SPA 刷新不 404
- [ ] `dist/` 构建产物已包含在 Docker 镜像中。
- [ ] 若启用 AI 功能,需正确配置 `GEMINI_API_KEY` 环境变量Vite 在构建时注入,修改后需重新构建)。
- [ ] 确保最终运行环境可访问 `https://fonts.googleapis.com/css2?family=Inter`(否则页面字体会降级为系统默认字体)。
- **语言**TypeScript严格模式未开启`noEmit: true`),但代码中广泛使用显式类型注解。
- **组件风格**:函数组件 + React Hooks所有页面组件默认导出
- **CSS**Tailwind CSS 工具类为主,复杂样式(如 `contenteditable` 内部元素、打印样式)写在 `src/index.css``@layer components`
- **路径别名**`@/` 指向项目根目录(`vite.config.ts``tsconfig.json` 均已配置)。
- **图标**:统一使用 `lucide-react`
- **事件处理**:原生 DOM 事件与 React 事件混用(编辑器内大量直接使用 `document.execCommand``addEventListener`)。
- **状态管理**:无 Redux/Zustand所有状态以 React `useState` + `useRef` + `useEffect` 管理,复杂页面(如 ReportEditor使用 `useRef` 保存最新状态快照以绕过闭包问题
---
## 8. 给 AI 代理的快速备忘
## 测试说明
- **不要直接操作 `localStorage`**,用 `src/utils/storage.ts`
- **不要引入重型富文本编辑器**,现有方案基于 `contentEditable` + `document.execCommand`,保持轻量。
- **打印逻辑**已封装在 `src/utils/print.ts`,需要打印 A4 报告时直接调用 `printDocument(htmlContent)`
- **修改样式时优先检查 `src/index.css`**Tailwind v4 的主题变量和打印样式都在那里。
- **添加新页面后**,记得在 `src/App.tsx` 注册路由,并在 `src/components/Sidebar.tsx``navItems` 中配置菜单和可见角色。
**本项目目前未配置任何测试框架**,没有单元测试、集成测试或 E2E 测试
如需添加测试,建议:
- 单元测试Vitest与 Vite 生态一致)
- 组件测试React Testing Library
- E2E 测试Playwright测试 `localStorage` 持久化、文件上传、打印流程等)
---
## 安全注意事项
> ⚠️ **关键警告**:本应用为纯前端实现,所有认证与授权逻辑均在客户端执行。
- 用户密码以**明文**形式存储在浏览器 `localStorage` 中,无任何哈希或加密处理。
- 权限控制依赖客户端路由守卫和 UI 隐藏,可被技术手段绕过。
- 所有报告数据、视频对象 URL、图片 DataURL 均保存在用户本地浏览器中,无云端备份。
- **生产环境部署建议**:仅限内网或受信任环境使用,不要直接暴露于公网。
- 环境变量 `.env.local` 中的 `GEMINI_API_KEY` 仅用于预留的 AI 功能,当前主业务逻辑未实际调用 Gemini API。
---
## 常见开发注意事项
1. **编辑器内容初始化**`ReportEditor.tsx` 中编辑器 `innerHTML` 的初始化逻辑分散在 `useEffect`(基于 reportId / draft`useLayoutEffect`(安全兜底)中,修改时需注意两者优先级,避免内容被覆盖。
2. **视频对象 URL 生命周期**:通过 `URL.createObjectURL()` 创建的视频 URL 仅在当前会话有效,刷新页面后视频需重新上传。报告保存时仅保留视频元数据(名称、时长),不保存视频文件本身。
3. **打印样式**`@media print` 规则定义在 `src/index.css` 中,修改编辑器内元素类名时,需同步检查打印样式是否失效。
4. **Tailwind CSS v4**:本项目使用 Tailwind CSS 4`@import "tailwindcss"` + `@theme`),与 v3 的 `tailwind.config.js` 方式不兼容,请勿混用旧版配置。
5. **HMR 特殊处理**`vite.config.ts` 中根据 `DISABLE_HMR` 环境变量控制 HMR 开关,该变量由 AI Studio 运行时注入,通常无需手动修改。

View File

@@ -1,49 +0,0 @@
version: "3.8"
services:
tuwen_system:
# 使用官方 Node 镜像,无需 Dockerfile 构建
image: node:20-alpine
container_name: tuwen_system
# 将 NAS 上的源码目录挂载到容器内
volumes:
- /share/Container/tuwen_system:/app
working_dir: /app
# 端口映射NAS 4002 端口 -> 容器 3000 端口
# (因为 package.json 中 dev 脚本固定为 --port=3000
ports:
- "4002:3000"
restart: unless-stopped
tty: true
stdin_open: true
# 启动命令:先检查 node_modules 是否存在,避免每次重启都重新安装
# 然后运行开发服务package.json 中已带 --host=0.0.0.0
command: >
sh -c "
if [ ! -d node_modules ]; then
echo 'Installing dependencies...' &&
npm install;
else
echo 'Dependencies already installed, skipping npm install...';
fi &&
echo 'Fixing binary permissions...' &&
chmod -R +x node_modules/.bin/ 2>/dev/null || true &&
echo 'Starting dev server...' &&
npm run dev
"
environment:
# 应用访问地址(用于系统内跳转链接等场景)
- APP_URL=http://192.168.31.5:4002
# 网络代理配置(国内环境下加速 npm install
# 如果不需要代理,可将下面 5 行注释掉
- HTTP_PROXY=http://192.168.31.7:7893
- HTTPS_PROXY=http://192.168.31.7:7893
- http_proxy=http://192.168.31.7:7893
- https_proxy=http://192.168.31.7:7893
- NO_PROXY=localhost,127.0.0.1,192.168.31.0/24

View File

@@ -1,11 +1,11 @@
version: "3.8"
services:
app:
tuwen_system:
build:
context: .
dockerfile: Dockerfile
container_name: medical-report-app
ports:
- "8080:80"
container_name: tuwen_system
restart: unless-stopped
ports:
- "4002:80"

View File

@@ -95,6 +95,76 @@
.manual-frame-badge {
@apply absolute top-1 left-1 px-1.5 py-0.5 bg-yellow-400 text-yellow-900 text-[9px] font-bold rounded shadow-sm pointer-events-none;
}
/* Smart Field Bindable Controls */
.smart-field-wrapper {
display: inline-flex;
align-items: center;
margin: 0 2px;
vertical-align: text-bottom;
white-space: nowrap;
}
.smart-field-wrapper .field-label {
color: #64748b;
user-select: none;
}
.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;
}
.smart-field-wrapper .field-value:empty::before {
content: '\200b';
}
.smart-field-wrapper .field-value:focus {
background-color: #e2e8f0;
border-color: #94a3b8;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
}
.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;
}
.template-editor-mode .smart-field-wrapper:hover .delete-btn,
.template-editor-mode .smart-field-wrapper:focus-within .delete-btn {
display: block;
}
.report-signature-img {
max-width: 120px;
max-height: 40px;
width: auto;
height: auto;
object-fit: contain;
vertical-align: middle;
display: inline-block;
}
}
@media print {
@@ -124,4 +194,23 @@
.print-content .image-placeholder:not(.has-image) {
display: none !important;
}
.print-content .smart-field-wrapper .field-value {
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px !important;
}
.print-content .smart-field-wrapper .delete-btn {
display: none !important;
}
.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;
}
}

View File

@@ -8,15 +8,19 @@ import { storage } from '../utils/storage';
export default function Dashboard() {
const navigate = useNavigate();
const [stats, setStats] = useState({
reportCount: 0,
totalCount: 0,
monthCount: 0,
templateCount: 0,
userCount: 0,
todayCount: 0,
trend: [0,0,0,0,0,0,0],
trendLabels: ['','','','','','',''],
trendFullDates: ['','','','','','',''],
maxTrend: 1
});
const [tooltip, setTooltip] = useState<{ visible: boolean; x: number; y: number; date: string; count: number }>({ visible: false, x: 0, y: 0, date: '', count: 0 });
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -35,32 +39,42 @@ export default function Dashboard() {
? reports.filter(r => r.author === user.username)
: reports;
const today = new Date().toISOString().split('T')[0];
const now = new Date();
const today = now.toISOString().split('T')[0];
const todayReports = userReports.filter(r => r.createdAt === today);
// 7-day trend data
// 本月报告数
const currentMonth = today.slice(0, 7);
const thisMonthReports = userReports.filter(r => r.createdAt && r.createdAt.startsWith(currentMonth));
// 动态趋势数据
const daysCount = timeRange === '7days' ? 7 : 30;
const trend: number[] = [];
const labels: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
const fullDates: string[] = [];
for (let i = daysCount - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
const label = `${d.getMonth() + 1}/${d.getDate()}`;
const label = timeRange === '7days' ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getDate()}`;
labels.push(label);
fullDates.push(dateStr);
trend.push(userReports.filter(r => r.createdAt === dateStr).length);
}
const maxTrend = Math.max(...trend, 1);
setStats({
reportCount: userReports.length,
totalCount: userReports.length,
monthCount: thisMonthReports.length,
templateCount: templates.length,
userCount: users.length,
todayCount: todayReports.length,
trend,
trendLabels: labels,
trendFullDates: fullDates,
maxTrend
});
}, [navigate]);
}, [navigate, timeRange]);
if (!currentUser) return null;
@@ -80,10 +94,15 @@ export default function Dashboard() {
</Link>
</header>
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="card-minimal">
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold"></div>
<div className="text-3xl font-bold text-text-main">{stats.totalCount}</div>
</div>
<div className="card-minimal">
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold"></div>
<div className="text-3xl font-bold text-text-main">{stats.reportCount}</div>
<div className="text-3xl font-bold text-text-main">{stats.monthCount}</div>
</div>
<div className="card-minimal">
@@ -104,11 +123,44 @@ export default function Dashboard() {
<TrendingUp size={16} className="text-accent" />
</span>
<span className="text-[10px] text-accent font-bold uppercase tracking-wider"> 7 </span>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setTimeRange('7days')}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '7days' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
> 7 </button>
<button
onClick={() => setTimeRange('1month')}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '1month' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
> 30 </button>
</div>
</div>
<div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative">
{/* SVG Area Chart */}
<svg viewBox="0 0 300 120" className="w-full h-full overflow-visible">
<svg
viewBox="0 0 300 135"
className="w-full h-full overflow-visible"
onMouseMove={(e) => {
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const mouseX = ((e.clientX - rect.left) / rect.width) * 300;
const paddingX = 10;
const chartW = 300 - paddingX * 2;
const n = stats.trend.length;
if (n <= 1) return;
let idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1));
idx = Math.max(0, Math.min(n - 1, idx));
const ptX = paddingX + (idx / (n - 1)) * chartW;
const ptY = 8 + (120 - 16) - (stats.maxTrend > 0 ? (stats.trend[idx] / stats.maxTrend) * (120 - 16) : 0);
setTooltip({
visible: true,
x: (ptX / 300) * rect.width,
y: (ptY / 135) * rect.height,
date: stats.trendFullDates[idx] || '',
count: stats.trend[idx]
});
}}
onMouseLeave={() => setTooltip(prev => ({ ...prev, visible: false }))}
>
<defs>
<linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" />
@@ -145,17 +197,37 @@ export default function Dashboard() {
<g>
<path d={areaPath} fill="url(#trendGradient)" />
<path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{/* Transparent capture layer for reliable mouse events */}
<rect x="0" y="0" width="300" height="135" fill="transparent" />
{points.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
<text x={p.x} y={120 - 2} textAnchor="middle" fontSize="8" fill="#94A3B8" fontWeight="bold">{p.label}</text>
{/* 7天模式显示圆点和数值30天模式隐藏 */}
{stats.trend.length <= 10 && (
<>
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
</>
)}
{/* 标签稀疏化7天每天显示30天每隔5天显示 */}
{(stats.trend.length <= 10 || i % 5 === 0) && (
<text x={p.x} y={128} textAnchor="middle" fontSize={stats.trendLabels.length > 10 ? '7' : '8'} fill="#94A3B8" fontWeight="bold">{p.label}</text>
)}
</g>
))}
</g>
);
})()}
</svg>
{/* Tooltip */}
{tooltip.visible && (
<div
className="absolute pointer-events-none bg-slate-800 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10"
style={{ left: tooltip.x, top: tooltip.y - 40, transform: 'translateX(-50%)' }}
>
<div className="font-bold">{tooltip.date}</div>
<div className="text-slate-300">: {tooltip.count}</div>
</div>
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Template, SystemSettings } from '../types';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { storage } from '../utils/storage';
import { User as UserIcon, Lock } from 'lucide-react';
@@ -41,6 +41,26 @@ export default function Login() {
console.log('Default users initialized');
}
const fieldsConfig = storage.get<FormField[]>('formFieldsConfig', []);
if (fieldsConfig.length === 0) {
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
}
const savedAssets = storage.get<{id: string; name: string; dataUrl: string}[]>('imageAssets', []);
if (savedAssets.length === 0) {
fetch('/logo_square.png')
.then(res => res.blob())
.then(blob => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
storage.set('imageAssets', [{ id: 'asset_logo', name: '医院Logo', dataUrl }]);
};
reader.readAsDataURL(blob);
})
.catch(() => {});
}
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (!settingsRaw.frameCount) {
const round1 = (n: number) => Math.round(n * 10) / 10;
@@ -54,7 +74,10 @@ export default function Login() {
apiEndpoint: '',
apiKey: '',
defaultTemplate: savedTemplates[0]?.id || '',
frameMode: 'uniform'
frameMode: 'uniform',
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
};
storage.set('systemSettings', defaultSettings);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Search, Eye, Edit, Trash2, FileText, History, X } from 'lucide-react';
import { User, Report } from '../types';
import { Search, Eye, Edit, Trash2, FileText, History, X, Download, Printer } from 'lucide-react';
import { User, Report, DEFAULT_FORM_FIELDS } from '../types';
import { storage } from '../utils/storage';
import { printDocument } from '../utils/print';
const formatDateTime = (iso: string) => {
if (!iso) return '-';
@@ -23,6 +24,9 @@ export default function ReportManage() {
const [dateFilter, setDateFilter] = useState('');
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [historyReport, setHistoryReport] = useState<Report | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportTarget, setExportTarget] = useState<Report | null>(null);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -82,6 +86,7 @@ export default function ReportManage() {
const updatedReports = reports.filter(r => r.id !== id);
setReports(updatedReports);
storage.set('reports', updatedReports);
setSelectedIds(prev => prev.filter(pid => pid !== id));
}
};
@@ -106,6 +111,82 @@ export default function ReportManage() {
setHistoryModalOpen(false);
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredReports.length && filteredReports.length > 0) {
setSelectedIds([]);
} else {
setSelectedIds(filteredReports.map(r => r.id));
}
};
const handleBulkDelete = () => {
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return;
const updated = reports.filter(r => !selectedIds.includes(r.id));
setReports(updated);
storage.set('reports', updated);
setSelectedIds([]);
};
const buildExportData = (report: Report) => {
const fields: Record<string, any> = {};
DEFAULT_FORM_FIELDS.forEach(f => {
fields[f.key] = (report as any)[f.key];
});
return {
meta: {
id: report.id,
title: report.title,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
author: report.author,
authorName: report.authorName,
status: report.status
},
fields
};
};
const downloadJSON = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportSinglePDF = (report: Report) => {
printDocument(report.content);
};
const exportSingleJSON = (report: Report) => {
const data = buildExportData(report);
downloadJSON(data, `报告_${report.patientName || '未命名'}_${report.id}.json`);
};
const exportBulkPDF = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>');
printDocument(mergedHTML);
};
const exportBulkJSON = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const data = selectedReports.map(r => buildExportData(r));
const timestamp = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
downloadJSON(data, `reports_export_${timestamp}.json`);
};
const openExportModal = (report: Report) => {
setExportTarget(report);
setExportModalOpen(true);
};
if (!currentUser) return null;
return (
@@ -122,7 +203,7 @@ export default function ReportManage() {
</div>
</header>
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex flex-wrap gap-4 mb-4">
<div className="relative flex-1 min-w-[240px] max-w-[400px]">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
<input
@@ -155,15 +236,54 @@ export default function ReportManage() {
<option value="month"></option>
</select>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-border rounded-lg">
<span className="text-sm font-semibold text-text-main"> {selectedIds.length} </span>
<div className="flex-1"></div>
<button
onClick={exportBulkPDF}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Printer size={14} /> PDF
</button>
<button
onClick={exportBulkJSON}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Download size={14} /> JSON
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
</button>
<button
onClick={() => setSelectedIds([])}
className="px-3 py-1.5 text-sm font-medium rounded-lg text-text-muted hover:bg-slate-100 transition-colors"
>
</button>
</div>
)}
<div className="card-minimal p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-4 text-left border-b border-border w-10">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={filteredReports.length > 0 && selectedIds.length === filteredReports.length}
onChange={toggleSelectAll}
/>
</th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24"></th>
@@ -174,6 +294,14 @@ export default function ReportManage() {
{filteredReports.length > 0 ? (
filteredReports.map((report) => (
<tr key={report.id} className="hover:bg-slate-50 transition-colors group">
<td className="px-4 py-4 border-b border-border">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={selectedIds.includes(report.id)}
onChange={() => toggleSelect(report.id)}
/>
</td>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-text-main">{report.title}</div>
<div className="text-xs text-text-muted font-mono mt-1">{report.id}</div>
@@ -228,13 +356,20 @@ export default function ReportManage() {
>
<History size={16} />
</button>
<button
onClick={() => openExportModal(report)}
className="p-2 rounded-lg bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
title="导出"
>
<Download size={16} />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-24 text-center">
<td colSpan={8} className="px-6 py-24 text-center">
<div className="flex flex-col items-center text-text-muted">
<FileText size={48} className="mb-4 opacity-20" />
<h3 className="text-base font-semibold text-text-main mb-1"></h3>
@@ -297,6 +432,45 @@ export default function ReportManage() {
</div>
</div>
)}
{exportModalOpen && exportTarget && (
<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">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-text-main"></h3>
<button
onClick={() => setExportModalOpen(false)}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<X size={18} />
</button>
</div>
<p className="text-sm text-text-muted mb-4"></p>
<div className="flex flex-col gap-3">
<button
onClick={() => { exportSinglePDF(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Printer size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> PDF</div>
<div className="text-xs text-text-muted"> PDF</div>
</div>
</button>
<button
onClick={() => { exportSingleJSON(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Download size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> JSON</div>
<div className="text-xs text-text-muted"></div>
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -36,9 +36,11 @@ export default function SystemSettings() {
savedSettings.defaultTemplate = savedTemplates[0].id;
}
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
setSettings(savedSettings);
} else if (savedTemplates.length > 0) {
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform' }));
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
}
setTemplates(savedTemplates);
}, [navigate]);
@@ -172,6 +174,33 @@ export default function SystemSettings() {
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoInsertFrames"
checked={settings.autoInsertFrames || false}
onChange={(e) => setSettings({ ...settings, autoInsertFrames: e.target.checked })}
className="w-4 h-4 accent-accent cursor-pointer"
/>
<label htmlFor="autoInsertFrames" className="text-sm text-text-main cursor-pointer"></label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (s)</label>
<input
type="number"
min={0}
step={0.1}
value={settings.autoInsertDelay || 0}
onChange={(e) => setSettings({ ...settings, autoInsertDelay: Math.max(0, parseFloat(e.target.value) || 0) })}
className="input-minimal bg-white w-full"
/>
</div>
</div>
<p className="text-[11px] text-text-muted"></p>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (%)</label>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
@@ -191,11 +220,30 @@ export default function SystemSettings() {
className="input-minimal w-full pr-6 text-center"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted">%</span>
{settings.autoInsertFrames && (
<span
onClick={() => {
const current = settings.autoInsertFrameIndices || [];
const next = current.includes(idx)
? current.filter(i => i !== idx)
: [...current, idx].sort((a, b) => a - b);
setSettings({ ...settings, autoInsertFrameIndices: next });
}}
className={`absolute top-1 left-1 cursor-pointer transition-colors ${
(settings.autoInsertFrameIndices || []).includes(idx) ? 'text-green-500' : 'text-slate-300'
}`}
>
<Check size={12} />
</span>
)}
<button
type="button"
onClick={() => {
const newPos = settings.framePositions.filter((_, i) => i !== idx);
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length });
const newIndices = (settings.autoInsertFrameIndices || [])
.filter(i => i !== idx)
.map(i => i > idx ? i - 1 : i);
setSettings({ ...settings, framePositions: newPos, frameCount: newPos.length, autoInsertFrameIndices: newIndices });
}}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100 transition-all shadow-sm"
>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { UserPlus, Edit, Trash2 } from 'lucide-react';
import { UserPlus, Edit, Trash2, Upload, X } from 'lucide-react';
import { User, Template } from '../types';
import { storage } from '../utils/storage';
@@ -56,6 +56,50 @@ export default function UserManage() {
storage.set('users', updatedUsers);
};
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;
});
};
const handleSignatureUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const compressed = await compressImage(file);
setFormData(prev => ({ ...prev, signature: compressed }));
} catch {
alert('图片压缩失败,请重试');
}
};
const handleDelete = (username: string) => {
if (username === 'admin') {
alert('不能删除默认超级管理员');
@@ -226,7 +270,7 @@ export default function UserManage() {
updatedUsers = users.map(u => {
if (u.username === formData.username) {
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password } as User;
return { ...u, role: finalRole, department: finalDepartment, manageableTemplates, visibleTemplates: adminVisible, password: formData.password || u.password, signature: formData.signature } as User;
}
if (u.role === 'user' && u.department === (oldUser.department || finalDepartment)) {
const currentVisible = Array.isArray(u.visibleTemplates) ? u.visibleTemplates : [];
@@ -372,6 +416,7 @@ export default function UserManage() {
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
</tr>
@@ -393,6 +438,13 @@ export default function UserManage() {
</span>
</td>
<td className="px-6 py-4 text-sm text-text-main">{user.department || '-'}</td>
<td className="px-6 py-4">
<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>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
@@ -568,6 +620,43 @@ export default function UserManage() {
</div>
)}
<div className="space-y-2">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"></label>
{formData.signature ? (
<div className="flex items-center gap-3">
<img
src={formData.signature}
alt="电子签名预览"
className="h-16 border border-border rounded bg-white object-contain"
/>
<div className="flex flex-col gap-2">
<label className="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-1">
<Upload size={12} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, signature: undefined }))}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors inline-flex items-center gap-1"
>
<X size={12} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<label className="px-4 py-2 text-sm font-medium rounded-lg bg-slate-100 hover:bg-slate-200 transition-colors cursor-pointer inline-flex items-center gap-2">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden" onChange={handleSignatureUpload} />
</label>
<span className="text-xs text-text-muted"> JPGPNG 500px </span>
</div>
)}
</div>
{showManageableTemplates && (
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">

View File

@@ -10,6 +10,7 @@ export interface User {
createdAt?: string;
visibleTemplates?: string[];
manageableTemplates?: string[];
signature?: string;
}
export interface Report {
@@ -66,6 +67,7 @@ export interface Template {
createdAt: string;
updatedAt?: string;
author: string;
fields?: FormField[];
}
export interface SystemSettings {
@@ -75,4 +77,69 @@ export interface SystemSettings {
apiKey: string;
defaultTemplate?: string;
frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean;
autoInsertFrameIndices?: number[];
autoInsertDelay?: number;
}
export interface BindableField {
key: string;
label: string;
}
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: 'startTime', label: '手术开始时间' },
{ key: 'endTime', label: '手术终止时间' },
{ key: 'surgeon', label: '手术者' },
{ key: 'assistant', label: '助手' },
{ key: 'anesthesiologist', label: '麻醉师' },
{ key: 'anesthesiaType', label: '麻醉方式' },
];
export type FieldType = 'text' | 'single_select' | 'multi_select' | 'time' | 'date' | 'signature' | 'image';
export interface FormField {
key: string;
label: string;
category: string;
type: FieldType;
visibleInForm: boolean;
isSystemLocked: boolean;
options?: string[];
timeFormat?: string;
timeDefault?: 'current' | 'specific';
fixedTimeValue?: string;
hasUnderline?: boolean;
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: false },
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: false },
{ 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: true, timeFormat: 'YYYY-MM-DD', 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' },
{ key: 'reportDate', label: '撰写时间', category: '时间', type: 'date', visibleInForm: true, isSystemLocked: true, timeFormat: 'YYYY年MM月DD日', timeDefault: 'current' },
{ key: 'surgeon', label: '手术者', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['张医生', '李医生', '王医生'] },
{ key: 'assistant', label: '助手', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['赵医生', '钱医生', '孙医生'] },
{ key: 'anesthesiologist', label: '麻醉师', category: '多选', type: 'multi_select', visibleInForm: true, isSystemLocked: true, options: ['周医生', '吴医生', '郑医生'] },
{ key: 'anesthesiaType', label: '麻醉方式', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['全麻', '局麻', '腰麻', '硬膜外麻醉', '静脉麻醉', '吸入麻醉'] },
{ key: 'preoperativeDiagnosis', label: '术前诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postoperativeDiagnosis', label: '术后诊断', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊结石伴慢性胆囊炎', '急性胆囊炎'] },
{ key: 'postOpCondition', label: '手术后情况', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['患者麻醉恢复后安返病房'] },
{ key: 'specimenDescription', label: '切除标本描述', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['胆囊一枚壁厚约0.3cm,内含数枚结石'] },
{ key: 'pathologyCheck', label: '是否送病理检查', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['是', '否'] },
{ key: 'frozenPathology', label: '冰冻病理结果', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: true, options: ['未见恶性', '待石蜡'] },
];

View File

@@ -1,80 +1,77 @@
const smartField = (key: string) => {
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value no-underline" data-bind="${key}" contenteditable="true" style="min-width:24px;padding:0 2px;margin:0;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:inherit;font-size:inherit;vertical-align:baseline;box-sizing:border-box;outline:none;text-align:center;"> </span><span class="delete-btn" contenteditable="false">×</span></span>&#8203;`;
};
export const defaultReportContent = `
<!-- 医院Logo -->
<p style="text-align: center; margin-bottom: 16px;" contenteditable="false">
<img src="/logo_square.png" alt="医院Logo" style="width: 65px; height: auto; display: block; margin: 0 auto;">
</p>
<!-- 医院名称 -->
<p style="text-align: center; font-family: SimSun; margin-bottom: 8px;" contenteditable="false">
<strong><u>西 安 交 通 大 学 第 一 附 属 医 院</u></strong>
</p>
<!-- 报告标题 -->
<h1 style="font-family: SimSun; font-size: 20px; margin: 16px 0; text-align: center;" contenteditable="false">手术记录</h1>
<div class="template-info-section">
<p style="font-family: SimSun;">
姓名:<span style="color: #ff0000;">*姓名*</span>
性别: <span style="color: #ff0000;">*性别*</span>
年龄:<span style="color: #ff0000;">*年龄*</span>
科别:<span style="color: #ff0000;">*科室*</span>
床号:<span style="color: #ff0000;">*床号*</span>
住院号:<span style="color: #ff0000;">*住院号*</span>
</p>
<p style="font-family: SimSun;">
<strong>手术日期:</strong><span style="color: #bdbdbd;">年 月 日</span>
</p>
<p style="font-family: SimSun;">
<strong>术前诊断:</strong><span style="color: #bdbdbd;">术前诊断</span>
</p>
<p style="font-family: SimSun;">
<strong>术后诊断:</strong><span style="color: #bdbdbd;">术后诊断</span>
</p>
<p style="font-family: SimSun;">
<strong>手术名称:</strong>腹腔镜胆囊切除术
</p>
<p style="font-family: SimSun;">
手术开始时间:<span style="color: #bdbdbd;">时 分</span>
手术终止时间:<span style="color: #bdbdbd;">时 分</span>
</p>
<p style="font-family: SimSun;">
手术者: <span style="color: #bdbdbd;">手术者</span>
助手: <span style="color: #bdbdbd;">助手</span>
</p>
<p style="font-family: SimSun;">
麻醉师:<span style="color: #bdbdbd;">麻醉师</span>
麻醉方式: 全麻
</p>
<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 4px;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;transform:translate(-5px,-5px);">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
</span>
<div style="text-align: center;">
<div style="font-size: 14pt; font-family: SimSun; border-bottom: 1px solid #000; padding-bottom: 1px; margin-bottom: 2px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div>
<div style="font-size: 16pt; font-family: SimSun;">手术记录</div>
</div>
</div>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0; line-height: 1; border-bottom: 1px solid #000;">
姓名:${smartField('patientName')}&nbsp;
性别:${smartField('patientGender')}&nbsp;
年龄:${smartField('patientAge')}&nbsp;
科别:${smartField('department')}&nbsp;
床号:${smartField('bedNumber')}&nbsp;
住院号:${smartField('hospitalId')}
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术日期:</strong>${smartField('surgeryDate')}
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>术前诊断:</strong>${smartField('preoperativeDiagnosis')}
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>术中诊断:</strong>${smartField('postoperativeDiagnosis')}
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术名称:</strong>${smartField('title')}
</p>
<table style="width: 100%; border: none; font-family: SimSun; font-size: 12pt; margin-top: 0; margin-bottom: 0;">
<tr>
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术开始时间:${smartField('startTime')}</td>
<td style="border: none; padding: 0; width: 50%; line-height: 1.5;">手术终止时间:${smartField('endTime')}</td>
</tr>
<tr>
<td style="border: none; padding: 0; line-height: 1.5;">手术者:${smartField('surgeon')}</td>
<td style="border: none; padding: 0; line-height: 1.5;">助手:${smartField('assistant')}</td>
</tr>
<tr>
<td style="border: none; padding: 0; line-height: 1.5;">麻醉师:${smartField('anesthesiologist')}</td>
<td style="border: none; padding: 0; line-height: 1.5;">麻醉方式:${smartField('anesthesiaType')}</td>
</tr>
</table>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术步骤、术中出现的情况及处理:</strong>
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
</p>
<p style="font-family: SimSun;">
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。
</p>
@@ -82,79 +79,80 @@ export const defaultReportContent = `
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
<tbody><tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图A 腹腔镜探查</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图B 胆囊管夹闭与离断</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图C 胆囊动脉夹闭与离断</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图C 胆囊动脉夹闭与离断</p>
</td>
</tr>
<tr>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图D 胆囊剥离与床面止血</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图E 胆囊取出与钛夹确认</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<div class="image-placeholder" data-placeholder="true" contenteditable="false">
<div class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">图F 止血材料覆盖及检查</p>
<p style="color: #64748b; font-size: 13px; margin: 0; padding: 0; line-height: 1.5;">图F 止血材料覆盖及检查</p>
</td>
</tr></tbody>
</table>
<div class="template-info-section">
<p style="font-family: SimSun;">
<strong>手术后情况</strong>患者麻醉恢复后安返病房
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>手术后情况</strong>${smartField('postOpCondition')}
</p>
<p style="font-family: SimSun;">
<strong>切除标本描述</strong><span style="color: #bdbdbd;">切除标本描述</span>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>切除标本描述</strong>${smartField('specimenDescription')}
</p>
<p style="font-family: SimSun;">
<strong>是否送病理检查</strong>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>是否送病理检查</strong>${smartField('pathologyCheck')}
</p>
<p style="font-family: SimSun;">
<strong>冰冻病理结果</strong><span style="color: #bdbdbd;">冰冻病理结果</span>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
<strong>冰冻病理结果</strong>${smartField('frozenPathology')}
</p>
<p style="font-family: SimSun;">
手术者签名:<span style="color: #bdbdbd;">签名</span>
<p style="text-align: right; font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0; white-space: nowrap;">
手术者签名:<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:200px;height:40px;line-height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
</p>
<p style="text-align: right; font-family: SimSun; color: #bdbdbd;">
年 月 日
<p style="margin: 0; padding: 0; line-height: 1.5;">&nbsp;</p>
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0;">
${smartField('reportDate')}
</p>
</div>
`;
// Backward compatibility alias
export const defaultContent = defaultReportContent;

View File

@@ -1,4 +1,6 @@
export const printDocument = (htmlContent: string) => {
export const printDocument = (htmlContent: string, docTitle: string = '图文报告') => {
const originalTitle = document.title;
document.title = docTitle;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
@@ -17,13 +19,14 @@ export const printDocument = (htmlContent: string) => {
<html>
<head>
<meta charset="utf-8">
<title>${docTitle}</title>
<style>
@page { size: A4; margin: 0; }
@page { size: A4; margin: 15mm 10mm; }
* { box-sizing: border-box; }
body { margin: 0; padding: 10mm; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
p { margin: 0; padding: 4px 0; line-height: 1.6; }
p { margin: 0; padding: 0; line-height: 1.5; }
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
strong, b { font-weight: 600; }
u { text-decoration: underline; }
@@ -31,9 +34,17 @@ export const printDocument = (htmlContent: string) => {
td { padding: 8px; border: 1px solid #e2e8f0; vertical-align: top; }
.image-placeholder { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; margin-bottom: 8px; background: #f8fafc; min-height: 70px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
.image-placeholder.has-image { border: none; background: transparent; padding: 0; min-height: 0; }
.image-placeholder .delete-btn { display: none !important; }
.delete-btn { display: none !important; }
.image-placeholder:not(.has-image) { display: none !important; }
.template-info-section { position: relative; margin-bottom: 16px; }
.smart-field-wrapper { display: inline-flex; align-items: baseline; margin: 0; vertical-align: baseline; }
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
@media print {
.smart-field-wrapper .field-value { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px 0px 2px !important; line-height: 1 !important; }
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
}
</style>
</head>
<body>
@@ -45,6 +56,7 @@ export const printDocument = (htmlContent: string) => {
win.focus();
setTimeout(() => {
win.print();
document.title = originalTitle;
setTimeout(() => {
if (iframe.parentNode) document.body.removeChild(iframe);
}, 1000);

View File

@@ -1,97 +0,0 @@
# 代码编纂工作流
> 本工作流为项目修改类需求的标准执行流程。后续所有项目修改相关需求,均需严格按以下步骤执行。
---
## 前置约定
- 时间戳格式:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 示例:`2026-04-16-18-35-00`
- 所有方案文档均存放于 `.\工程分析\` 目录下。
---
## 执行步骤
### Step 0. 记录开始时间
每次执行前,以当前时间生成时间戳,作为本次需求的唯一标识。
### Step 1. 创建/确认工程分析目录
确保 `.\工程分析\` 文件夹存在。如不存在,则自动创建。
### Step 2. 需求分析
将用户提出的需求整理、拆解、澄清后,写入文档:
```
.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 原始需求摘要
- 需求拆解(功能点 / 非功能点)
- 待确认问题(如有)
- 影响范围预估
### Step 3. 实现方案(需人工确认)
基于需求分析,撰写详细的实现方案,写入文档:
```
.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 实现思路与架构决策
- 涉及修改的文件清单
- 具体的代码变更说明
- 风险点与回滚策略
**⚠️ 此文档写完后,必须提交给用户进行二次人工审核确认,得到明确批准后方可进入下一步。**
### Step 4. 测试方案(需人工确认)
基于实现方案,撰写测试方案,写入文档:
```
.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md
```
内容要求:
- 测试目标
- 测试用例设计
- 测试环境准备
- 验收标准
**⚠️ 此文档写完后,必须提交给用户进行二次人工审核确认,得到明确批准后方可进入下一步。**
### Step 5. 执行修改前准备
1. **阅读 `.\工程分析\经验记录.md`**,回顾历史问题,避免重复犯错。
2. 确认实现方案和测试方案均已获得用户批准。
### Step 6. 执行修改
按照已批准的实现方案和测试方案,执行具体的代码修改与测试验证。
### Step 7. 更新经验记录
修改完成后,将本次执行过程中遇到的关键问题及解决方案,以 **四段式** 追加写入 `.\工程分析\经验记录.md`
- **A. 具体问题**
- **B. 产生问题原因**
- **C. 解决问题方案**
- **D. 后续如何避免问题**
### Step 8. Gitea 备份 Commit
`.\工程分析\` 目录下的所有文档使用 Gitea 进行备份,提交 Commit。
Commit Message 格式:
```
{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - {本次修改的简要描述}
```
Commit 完成后,提醒用户备份已完成。
---
## 当前状态
- [x] 工作流文档建立
- [x] 工程分析目录创建
- [x] 经验记录初始文档创建

View File

@@ -1,69 +0,0 @@
# 实现方案 — 2026-04-16-16-51-00
## 技术思路
本次修复聚焦于 `ReportEditor.tsx` 中草稿draft恢复与默认模板加载的竞争条件问题。核心思路是
1. **拒绝加载空白草稿**:将空字符串(或仅空白字符)的 draft 视为无效,不拦截默认模板加载流程。
2. **草稿中携带模板信息**:在自动保存 draft 时追加 `loadedTemplateId`,恢复草稿时同步还原,确保模板选择器显示正确。
3. **统一初始化路径**`useEffect``useLayoutEffect` 中的草稿/默认模板判断逻辑保持一致,避免其中一个抢先设置空白内容。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 修复草稿恢复条件、保存/恢复 `loadedTemplateId`、统一空白判断逻辑 |
---
## 关键代码变更说明
### 1. 保存 draft 时追加 `loadedTemplateId`
`saveDraftToStorage` 回调中,将当前 `loadedTemplateId` 一并持久化:
```tsx
storage.set(key, {
content: contentRef.current,
loadedTemplateId, // 新增
draftReportId: reportId || null,
...stateRef.current
});
```
### 2. 恢复 draft 时同步恢复 `loadedTemplateId`useEffect 初始化逻辑)
**新建报告分支(无 `reportId`**
- 将条件 `typeof draft.content === 'string'` 改为 `typeof draft.content === 'string' && draft.content.trim().length > 0`
- 若草稿有效,除了回填内容,还要执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`
**编辑报告分支(有 `reportId`**
- 同样使用 `draft.content.trim().length > 0` 判断,避免空白草稿覆盖已有报告内容。
- 恢复 `loadedTemplateId`(虽然在编辑模式下模板选择器通常不显示具体模板名,但保持一致性)。
### 3. useLayoutEffect 安全网逻辑同步修复
`useLayoutEffect`(第 611 行起)作为 editor ref 就绪后的二次安全网,其草稿判断条件也要同步修改:
- `typeof draft.content === 'string'``typeof draft.content === 'string' && draft.content.trim().length > 0`
- 恢复 `loadedTemplateId`
### 4. 默认模板加载时同步设置 `loadedTemplateId`
在加载 `settings.defaultTemplate` 对应模板内容时,当前代码已经设置了 `setLoadedTemplateId(tpl.id)`,无需改动。
在兜底使用 `defaultReportContent` 时,`loadedTemplateId` 保持为空字符串(显示"无"),这符合语义,因为兜底内容不是用户选中的模板。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| 修改了多处 draft 判断逻辑,可能漏改 | 某些路由切换场景仍出现空白 | 在 `useEffect``useLayoutEffect` 两处共 **4 个草稿加载点** 统一替换判断条件 |
| `loadedTemplateId` 加入 draft 后,旧 draft 兼容性 | 旧 draft 没有该字段,恢复时值为 `undefined`,模板选择器短暂显示"无" | 使用 `draft.loadedTemplateId || ''` 兜底,不影响功能 |
| 用户之前确实清空了内容再离开 | 会被视为无效草稿而丢失空白状态 | 这是预期行为:空白内容等价于未开始编辑,应回退到默认模板 |
---
## 改动范围总结
- 仅修改 `src/pages/ReportEditor.tsx`,不触及路由、存储封装或其他页面逻辑。
- 不引入新依赖。

View File

@@ -1,102 +0,0 @@
# 实现方案 — 2026-04-16-17-07-04
## 技术思路
`ReportEditor.tsx` 中为关键帧卡片新增一个 **"插入"** 按钮,点击后自动将当前帧图片填充到编辑器中第一个空置的 `.image-placeholder` 中。
为保持代码整洁,将现有 `handleDrop` 中的占位符填充逻辑抽取为可复用的 `fillPlaceholder` 函数,供拖拽放下和按钮点击共同调用。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 新增 `fillPlaceholder` 函数、新增 `insertFrameToPlaceholder` 函数、修改关键帧卡片 JSX |
---
## 关键代码变更说明
### 1. 抽取公共填充函数 `fillPlaceholder`
`handleDrop` 中的图片填充逻辑抽离:
```tsx
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" 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();
};
```
并同步简化 `handleDrop`
```tsx
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
fillPlaceholder(placeholder, frame);
}
};
```
### 2. 新增一键插入函数 `insertFrameToPlaceholder`
```tsx
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;
}
fillPlaceholder(emptyPlaceholder, frame);
};
```
### 3. 在关键帧卡片底部添加 "插入" 按钮
修改现有 JSX约第 1303 行附近),在 `timeFormatted` 与 "可拖拽" 之间插入按钮:
```tsx
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="text-accent opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
```
按钮使用 `opacity-0 group-hover:opacity-100 transition-opacity`,与 "可拖拽" 显隐行为完全一致。`e.stopPropagation()` 避免触发卡片的 `onClick`(即跳转到视频位置)。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| `editorRef.current` 为空时点击插入 | JS 报错 | 函数开头增加判空并 alert 提示 |
| 没有空占位符时点击插入 | 用户困惑 | 未找到 `.image-placeholder:not(.has-image)` 时弹出友好提示 |
| 按钮点击触发卡片 onClick | 视频意外跳转 | 使用 `e.stopPropagation()` 阻止冒泡 |
| `handleDrop` 抽离后功能回退 | 拖拽失效 | 保持 `handleDrop` 调用 `fillPlaceholder`,逻辑与原来一致 |
---
## 改动范围总结
- 仅修改 `src/pages/ReportEditor.tsx`,不触及其他文件。
- 不引入新依赖。

View File

@@ -1,81 +0,0 @@
# 实现方案 — 2026-04-16-17-15-37
## 技术思路
仅调整 `ReportEditor.tsx` 中关键帧卡片的 JSX 结构:将 "插入" 按钮从底部文字行移到图片层的相对定位容器中,并修改其 className 为实体按钮样式(绿色背景、白色文字、圆角、阴影)。
`insertFrameToPlaceholder` 等逻辑函数无需改动。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 "插入" 按钮位置、更新样式 className |
---
## 关键代码变更说明
### 当前 JSX 片段(约第 1303~1318 行)
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="text-accent opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
```
### 修改后 JSX 片段
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="absolute inset-0 m-auto w-fit h-fit px-3 py-1.5 bg-emerald-500 text-white text-[10px] font-bold rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-emerald-600"
>
</button>
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
```
### 样式说明
- `absolute inset-0 m-auto w-fit h-fit`:使按钮在图片容器内水平和垂直居中。
- `px-3 py-1.5`:实体按钮的内边距。
- `bg-emerald-500 text-white`:翠绿背景 + 白色文字,与 "可拖拽" 的蓝色(`text-accent`)形成明显区分。
- `rounded-full shadow-md`:圆角胶囊形状 + 轻微阴影,呈现按钮质感。
- `opacity-0 group-hover:opacity-100 transition-opacity`:保持 hover 显示/隐藏行为与 "可拖拽" 一致。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| 按钮遮挡 "手动" 徽章 | 视觉冲突 | "手动" 徽章位于左上角,按钮位于正中央,空间不重叠 |
| 按钮点击触发卡片 onClick | 视频跳转 | 保留 `e.stopPropagation()` |
| 绝对定位在相对容器中未居中 | 偏位 | 使用 `inset-0 m-auto w-fit h-fit` 确保 Flex/Grid 内的居中 |
---
## 改动范围总结
- 仅做 JSX 结构和样式的微调整,不修改任何逻辑函数。
- 不引入新依赖。

View File

@@ -1,77 +0,0 @@
# 实现方案 — 2026-04-16-17-21-58
## 技术思路
`ReportEditor.tsx` 中关键帧卡片的 "插入" 按钮从图片层的 absolute 覆盖层移回底部文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。颜色恢复为与 "可拖拽" 一致的蓝色(`bg-accent` / `text-white`),但保留实体胶囊按钮样式。
---
## 修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 "插入" 按钮位置、调整颜色为蓝色 |
---
## 关键代码变更说明
### 当前 JSX 片段(约第 1316~1331 行)
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="absolute inset-0 m-auto w-fit h-fit px-3 py-1.5 bg-emerald-500 text-white text-[10px] font-bold rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-emerald-600"
>
</button>
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
```
### 修改后 JSX 片段
```tsx
<div className="relative">
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded-lg" />
{frame.isManual && <span className="manual-frame-badge"></span>}
</div>
<div className="text-[9px] font-bold text-text-muted mt-1.5 px-1 flex justify-between items-center">
<span>{frame.timeFormatted}</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); insertFrameToPlaceholder(frame); }}
className="px-2 py-0.5 bg-accent text-white text-[9px] font-bold rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-700"
>
</button>
<span className="text-accent opacity-0 group-hover:opacity-100 transition-opacity"></span>
</div>
</div>
```
### 样式说明
- 按钮位于底部 `timeFormatted` 与 "可拖拽" 之间,不再覆盖图片。
- `px-2 py-0.5 bg-accent text-white rounded-full shadow-sm`:蓝色实体小胶囊按钮,与 "可拖拽" 蓝色一致。
- `hover:bg-blue-700`:加深蓝色反馈。
- `opacity-0 group-hover:opacity-100 transition-opacity`:保持 hover 显隐与 "可拖拽" 同步。
---
## 风险点及应对策略
| 风险 | 影响 | 应对策略 |
|------|------|----------|
| 按钮太小导致不易点击 | 体验下降 | 保留 `px-2 py-0.5` 的实体按钮,比纯文字链接更易点击 |
| 底部文字区域变宽 | 布局错乱 | 使用 `flex items-center gap-2` 控制间距,保持在一行内 |
---
## 改动范围总结
- 纯 JSX 结构和样式的微调整,不修改任何逻辑函数。
- 不引入新依赖。

View File

@@ -1,118 +0,0 @@
# 实现方案 — 2026-04-16-18-51-06
## 根因分析
`ReportEditor.tsx` 中使用了 `stateRef.current` 作为「草稿自动保存」的数据来源。组件卸载时,`saveDraftToStorage()` 会将 `stateRef.current` 写入 `localStorage`
但在页面初始化(`useEffect``useLayoutEffect`)从已保存报告或 draft 恢复数据时,仅通过 `setState` 更新了 React state**没有同步更新 `stateRef.current` 中的 `videos``capturedFrames` 字段**。
这导致:
1. 用户首次进入 `/report-editor` 时,数据从 localStorage 正确恢复;
2. 用户离开页面时,`stateRef.current` 仍保存着初始的空数组;
3. 组件卸载触发的 `saveDraftToStorage()` 用空数组覆盖了 draft
4. 用户再次返回 `/report-editor` 时,系统优先读取被覆盖后的 draft导致视频分析数据全部丢失。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 在 4 处数据恢复逻辑后追加 `stateRef.current` 同步赋值 |
## 具体代码变更
### 修改点 1初始化 useEffect — 从 draft 恢复已有报告(约第 128 行后)
在已有代码:
```tsx
setLoadedTemplateId(draft.loadedTemplateId || '');
stateRef.current = { ...stateRef.current, loadedTemplateId: draft.loadedTemplateId || '' };
```
**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 2初始化 useEffect — 从已保存报告found恢复约第 146 行后)
在设置完 `contentLoadedRef.current = true;` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
```
### 修改点 3初始化 useEffect — 从 draft 恢复新建报告(约第 176 行后)
与修改点 1 类似,在 `setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 4useLayoutEffect 安全网 — 从 draft 恢复已有报告(约第 677 行后)
`setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
### 修改点 5useLayoutEffect 安全网 — 从已保存报告found恢复约第 692 行后)
`contentLoadedRef.current = true;` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
```
### 修改点 6useLayoutEffect 安全网 — 从 draft 恢复新建报告(约第 701 行后)
`setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**
```tsx
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
```
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `stateRef` 仍可能在其他未覆盖路径中不同步 | 低 | 已检查所有数据恢复入口init effect + layout effect后续若新增恢复逻辑需保持同步习惯 |
| `found.videos` / `found.capturedFrames` 为 undefined | 低 | 代码中使用 `|| []` 做防御性处理 |
## 回滚策略
本次修改仅增加 `stateRef.current` 的同步赋值语句,不涉及删除或重构现有逻辑。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -1,132 +0,0 @@
# 实现方案 — 2026-04-16-19-06-18
## 根因分析
### 问题 1路由切换后所有内容丢失
`ReportEditor.tsx` 的初始化 `useEffect` 在从 draft 恢复数据时,将 `stateRef.current` 的同步代码放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的**内部**。
这导致当以下任一情况发生时,`stateRef.current` 不会被同步:
- `editorRef.current` 在 useEffect 执行时尚未挂载(组件首次渲染时常见);
- `draft.content` 为空字符串或仅包含空白(新建报告时常见)。
`stateRef.current` 保持为初始值(空的 `reportData`、空的 `videos`、空的 `capturedFrames`)时,用户离开页面触发的 `saveDraftToStorage()` 会**用这些空值覆盖 localStorage 中的 draft**。再次返回 `/report-editor` 时,系统读取了这个被清空的 draft导致所有内容全部丢失。
### 问题 2自动帧插入 UI 批量刷新
`autoCaptureFrames` 是一个 `async` 函数,内部通过 `for` 循环逐帧处理。循环中每次摘取到新帧后都会调用 `setCapturedFrames(accumulatedFrames)`,但由于 React 18 的**自动批处理**机制在异步函数中连续调用的状态更新会被合并DOM 重渲染被推迟到整个循环结束后才执行一次。因此用户看不到逐帧实时摘取的过程,只有等全部帧处理完后,关键帧列表和 placeholder 中的图片才会一次性批量出现。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 移动 stateRef 同步位置 + 引入 `flushSync` |
## 具体代码变更
### 变更 1useEffect — draft 恢复已有报告(约第 123 行区域)
**当前代码(有问题的结构):**
```tsx
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
// ...
stateRef.current = { ...stateRef.current, reportData: draft.reportData, ... };
}
```
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**,紧接在 `setCapturedFrames` 之后:
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
contentRef.current = draft.content;
contentLoadedRef.current = true;
setLoadedTemplateId(draft.loadedTemplateId || '');
setTimeout(() => updatePageHeight(), 0);
}
```
### 变更 2useEffect — found 恢复已有报告(约第 141 行区域)
**修改为:**
`stateRef.current` 的赋值提取到 `if (editorRef.current)` 的**外部**,紧接在 `setReportData(found)` 之后:
```tsx
setReportData(found);
stateRef.current = {
...stateRef.current,
reportData: found,
videos: found.videos || [],
capturedFrames: found.capturedFrames || []
};
if (editorRef.current) {
// ... 恢复 editor content ...
}
```
同时顺手清理重复赋值 `contentRef.current = found.content;`(第 149-150 行重复)。
### 变更 3useEffect — draft 恢复新建报告(约第 184 行区域)
**修改为:**
与变更 1 类似,将 `stateRef.current` 的赋值提取到 `if (editorRef.current && ...)` 的**外部**
```tsx
if (draft.activeTab) setActiveTab(draft.activeTab);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
// ... 恢复 editor content ...
}
```
### 变更 4autoCaptureFrames 引入 flushSync约第 519-521 行区域)
在文件顶部增加导入:
```tsx
import { flushSync } from 'react-dom';
```
`autoCaptureFrames` 的 for 循环中,每次更新 `capturedFrames` 时使用 `flushSync` 强制同步渲染:
**当前代码:**
```tsx
setCapturedFrames(accumulatedFrames);
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
**修改为:**
```tsx
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
```
这样每一帧被摘取后都会立即触发 DOM 更新,用户可以在右侧「视频分析」面板中实时看到关键帧逐张出现,同时自动插入逻辑也能按配置延迟后逐张填充 placeholder。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `flushSync` 在循环中可能导致轻微渲染卡顿 | 低 | 关键帧数量通常仅 10~20 张,现代浏览器完全可以承受;已在 async 函数中使用,不会阻塞视频 seek 事件 |
| 移动 `stateRef` 同步位置可能与其他逻辑产生时序影响 | 低 | 仅将同步从 `if` 内部移到外部,逻辑语义完全一致,只是确保在所有路径下都被执行 |
| `found` 分支中 `contentRef.current` 重复赋值 | 极低 | 顺手清理,不影响行为 |
## 回滚策略
本次修改仅调整代码执行顺序并引入一个 React 标准 API`flushSync`),未改变数据结构或接口。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -1,25 +0,0 @@
# 实现方案 — 2026-04-16-19-18-14
## 部署步骤
1. **构建生产包**:运行 `npm run build` 先本地验证构建是否通过(可选但推荐)。
2. **停止旧容器**`docker-compose down` 停止并移除当前运行的 `medical-report-app` 容器。
3. **重新构建镜像**`docker-compose build --no-cache` 基于最新代码重新构建 Docker 镜像。
4. **启动新容器**`docker-compose up -d` 后台启动新容器。
5. **验证部署**:检查容器状态 `docker ps`,并尝试访问 `http://localhost:8080` 确认应用正常。
## 修改文件清单
无需修改源代码,仅执行构建和容器操作。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 构建失败 | 低 | 本地已执行 `tsc --noEmit` 通过,构建风险低 |
| 端口 8080 被占用 | 低 | `docker-compose down` 会先释放旧容器占用的端口 |
| Docker 未安装/未启动 | 中 | 如遇报错,根据错误信息处理 |
## 回滚策略
若部署后异常,可执行 `docker-compose down` 后回退到上一个可用的 Git commit 再重新构建。

View File

@@ -1,141 +0,0 @@
# 实现方案 — 2026-04-16-19-28-04
## 根因分析
当前 `ReportEditor.tsx` 的自动保存机制过度依赖两个 `useRef``stateRef``contentRef`)作为"数据快照"
1. **用户操作时**:各事件处理器先更新 React state再手动同步 `stateRef.current`,然后调用 `saveDraftToStorage()` 写入 localStorage。
2. **组件卸载时**`useEffect` 的 cleanup 调用 `save()`,同样读取 `stateRef.current``contentRef.current` 来保存 draft。
这个机制存在致命缺陷:
- **React 18 `StrictMode`** 在开发/预览环境下会"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 还是组件创建时的初始空值(`videos: []``capturedFrames: []``reportData: 默认值`。cleanup 中的 `save()` 会用这个空值 **覆盖** localStorage 里已经存在的正确 draft。
- 即使不在 StrictMode 下,只要任何恢复路径或用户操作路径遗漏了 `stateRef.current` 的同步,卸载保存时就会丢失数据。
此前两次修复只把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 `stateRef` 的依赖**,因此问题依旧。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 自动保存 effect + `useLayoutEffect` 依赖 |
## 具体代码变更
### 变更 1重构 `saveDraftToStorage`
**当前实现(依赖 ref**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: contentRef.current,
draftReportId: reportId || null,
...stateRef.current
});
}
}, [reportId]);
```
**修改为(直接从最新 state 和 DOM 读取):**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
draftReportId: reportId || null,
reportData,
videos,
capturedFrames,
activeTab,
loadedTemplateId
});
}
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]);
```
**效果**
- `saveDraftToStorage` 的闭包永远绑定当前渲染周期的最新 state不再需要 `stateRef` 作为中转。
- 编辑器内容直接从 `editorRef.current?.innerHTML` 读取,不再依赖可能滞后的 `contentRef`
- 用户操作中所有调用 `saveDraftToStorage()` 的地方(表单 onChange、上传视频、截图等会自动生效。
### 变更 2重构自动保存 effect
**当前实现:**
```tsx
useEffect(() => {
const save = () => { ... };
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', save);
return () => {
window.removeEventListener('beforeunload', save);
document.removeEventListener('visibilitychange', save);
save();
};
}, [reportId]);
```
**修改为:**
```tsx
useEffect(() => {
const handleBeforeUnload = () => saveDraftToStorage();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') saveDraftToStorage();
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
saveDraftToStorage();
};
}, [saveDraftToStorage]);
```
**效果**
- 由于 `saveDraftToStorage` 的 dependency 包含了所有关键 state每次 state 变化后 effect 都会重新注册,但最重要的是 **cleanup 中调用的 `saveDraftToStorage` 永远指向最新的闭包**,不会因为 ref 滞后而用空值覆盖 draft。
### 变更 3给 `useLayoutEffect` 安全网添加 `[]` 依赖
**当前:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
});
```
**修改为:**
```tsx
React.useLayoutEffect(() => {
if (contentLoadedRef.current || !editorRef.current) return;
// ... 恢复逻辑 ...
}, []);
```
**效果**
- 避免在每次渲染后重复执行安全网,防止潜在的意外覆盖或性能损耗。
- 保留其原有功能:仅在组件挂载时,如果 `useEffect` 初始化因 ref 未 ready 未能恢复内容,则作为兜底恢复编辑器 DOM。
### 变更 4可选但建议移除对 `contentRef` 的强依赖
`contentRef` 在旧代码中用于 draft 保存。修改 `saveDraftToStorage` 后直接读取 `editorRef.current?.innerHTML``contentRef` 仍可保留供其他旧代码使用,不影响功能。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| `saveDraftToStorage` dependency 数组较长,可能导致 effect 频繁重新注册 | 低 | 重新注册事件监听器的开销极小,远小于 localStorage 写入本身 |
| `editorRef.current?.innerHTML` 在卸载时读取可能拿到不完整 DOM | 极低 | `editorRef` 指向的 DOM 在 cleanup 执行时尚未被 React 移除,内容完整 |
| `useLayoutEffect` 添加 `[]` 后闭包值陈旧 | 低 | `useLayoutEffect` 内部仅读取 `reportId`(来自 URL在生命周期内不变和 localStorage不受影响 |
## 回滚策略
本次修改仅重构保存函数的实现方式,不改变数据结构或存储 key。如出现异常可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View 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>&nbsp;
`;
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` 的新增分支,保留原有草稿保存逻辑即可恢复。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View 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` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View 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')` 恢复默认字段配置。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View File

@@ -0,0 +1,162 @@
# 实现方案 — 2026-04-17-09-36-07
## 根因分析
1. **多余空格**`TemplateManage.tsx``insertSmartField` 函数在 HTML 字符串末尾追加了 `&nbsp;`,这是导致字段后跟随大量空白的主要原因。
2. **异常换行**`inline-block` 元素默认会在边界处根据容器宽度自动换行;`contenteditable="false"` 节点在行尾时,浏览器可能将其视为独立的渲染单元进行换行。
3. **Backspace 误删整行**:当光标位于 `contenteditable="false"` 的内联元素之后时Webkit/Blink 内核的默认行为无法正确删除该节点,而是向上寻找到父级 `<p>` 并将其删除。这是 `contentEditable` 的经典 Bug。
4. **默认模板未预置**`defaultContent.ts` 中的第一行仍使用红色纯文本占位符,没有使用 `smartField()` 函数生成智能控件。
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/TemplateManage.tsx` | 修改 | 优化 `insertSmartField` HTML移除 `&nbsp;`、压缩为一行);增加 `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>&nbsp;
`;
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();
};
```
**改动点**
- 移除末尾的 `&nbsp;`
- 将多行模板字符串压缩为一行,消除源码换行被渲染为空格的问题。
-`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 不带 `&nbsp;`(且是压缩的一行),`defaultContent.ts` 中的这段代码本身没有问题。
**但**:如果之前的 `smartField()` 定义末尾带有 `&nbsp;`,则需要一并修正。当前 `defaultContent.ts` 中的 `smartField` 定义已经在上一版中被修正为压缩的一行且不带 `&nbsp;`,所以默认模板本身已经符合要求。
**确认结果**`defaultContent.ts` 中的第一行在上一版(`2026-04-17-00-13-09`)中已经替换为 `smartField('patientName')` 等智能控件。**本次只需确保 `smartField` 辅助函数的定义与变更 1 保持一致(移除 `&nbsp;`、压缩为一行、增加 `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` 增加此属性可作为双重保险。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 移除 `&nbsp;` 后,字段与前/后文本之间没有间隔,显得拥挤 | 低 | `margin: 0 2px` 已经提供了 2px 的左右间距,视觉上足够紧凑 |
| `keydown` 拦截可能影响编辑器其他正常删除操作 | 低 | 拦截逻辑严格限定在光标前一个节点为 `.smart-field-wrapper` 时才生效,其他情况正常放行 |
| 老模板中已插入的字段仍带有 `&nbsp;` | 低 | 老模板中的字段只是带有一个额外的空格,不影响功能;用户可手动删除重插 |
## 回滚策略
本次修改范围极小,仅调整 `insertSmartField` 的 HTML 输出和增加一个 `keydown` 事件监听。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**

View 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>` 最左侧增加 Checkboxchecked 状态为 `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` 的修改。

View 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 个文件的修改。

View 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 个文件的修改。

View 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 字符串末尾增加 `&#8203;`(零宽空格),作为行内锚点,防止浏览器将字段挤到新行:
```html
<span class="smart-field-wrapper" ...>...</span>&#8203;
```
### 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 支持良好。
- **风险**`&#8203;` 零宽空格在极少数场景下可能导致光标异常,但其为无形字符,影响极小。
- **回滚**:如出现问题,可回退上述 5 个文件的修改。

View 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` 的修改。

View 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>&#8203;`;
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` 可正确定位光标。
- 末尾的 `&#8203;`(零宽空格)作为 `TextNode` 被一同插入,依然起到防止字段被意外吞并的作用。
---
## 回滚策略
- 修改前 `git` 仓库已处于干净状态(最新提交 `b822bb1`)。
- 若验证失败,可直接 `git checkout -- src/pages/TemplateManage.tsx` 回滚到上一版本,重新分析。
---
## 无其他依赖变更
- 不新增 npm 依赖。
- 不修改 `defaultContent.ts``index.css`

View 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);
```
弹窗 JSXModal包含三个 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 能力完成。

View 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>&#8203;`;
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>&#8203;`;
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 依赖

View 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 改动为增量添加,回滚时移除条件渲染块即可。

View 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` 的修改均为增量逻辑,回滚时移除条件块即可。

View File

@@ -0,0 +1,196 @@
# 实现方案 — 2026-04-17-23-38-34
## 根因分析
### 问题1原生 datalist 交互体验差
- 原生 `<input list>` + `<datalist>` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。
- 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。
### 问题2execCommand('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>&#8203;`;
}
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` 样式。

View 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;`,图片可能溢出占位符。
### 问题2prompt 弹窗体验差 + 自动帧插入无区分
- `insertImage` 使用浏览器原生 `prompt` 询问宽高,交互体验不佳。
- 所有 `.image-placeholder` 一视同仁,`autoCaptureFrames` 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
- 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
### 问题3insertTable 使用 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>&#8203;`;
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` 中的选择器和拦截逻辑。

View 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>&#8203;`;
}
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()` 行为和占位符逻辑。

View 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`,回滚时直接还原该文件即可。

View File

@@ -0,0 +1,83 @@
# 实现方案 —— 2026-04-18-16-45-02
## 方案目标
建立一套标准化、可复用的代码编纂工作流,确保后续所有项目修改需求都能按统一流程执行,减少遗漏和错误。
## 方案内容
### 阶段一:工程分析文件夹确认(步骤 1
1. 检查 `.\工程分析` 文件夹是否存在。
2. 若不存在则创建;若存在则确认其包含以下文件类型:
- `需求分析-*.md`
- `实现方案-*.md`
- `测试方案-*.md`
- `经验记录.md`
### 阶段二:需求分析文档生成(步骤 2
每次用户提出修改需求时:
1. 记录开始时间 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
2. 创建 `.\工程分析\需求分析-{时间}.md`
3. 文档内容包含:
- 需求来源
- 需求概述(一句话描述)
- 功能详细描述
- 涉及文件/模块清单
- 需求影响范围
### 阶段三:实现方案文档生成与用户审核(步骤 3
1. 基于需求分析,编写 `.\工程分析\实现方案-{时间}.md`
2. 文档内容包含:
- 方案目标
- 具体实现步骤(分阶段)
- 涉及文件及修改点
- 风险与注意事项
3. **此处为强制审核节点**:文档生成后停止执行,等待用户确认"方案无误,继续执行"。
4. 用户确认后方可进入下一阶段。
### 阶段四:测试方案文档生成与用户审核(步骤 4
1. 基于实现方案,编写 `.\工程分析\测试方案-{时间}.md`
2. 文档内容包含:
- 测试目标
- 测试用例清单(编号、操作步骤、预期结果)
- 回归测试范围
3. **此处为强制审核节点**:文档生成后停止执行,等待用户确认"方案无误,继续执行"。
4. 用户确认后方可进入下一阶段。
### 阶段五:经验记录阅读与执行(步骤 5
1. **执行前**:读取 `.\工程分析\经验记录.md`,提取与本次修改相关的经验条目。
2. **执行中**:按照已审核的实现方案修改代码。
3. **执行后**:若过程中遇到新的关键问题,按四段式追加到 `.\工程分析\经验记录.md`
- A. 具体问题
- B. 产生问题原因
- C. 解决问题方案
- D. 后续如何避免问题
### 阶段六Gitea 备份(步骤 6
1. 执行以下 Git 操作:
```bash
git add .
git commit -m "{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - {简要描述}"
git push origin main
git tag -a v{版本号} -m "{版本描述}"
git push origin v{版本号}
```
2. 向用户汇报备份完成。
### 阶段七:重新部署(步骤 7
1. 执行 `npm run build` 构建生产版本。
2. 验证构建产物 `dist/` 已生成。
3. 启动预览服务 `npm run preview`(或用户指定的部署方式)。
## 工作流强制审核点
| 审核点 | 触发条件 | 用户操作 |
|--------|----------|----------|
| 实现方案审核 | 实现方案文档生成完毕 | 用户阅读并回复"确认" |
| 测试方案审核 | 测试方案文档生成完毕 | 用户阅读并回复"确认" |
## 本次(建立工作流)的执行差异
由于本次需求不涉及业务代码修改,阶段五(代码执行)、阶段六(备份)、阶段七(部署)均跳过或简化处理。重点在于将工作流规范文档化并确认用户理解。
## 风险与注意事项
1. 用户必须理解两个强制审核节点(实现方案、测试方案),不可跳过。
2. 若用户在审核阶段提出修改意见,需重新生成对应文档并再次等待确认。
3. 经验记录文档需持续维护,成为项目知识库的核心资产。

View File

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

View File

@@ -0,0 +1,82 @@
# 实现方案 —— 2026-04-18-17-27-51
## 方案目标
修复 TemplateManage 静态占位符插入 Bug重构默认报告模板顶部排版修复 Logo 删除按钮交互。
## 需求 1修复静态图片占位符插入不显示
### 问题根因
`TemplateManage.tsx``insertImage()` 使用 `document.execCommand('insertHTML', false, html)`。现代浏览器对含 `contenteditable="false"` 的复杂嵌套标签会自动修正/拍平,导致外层 `.image-placeholder` 容器丢失DOM 仅剩零散子元素,视觉上不可见。
### 解决步骤
1. **定位 `insertImage` 函数**:找到 `TemplateManage.tsx` 中通过 `document.execCommand('insertHTML')` 插入占位符的逻辑。
2. **替换为 `Range.insertNode`**
- 创建临时 `div`,将 HTML 字符串写入 `innerHTML`
- 将子节点逐个移入 `DocumentFragment`
- 获取当前 `Selection``RangeAt(0)`
- 调用 `range.deleteContents()` 清空当前选区。
- 调用 `range.insertNode(fragment)` 精确插入。
- 将光标移动到插入内容之后。
3. **保持原有弹窗逻辑不变**Modal 中的模式选择frame/manual、宽高输入等逻辑不受影响。
## 需求 2重构默认报告模板排版
### 排版设计
#### 页眉Logo + 医院名 + 标题)
使用 3 列 `<table>`(左 20%、中 60%、右 20%),中间列绝对居中:
- 左列Logo 占位符65×65`data-mode="manual"``position:relative`
- 中列:
- 第一行14pt SimSun「西 安 交 通 大 学 第 一 附 属 医 院」(带 `border-bottom: 1px solid #000` 下划线,使用 `display: inline-block`
- 第二行16pt SimSun「手术记录」
- 右列:留空
#### 基本信息栏(下划线贯穿)
使用 `<div style="border-bottom: 1px solid #000; padding-bottom: 4px; margin-bottom: 12px;">` 包裹一行:
- 11pt SimSun不加粗
- 姓名、性别、年龄、科别、床号、住院号,用 `&nbsp;` 间隔
#### 诊断/手术信息(单行加粗)
每项独立 `<p>`
- 12pt SimSun`font-weight: bold`
- 手术日期、术前诊断、术中诊断、手术名称
#### 双列信息(两项一行,不加粗)
使用 `<table style="width: 100%; border: none;">`
- 三行两列,每列 50%
- 12pt SimSun不加粗
- 手术开始/终止时间、手术者/助手、麻醉师/麻醉方式
#### 手术步骤标题
- 12pt SimSun`font-weight: bold`
- 「手术步骤、术中出现的情况及处理:」
#### 保留内容
- 5 条手术步骤段落文字(不变)
- 手术图片说明表格(需求 3 中已替换的最新 6 图格表格)
- 手术后情况段落(术后诊断、标本描述、病理检查、冰冻病理)
- 手术者签名占位符 + 撰写时间字段
### 涉及文件
`src/utils/defaultContent.ts` —— 完全重写 `defaultReportContent` 变量。
## 需求 3修复顶部 Logo 删除按钮
### 解决步骤
`defaultContent.ts` 中 Logo 占位符的 `style` 属性中增加 `position: relative;`,使绝对定位的 `.delete-btn` 相对于占位符自身定位,而非向外层逃逸。
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="position:relative;display:inline-flex;...">
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/TemplateManage.tsx` | `insertImage``execCommand('insertHTML')``Range.insertNode` |
| `src/utils/defaultContent.ts` | 完全重写顶部排版Logo 增加 `position:relative`;保留手术步骤/表格/底部段落 |
## 风险与注意事项
1. `Range.insertNode` 要求编辑器有有效光标/选区。若编辑器未聚焦或选区不在编辑器内需增加保护逻辑fallback 到 `editorRef.current.appendChild`)。
2. 默认模板重写后,需验证 `smartField()` 生成的所有字段占位符在新排版中是否正确渲染。
3. 打印时需确认新排版的下划线、表格边框在 `@media print` 中正常显示。
4. `&nbsp;` 分隔的基本信息栏在打印时可能换行,需测试实际打印效果。

View File

@@ -0,0 +1,100 @@
# 实现方案 —— 2026-04-18-17-48-59
## 方案目标
修复默认模板排版细节和打印样式问题,提升报告的视觉一致性和打印输出质量。
## 需求 1缩减基本信息栏字段间空格
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将基本信息栏 `<p>` 中字段之间的 `&nbsp;&nbsp;&nbsp;` 替换为单个 `&nbsp;`
**修改前**
```html
姓名:${smartField('patientName')}&nbsp;&nbsp;&nbsp;
性别:${smartField('patientGender')}&nbsp;&nbsp;&nbsp;
年龄:${smartField('patientAge')}&nbsp;&nbsp;&nbsp;
科别:${smartField('department')}&nbsp;&nbsp;&nbsp;
床号:${smartField('bedNumber')}&nbsp;&nbsp;&nbsp;
住院号:${smartField('hospitalId')}
```
**修改后**
```html
姓名:${smartField('patientName')}&nbsp;
性别:${smartField('patientGender')}&nbsp;
年龄:${smartField('patientAge')}&nbsp;
科别:${smartField('department')}&nbsp;
床号:${smartField('bedNumber')}&nbsp;
住院号:${smartField('hospitalId')}
```
## 需求 2Logo 与医院名/标题靠拢并整体居中
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将顶部 3 列 `<table>` 替换为 `<div style="display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 16px;">`
- Logo 占位符放在 flex 子 div 中
- 医院名和标题放在另一个 flex 子 div 中(`text-align: center`
- 整体通过 `justify-content: center` 实现居中
- `gap: 12px``margin-right: 12px` 控制 Logo 与文字间距
## 需求 3打印时隐藏所有「×」删除按钮
### 修改文件
`src/utils/print.ts`
### 修改内容
`print.ts` 生成的 `<style>` 标签中,将 `.image-placeholder .delete-btn { display: none !important; }` 扩展为全局规则:
```css
.delete-btn { display: none !important; }
```
这样无论删除按钮位于 `.image-placeholder` 内还是 `.smart-field-wrapper` 内,打印时均不可见。
## 需求 4统一全文行距为 1.5,消除段前段后间距
### 修改文件
`src/utils/defaultContent.ts``src/utils/print.ts`
### 修改内容
1. **`defaultContent.ts`**:将所有 `<p>` 标签的内联样式统一为 `line-height: 1.5; margin: 0; padding: 0;`。移除原有的 `line-height: 1.8`、默认 margin 等不一致设置。
2. **`print.ts`**:将全局 `p` 样式从 `margin: 0; padding: 4px 0; line-height: 1.6;` 修改为 `margin: 0; padding: 0; line-height: 1.5;`
## 需求 5下划线紧贴文字底部
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
1. **医院名称下划线**:将包裹医院名的 `div``padding-bottom: 4px` 移除或改为 `0`。同时在该 `div` 上增加 `line-height: 1`,消除中文字体自带的底部留白,使 `border-bottom` 紧贴文字。
2. **基本信息栏下划线**:将外层 `<div style="border-bottom: 1px solid #000; ...">``padding-bottom: 4px` 移除。内部 `<p>``line-height` 已统一为 1.5(需求 4若仍有间距问题可进一步在该 `<p>` 上设置 `line-height: 1.2` 或让下划线直接由 `<p>``border-bottom` 实现。
### 优化策略
更简洁的做法:让下划线直接由承载文字的 `<p>` 元素生成,而非由外层 `<div>` 生成。例如:
```html
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0 0 2px 0; line-height: 1.2; border-bottom: 1px solid #000;">
姓名:... 性别:...
</p>
```
这样文字底部与下划线之间仅由 `padding-bottom: 2px``line-height` 控制,可精确调整。
对于医院名称,同理:
```html
<div style="font-size: 14pt; font-family: SimSun; line-height: 1; border-bottom: 1px solid #000; padding-bottom: 0; margin-bottom: 8px; display: inline-block;">
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/utils/defaultContent.ts` | 缩减空格;改 Flex 抬头;统一 line-height/margin/padding调整下划线贴底 |
| `src/utils/print.ts` | 全局隐藏 `.delete-btn`;统一 p 标签 line-height/margin/padding |
## 风险与注意事项
1. `print.ts` 的全局 `.delete-btn { display: none !important; }` 会覆盖所有删除按钮,包括未来可能新增的其他类型。这是预期行为(打印时不应显示任何交互按钮)。
2. `line-height: 1` 在部分中文字体下可能导致字符上下紧贴甚至重叠,需在实际打印中验证。若出现问题,可微调为 `line-height: 1.1`
3. 修改 `defaultContent.ts` 后,新建报告会加载新模板,但已有报告(保存在 localStorage 中)不会自动更新。这是预期行为。

View File

@@ -0,0 +1,91 @@
# 实现方案 —— 2026-04-18-18-08-37
## 方案目标
修复并增强编辑器工具栏的字体/字号/行距功能,调整默认模板排版细节。
## 需求 1修复字体选择并新增字号、行距功能
### 修改文件
`src/pages/ReportEditor.tsx``src/pages/TemplateManage.tsx`
### 实现步骤
1. **修复字体选择**:确保工具栏中的字体选择 `<select>` 使用 `execCmd('fontName', value)`。若失效,检查是否有全局 CSS `font-family: !important` 覆盖。如有,在打印样式中保留覆盖,但在编辑器样式中移除。
2. **新增字号选择**:在工具栏字体选择旁边增加 `<select>`
```tsx
<select onChange={e => { if (e.target.value) { execCmd('fontSize', e.target.value); } e.target.value = ''; }}>
<option value="">字号</option>
<option value="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
</select>
```
`execCommand('fontSize')` 使用 1-7 的相对字号3 对应 12pt4 对应 14pt5 对应 18pt。
3. **新增行距选择**`execCommand` 不支持行距,需手写 `changeLineHeight` 函数:
```tsx
const changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3');
if (block) {
(block as HTMLElement).style.lineHeight = height;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
```
在工具栏增加行距 `<select>` 绑定此函数。
## 需求 2修复手术者签名右对齐时图片框换行
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将「手术者签名」所在 `<p>` 增加 `white-space: nowrap;`,并将图片占位符的 `display` 改为 `inline-block`
```html
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0; white-space: nowrap;">
手术者签名:<span class="image-placeholder" ... style="display:inline-block; vertical-align:middle; ...">...</span>
</p>
```
## 需求 3缩减「手术记录」与「姓名」之间的距离
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将顶部 Flex 容器的 `margin-bottom: 16px` 缩小为 `margin-bottom: 4px`。
## 需求 4消除「手术名称」与「手术开始时间」之间的多余间距
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将双列信息 `<table>` 的 `margin-bottom: 12pt` 改为 `margin-bottom: 0; margin-top: 0;`。同时确保「手术名称」`<p>` 的 `margin: 0; padding: 0;`。
## 需求 5统一「手术日期」及以下内容为 12pt、1.5 行距、无段间距
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
为所有手术步骤段落1~5以及手术后情况段落补充 `font-size: 12pt;`
```html
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/ReportEditor.tsx` | 工具栏新增字号选择、行距选择;修复字体选择 |
| `src/pages/TemplateManage.tsx` | 工具栏新增字号选择、行距选择;修复字体选择 |
| `src/utils/defaultContent.ts` | 签名行 `white-space: nowrap`; 顶部 `margin-bottom: 4px`; 表格 `margin: 0`; 补全 `font-size: 12pt` |
## 风险与注意事项
1. `execCommand('fontSize')` 生成的是 `<font size="N">` 标签,与现代 HTML5 规范不完全兼容,但在 `contentEditable` 中是浏览器广泛支持的方式。
2. `changeLineHeight` 直接操作 DOM style在 `ReportEditor` 中修改后需同步 `contentRef.current` 和调用 `saveDraftToStorage()`。
3. `TemplateManage` 中修改行距后需调用 `saveTemplateContent()`。
4. `white-space: nowrap` 在签名行可能导致超长内容溢出,但考虑到签名行通常较短,风险可控。

View File

@@ -0,0 +1,91 @@
# 实现方案 —— 2026-04-18-18-36-43
## 方案目标
实现五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
## 需求 1ReportManage 列名修正
### 修改文件
`src/pages/ReportManage.tsx`
### 修改内容
找到 `<thead>` 中「患者号」`<th>`,将文本改为「住院号」。同步检查表格数据渲染中是否有对应的 patientId/hospitalId 显示逻辑需调整。
## 需求 2字段管理增加下划线控制
### 修改文件
- `src/types.ts`
- `src/pages/TemplateManage.tsx`
- `src/utils/print.ts`
### 实现步骤
1. **扩展 FormField 接口**:增加 `hasUnderline?: boolean`(默认 `true`)。
2. **修改 DEFAULT_FORM_FIELDS**:为所有默认字段设置 `hasUnderline: true`
3. **TemplateManage 字段管理 UI**
- 新增字段表单中增加「打印时显示下划线」checkbox默认勾选。
- 编辑字段面板中同样增加该 checkbox。
- 保存字段配置时将 `hasUnderline` 写入 `formFieldsConfig`
4. **insertSmartField 注入类名**
- 在生成 `smart-field-wrapper` HTML 时,若 `field.hasUnderline === false`,给 `.field-value` 增加 `no-underline` 类。
5. **print.ts 打印样式**
-`@media print` 中增加 `.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }`
## 需求 3ReportEditor / TemplateManage 新增下载按钮
### 修改文件
- `src/pages/ReportEditor.tsx`
- `src/pages/TemplateManage.tsx`
- `src/utils/print.ts`
### 实现步骤
1. **print.ts 支持自定义标题**
- `printDocument(htmlContent: string, docTitle?: string)` 增加可选 `docTitle` 参数。
- 在 iframe HTML 的 `<head>` 中注入 `<title>${docTitle || '图文报告'}</title>`,使浏览器保存 PDF 时使用该文件名。
2. **ReportEditor 下载功能**
- 引入 `Download` 图标。
- 在顶部操作栏打印按钮旁增加下载按钮。
- 新增 `exportModalOpen` 状态控制导出弹窗。
- 实现 `getExportFilename()`:基于 `reportData.title``patientName``hospitalId` 和当前时间生成文件名。
- 实现 `downloadJSON()`:将 `reportData` 序列化为 JSON Blob 并触发下载。
- 导出 PDF 时调用 `printDocument(editorRef.current.innerHTML, getExportFilename())`
3. **TemplateManage 下载功能**
- 类似实现。模板管理页面没有 reportData文件名中患者信息使用"模板"或空值替代。
- PDF 导出调用 `printDocument(editorRef.current.innerHTML, filename)`
- JSON 导出下载模板内容。
## 需求 4修复右对齐时签名与图片框分离
### 修改文件
- `src/pages/TemplateManage.tsx`(占位符插入逻辑)
- `src/pages/ReportEditor.tsx`(占位符插入逻辑,如有)
- `src/utils/defaultContent.ts`(默认模板签名占位符)
### 实现步骤
`display: inline-flex` 改为 `display: inline-block`,并通过 `line-height` 实现垂直居中:
- **运行时插入**`styleStr``display:inline-flex;align-items:center;justify-content:center;` 改为 `display:inline-block;text-align:center;position:relative;line-height:${h}px;`
- **占位文本**`.placeholder-text` 增加 `display:inline-block;vertical-align:middle;line-height:normal;`
- **默认模板**:手术者签名占位符同步应用上述样式。
## 需求 5默认模板手术者签名右对齐
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将「手术者签名」`<p>` 增加 `text-align: right;`,并应用需求 4 的 `inline-block` 样式修复。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/ReportManage.tsx` | 「患者号」→「住院号」 |
| `src/types.ts` | `FormField` 增加 `hasUnderline?: boolean` |
| `src/pages/TemplateManage.tsx` | 字段管理 UI 增加下划线 checkboxinsertSmartField 注入 no-underline 类;工具栏增加下载按钮和弹窗 |
| `src/pages/ReportEditor.tsx` | 工具栏增加下载按钮和弹窗;占位符插入样式改为 inline-block |
| `src/utils/print.ts` | 增加 `docTitle` 参数;打印样式支持 `.no-underline` |
| `src/utils/defaultContent.ts` | 签名占位符改为 inline-block签名行设为 `text-align: right` |
## 风险与注意事项
1. `FormField` 接口扩展后,需确保 `DEFAULT_FORM_FIELDS` 和所有已有字段配置localStorage 中的 `formFieldsConfig`)兼容。对于旧数据缺少 `hasUnderline` 的情况,按 `true` 处理。
2. `printDocument` 增加 `docTitle` 参数后,需检查所有调用方是否已更新。现有调用方(如 ReportView可保持默认行为。
3. `inline-block` 替换 `inline-flex` 后,需验证占位符在非右对齐场景(如正常左对齐)下的垂直居中效果是否正常。
4. 下载 JSON 时TemplateManage 的 JSON 内容与 ReportEditor 不同(模板 vs 报告),需分别处理。

View File

@@ -0,0 +1,97 @@
# 实现方案 —— 2026-04-18-19-08-43
## 方案目标
优化编辑器交互体验和模板排版细节,提升视频面板空间利用率和图片占位符自适应能力。
## 需求 1基础信息字段默认无下划线
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
修改 `smartField()` 函数,对 6 个基础字段自动附加 `.no-underline` 类:
```ts
const noUnderlineKeys = ['patientName', 'patientGender', 'patientAge', 'department', 'bedNumber', 'hospitalId'];
const noUlClass = noUnderlineKeys.includes(key) ? ' no-underline' : '';
```
在生成的 HTML 中,`.field-value` 的 class 变为 `field-value${noUlClass}`
## 需求 2字段联动高亮并居中滚动
### 修改文件
`src/pages/ReportEditor.tsx`
### 修改内容
1. **新增状态**`const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);`
2. **修改点击处理**:在 `handleEditorClick``.field-value` 点击分支中,增加 `setActiveFieldKey(bindKey)`
3. **修改滚动逻辑**:将 `scrollIntoView``block``'center'` 改为更精确的控制(`block: 'center'` 本身就是居中,满足 1/3~2/3 需求)。
4. **高亮样式**:在右侧表单渲染中,为每个字段容器 `div` 增加动态类名:
```tsx
className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}
```
## 需求 3视频上传按钮整合进缩略图列表
### 修改文件
`src/pages/ReportEditor.tsx`
### 修改内容
1. 删除原本独立的「上传视频」大按钮区域。
2. 在 `videos.map()` 所在的滚动容器 `<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">` 的第一个位置,插入缩小版的上传按钮:
```tsx
<button
onClick={() => videoInputRef.current?.click()}
className="shrink-0 w-24 h-[68px] flex flex-col items-center justify-center gap-1 border-2 border-dashed border-border rounded-xl hover:border-accent hover:bg-slate-50 transition-all text-text-muted hover:text-accent"
>
<Video size={18} />
<span className="text-[10px] font-bold">上传视频</span>
</button>
```
## 需求 4视频模块间距紧凑化
### 修改文件
`src/pages/ReportEditor.tsx`
### 修改内容
1. 最外层容器从 `space-y-4` 改为 `space-y-2`。
2. 视频播放器与控制按钮之间从 `space-y-4` 改为 `space-y-2`。
3. 控制按钮区域(播放/暂停/进度条等)的 `gap` 或 `margin` 适当缩减。
4. 「关键帧摘取」标题区域的 `padding-top` 缩减,可增加 `border-t` 作为视觉分隔。
## 需求 5签名与日期之间增加空行
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
在「手术者签名」`<p>` 和「撰写时间」`<p>` 之间插入:
```html
<p style="margin: 0; padding: 0; line-height: 1.5;">&nbsp;</p>
```
## 需求 6图片占位符填充后高度自适应
### 修改文件
`src/pages/ReportEditor.tsx` 和 `src/pages/TemplateManage.tsx`
### 修改内容
在所有填充图片的逻辑中(`fillPlaceholderSrc`、`handleDrop`、`autoCaptureFrames` 等),在 `placeholder.classList.add('has-image')` 之后,增加:
```ts
placeholder.style.height = 'auto';
placeholder.style.width = 'auto';
placeholder.style.lineHeight = 'normal';
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/utils/defaultContent.ts` | `smartField()` 注入 `.no-underline`;签名与日期之间插入空行 |
| `src/pages/ReportEditor.tsx` | `activeFieldKey` 状态 + 高亮样式;视频上传按钮整合;视频面板间距缩减;占位符自适应样式 |
| `src/pages/TemplateManage.tsx` | 占位符自适应样式 |
## 风险与注意事项
1. `smartField()` 中硬编码的 6 个 key 需与 `DEFAULT_FORM_FIELDS` 严格一致。
2. `activeFieldKey` 高亮样式使用 `transition-all duration-300`,需确保不会与现有样式冲突。
3. 视频上传按钮移入缩略图列表后,需确保 `videoInputRef` 的点击触发逻辑不受影响。
4. 占位符 `height: auto` 后需验证图片在表格内table cell和正文中的显示是否正常。

View File

@@ -0,0 +1,82 @@
# 实现方案 —— 2026-04-18-19-23-31
## 方案目标
修复视频分析模块空白问题,重构图片占位符的填充后尺寸逻辑。
## 需求 1修复视频分析模块空白
### 修改文件
`src/pages/ReportEditor.tsx`
### 修改内容
将「上传视频」按钮和视频缩略图列表从 `videos.length > 0` 条件内部移出,使其始终渲染。仅保留视频播放器和关键帧网格在 `currentVideoIndex !== -1 && videos.length > 0` 条件下渲染。
修改后结构:
```tsx
{activeTab === 'video' && (
<div className="space-y-2">
<input ref={videoInputRef} ... />
{/* 始终可见:上传按钮 + 视频缩略图列表 */}
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
<button></button>
{videos.map(...)}
</div>
{/* 条件渲染:视频播放器和关键帧 */}
{currentVideoIndex !== -1 && videos.length > 0 && (
<div className="space-y-2">...</div>
)}
</div>
)}
```
## 需求 2图片占位符尺寸自适应
### 核心逻辑
1. **插入占位符时**:在 `style` 中注入 `max-width``max-height`,与 `width`/`height` 相同,便于后续读取限制值。
2. **填充图片时**
- 读取占位符当前的 `max-width` / `max-height`(或回退到 `width` / `height`
- 将这两个值赋给内部 `<img>``max-width` / `max-height`
- 设置 `object-fit: contain; object-position: left top`
- 将占位符外壳的 `width``height``line-height` 设为 `auto` / `normal`
- 保留 `max-width``max-height` 作为硬限制
- 设置 `text-align: left; vertical-align: top`
### 修改文件及位置
| 文件 | 函数/位置 | 修改内容 |
|------|-----------|----------|
| `src/pages/ReportEditor.tsx` | `fillPlaceholderSrc` | 填充后读取限制值,设置 img 和外壳样式 |
| `src/pages/ReportEditor.tsx` | `fillPlaceholder` | 同上 |
| `src/pages/ReportEditor.tsx` | `autoCaptureFrames` | 同上 |
| `src/pages/ReportEditor.tsx` | placeholderModal 确认插入 | style 中增加 `max-width` / `max-height` |
| `src/pages/TemplateManage.tsx` | `fillPlaceholderSrc` | 同上 |
| `src/pages/TemplateManage.tsx` | placeholderModal 确认插入 | style 中增加 `max-width` / `max-height` |
### 样式值示例
```ts
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:100%;max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.lineHeight = 'normal';
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
```
## 需求 3Logo 框大小保持 65px × 65px
默认模板中 Logo 占位符的 `width:65px;height:65px` 保持不变。此需求通过不修改 Logo 占位符相关代码即可满足。
## 风险与注意事项
1. 视频按钮移出条件渲染后,需确保 `videoInputRef` 的引用始终有效。
2. 占位符 `width:auto` 后,在表格单元格(`td`)内的表现需要验证,确保不会超出单元格。
3. `object-position: left top` 仅在 `object-fit: contain` 时生效。
4. 需确保 `max-width` / `max-height` 在打印样式中不会被 `@media print` 规则覆盖。

View File

@@ -0,0 +1,101 @@
# 实现方案 —— 2026-04-18-19-37-56
## 方案目标
修复编辑器中的 4 个体验问题,提升视频面板、图片占位符和对齐功能的稳定性。
## 需求 1视频上传按钮位置调整
### 修改文件
`src/pages/ReportEditor.tsx`
### 修改内容
在「视频分析」面板的缩略图滚动容器中,将 `<button>上传视频</button>``videos.map()` 之前移至之后。保持按钮样式和点击逻辑不变。
## 需求 2图片占位符提示文字绝对居中
### 修改文件
`src/pages/ReportEditor.tsx``src/pages/TemplateManage.tsx``src/utils/defaultContent.ts`
### 修改内容
`.placeholder-text` 的样式改为绝对定位居中:
```css
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
width: 100%;
```
需要确保 `.image-placeholder` 父容器带有 `position: relative;`(默认模板和运行时插入逻辑中已具备)。
修改位置:
1. `defaultContent.ts` 中 8 个占位符的 `.placeholder-text` style
2. `ReportEditor.tsx``placeholderModal` 确认插入时的 `.placeholder-text` style
3. `TemplateManage.tsx``placeholderModal` 确认插入时的 `.placeholder-text` style
4. `ReportEditor.tsx``TemplateManage.tsx``handleEditorClick` 删除图片后重建 `.placeholder-text` 的 innerHTML
## 需求 3删除图片后占位符恢复原始大小
### 修改文件
`src/pages/ReportEditor.tsx``src/pages/TemplateManage.tsx`
### 修改内容
`handleEditorClick` 中处理 `.delete-btn` 点击、恢复占位符为空的逻辑中,增加尺寸恢复:
```ts
const mw = placeholder.style.maxWidth;
const mh = placeholder.style.maxHeight;
if (mw) placeholder.style.width = mw;
if (mh) {
placeholder.style.height = mh;
placeholder.style.lineHeight = mh;
}
placeholder.style.textAlign = 'center';
```
同时需要恢复其他被修改的样式:
- `border: 1px dashed #cbd5e1`
- `background: #f8fafc`
- `vertical-align: middle`inline-block 占位符)
- `justify-content: center; align-items: center`flex 占位符)
由于无法直接区分 flex 和 inline-block可以通过检查 `placeholder.style.display` 或简单地将 `justifyContent``alignItems` 重置为 `center`(对 inline-block 无影响)。
## 需求 4对齐按钮改用安全的 DOM 操作
### 修改文件
`src/pages/ReportEditor.tsx``src/pages/TemplateManage.tsx`
### 修改内容
1. **新增 `changeAlignment` 方法**
```ts
const changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, h4, h5, h6, li');
if (block) {
(block as HTMLElement).style.textAlign = align;
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage(); // ReportEditor
// saveTemplateContent(); // TemplateManage
}
}
};
```
2. **替换工具栏按钮**:将三个对齐按钮的 `onClick={() => execCmd('justifyLeft')}` 等替换为 `onClick={() => changeAlignment('left')}` 等。保留 `onMouseDown={(e) => e.preventDefault()}` 以防止编辑器失焦。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/ReportEditor.tsx` | 视频按钮位置placeholder-text 样式3 处插入、删除恢复、Modal删除恢复时尺寸复原新增 changeAlignment替换对齐按钮 |
| `src/pages/TemplateManage.tsx` | placeholder-text 样式3 处);删除恢复时尺寸复原;新增 changeAlignment替换对齐按钮 |
| `src/utils/defaultContent.ts` | 8 个占位符的 placeholder-text 样式更新为绝对居中 |
## 风险与注意事项
1. `changeAlignment` 中 `closest('p, div, ...')` 如果选中了编辑器根容器(`contenteditable` div可能会对齐整个文档。但由于工具栏按钮要求编辑器已聚焦通常选区在正文内部风险较低。
2. 占位符删除恢复时,`maxWidth`/`maxHeight` 的回退逻辑需确保在所有场景下(默认模板、运行时插入)都能正确读取。
3. 绝对居中的 `position:absolute` 需要父容器 `position:relative`,需验证所有占位符均满足。

View File

@@ -0,0 +1,130 @@
# 实现方案 —— 2026-04-18-20-03-44
## 方案目标
实现模板的导入/导出迁移能力,统一默认模板 Logo 的交互行为。
## 需求 1模板导出功能
### 修改文件
`src/pages/TemplateManage.tsx`
### 修改内容
在模板列表的每个模板行操作列中增加「导出」按钮(使用 Download 图标)。点击时:
```ts
const handleExportTemplate = (template: Template) => {
const exportData = {
version: '1.0',
type: 'surclaw_template_package',
title: template.title,
description: template.description,
content: template.content,
fields: template.fields || []
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `模板导出-${template.title}.json`;
a.click();
URL.revokeObjectURL(url);
};
```
## 需求 2模板导入功能
### 修改文件
`src/pages/TemplateManage.tsx`
### 修改内容
1. **新增状态**
```ts
const [importedContent, setImportedContent] = useState<{content: string, fields: FormField[]} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
```
2. **新增导入处理函数**
```ts
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
if (json.type !== 'surclaw_template_package') {
alert('无效的模板包文件');
return;
}
setNewTemplateTitle(json.title || '');
setNewTemplateDescription(json.description || '');
setImportedContent({
content: json.content || '',
fields: Array.isArray(json.fields) ? json.fields : []
});
} catch {
alert('文件解析失败,请检查 JSON 格式');
}
};
reader.readAsText(file);
};
```
3. **修改创建逻辑**:在 `handleCreateTemplate` 中,如果有 `importedContent`,优先使用导入的内容和字段:
```ts
const newTemplate: Template = {
id: 'tmpl_' + Date.now(),
title: newTemplateTitle,
description: newTemplateDescription,
content: importedContent?.content || `<div style="font-size:12pt;line-height:1.5;"><p>请输入模板内容...</p></div>`,
fields: importedContent?.fields || [],
createdAt: new Date().toISOString()
};
```
4. **UI 调整**:在新增模板 Modal 中标题下方加入导入区域:
```tsx
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
<button
onClick={() => fileInputRef.current?.click()}
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
>
<Upload size={16} />
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
</div>
```
5. **关闭 Modal 时重置**`setImportedContent(null)`
## 需求 3Logo 替换为可交互占位符
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
将默认模板顶部的 Logo HTML 替换为标准 `image-placeholder`
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
</span>
```
关键点:
- `class="image-placeholder"`:触发编辑器中的占位符交互逻辑
- `data-mode="manual"`:标记为静态图片占位,不支持自动帧插入
- `position:relative` + `position:absolute` 居中:确保提示文字绝对居中
- `delete-btn`:支持点击右上方的「×」删除
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/TemplateManage.tsx` | 新增 `handleExportTemplate`;新增 `importedContent` 状态和 `handleImportFile`;修改 `handleCreateTemplate` 使用导入数据;新增模板 Modal 中增加导入 UI模板列表操作列增加导出按钮 |
| `src/utils/defaultContent.ts` | 顶部 Logo 替换为标准 `image-placeholder` |
## 风险与注意事项
1. 导入的 JSON 中 `fields` 数组需要与 `FormField` 类型结构兼容。由于 JSON 导入的是纯数据,直接赋值给 `template.fields` 即可TypeScript 编译时类型校验通过)。
2. 导出文件名中包含模板标题,需注意标题中的特殊字符可能影响文件名(但浏览器通常会自动处理)。
3. Logo 占位符替换后,原有「西安交通大学第一附属医院」的样式应保持不变,仅替换 Logo 部分。
4. 新增模板弹窗关闭时,需同步重置 `importedContent` 为 `null`,避免影响下一次创建。

View File

@@ -0,0 +1,66 @@
# 实现方案 —— 2026-04-18-22-59-10
## 方案目标
将字段下划线默认行为改为「默认不显示」,修复占位符提示文字居中问题。
## 需求 1所有字段默认打印时不显示下划线
### 修改文件 1`src/pages/TemplateManage.tsx`
1. **新增字段默认状态**
```ts
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(false);
```
2. **编辑字段回显默认值**:在 `startEditField` 或等效函数中:
```ts
setEditFieldHasUnderline(field.hasUnderline ?? false);
```
3. **插入字段类名判断**:在 `insertSmartField` 中:
```ts
const underlineClass = field.hasUnderline !== true ? ' no-underline' : '';
```
### 修改文件 2`src/utils/defaultContent.ts`
移除 `noUnderlineKeys` 数组,直接在 `smartField()` 中给所有字段加 `.no-underline`
```ts
const smartField = (key: string) => {
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value no-underline" 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;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>&#8203;`;
};
```
## 需求 2修复占位符文字偏左
### 修改文件
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`、`src/utils/defaultContent.ts`
### 修改内容
在所有 `.placeholder-text` 的 `style` 属性中追加 `text-align:center;`。
需要修改的位置:
1. `defaultContent.ts`Logo 占位符 + 6 个表格占位符 + 签名占位符
2. `ReportEditor.tsx`
- `handleEditorClick` 删除恢复逻辑中的 `.placeholder-text`
- `placeholderModal` 确认插入时的 `.placeholder-text`table 内 + inline-block
3. `TemplateManage.tsx`
- `handleEditorClick` 删除恢复逻辑中的 `.placeholder-text`
- `placeholderModal` 确认插入时的 `.placeholder-text`table 内 + inline-block
统一的新样式:
```
color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/TemplateManage.tsx` | `newFieldHasUnderline` 默认 `false`;编辑回显默认 `false``insertSmartField` 判断逻辑placeholder-text 样式 |
| `src/utils/defaultContent.ts` | `smartField()` 直接加 `.no-underline`;所有 placeholder-text 加 `text-align:center` |
| `src/pages/ReportEditor.tsx` | 所有 placeholder-text 加 `text-align:center` |
## 风险与注意事项
1. `smartField()` 中移除 `noUnderlineKeys` 后,所有默认模板字段将统一无下划线。此前通过 `hasUnderline` 配置自定义下划线的机制仍然保留(`field.hasUnderline === true` 时不加 `.no-underline`),只是默认值变为 `false`。
2. `text-align:center` 追加时需注意不破坏已有的其他样式属性顺序。
3. 批量替换 `placeholder-text` 样式时,应使用精确的字符串匹配,避免误伤其他元素。

View File

@@ -0,0 +1,90 @@
# 实现方案 —— 2026-04-18-23-19-44
## 方案目标
修复排版对齐问题,优化导出文件名,实现模板批量操作。
## 需求 1修复 field-value 输入内容往上飘
### 修改文件
`src/utils/defaultContent.ts``src/utils/print.ts`
### 修改内容
- `defaultContent.ts``smartField()`
- `vertical-align:text-bottom``vertical-align:baseline`
- `line-height:1.2;min-height:1.2em;``line-height:inherit;`
- `print.ts``.field-value` 打印样式同步修改 `vertical-align:baseline; line-height:inherit;`
- 打印时下划线 `padding-bottom` 改为 `1px` 以紧贴文字
## 需求 2、3、4微调排版间距和 Logo 位置
### 修改文件
`src/utils/defaultContent.ts`
### 修改内容
- 姓名栏横线:`padding-bottom: 1px;`(原来是 `padding: 0 0 1px 0`,可能需要调整)
- 手术记录标题:`margin-top: 2px;`(原来是 `margin-bottom: 8px` 等,需要精确调整)
- Logo使用 `position:absolute` 向左上偏移 5px或调整父容器 `gap`/`margin`
## 需求 5导出 PDF 文件名修正
### 修改文件
`src/utils/print.ts`
### 修改内容
`printDocument` 函数中:
1. 保存原始 `document.title`
2. 设置 `document.title = docTitle`
3. 打印完成后恢复 `document.title = originalTitle`
这样浏览器在 `window.print()` 时会使用正确的文件名。
## 需求 6导出 JSON 时间使用北京时间
### 修改文件
`src/pages/ReportEditor.tsx``src/pages/ReportManage.tsx``src/pages/TemplateManage.tsx`
### 修改内容
定义一个全局格式化函数 `getBeijingTimeStr()`
```ts
const getBeijingTimeStr = () => {
const d = new Date();
const bjTime = new Date(d.getTime() + (8 * 60 * 60 * 1000));
return bjTime.toISOString().replace(/T/, '-').replace(/:/g, '-').slice(0, 16);
};
```
替换所有 `new Date().toISOString().replace(/[:.]/g, '-')` 的调用。
## 需求 7模板管理批量操作
### 修改文件
`src/pages/TemplateManage.tsx`
### 修改内容
1. **新增状态**`const [selectedIds, setSelectedIds] = useState<string[]>([]);`
2. **批量删除**`handleBatchDelete()` 过滤掉选中 ID清空 `selectedIds`
3. **批量导出**`handleBatchExport()` 将选中模板打包为 JSON 数组下载
4. **UI 调整**
- 模板列表每行前增加复选框
- 当有选中项时,显示批量操作工具栏(批量删除 + 批量导出)
5. **允许空列表**:移除 `templates.length > 1` 对删除按钮的限制(改为只在批量删除时确认)
### 冲突检查
- 现有 `handleDeleteTemplate` 单个删除逻辑可复用
- `Login.tsx` 中的默认模板初始化逻辑需要检查:如果用户删除了所有模板,系统是否会在登录时强制创建默认模板
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/utils/defaultContent.ts` | smartField 基线对齐姓名栏间距手术记录间距Logo 位置 |
| `src/utils/print.ts` | field-value 打印样式document.title 动态设置 |
| `src/pages/ReportEditor.tsx` | 导出文件名使用北京时间 |
| `src/pages/ReportManage.tsx` | 导出文件名使用北京时间 |
| `src/pages/TemplateManage.tsx` | 导出文件名使用北京时间;批量操作状态和 UI |
## 风险与注意事项
1. `vertical-align:baseline` 后,需要验证不同字号混合时(如 11pt 正文 + 12pt 字段)的对齐效果。
2. Logo 使用 `position:absolute` 时需要确保父容器有 `position:relative`,且不会遮挡其他元素。
3. 修改 `document.title` 后需确保在打印失败或用户取消时也能恢复。
4. 批量删除后如果 `currentTemplateId` 被删除,需要重置为 `null` 或自动选中其他模板。
5. 北京时间计算 `new Date(d.getTime() + (8 * 60 * 60 * 1000))` 在夏令时转换时可能有 1 小时偏差,但中国大陆不使用夏令时,所以安全。

View File

@@ -0,0 +1,108 @@
# 实现方案 —— 2026-04-18-23-39-35
## 方案目标
修复下划线功能、统一导出文件名、缩紧输入框间距、实现表单逆向联动。
## 需求 1修复下划线勾选状态异常及打印失效
### 修改文件 1`src/types.ts`
`DEFAULT_FORM_FIELDS` 数组中,为所有字段显式设置 `hasUnderline: false`(如果当前为 `true` 或未指定)。
### 修改文件 2`src/pages/TemplateManage.tsx`
在编辑字段的回显逻辑中:
```ts
setEditFieldHasUnderline(field.hasUnderline === true);
```
确保 `undefined` 时默认不勾选。
### 修改文件 3`src/utils/print.ts`
恢复默认显示下划线的白名单机制:
```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 0px 2px !important;
}
.smart-field-wrapper .field-value.no-underline {
border-bottom: none !important;
}
}
```
## 需求 2统一 PDF 和 JSON 导出文件名
### 修改文件:`src/utils/print.ts`
确保 `printDocument` 中:
1. 保存原始 `document.title`
2. 设置 `document.title = docTitle`
3. iframe HTML 中也写入 `<title>${docTitle}</title>`
4. 打印完成后恢复 `document.title`
同时检查 `ReportEditor.tsx``ReportManage.tsx` 中调用 `printDocument` 时传入的 `docTitle` 是否与 JSON 文件名一致。
## 需求 3缩紧 field-value 内文字间距
### 修改文件 1`src/utils/defaultContent.ts`
```ts
// padding:0 4px → padding:0 2px
// margin:0 2px → margin:0
// min-width:32px → min-width:24px
// 增加 text-align:center 让文字居中
```
### 修改文件 2`src/utils/print.ts`
同步修改打印样式中的 `.field-value`
```css
.smart-field-wrapper .field-value {
min-width: 24px;
padding: 0;
margin: 0;
...
}
```
## 需求 4ReportEditor 表单逆向联动
### 修改文件:`src/pages/ReportEditor.tsx`
1. **新增 useEffect 监听 activeFieldKey**
```ts
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
allFields.forEach(el => {
(el as HTMLElement).style.backgroundColor = '#f8fafc';
(el as HTMLElement).style.boxShadow = 'none';
});
if (activeFieldKey) {
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="${activeFieldKey}"]`) as HTMLElement;
if (targetEl) {
targetEl.style.backgroundColor = '#eff6ff';
targetEl.style.boxShadow = '0 0 0 2px #3b82f6';
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [activeFieldKey]);
```
2. **右侧表单添加 onFocus/onClick**
在右侧表单字段容器的 `onClick` 中增加 `setActiveFieldKey(field.key)`,在 input/select 的 `onFocus` 中也增加 `setActiveFieldKey(field.key)`
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/types.ts` | DEFAULT_FORM_FIELDS 中 hasUnderline 设为 false |
| `src/pages/TemplateManage.tsx` | 编辑字段回显逻辑 |
| `src/utils/print.ts` | 打印下划线白名单机制document.title 设置field-value 间距 |
| `src/utils/defaultContent.ts` | smartField padding/margin 缩小text-align:center |
| `src/pages/ReportEditor.tsx` | activeFieldKey useEffect 高亮滚动;表单 onFocus 联动 |
| `src/pages/ReportManage.tsx` | 检查导出文件名一致性 |
## 风险与注意事项
1. `DEFAULT_FORM_FIELDS` 修改后,现有用户的 localStorage 中已保存的字段配置不会自动更新,需要手动编辑或清除 `formFieldsConfig` 才能看到效果。
2. `activeFieldKey` 的 useEffect 直接操作 DOM style需要确保在组件卸载或切换 tab 时清除高亮。
3. 缩小 padding/margin 后需要验证在表格单元格td内的显示是否正常。
4. 打印样式中 `.field-value.no-underline` 的优先级必须高于基础 `.field-value` 规则。

View File

@@ -0,0 +1,65 @@
# 实现方案 —— 2026-04-19-00-01-50
## 方案目标
修复高亮样式、实现点击空白取消、阻断打印高亮、同步字段下划线配置到已插入的 DOM。
## 需求 1 & 2优化高亮样式、点击空白取消、阻断打印
### 修改文件 1`src/pages/ReportEditor.tsx`
1. **点击空白取消高亮**:在 `handleEditorClick` 中,如果点击目标不是 `.field-value`,则设置 `setActiveFieldKey(null)`
2. **柔和高亮样式**:修改 `activeFieldKey``useEffect`
- 清除样式时:恢复为 `''`(空字符串)而非硬编码颜色,让 CSS 类重新接管
- 高亮时:`backgroundColor: '#f1f5f9'`(浅灰)、`outline: '1px solid #94a3b8'`(细灰边框)、`outlineOffset: '1px'`
- 不再使用 `box-shadow`
### 修改文件 2`src/utils/print.ts`
`@media print` 中强制抹除 `outline``box-shadow`
```css
@media print {
.smart-field-wrapper .field-value {
outline: none !important;
box-shadow: none !important;
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px 1px 2px !important;
}
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
}
```
## 需求 3修复下划线勾选无效
### 修改文件:`src/pages/TemplateManage.tsx`
`saveFieldEdit` 函数中,保存字段配置后,扫描编辑器中所有 `data-bind` 匹配的 `.field-value`,根据新的 `hasUnderline` 值动态添加/移除 `.no-underline` 类:
```ts
if (editorRef.current) {
const els = editorRef.current.querySelectorAll(`.field-value[data-bind="${editingFieldId}"]`);
els.forEach(el => {
if (editFieldHasUnderline) {
el.classList.remove('no-underline');
} else {
el.classList.add('no-underline');
}
});
saveTemplateContent();
}
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/ReportEditor.tsx` | handleEditorClick 点击空白取消高亮useEffect 柔和高亮样式 |
| `src/utils/print.ts` | @media print 强制抹除 outline/box-shadow |
| `src/pages/TemplateManage.tsx` | saveFieldEdit 同步更新已插入字段的 classList |
## 风险与注意事项
1. `handleEditorClick` 中增加 `setActiveFieldKey(null)` 时,需确保不会影响 `.image-placeholder` 的点击处理逻辑placeholder 点击在 field-value 判断之后)。
2. `useEffect` 中清除样式时使用 `style.backgroundColor = ''` 而非 `= '#f8fafc'`,这样可以让元素的 CSS 类样式重新生效,避免硬编码颜色与 CSS 类冲突。
3. `saveFieldEdit` 中扫描 DOM 并修改 classList 后,必须调用 `saveTemplateContent()` 将变更持久化到 localStorage。
4. 打印样式中 `outline: none !important``box-shadow: none !important` 的优先级需确保高于任何内联样式。

View File

@@ -0,0 +1,37 @@
# 实现方案 —— 2026-04-19-00-13-20
## 方案目标
使打印/PDF导出时 `.field-value` 的下划线紧贴文字底部。
## 修改点
### 修改文件:`src/utils/print.ts`
`@media print``.smart-field-wrapper .field-value` 样式中增加 `line-height: 1 !important;`
**原因**:即使 `padding-bottom` 已设为 `0px`,父级文档的 `line-height: 1.5` 仍会在文字下方保留不可见的行高留白。通过强制压缩行高到 `1`,可以消除底部留白,使 `border-bottom` 紧贴文字。
```css
@media print {
.smart-field-wrapper .field-value {
outline: none !important;
box-shadow: none !important;
border: none !important;
border-bottom: 1px solid #000 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 2px 0px 2px !important;
line-height: 1 !important;
}
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
}
```
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/utils/print.ts` | @media print 中 .field-value 增加 line-height: 1 !important |
## 风险与注意事项
1. `line-height: 1` 会显著压缩行高,但由于 `.field-value` 在打印时已经是 `inline-block` 且独立显示,不会影响周围段落的整体行距。
2. `!important` 确保优先级高于任何内联样式。

View File

@@ -0,0 +1,41 @@
# 实现方案 —— 2026-04-19-00-24-02
## 方案目标
完善 Dashboard 数据概览:新增全部报告卡片、修复图表重叠、增加时间维度切换。
## 需求 1新增"全部报告总数"卡片
### 修改文件:`src/pages/Dashboard.tsx`
1. **扩展 stats 结构**:增加 `totalCount` 字段,表示全部报告总数(原 `reportCount` 改为仅统计本月)。
2. **更新计算逻辑**:在 useEffect 中计算 `userReports.length` 作为 `totalCount`,原 `reportCount` 保留为当月数量。
3. **调整卡片布局**:将原来的 4 卡片网格改为包含 5 个统计项,或保持 4 卡片但替换/调整内容。根据用户要求,在"本月报告总数"左侧插入"全部报告总数",并将网格列数从 4 改为 5`lg:grid-cols-5`)或保持 4 列但替换其中一个卡片。
## 需求 2修复图表日期文字与 X 轴重叠
### 修改文件:`src/pages/Dashboard.tsx`
1. **增大底部留白**:将 Canvas 的 `padding` 或图表绘制区域的高度计算中加入更大的底部偏移(如 `bottomPadding = 30` 而非原来的 10
2. **调整文字 Y 坐标**:将 `ctx.fillText(label, x, h - 10)` 改为 `ctx.fillText(label, x, h - 5)` 或更下方,确保文字不会与 X 轴线(通常在 `h - padding` 位置绘制)重叠。
3. **调整字体大小**30 天模式下缩小字体到 9px避免文字过密。
## 需求 3时间维度切换
### 修改文件:`src/pages/Dashboard.tsx`
1. **增加状态**`const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');`
2. **响应式计算**:将 `useEffect` 的依赖数组增加 `timeRange`,当切换时重新计算 `trend``trendLabels`
3. **标签格式化**
- 7 天模式:显示 `MM-DD`(如 04-13
- 30 天模式:显示 `DD`(如 13避免过密
4. **UI 控件**:在图表标题右侧增加切换按钮组(最近 7 天 / 最近 30 天)。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/Dashboard.tsx` | stats 结构扩展、totalCount 计算、卡片布局调整、timeRange 状态、趋势数据响应式计算、Canvas 绘制坐标修复、时间切换 UI |
## 风险与注意事项
1. 原代码中 `reportCount` 可能表示的是全部报告数,需要确认其原意。如果原意是全部报告数,则需要新增 `monthCount` 而非修改 `reportCount`。根据用户方案,将 `reportCount` 改为当月数,`totalCount` 为全部数。
2. Canvas 绘制中 `padding``chartH` 的计算需要同步调整,确保数据线不会画到文字区域。
3. 30 天模式下数据点密集,需要考虑是否跳点显示标签(如只显示奇数天)。

View File

@@ -0,0 +1,34 @@
# 实现方案 —— 2026-04-19-00-33-44
## 方案目标
解决 30 天趋势图表过密问题,通过稀疏化标签 + Tooltip 提升可读性。
## 修改点
### 修改文件:`src/pages/Dashboard.tsx`
1. **条件渲染数据点和数值**:在 SVG 的 `points.map()` 中,对圆点和数值文本增加条件判断:
- 7 天模式:正常显示圆点和数值
- 30 天模式:不显示圆点和数值文本(仅保留折线和面积图)
2. **稀疏化 X 轴标签**
- 7 天模式:每天显示标签
- 30 天模式:每隔 5 天显示一次标签(`i % 5 === 0`
3. **SVG 鼠标事件与 Tooltip**
- 在图表容器上绑定 `onMouseMove``onMouseLeave`
- 计算鼠标在 SVG 坐标系中的相对位置,映射到最近的数据点索引
- 用 React state 管理 `tooltipData: { index, x, y, visible }`
- 用绝对定位的 div 渲染 Tooltip显示日期和数值
4. **透明捕获层**:在 SVG 中增加一个覆盖整个图表区域的透明 `<rect>`,确保鼠标在空白区域也能触发事件。
## 涉及文件及修改点
| 文件 | 修改点 |
|------|--------|
| `src/pages/Dashboard.tsx` | 条件渲染 circle/text、标签稀疏化、Tooltip state、SVG 鼠标事件、透明捕获层 |
## 风险与注意事项
1. Tooltip 坐标计算需考虑 SVG 的 viewBox 到屏幕像素的映射比例。
2. 鼠标移出 SVG 区域时必须隐藏 Tooltip。
3. 7 天模式的显示效果必须保持完全不变。

View File

@@ -1,71 +0,0 @@
# 项目修改工作流指南
> 本工作流适用于所有项目修改相关需求。每次收到修改需求时,必须严格按照以下步骤执行。
---
## 工作流步骤
### Step 0: 备份与记录时间戳
每次执行前,必须先用 Gitea 进行代码备份,并记录问题开始时间:
- 时间戳格式:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 备份命令:
```bash
git init
git checkout -b main
git add .
git commit -m "backup before modification at {时间戳}"
git remote add origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git
git push -u origin main
```
- 若远程已存在,则使用 `git remote set-url origin http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git`
### Step 1: 工程分析目录检查
确保 `..\工程分析` 文件夹存在(已创建)。
### Step 2: 需求分析文档
将用户提出的需求整理后写入:
```
.\工程分析\需求分析-{时间戳}.md
```
- 内容需包含:需求背景、功能目标、涉及页面/模块、验收标准
### Step 3: 实现方案文档
基于需求分析,编写详细实现方案并写入:
```
.\工程分析\实现方案-{时间戳}.md
```
- 内容需包含:技术思路、修改文件清单、关键代码变更说明、风险点
- **写完此文档后,必须暂停并交由用户进行二次人工审核确认。未经确认不得继续。**
### Step 4: 测试方案文档
实现方案确认后,编写测试方案并写入:
```
.\工程分析\测试方案-{时间戳}.md
```
- 内容需包含:测试项、测试步骤、预期结果、回归验证范围
- **写完此文档后,必须暂停并交由用户进行二次人工审核确认。未经确认不得继续。**
### Step 5: 执行修改与经验记录
测试方案确认后,开始执行代码修改:
1. 按方案实施修改
2. 执行 `npm run lint` 进行类型检查
3. 如有必要,执行 `npm run build` 验证构建
4. 修改完成后,在以下文档中追加本次执行过程中的关键问题及解决方案:
```
.\工程分析\经验记录.md
```
- 记录格式(四段式):
- **A. 具体问题**
- **B. 产生问题原因**
- **C. 解决问题方案**
- **D. 后续如何避免问题**
---
## 快捷入口
- **工程分析目录**: `C:\Users\Administrator\Downloads\Gemini-图文报告系统-V1.1\工程分析`
- **Gitea 远程地址**: `http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git`
- **类型检查命令**: `npm run lint`
- **构建命令**: `npm run build`

View File

@@ -1,76 +0,0 @@
# 测试方案 — 2026-04-16-16-51-00
## 测试环境准备
1. 确保项目依赖已安装:`npm install` 已完成。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3.**系统设置** 页面中,确认 **图文报告生成默认模板** 已设置为 **"腹腔镜胆囊切除术报告"**。
---
## 测试项清单
### 测试项 1新建报告时正确加载默认模板
**测试步骤**
1. 登录后,点击左侧菜单 **"图文报告生成"**`/report-editor`,无 `id` 参数)。
2. 观察页面顶部 **"当前模板(及重置模板):"** 下拉框显示内容。
3. 观察中间编辑区域(`editor-content-wrapper print-wrapper`)是否有模板内容。
**预期结果**
- 顶部模板选择器显示 **"腹腔镜胆囊切除术报告"**(或用户设置的默认模板名称),而非"无"。
- 编辑区域显示该模板的完整 HTML 内容(包含标题、表格、图片占位符等),不是纯白色空白。
---
### 测试项 2从其他页面返回后未编辑不显示空白
**测试步骤**
1.**工作台**`/dashboard`)页面停留。
2. 通过左侧菜单再次进入 **"图文报告生成"**。
3. 不要输入任何内容,直接再切回 **工作台**,然后再切回 **"图文报告生成"**。
**预期结果**
- 每次进入 `/report-editor`,编辑区域都应正确显示默认模板内容。
- 不会出现白色空白页面。
---
### 测试项 3编辑已有报告时不被空白草稿覆盖
**测试步骤**
1. 进入 **报告管理**打开一份已有内容的报告进行编辑URL 带有 `?id=xxx`)。
2. 不要做任何修改,直接刷新浏览器页面。
**预期结果**
- 页面加载后显示该报告原有的完整内容,不会被空白草稿覆盖为默认模板。
---
### 测试项 4有效草稿恢复后模板选择器显示正确
**测试步骤**
1. 进入 **"图文报告生成"**,确认已加载默认模板。
2. 在编辑器中随意输入几个字(确保内容非空)。
3. 切到 **工作台**,再切回 **"图文报告生成"**。
**预期结果**
- 编辑器恢复刚才输入的内容。
- 顶部模板选择器仍显示 **"腹腔镜胆囊切除术报告"**(因为草稿中已保存 `loadedTemplateId`)。
---
### 测试项 5构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功,生成 `dist/` 目录。
---
## 回归验证范围
- [ ] `SystemSettings.tsx` 未被修改,默认模板设置功能保持正常。
- [ ] `storage.ts` 未被修改localStorage 读写保持正常。
- [ ] 报告保存(草稿/完成)功能未被破坏。
- [ ] 视频分析面板与编辑器交互保持正常。

View File

@@ -1,88 +0,0 @@
# 测试方案 — 2026-04-16-17-07-04
## 测试环境准备
1. 项目已构建通过,无类型错误。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3. 进入 **图文报告生成** 页面,确认编辑器中已加载默认模板(包含若干 `image-placeholder` 图片占位符)。
4.**视频分析** 面板中上传至少一个手术视频,等待系统自动抽帧完成。
---
## 测试项清单
### 测试项 1"插入" 按钮显隐行为正确
**测试步骤**
1. 在视频分析面板中查看自动抽帧的关键帧列表。
2. 将鼠标移入某张关键帧截图卡片上。
3. 再将鼠标移出该卡片。
**预期结果**
- 鼠标移入时,截图卡片底部同时显示 **"插入"** 和 **"可拖拽"** 两个提示文字。
- 鼠标移出时,两者同时隐藏。
- "插入" 位于截图时间(如 `00:15`)和 "可拖拽" 之间。
---
### 测试项 2点击 "插入" 自动填充第一个空置占位符
**测试步骤**
1. 确认编辑器中存在至少一个尚未填充图片的 `.image-placeholder`(未显示图片的灰色虚线框)。
2. 在视频分析面板中,将鼠标悬停到第一张关键帧截图上,点击 **"插入"**。
3. 再次点击第二张关键帧截图的 **"插入"**。
**预期结果**
- 第一次点击后,编辑器中**第一个**空置占位符被填充为第一张关键帧图片。
- 第二次点击后,编辑器中**第二个**空置占位符被填充为第二张关键帧图片。
- 填充后的占位符变为正常图片显示,且带有删除按钮 `×`
---
### 测试项 3无空占位符时给出提示
**测试步骤**
1. 连续点击 "插入" 直到编辑器中所有 `image-placeholder` 都被填满。
2. 再次点击任意关键帧的 **"插入"**。
**预期结果**
- 浏览器弹出提示框:**"没有可插入图片的空位"**。
- 不会报错,现有已填充的图片不受影响。
---
### 测试项 4点击 "插入" 不触发视频跳转
**测试步骤**
1. 点击某张关键帧卡片的 **"插入"** 按钮。
2. 观察左侧视频播放器。
**预期结果**
- 视频播放器的当前时间**不发生变化**(不会因为点击了卡片本身而跳转到该帧位置)。
---
### 测试项 5原有拖拽功能保持正常
**测试步骤**
1. 手动拖拽视频分析面板中的某张关键帧截图到编辑器中的空置 `image-placeholder` 上。
**预期结果**
- 拖拽释放后,该占位符正确显示被拖拽的图片。
- 拖拽功能与 "插入" 按钮功能互不干扰。
---
### 测试项 6构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功。
---
## 回归验证范围
- [ ] `SystemSettings.tsx` 未被修改,系统设置功能保持正常。
- [ ] `storage.ts` 未被修改,存储读写保持正常。
- [ ] 编辑器打印、保存草稿、完成报告功能保持正常。
- [ ] 关键帧的 "手动截取"、删除功能保持正常。

View File

@@ -1,73 +0,0 @@
# 测试方案 — 2026-04-16-17-15-37
## 测试环境准备
1. 项目已完成上一次构建,无类型错误。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3. 进入 **图文报告生成** 页面并上传视频完成抽帧,确保视频分析面板有关键帧截图。
---
## 测试项清单
### 测试项 1"插入" 按钮位于图片中央且为实体按钮样式
**测试步骤**
1. 将鼠标悬停到任意关键帧截图卡片上。
2. 观察按钮的位置、颜色、形状。
**预期结果**
- 按钮显示在图片的**正中央**。
- 按钮是**实体胶囊按钮**(带背景色、圆角、阴影),不是纯文字链接。
- 按钮颜色为**绿色系**`emerald-500`),与底部 "可拖拽" 的蓝色明显不同。
---
### 测试项 2"插入" 按钮显隐行为正确
**测试步骤**
1. 鼠标移入关键帧卡片。
2. 鼠标移出关键帧卡片。
**预期结果**
- 移入时按钮与 "可拖拽" 文字同时淡入显示。
- 移出时按钮与 "可拖拽" 文字同时淡出隐藏。
- 未悬停时图片上无遮挡文字。
---
### 测试项 3点击按钮功能保持正常
**测试步骤**
1. 鼠标悬停到关键帧卡片,点击中央的 **"插入"** 按钮。
**预期结果**
- 编辑器中第一个空置的 `image-placeholder` 被填充为该帧图片。
- 视频播放器时间不发生变化(未触发卡片跳转)。
- 若无可插入空位,弹出 `没有可插入图片的空位` 提示。
---
### 测试项 4"手动" 徽章不被遮挡
**测试步骤**
1. 查看手动截取的关键帧截图。
**预期结果**
- 左上角的 **"手动"** 徽章清晰可见,未被中央按钮遮挡。
---
### 测试项 5构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功。
---
## 回归验证范围
- [ ] 拖拽插入功能未被破坏。
- [ ] 关键帧卡片的删除、视频跳转功能正常。
- [ ] 编辑器保存、打印功能正常。

View File

@@ -1,72 +0,0 @@
# 测试方案 — 2026-04-16-17-21-58
## 测试环境准备
1. 项目已完成上一次构建,无类型错误。
2. 使用测试账号 `admin / 123456`(超级管理员)登录系统。
3. 进入 **图文报告生成** 页面并上传视频完成抽帧,确保视频分析面板有关键帧截图。
---
## 测试项清单
### 测试项 1"插入" 按钮位于底部正确位置且不遮盖图片
**测试步骤**
1. 将鼠标悬停到任意关键帧截图卡片上。
2. 观察 "插入" 按钮的位置。
**预期结果**
- 按钮位于卡片底部,**时间格式(如 `00:15`**和 **"可拖拽"** 提示之间。
- 按钮**不覆盖在图片上**,图片内容完全可见,无遮挡。
---
### 测试项 2"插入" 按钮为蓝色实体按钮样式
**测试步骤**
1. 鼠标悬停到关键帧卡片,观察 "插入" 按钮的颜色和形状。
**预期结果**
- 按钮为**蓝色实体胶囊按钮**`bg-accent` 背景 + 白色文字 + 圆角 + 轻微阴影)。
- 按钮颜色与底部 "可拖拽" 的蓝色一致,视觉上协调统一。
- 不是纯文字链接样式。
---
### 测试项 3显隐行为与 "可拖拽" 同步
**测试步骤**
1. 鼠标移入关键帧卡片。
2. 鼠标移出关键帧卡片。
**预期结果**
- 移入时,"插入" 按钮与 "可拖拽" 文字**同时淡入显示**。
- 移出时,"插入" 按钮与 "可拖拽" 文字**同时淡出隐藏**。
---
### 测试项 4点击功能与事件冒泡正常
**测试步骤**
1. 鼠标悬停到关键帧卡片,点击 **"插入"** 按钮。
**预期结果**
- 编辑器中第一个空置的 `image-placeholder` 被自动填充(或弹出 `没有可插入图片的空位` 提示)。
- 视频播放器时间不发生变化(未触发卡片跳转)。
---
### 测试项 5构建与类型检查回归
**测试步骤**
1. 在项目根目录执行:
```bash
npm run lint
npm run build
```
**预期结果**
- `npm run lint` 无 TypeScript 编译错误。
- `npm run build` 构建成功。
---
## 回归验证范围
- [ ] 拖拽插入功能未被破坏。
- [ ] 关键帧卡片的删除、手动徽章显示正常。
- [ ] 编辑器保存、打印功能正常。

View File

@@ -1,90 +0,0 @@
# 测试方案 — 2026-04-16-18-51-06
## 测试目标
验证在 `/report-editor` 页面离开并重新返回后,**视频分析相关的所有图片数据(自动关键帧、手动截图、拖拽到 placeholder 的截图)能够正确恢复**,且不影响报告基本信息和其他页面功能。
## 测试环境
- 浏览器Chrome / Edge推荐
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件(用于自动关键帧摘取)
## 测试用例设计
### 用例 1新建报告 — 自动关键帧摘取后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载,显示默认模板内容 |
| 1.2 | 填写患者姓名、住院号等基本信息 | 基本信息输入正常 |
| 1.3 | 切换到「视频分析」页签,上传测试视频 | 视频上传成功,视频列表中显示文件名 |
| 1.4 | 点击「自动关键帧摘取」 | 右侧生成多张自动关键帧缩略图 |
| 1.5 | 点击浏览器地址栏,手动跳转至 `/report-manage` | 页面跳转成功 |
| 1.6 | 再次在地址栏输入 `/report-editor` 返回 | **右侧「视频分析」中自动关键帧缩略图全部保留** |
| 1.7 | 点击「保存草稿」,再跳转离开并返回 | 自动关键帧缩略图仍然保留 |
### 用例 2新建报告 — 手动截图后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 在新建报告页面上传视频并播放 | 视频正常播放 |
| 2.2 | 拖动进度条到某一时刻,点击「手动截图」 | 右侧生成一张手动截图缩略图 |
| 2.3 | 再次截取 2-3 张不同时间点的截图 | 所有手动截图均显示在右侧列表 |
| 2.4 | 跳转至 `/report-manage`,再返回 `/report-editor` | **所有手动截图缩略图全部保留** |
| 2.5 | 点击「保存草稿」后再次离开并返回 | 手动截图仍然保留 |
### 用例 3新建/编辑报告 — 拖拽截图到 image-placeholder 后路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在编辑器中插入一个 `image-placeholder` | placeholder 正常显示在编辑器中 |
| 3.2 | 从右侧「视频分析」中拖拽一张自动关键帧到 placeholder | placeholder 中显示该图片,且带有删除按钮 |
| 3.3 | 再插入一个 placeholder拖拽一张手动截图到其中 | 第二张 placeholder 也正确显示图片 |
| 3.4 | 跳转至 `/report-manage`,再返回 `/report-editor` | **编辑器中两个 placeholder 内的图片均保留可见** |
| 3.5 | 查看右侧「视频分析」面板 | 被拖拽的原始帧/截图缩略图也仍然保留在列表中 |
| 3.6 | 点击「保存草稿」后再次离开并返回 | 编辑器和右侧面板的图片均保留 |
### 用例 4编辑已有报告 — 保存后数据完整恢复
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 对任意一份已有报告点击「编辑」,进入 `/report-editor?id=xxx` | 报告内容和基本信息正常加载 |
| 4.2 | 上传视频、自动摘取关键帧、手动截图、拖拽一张到 placeholder | 所有操作正常生效 |
| 4.3 | 点击「保存草稿」 | 提示保存成功 |
| 4.4 | 跳转至 `/report-manage`,找到该报告,再次点击「编辑」 | 进入 `/report-editor?id=xxx` |
| 4.5 | 检查编辑器、基本信息、视频分析面板 | **所有数据和图片完整恢复,无丢失** |
### 用例 5边界场景 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 在 `/report-editor` 中完成视频上传、截图、拖拽 | 数据正常 |
| 5.2 | 快速连续切换:/report-editor → /report-manage → /report-editor → /report-manage → /report-editor | **最终返回时,所有视频分析数据仍然完整保留** |
| 5.3 | 检查 localStorage 中 `reportEditorDraft_{username}` | draft 中 `videos``capturedFrames` 均非空数组 |
### 用例 6回归测试 — 模板切换不污染数据
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频并截取若干帧 | 数据正常 |
| 6.2 | 切换模板(顶部模板选择下拉框) | 编辑器内容按模板重置,报告基本信息清空 |
| 6.3 | 检查视频分析面板 | 根据现有逻辑,模板切换会清空 videos 和 capturedFrames此行为保持不变 |
| 6.4 | 若切换模板后不希望丢失视频数据,可后续作为优化项提出 | — |
## 验收标准
- [ ] 用例 1自动关键帧在路由切换后 100% 保留;
- [ ] 用例 2手动截图在路由切换后 100% 保留;
- [ ] 用例 3拖拽到 placeholder 的图片在路由切换后 100% 保留;
- [ ] 用例 4编辑已有报告保存后再次编辑数据完整无丢失
- [ ] 用例 5多次快速切换路由后数据不丢失、不异常
- [ ] 用例 6模板切换的现有行为未被意外改变。
## 测试方式
由于本项目目前无自动化测试框架,所有测试用例均通过 **手工浏览器验证** 执行。测试人员按上表逐步操作,观察实际结果是否与预期一致。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段(修改代码 + 更新经验记录 + Gitea 备份)。**

View File

@@ -1,89 +0,0 @@
# 测试方案 — 2026-04-16-19-06-18
## 测试目标
1. 验证路由切换后,报告编辑器内容、基本信息、视频分析数据(自动关键帧、手动截图、拖拽图片)均**完整保留不丢失**。
2. 验证开启「自动帧插入」后,关键帧在自动摘取过程中能够**实时逐张显示**,并在达到延迟时间后**逐张插入**到 `image-placeholder` 中,而不是全部处理完成后一次性批量出现。
## 测试环境
- 浏览器Chrome / Edge推荐
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 60 秒的 MP4 视频文件
- 系统设置:在「系统设置」中开启「自动帧插入」,配置插入延迟为 1~2 秒,并勾选若干自动插入的帧索引(如第 0、3、5 帧)
## 测试用例设计
### 用例 1新建报告 — 路由切换后数据完整保留
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室等基本信息 | 输入内容正常保留在表单中 |
| 1.3 | 在编辑器中输入一段自定义文字 | 编辑器内容正常显示 |
| 1.4 | 切换到「视频分析」页签,上传测试视频 | 视频出现在右侧列表 |
| 1.5 | 点击「自动关键帧摘取」 | 右侧出现多张自动关键帧缩略图 |
| 1.6 | 手动截取 2 张截图 | 手动截图出现在右侧列表 |
| 1.7 | 拖拽 1 张自动关键帧到编辑器的 `image-placeholder` | placeholder 正确显示图片 |
| 1.8 | 在地址栏手动跳转到 `/report-manage` | 页面正常跳转 |
| 1.9 | 再次输入 `/report-editor` 返回新建报告页面 | **编辑器内容、基本信息、自动关键帧、手动截图、placeholder 中的图片均完整保留** |
| 1.10 | 点击「保存草稿」,再次离开并返回 | 所有数据仍然完整 |
### 用例 2编辑已有报告 — 保存后重新编辑数据完整
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 进入 `/report-manage`,对已有报告点击「编辑」 | `/report-editor?id=xxx` 正常加载 |
| 2.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 修改和视频分析数据正常显示 |
| 2.3 | 点击「保存草稿」 | 提示保存成功 |
| 2.4 | 跳转离开后再返回 `/report-editor?id=xxx` | **修改后的基本信息、报告内容、视频分析数据完整恢复** |
### 用例 3边界场景 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 在 `/report-editor` 中完成用例 1 的全部操作 | 数据正常 |
| 3.2 | 快速连续切换:/report-editor → /report-manage → /report-editor → /report-manage → /report-editor | 最终返回时,**没有任何数据丢失或变空** |
| 3.3 | 检查 localStorage 中 `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames` 均非空 |
### 用例 4自动帧插入 — 关键帧实时逐张显示
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 进入系统设置,开启「自动帧插入」,设置延迟 1 秒,勾选索引 0、2、4 为自动插入 | 设置保存成功 |
| 4.2 | 进入 `/report-editor`,上传视频,确保编辑器中有至少 3 个空的 `image-placeholder` | placeholder 就位 |
| 4.3 | 点击「自动关键帧摘取」 | **右侧关键帧列表中,每隔一小段时间(视频 seek 时间)就有新的一张缩略图出现**,而不是等全部结束后一次性全部出现 |
| 4.4 | 观察 placeholder | 当第 0 帧被摘取后,等待约 1 秒,第一个 placeholder 被填充;第 2 帧摘取后约 1 秒,第二个 placeholder 被填充;以此类推,**逐张插入** |
### 用例 5自动帧插入 — 无延迟配置的即时插入
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 在系统设置中将自动插入延迟设为 0 秒 | 设置保存成功 |
| 5.2 | 进入 `/report-editor`,上传视频,点击「自动关键帧摘取」 | 每摘取到一张指定索引的帧,**几乎立即**填充到下一个空的 placeholder 中 |
| 5.3 | 观察右侧关键帧列表 | 同样能看到帧逐张实时出现 |
### 用例 6回归测试 — 模板切换行为不变
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频、截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容按模板重置,基本信息清空,视频分析根据现有逻辑可能被清空 |
| 6.3 | 离开并返回 | 模板切换后的状态保持一致,无异常崩溃 |
## 验收标准
- [ ] 用例 1新建报告路由切换后编辑器内容、基本信息、视频分析数据 100% 保留;
- [ ] 用例 2编辑已有报告保存后再次编辑数据完整无丢失
- [ ] 用例 3多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 4开启自动帧插入且有延迟时关键帧实时逐张显示、逐张插入 placeholder
- [ ] 用例 5延迟为 0 时,指定帧摘取后立即插入 placeholder
- [ ] 用例 6模板切换的现有行为未被意外改变页面无崩溃。
## 测试方式
由于本项目目前无自动化测试框架,所有测试用例均通过 **手工浏览器验证** 执行。测试人员按上表逐步操作,观察实际结果是否与预期一致。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段(修改代码 + 更新经验记录 + Gitea 备份)。**

View File

@@ -1,19 +0,0 @@
# 测试方案 — 2026-04-16-19-18-14
## 测试目标
验证应用重新部署后,服务正常启动且可通过端口访问。
## 测试用例
| 用例 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 执行 `docker ps` | 看到 `medical-report-app` 容器在运行,端口映射为 `8080:80` |
| 2 | 浏览器访问 `http://localhost:8080` | 页面正常加载,显示登录或报告管理界面 |
| 3 | 登录后进入 `/report-editor` | 页面正常加载,无白屏或报错 |
## 验收标准
- [ ] 容器运行状态正常
- [ ] 浏览器可正常访问应用首页
- [ ] 核心页面 `/report-editor` 可正常加载

View File

@@ -1,80 +0,0 @@
# 测试方案 — 2026-04-16-19-28-04
## 测试目标
彻底验证路由切换后,报告编辑器内容、基本信息、视频列表、关键帧截图在任何场景下均不再丢失。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件已登录系统localStorage 中有当前用户信息
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
## 测试用例设计
### 用例 1新建报告 — 填写基本信息后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`(不带 `?id` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号、科室 | 输入内容保留在表单中 |
| 1.3 | 在编辑器中输入文字 | 编辑器内容正常显示 |
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **基本信息和编辑器内容均完整保留** |
### 用例 2新建报告 — 上传视频 + 自动关键帧后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 在 `/report-editor` 中上传视频 | 视频出现在右侧列表 |
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧缩略图 |
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
### 用例 3新建报告 — 拖拽图片到 placeholder 后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 插入 `image-placeholder`,拖拽一张关键帧到其中 | placeholder 显示图片 |
| 3.2 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
### 用例 4编辑已有报告 — 修改后保存并重新编辑
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 从 `/report-manage` 编辑已有报告 | `/report-editor?id=xxx` 正常加载 |
| 4.2 | 修改患者姓名,上传视频,自动摘取关键帧 | 所有数据正常显示 |
| 4.3 | 点击「保存草稿」 | 提示保存成功 |
| 4.4 | 离开并重新进入 `/report-editor?id=xxx` | **修改后的所有数据完整恢复** |
### 用例 5边界 — 多次快速路由切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 完成用例 2 和 3 的所有操作 | 数据正常 |
| 5.2 | 连续快速切换 /report-editor ↔ /report-manage 3 次以上 | **最终返回时没有任何数据丢失或变空** |
| 5.3 | 检查 localStorage `reportEditorDraft_{username}` | draft 中 `reportData``videos``capturedFrames``content` 均非空 |
### 用例 6回归 — 模板切换后行为正常
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 6.1 | 在 `/report-editor` 中上传视频并截取若干帧 | 数据正常 |
| 6.2 | 切换模板 | 编辑器内容重置,基本信息清空(现有预期行为) |
| 6.3 | 离开并返回 | 页面无崩溃,状态与模板切换后一致 |
## 验收标准
- [ ] 用例 1基本信息和编辑器内容在路由切换后 100% 保留;
- [ ] 用例 2视频列表和关键帧在路由切换后 100% 保留;
- [ ] 用例 3拖拽到 placeholder 的图片在路由切换后 100% 保留;
- [ ] 用例 4编辑已有报告保存后重新编辑数据完整无丢失
- [ ] 用例 5多次快速切换路由后数据不丢失、不异常变空
- [ ] 用例 6模板切换行为未被意外改变页面无崩溃。
## 测试方式
手工浏览器验证。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View 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` 属性。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View 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` 类型检查。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View 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 | 选择"侧卧位" | 编辑器内对应方格显示"侧卧位" |
### 用例 8UI 紧凑化验证
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 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` 配置。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View 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 中没有 `&nbsp;``smart-field-wrapper` 与前后文本节点紧密相连 |
### 用例 2行尾插入字段不异常换行
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 2.1 | 在第一行"住院号:"的方格后点击,使光标位于行尾 | — |
| 2.2 | 点击字段库插入「手术日期」 | 新插入的方格**紧跟在住院号方格后面**,不会跳到下一行(只要一行空间足够) |
### 用例 3Backspace 删除字段不误删整行
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 3.1 | 在编辑器中插入一个「手术名称」字段 | 方格正常插入 |
| 3.2 | 将光标定位到该方格的**紧右侧**(点击方格后方的文字前) | 光标闪烁在方格之后 |
| 3.3 | 按下键盘 **Backspace** 键 | **仅删除该「手术名称」方格**,方格前方的文字(如"手术名称:")和整行 `<p>` **完好保留** |
| 3.4 | 再次按 Backspace | 正常删除方格前方的文字字符(如冒号或文字),不会删行 |
### 用例 4Delete 键同样受保护
| 步骤 | 操作 | 预期结果 |
|------|------|--------- |
| 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 结构和键盘事件响应。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -0,0 +1,47 @@
# 测试方案 — 模板字段唯一性、删除交互与报告批量导出2026-04-17-10-21-18
## 一、编译检查
- 执行 `npm run lint``tsc --noEmit`),确保全量 TypeScript 无编译错误。
## 二、功能验证步骤
### 测试 1TemplateManage 字段唯一性
1. 进入【模板管理】,选择一个模板。
2. 在右侧字段库点击"姓名"`patientName`),确认成功插入智能字段方框。
3. 再次点击"姓名",确认弹出提示 `"姓名" 已存在,请勿重复插入。`,且没有再次插入方框。
### 测试 2TemplateManage 字段删除(点击删除按钮)
1. 在模板编辑器中,鼠标悬停在任意智能字段方框上,确认左上角出现红色小圆 ×。
2. 点击该 ×,确认字段方框被移除,模板内容自动保存。
3. 刷新页面,确认该字段确实已被删除。
### 测试 3TemplateManage 字段删除(键盘 Backspace/Delete
1. 将光标定位在智能字段方框**正后方**(字段与后续文字之间),按 Backspace确认字段被删除。
2. 将光标定位在智能字段方框**正前方**(段落开头,字段前面),按 Delete确认字段被删除。
3. 尝试在段落中间的其他位置按 Backspace/Delete确认不影响正常文本编辑。
### 测试 4ReportManage 单报告导出
1. 进入【报告管理】,确保列表中至少有一份已完成的报告。
2. 点击某报告操作列的"导出"按钮,弹出导出选项弹窗。
3. 选择 **PDF**:确认调用 `printDocument` 弹出浏览器打印窗口(可选择"另存为 PDF")。
4. 再次点击"导出",选择 **JSON**:确认浏览器下载了一个 `.json` 文件。
5. 打开该 JSON 文件,确认结构包含 `meta`id、title、createdAt 等)和 `fields`patientName、hospitalId 等字段值)。
### 测试 5ReportManage 复选框与批量删除
1. 在报告列表中,点击多行的左侧复选框,确认 `selectedIds` 状态更新,顶部出现批量操作栏并显示"已选择 N 项"。
2. 点击表头全选 Checkbox确认所有行被选中再次点击确认全部取消。
3. 选中 2 份报告,点击批量操作栏的"批量删除",在确认弹窗中点击"取消",确认报告未被删除。
4. 再次点击"批量删除"并确认,确认选中的报告从列表和 localStorage 中移除,批量操作栏消失。
### 测试 6ReportManage 批量导出
1. 选中 2 份报告,点击"批量导出 JSON",确认下载的 JSON 文件中包含一个数组,数组长度为 2每个元素结构同单报告导出。
2. 选中 2 份报告,点击"批量导出 PDF",确认弹出浏览器打印窗口,打印内容中两份报告之间有明显的分页(或分页符空白)。
## 三、预期结果
- `npm run lint` 0 错误。
- 模板字段唯一性校验生效,重复插入被阻止。
- 模板字段可通过点击 × 或键盘 Backspace/Delete 删除。
- 报告管理支持单报告 PDF/JSON 导出。
- 报告管理支持复选框全选、批量删除、批量 PDF/JSON 导出。

View File

@@ -0,0 +1,56 @@
# 测试方案 — 字段聚焦高亮、删除按钮显隐控制与 .map Bug 修复2026-04-17-11-14-28
## 一、编译检查
- 执行 `npm run lint``tsc --noEmit`),确保全量 TypeScript 无编译错误。
## 二、功能验证步骤
### 测试 1TemplateManage 字段聚焦高亮
1. 进入【模板管理】,选择默认模板。
2. 点击模板中的任意智能字段方框(如"姓名"),观察背景色是否明显变深(如 `#e2e8f0`),边框是否变深,是否有蓝色外发光。
3. 点击另一个字段方框,确认高亮状态随焦点切换。
### 测试 2TemplateManage 删除按钮位置与显隐
1. 鼠标悬浮在智能字段方框上(不点击),确认字段**右上角**出现红色圆形 ×。
2. 鼠标移开字段区域,确认红色 × 消失。
3. 点击字段方框使其获得焦点,确认红色 × 保持显示。
4. 点击红色 ×,确认该字段被删除,模板自动保存。
### 测试 3ReportEditor 中不显示删除按钮
1. 进入【新建报告】或编辑已有报告。
2. 观察编辑器中的所有智能字段方框,确认**没有任何红色 × 显示**(无论悬浮还是聚焦)。
3. 在 ReportEditor 的 field-value 中输入文字,确认编辑器正常工作。
### 测试 4ReportEditor 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 脏数据,不崩溃。

View File

@@ -0,0 +1,49 @@
# 测试方案 — 字段悬浮高亮、电子签上传与手术者签名联动2026-04-17-11-34-24
## 一、编译检查
- 执行 `npm run lint``tsc --noEmit`),确保全量 TypeScript 无编译错误。
## 二、功能验证步骤
### 测试 1TemplateManage 字段悬浮高亮
1. 进入【模板管理】,选择默认模板。
2. 将鼠标悬浮在右侧字段库中的"姓名"按钮上(不点击)。
3. 观察编辑器中"姓名"对应的智能字段方框,确认出现蓝色外发光/背景变浅蓝色高亮。
4. 鼠标移开"姓名"按钮,确认高亮效果消失,字段框恢复原样。
5. 尝试悬浮其他字段按钮(如"手术名称"、"手术日期"),确认高亮定位准确。
### 测试 2UserManage 电子签上传与压缩
1. 进入【用户管理】,点击任意医生用户的"编辑"按钮。
2. 在编辑弹窗中找到"电子签名"区域,点击"上传签名"。
3. 选择一张大于 500×500 像素的本地图片(如 1200×800 的 PNG/JPG
4. 确认上传后预览图显示正常,且图片已被等比例压缩(宽或高最大不超过 500px
5. 点击"保存用户",刷新页面后再次编辑该用户,确认签名图片仍然保留。
6. 点击"清除签名",确认预览图消失;保存后刷新,确认签名已清除。
### 测试 3TemplateManage 新增"手术者签名"字段
1. 进入【模板管理】,查看右侧【插入字段】面板。
2. 确认分类列表中新增"图片"分类,下方有"手术者签名"按钮。
3. 点击"手术者签名"按钮,确认模板中插入一个智能字段方框(`data-bind="surgeonSignature"`)。
4. 再次点击"手术者签名",确认弹出"已存在,请勿重复插入"的提示。
### 测试 4ReportEditor 签名自动填充
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 行文字高度,排版美观。

View File

@@ -0,0 +1,56 @@
# 测试方案 — 撤销栈修复、字段删除交互优化与签名字段闭环2026-04-17-12-34-56
## 一、编译检查
- 执行 `npm run lint``tsc --noEmit`),确保全量 TypeScript 无编译错误。
## 二、功能验证步骤
### 测试 1TemplateManage 撤销功能恢复
1. 进入【模板管理】,选择默认模板。
2. 点击某智能字段(如"手术日期")右上角的红色 × 删除该字段。
3. 点击编辑器工具栏的"撤销"按钮(↶)。
4. 确认被删除的字段重新出现,撤销功能正常。
### 测试 2TemplateManage 插入字段不强制换行
1. 在模板编辑器中,将光标定位到一行文字中间(如"手术名称:"后面)。
2. 点击右侧字段库插入"手术日期"。
3. 确认"手术日期"字段框紧跟在光标位置,没有跳到下一行。
4. 再次插入"手术者签名",确认同样保持在当前行。
### 测试 3TemplateManage 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 约束,等比例缩放。
- 表单联动正常:已签字→显示图片,未签字→显示"【未签字】",无签名图→显示"【请上传电子签】"。
- 完成报告时签名异常给出弱阻断提示,用户可取消或继续。

View 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 仍然只删除字段本身,不会误删整段。
---
## 判定标准
- 所有编译检查和手工测试均通过,方可认为任务完成。
- 若任一测试失败,回滚修改并重新分析根因。

View 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. **完成报告**:点击"完成报告",确认弱提示(签名确认弹窗)逻辑仍然生效。
---
## 判定标准
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。

View 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` 中的视频关键帧仍能正常插入到图片占位符中。
---
## 判定标准
全部测试通过后方可认为任务完成。若任何测试失败,需回滚并重新分析根因。

View File

@@ -0,0 +1,96 @@
# 测试方案 — 2026-04-17-21-32-27
## 测试目标
验证时间/日期字段的格式配置、默认值策略、以及模板底部「撰写时间」动态字段的正确性。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome/Edge
- 测试账号admin / 123456超级管理员
## 测试用例
### TC-1TemplateManage 新增时间字段配置
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 登录 admin进入「模板管理」 |
| 2 | 点击「新增字段」category 选「时间」type 选「日期」 | 下方出现「默认值」select手动选择/当前时间和「显示格式」selectYYYY-MM-DD / YYYY年MM月DD日 |
| 3 | 默认值选「当前时间」格式选「YYYY年MM月DD日」填写标签「出院日期」点击「添加字段」 | 字段列表中出现「出院日期」category 显示「时间 · date」 |
| 4 | 新增字段 category 选「时间」type 选「时分」 | 显示格式 select 出现「24小时制 / 12小时制」 |
### TC-2TemplateManage 编辑已有时间字段配置
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在字段列表中点击「手术日期」 | 进入编辑模式 |
| 2 | 修改显示格式为「YYYY年MM月DD日」保存 | 字段信息更新 |
| 3 | 点击「手术开始时间」 | 编辑模式中出现 24h/12h 选项 |
### TC-3ReportEditor 日期格式同步到富文本
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 新建报告,加载默认模板 | 基本信息中出现「撰写时间」字段 |
| 2 | 在 TemplateManage 中将「手术日期」格式设为「YYYY年MM月DD日」 | — |
| 3 | 回到 ReportEditor手术日期选「2026-04-17」 | 编辑器中「手术日期」smart field 显示为「2026年04月17日」 |
| 4 | 将格式改回「YYYY-MM-DD」 | 编辑器中显示为「2026-04-17」 |
### TC-4ReportEditor 时间 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-DDtime 显示 24h |
## 验收标准
- [ ] `npm run lint` 无 TypeScript 编译错误
- [ ] TemplateManage 中时间字段可正常新增、编辑、保存配置
- [ ] ReportEditor 中 date 字段可根据格式正确显示在富文本中
- [ ] ReportEditor 中 time 字段 12h/24h 切换正常,存储值正确
- [ ] 自动填充当前时间仅在值为空时触发
- [ ] 模板底部「撰写时间」动态显示且可编辑
- [ ] 通用 time 字段有表单渲染并能正确同步到富文本
- [ ] 现有报告和历史数据不受本次改动影响
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View 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` 被自动清理
- [ ] 字段编辑面板展开后自动滚动对齐,底部输入框可正常点击
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View 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
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View 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 支持输入行数/列数并正常插入表格
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View 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-07ReportEditor 和 TemplateManage 的表格/图片占位符弹窗正常工作,焦点恢复无误。
- [ ] TC-08 ~ TC-10「仅限关键帧」占位符正确拦截上传类操作放行关键帧操作。
- [ ] TC-11 ~ TC-14「仅限上传类」占位符正确拦截关键帧操作放行上传类操作。
- [ ] TC-15旧数据无 `data-allow-source` 时默认行为不受影响。
- [ ] `npm run lint` 无 TypeScript 编译错误。
## 测试方式
手工验证。本项目无自动化测试框架,所有用例通过浏览器交互逐项确认。

View 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×65pxDOM 中可见 `data-mode="manual"`;从右侧视频分析面板拖拽关键帧到 Logo 占位符时,弹出提示「此处为静态图片占位符...」并拒绝插入。 |
| TC-02 | 默认模板签名占位符 | 1. 新建报告,滚动到底部「手术者签名」处。<br>2. 查看占位符 DOM。 | 占位符尺寸仍为 200×40pxDOM 中可见 `data-mode="manual"`;提示文本为「插入/点击放置图片」;拖拽关键帧到签名区域时被拦截。 |
| TC-03 | 默认模板表格内影像占位符 | 1. 新建报告,查看「手术图片说明表格」中的 6 个占位符。<br>2. 检查 DOM。 | 每个占位符尺寸仍为 100%×150pxDOM 中可见 `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 属性,并通过拖拽/自动插入验证隔离逻辑。

View File

@@ -0,0 +1,96 @@
# 测试方案 —— 2026-04-18-16-45-02
## 测试目标
验证代码编纂工作流是否能够正确运行,确保后续所有项目修改需求都能按规范流程执行,无步骤遗漏。
## 测试用例
### TC-01工作流文档完整性验证
**前置条件**:用户已提出建立工作流的需求
**操作步骤**
1. 检查 `.\工程分析` 文件夹是否存在
2. 检查 `需求分析-2026-04-18-16-45-02.md` 是否生成
3. 检查 `实现方案-2026-04-18-16-45-02.md` 是否生成
4. 检查 `测试方案-2026-04-18-16-45-02.md` 是否生成
5. 检查 `经验记录.md` 是否存在
**预期结果**
- `.\工程分析` 文件夹存在
- 需求分析、实现方案、测试方案文档均已生成,内容完整
- `经验记录.md` 存在且格式正确
---
### TC-02实现方案审核节点验证
**前置条件**:实现方案文档已生成
**操作步骤**
1. AI 展示实现方案文档内容
2. 用户阅读文档
3. 用户回复"确认"或提出修改意见
**预期结果**
- AI 在实现方案生成后主动停止,等待用户输入
- 用户未确认前AI 不进入下一阶段
---
### TC-03测试方案审核节点验证
**前置条件**:测试方案文档已生成,且实现方案已通过用户审核
**操作步骤**
1. AI 展示测试方案文档内容
2. 用户阅读文档
3. 用户回复"确认"或提出修改意见
**预期结果**
- AI 在测试方案生成后主动停止,等待用户输入
- 用户未确认前AI 不进入下一阶段
---
### TC-04经验记录读取验证
**前置条件**:存在历史经验记录文档
**操作步骤**
1. AI 读取 `.\工程分析\经验记录.md`
2. 检查是否能正确解析四段式格式
**预期结果**
- AI 能正确读取并理解经验记录内容
- 执行代码修改前能引用相关经验防止重复犯错
---
### TC-05Gitea 备份验证(后续真实需求执行时)
**前置条件**:代码修改已完成
**操作步骤**
1. AI 执行 `git add .`
2. AI 执行 `git commit -m "{时间戳} - {描述}"`
3. AI 执行 `git push origin main`
4. AI 执行 `git tag` 并推送
**预期结果**
- Commit 成功推送到远程 main 分支
- 标签成功推送到远程
- AI 向用户汇报备份完成
---
### TC-06重新部署验证后续真实需求执行时
**前置条件**:代码修改已提交
**操作步骤**
1. AI 执行 `npm run build`
2. 检查 `dist/` 目录是否存在且包含构建产物
3. AI 执行 `npm run preview` 或等效部署命令
**预期结果**
- 构建成功,无报错
- `dist/` 目录包含 `index.html``assets/`
- 预览服务正常运行
---
## 回归测试范围
- 无业务代码变更,不涉及回归测试
- 需确认 `.\工程分析` 目录下的新文档不会影响项目构建(即 `.gitignore` 或构建配置不会误处理 `.md` 文件)
## 测试结论
本次测试的核心是验证"工作流机制本身是否成立"。由于不涉及业务代码修改TC-05 和 TC-06 将在后续真实需求中实际执行并验证。

View File

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

View File

@@ -0,0 +1,155 @@
# 测试方案 —— 2026-04-18-17-27-51
## 测试目标
验证 TemplateManage 静态占位符插入修复、默认模板排版重构、Logo 删除按钮修复。
## 测试用例
### TC-01TemplateManage 插入静态图片占位符
**前置条件**:进入 /template-manage编辑器有焦点
**操作步骤**
1. 点击工具栏「插入图片占位符」
2. 在弹窗中选择「静态图片占位」
3. 输入宽度 200高度 200
4. 点击「确认插入」
**预期结果**
- 编辑器中出现虚线边框的占位符框
- 占位符带有 `class="image-placeholder"``data-mode="manual"`
- 占位符内部显示「插入/点击放置图片」文字
- 占位符右上角显示红色「×」删除按钮
---
### TC-02TemplateManage 插入手术影像占位符
**前置条件**:进入 /template-manage
**操作步骤**
1. 点击工具栏「插入图片占位符」
2. 选择「手术影像占位」
3. 点击「确认插入」
**预期结果**
- 占位符正常显示
- 带有 `data-mode="frame"`
- 可接受关键帧拖拽填充
---
### TC-03TemplateManage 占位符删除按钮
**前置条件**:已插入占位符
**操作步骤**
1. 鼠标悬浮在占位符上
2. 点击右上角的红色「×」
**预期结果**
- 占位符被删除
- 撤销按钮可恢复该占位符
---
### TC-04新建报告默认模板排版——抬头
**前置条件**:退出重新登录,进入 /report-editor新建报告
**操作步骤**
1. 查看报告顶部
**预期结果**
- 左侧有 65×65 的 Logo 占位符(虚线框)
- 中间偏右有 14pt 下划线文字「西 安 交 通 大 学 第 一 附 属 医 院」
- 下方有 16pt 文字「手术记录」
- 整体居中对齐
---
### TC-05新建报告默认模板排版——基本信息栏
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 查看抬头下方的基本信息行
**预期结果**
- 一行显示:姓名、性别、年龄、科别、床号、住院号
- 字体 11pt不加粗
- 整行下方有一条黑色贯穿下划线
---
### TC-06新建报告默认模板排版——诊断信息
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 查看手术日期、术前诊断、术中诊断、手术名称
**预期结果**
- 每项独立一行
- 12pt 字体,加粗
- 格式为:「手术日期:」+ smartField 占位符
---
### TC-07新建报告默认模板排版——双列信息
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 查看时间、人员、麻醉信息
**预期结果**
- 手术开始/终止时间在同一行,左右各 50%
- 手术者/助手在同一行
- 麻醉师/麻醉方式在同一行
- 12pt 字体,不加粗
---
### TC-08新建报告默认模板排版——手术步骤标题
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 查看「手术步骤、术中出现的情况及处理:」
**预期结果**
- 12pt 字体,加粗
- 位于双列信息下方
---
### TC-09Logo 占位符删除按钮可点击
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 鼠标悬浮在顶部 Logo 占位符上
2. 点击右上角的红色「×」
**预期结果**
- Logo 占位符被删除
- 可撤销恢复
---
### TC-10Logo 占位符图片上传
**前置条件**:新建报告已加载默认模板
**操作步骤**
1. 点击顶部 Logo 占位符
2. 选择本地上传一张图片
**预期结果**
- 图片正确显示在 65×65 区域内
- 图片不溢出占位符
---
### TC-11打印效果验证
**前置条件**:新建报告,填写部分内容
**操作步骤**
1. 点击打印按钮
2. 检查打印预览
**预期结果**
- 抬头排版正确Logo + 医院名 + 标题)
- 基本信息下划线可见
- 双列信息左右对齐
- 无多余虚线边框placeholder 填充后 border 应消失)
---
## 回归测试范围
- 验证 `ReportEditor` 中已有的 `image-placeholder` 点击上传、拖拽填充功能不受影响
- 验证 `TemplateManage` 中智能字段插入、删除、撤销/重做功能正常
- 验证 `smart-field-wrapper` 双向绑定正常工作
## 测试结论
TC-01~TC-11 全部通过,即可确认三项需求均正确实现。

View File

@@ -0,0 +1,111 @@
# 测试方案 —— 2026-04-18-17-48-59
## 测试目标
验证默认模板排版微调和打印样式修复是否正确生效。
## 测试用例
### TC-01基本信息栏字段间距
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看「姓名:性别:年龄:科别:床号:住院号:」一行
**预期结果**
- 各字段之间仅有一个空格间距
- 字段分布紧凑,不会过度分散
---
### TC-02抬头整体居中
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看报告最顶部
**预期结果**
- Logo 与「西 安 交 通 大 学 第 一 附 属 医 院 + 手术记录」作为一个整体水平居中
- Logo 与文字之间间距较小(约 12px
- 不会出现 Logo 偏左、文字偏右的分离感
---
### TC-03打印时不显示删除按钮
**前置条件**:新建报告,填写部分字段内容
**操作步骤**
1. 点击打印按钮
2. 检查打印预览
**预期结果**
- 所有红色「×」删除按钮均不可见
- `.image-placeholder` 中的 × 不可见
- `.smart-field-wrapper` 中的 × 不可见
- 已填充的图片占位符正常显示图片
---
### TC-04全文行距统一
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看手术日期、术前诊断等段落
2. 查看手术步骤段落
**预期结果**
- 所有段落行距一致,为 1.5 倍
- 段落之间无额外 margin/padding 间距
- 整体排版紧凑均匀
---
### TC-05打印行距验证
**前置条件**:报告有内容
**操作步骤**
1. 点击打印
2. 检查打印预览中的段落间距
**预期结果**
- 打印输出行距为 1.5 倍
- 无段前段后 padding
---
### TC-06医院名称下划线贴底
**前置条件**:新建报告
**操作步骤**
1. 查看「西 安 交 通 大 学 第 一 附 属 医 院」下方横线
**预期结果**
- 下边框紧贴文字底部
- 无明显的 padding-bottom 间隙
---
### TC-07基本信息栏下划线贴底
**前置条件**:新建报告
**操作步骤**
1. 查看「姓名:...住院号:」整行下方的横线
**预期结果**
- 下边框紧贴文字底部
- 无明显的 padding-bottom 间隙
- 横线与文字之间仅有极小间距(≤ 2px
---
### TC-08打印下划线验证
**前置条件**:报告有内容
**操作步骤**
1. 点击打印
2. 检查医院名和基本信息栏的下划线位置
**预期结果**
- 打印时下边框紧贴文字底部
- 与屏幕预览一致
---
## 回归测试范围
- 验证 smart-field-wrapper 的双向绑定(表单→正文、正文→表单)正常工作
- 验证 image-placeholder 的点击上传、拖拽填充、删除功能不受影响
- 验证手术图片说明表格的 6 图格布局正常
## 测试结论
TC-01~TC-08 全部通过,即可确认五项排版优化均正确实现。

View File

@@ -0,0 +1,134 @@
# 测试方案 —— 2026-04-18-18-08-37
## 测试目标
验证编辑器工具栏字号/行距功能、字体选择修复,以及默认模板排版调整。
## 测试用例
### TC-01ReportEditor 字体选择修复
**前置条件**:进入 /report-editor编辑器中有文字
**操作步骤**
1. 选中一段文字
2. 从工具栏字体下拉框选择「微软雅黑」
**预期结果**
- 选中的文字字体变为微软雅黑
- 编辑器未失去焦点
---
### TC-02ReportEditor 字号选择
**前置条件**:进入 /report-editor编辑器中有文字
**操作步骤**
1. 选中一段文字
2. 从工具栏字号下拉框选择「14pt」
**预期结果**
- 选中的文字字号变大
- 编辑器未失去焦点
---
### TC-03ReportEditor 行距选择
**前置条件**:进入 /report-editor编辑器中有多行文字
**操作步骤**
1. 将光标放在某一段落内
2. 从工具栏行距下拉框选择「2.0」
**预期结果**
- 当前段落行距变为 2.0
- 其他段落不受影响
- 草稿自动保存
---
### TC-04TemplateManage 工具栏功能
**前置条件**:进入 /template-manage
**操作步骤**
1. 分别测试字体、字号、行距选择功能
**预期结果**
- 字体选择生效
- 字号选择生效
- 行距选择生效
- 撤销/重做能恢复行距修改
---
### TC-05手术者签名右对齐不换行
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 找到「手术者签名」行
2. 将光标放在该行,点击工具栏「右对齐」
**预期结果**
- 「手术者签名:」文字和图片占位符在同一行
- 两者一起靠右对齐
- 图片框不会单独换到下一行
---
### TC-06手术记录与姓名间距
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看「手术记录」标题与「姓名:」之间的间距
**预期结果**
- 间距明显缩小(约 4px
- 不再有过大的空白区域
---
### TC-07手术名称与手术开始时间间距
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看「手术名称」与「手术开始时间」之间的间距
**预期结果**
- 两者间距仅为 1.5 行距的自然间距
- 无额外 margin/padding 造成的空白
---
### TC-08手术步骤段落字体统一
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看手术步骤 1~5 的字体大小
**预期结果**
- 所有手术步骤段落均为 12pt 字体
- 与上方「手术日期」等诊断信息字体大小一致
---
### TC-09手术后情况段落字体
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看「手术后情况」「切除标本描述」等段落的字体大小
**预期结果**
- 均为 12pt 字体
- 行距 1.5,无段前段后间距
---
### TC-10打印效果验证
**前置条件**:报告有内容
**操作步骤**
1. 点击打印
2. 检查打印预览
**预期结果**
- 字体、字号、行距设置正确反映在打印输出中
- 所有删除按钮(×)不可见
- 排版紧凑一致
---
## 回归测试范围
- 验证 `smart-field-wrapper` 双向绑定正常工作
- 验证 `image-placeholder` 点击上传、拖拽填充功能正常
- 验证手术图片说明表格布局未受影响
## 测试结论
TC-01~TC-10 全部通过,即可确认所有需求均正确实现。

View File

@@ -0,0 +1,117 @@
# 测试方案 —— 2026-04-18-18-36-43
## 测试目标
验证五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
## 测试用例
### TC-01ReportManage 列名显示
**前置条件**:进入 /report-manage
**操作步骤**
1. 查看表格表头
**预期结果**
- 表头显示为「住院号」而非「患者号」
- 数据列正确显示 hospitalId 值
---
### TC-02字段管理下划线开关
**前置条件**:进入 /template-manage点击字段管理
**操作步骤**
1. 新建一个字段
2. 观察「打印时显示下划线」checkbox默认应为勾选
3. 取消勾选并保存
4. 将该字段插入模板
**预期结果**
- 新建字段表单中有「打印时显示下划线」选项
- 编辑字段时也可修改该选项
- 取消下划线的字段插入后,`.field-value` 带有 `no-underline`
---
### TC-03打印时下划线控制
**前置条件**:模板中有带/不带下划线的字段
**操作步骤**
1. 进入 report-editor新建报告
2. 填写字段内容
3. 点击打印
**预期结果**
- 默认勾选下划线的字段,打印时 `.field-value` 底部有黑色下划线
- 取消下划线的字段,打印时 `.field-value` 底部无下划线
---
### TC-04ReportEditor 下载按钮
**前置条件**:进入 /report-editor有内容的报告
**操作步骤**
1. 点击顶部下载按钮
2. 在弹窗中选择「导出 PDF」
3. 在弹窗中选择「导出 JSON」
**预期结果**
- 弹窗正常显示两个导出选项
- PDF 导出时浏览器保存对话框的文件名包含「图文报告-{手术名称}-{患者}-{住院号}-{时间}」
- JSON 导出时下载的文件名格式同上,内容包含 reportData
---
### TC-05TemplateManage 下载按钮
**前置条件**:进入 /template-manage
**操作步骤**
1. 点击顶部下载按钮
2. 选择导出 PDF/JSON
**预期结果**
- 导出功能正常
- 文件名格式合理(模板名称 + 时间)
---
### TC-06右对齐时签名不换行
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 找到「手术者签名」行
2. 选中该行,点击工具栏「右对齐」
**预期结果**
- 「手术者签名:」文字与图片占位符在同一行
- 两者一起靠右对齐
- 图片框不会单独换到下一行
---
### TC-07默认模板签名右对齐
**前置条件**:新建报告,加载默认模板
**操作步骤**
1. 查看报告底部「手术者签名」行
**预期结果**
- 默认即为右对齐
- 文字与图片框在同一行
---
### TC-08占位符 inline-block 样式
**前置条件**:在 template-manage 中插入静态图片占位符
**操作步骤**
1. 点击工具栏「插入图片占位符」
2. 选择「静态图片占位」
3. 确认插入
**预期结果**
- 占位符的 style 中 `display``inline-block` 而非 `inline-flex`
- 占位符在编辑器中正常显示,垂直居中
---
## 回归测试范围
- 验证所有现有字段(默认模板中的)打印时仍显示下划线
- 验证 smart-field-wrapper 双向绑定正常工作
- 验证 image-placeholder 点击上传、拖拽填充、删除功能正常
- 验证 report-manage 的搜索、筛选、批量操作不受影响
## 测试结论
TC-01~TC-08 全部通过,即可确认所有需求均正确实现。

View File

@@ -0,0 +1,64 @@
# 测试方案 —— 2026-04-18-19-08-43
## 测试目标
验证六项需求修改的正确性和稳定性。
## 测试用例
### TC-1基础信息字段打印无下划线
**前置条件**:新建报告,默认模板已加载。
**步骤**
1. 点击「打印预览」或「下载 PDF」。
2. 检查「姓名、性别、年龄、科别、床号、住院号」区域。
**预期结果**:这 6 个字段不显示下划线,其他字段(如手术名称、诊断等)正常显示下划线。
### TC-2点击 field-value 联动高亮并居中滚动
**前置条件**:编辑器已加载默认模板,右侧基本信息 Tab 可见。
**步骤**
1. 点击正文中任意 `class="field-value"`(如「手术名称」)。
2. 观察右侧对应输入框。
**预期结果**
- 对应输入框出现蓝色背景高亮(`bg-blue-50 ring-1 ring-accent`)。
- 页面平滑滚动,使该输入框位于可视区域中部。
- 输入框获得焦点。
### TC-3视频上传按钮整合进缩略图列表
**前置条件**:已上传至少一个视频。
**步骤**
1. 切换到「视频分析」Tab。
2. 观察视频缩略图列表。
**预期结果**
- 列表第一个位置是一个缩小版「上传视频」按钮,尺寸与视频卡片一致(约 `w-24`)。
- 点击该按钮能正常打开文件选择器。
- 原本独立的「点击上传手术视频」大按钮已消失。
### TC-4视频模块间距紧凑化
**前置条件**:视频分析面板展开,有视频和关键帧。
**步骤**
1. 观察缩略图列表与播放器之间的间距。
2. 观察播放器与控制按钮之间的间距。
3. 观察控制按钮与「关键帧摘取」标题之间的间距。
**预期结果**:各项间距明显缩小,下方关键帧列表获得更多展示空间。
### TC-5签名与日期之间增加空行
**前置条件**:默认模板已加载。
**步骤**
1. 滚动到模板底部,查看「手术者签名」与「撰写时间」之间。
**预期结果**:两者之间有一个空行(约一行高度的空白)。
### TC-6图片占位符填充后高度自适应
**前置条件**:模板中有空图片占位符,有较小的图片(高度 < 200px
**步骤**
1. 将图片插入占位符(通过上传、拖拽或自动摘取)。
2. 观察占位符区域。
**预期结果**
- 占位符高度随图片实际尺寸自适应,不再保留 200px 固定高度。
- 图片下方不会出现大片空白。
## 回归测试
- 确保打印功能PDF 导出)正常工作。
- 确保视频播放、关键帧摘取、拖拽插入功能正常。
- 确保 `template-manage` 中的图片占位符同样支持高度自适应。
## 测试通过标准
所有用例均通过,无控制台报错,打印样式正常。

View File

@@ -0,0 +1,54 @@
# 测试方案 —— 2026-04-18-19-23-31
## 测试目标
验证视频分析模块空白修复和图片占位符自适应逻辑。
## 测试用例
### TC-1视频分析模块无视频时显示上传按钮
**前置条件**新建报告切换到「视频分析」Tab尚未上传任何视频。
**步骤**
1. 点击「视频分析」Tab。
**预期结果**
- 面板显示「上传视频」按钮(缩小版,在水平滚动区域首位)。
- 面板不显示视频播放器和关键帧区域。
- 点击上传按钮可正常打开文件选择器。
### TC-2视频分析模块有视频时正常显示
**前置条件**:已上传至少一个视频。
**步骤**
1. 切换到「视频分析」Tab。
**预期结果**
- 上传按钮和视频缩略图列表均可见。
- 选中视频后,播放器和关键帧区域正常显示。
### TC-3图片占位符填充后尺寸自适应小图片
**前置条件**:模板中有 200×200 的图片占位符,准备一张 100×80 的小图片。
**步骤**
1. 将小图片插入占位符。
**预期结果**
- 占位符宽度收缩为约 100px高度收缩为约 80px。
- 图片靠左上方放置,无多余空白。
### TC-4图片占位符填充后尺寸自适应大图片
**前置条件**:模板中有 200×200 的图片占位符,准备一张 800×600 的大图片。
**步骤**
1. 将大图片插入占位符。
**预期结果**
- 图片等比例缩小,最大不超过 200×200。
- 占位符宽度收缩为缩小后的图片宽度≤200px高度同理。
- 图片靠左上方放置。
### TC-5Logo 占位符大小保持 65px × 65px
**前置条件**:默认模板已加载。
**步骤**
1. 检查顶部 Logo 占位符。
**预期结果**:占位符尺寸为 65px × 65px不受本次修改影响。
## 回归测试
- 确保视频播放、关键帧摘取、拖拽插入功能正常。
- 确保 `template-manage` 中的图片占位符同样支持尺寸自适应。
- 确保打印样式正常,图片不会被截断。
## 测试通过标准
所有用例均通过,无控制台报错,视频模块和图片占位符行为符合预期。

View File

@@ -0,0 +1,48 @@
# 测试方案 —— 2026-04-18-19-37-56
## 测试目标
验证 4 项编辑器体验修复的正确性和稳定性。
## 测试用例
### TC-1视频上传按钮位于列表末尾
**前置条件**:已上传至少一个视频。
**步骤**
1. 切换到「视频分析」Tab。
**预期结果**
- 视频缩略图列表中,已有视频在前,「上传视频」按钮在最后。
- 点击上传按钮可正常打开文件选择器。
### TC-2图片占位符提示文字绝对居中
**前置条件**:默认模板已加载。
**步骤**
1. 查看顶部 Logo 占位符和表格内图片占位符。
**预期结果**
- 「插入图片」或「插入/点击放置图片」文字在占位框正中心,不偏上也不偏下。
- 占位符高度不同时65px vs 200px文字始终居中。
### TC-3删除图片后占位符恢复原始大小
**前置条件**:模板中有 200×200 的图片占位符,已插入图片。
**步骤**
1. 点击图片上的「×」删除按钮。
**预期结果**
- 占位符恢复为虚线框,宽度恢复为 200px高度恢复为 200px。
- 提示文字居中显示。
- 占位符仍可重新插入图片。
### TC-4对齐按钮不破坏混合排版
**前置条件**:默认模板已加载,「手术者签名:」行包含文字和签名占位符。
**步骤**
1. 将光标放在「手术者签名:」这一行。
2. 分别点击「左对齐」「居中」「右对齐」按钮。
**预期结果**
- 整行(文字 + 占位符)作为一个整体对齐,不会换行分离。
- `.field-value` 所在行同样适用,对齐时不破坏字段与文字的同行关系。
## 回归测试
- 确保视频上传、播放、关键帧摘取功能正常。
- 确保图片占位符的插入、拖拽、自动帧填充功能正常。
- 确保打印样式正常,图片和字段显示正确。
## 测试通过标准
所有用例均通过,无控制台报错,排版结构完整。

View File

@@ -0,0 +1,62 @@
# 测试方案 —— 2026-04-18-20-03-44
## 测试目标
验证模板导入/导出功能和默认模板 Logo 替换的正确性。
## 测试用例
### TC-1模板导出
**前置条件**:模板列表中已有至少一个模板,且该模板有内容和字段配置。
**步骤**
1. 在模板列表中找到目标模板。
2. 点击操作列的「导出」按钮。
**预期结果**
- 浏览器下载一个 JSON 文件,文件名为 `模板导出-{模板名称}.json`
- JSON 内容包含 `version``type``title``description``content``fields` 字段。
- `fields` 数组与模板原有的字段配置一致。
### TC-2模板导入自动填充名称和描述
**前置条件**:已有一个有效的模板导出 JSON 文件。
**步骤**
1. 点击「新增模板」按钮。
2. 在弹窗中点击导入图标,选择 JSON 文件。
**预期结果**
- 模板名称输入框自动填充为 JSON 中的 `title`
- 模板描述输入框自动填充为 JSON 中的 `description`
- 无控制台报错。
### TC-3模板导入后创建
**前置条件**:已完成 TC-2 的导入操作。
**步骤**
1. 点击「创建」按钮。
2. 在新创建的模板中点击「编辑模板」。
**预期结果**
- 编辑器中显示的内容与导入 JSON 中的 `content` 一致。
- 字段管理中的配置与导入 JSON 中的 `fields` 一致。
### TC-4导入无效文件
**前置条件**:准备一个非 JSON 文件或格式错误的 JSON。
**步骤**
1. 在新增模板弹窗中选择无效文件。
**预期结果**
- 弹出提示「文件解析失败,请检查 JSON 格式」或「无效的模板包文件」。
- 表单保持空白,不填充任何数据。
### TC-5Logo 占位符交互
**前置条件**:新建报告,默认模板已加载。
**步骤**
1. 查看顶部 Logo 区域。
2. 点击 Logo 占位符右上方的「×」。
3. 再次点击 Logo 区域。
**预期结果**
- Logo 区域显示为虚线框提示文字「LOGO」居中显示。
- 点击「×」后 Logo 占位符被删除。
- 再次点击可打开图片选择器插入图片。
## 回归测试
- 确保模板列表的加载、编辑、删除功能正常。
- 确保默认模板的其他部分(基础信息、手术步骤、图片表格等)不受影响。
- 确保打印样式正常。
## 测试通过标准
所有用例均通过,无控制台报错,导入/导出数据完整准确。

View File

@@ -0,0 +1,54 @@
# 测试方案 —— 2026-04-18-22-59-10
## 测试目标
验证字段下划线默认行为和占位符文字居中修复。
## 测试用例
### TC-1新增字段默认不下划线
**前置条件**:进入模板管理 → 字段管理 → 新增字段。
**步骤**
1. 点击「添加字段」。
2. 观察「打印时显示下划线」复选框状态。
**预期结果**:复选框默认未勾选。
### TC-2插入字段默认带 no-underline 类
**前置条件**:模板管理中已有字段(默认或新增)。
**步骤**
1. 在编辑器中插入任意字段。
2. 检查生成的 HTML。
**预期结果**`.field-value` 带有 `.no-underline` 类。
### TC-3显式勾选下划线后打印正常显示
**前置条件**:某个字段的「打印时显示下划线」已勾选。
**步骤**
1. 插入该字段。
2. 点击打印预览。
**预期结果**:该字段显示下划线,其他未勾选字段不显示。
### TC-4默认模板所有字段打印无下划线
**前置条件**:新建报告,加载默认模板。
**步骤**
1. 点击打印预览。
2. 检查「姓名、性别、年龄、科别、床号、住院号」等字段。
**预期结果**:所有字段均不显示下划线。
### TC-5删除图片后占位符文字居中
**前置条件**:模板中有图片占位符,已插入图片。
**步骤**
1. 点击图片右上角的「×」删除。
**预期结果**:提示文字(如「插入/点击放置图片」或「LOGO」在虚线框正中心不偏左。
### TC-6不同尺寸占位符文字均居中
**前置条件**模板中有不同尺寸的占位符65px Logo、200px 表格占位符)。
**步骤**
1. 分别检查各占位符的文字位置。
**预期结果**:所有占位符文字均绝对居中。
## 回归测试
- 确保字段插入、编辑、删除功能正常。
- 确保图片占位符的插入、删除、拖拽功能正常。
- 确保打印样式正常。
## 测试通过标准
所有用例均通过,无控制台报错,排版居中对齐准确。

View File

@@ -0,0 +1,73 @@
# 测试方案 —— 2026-04-18-23-19-44
## 测试目标
验证排版修复、导出文件名优化和模板批量操作的正确性。
## 测试用例
### TC-1field-value 文字与正文齐平
**前置条件**:新建报告,加载默认模板。
**步骤**
1. 在「姓名」字段中输入文字。
2. 观察文字与「姓名:」的基线对齐情况。
**预期结果**:字段中的文字与周围正文在同一水平线上,无明显上浮。
### TC-2打印时下划线紧贴文字
**前置条件**:模板中有带下划线的字段。
**步骤**
1. 点击打印预览。
2. 观察下划线与文字的距离。
**预期结果**:下划线与文字底部距离约 1px不悬空。
### TC-3排版间距微调
**前置条件**:默认模板已加载。
**步骤**
1. 观察「姓名:」与下方横线的距离。
2. 观察「手术记录」与上方横线的距离。
3. 观察 Logo 与医院名称的相对位置。
**预期结果**
- 姓名栏横线紧贴文字下方(约 1px
- 手术记录距上方横线约 2px
- Logo 比原来偏左上约 5px
### TC-4导出 PDF 文件名正确
**前置条件**:报告已填写完整信息。
**步骤**
1. 点击「导出报告」→「导出 PDF」。
**预期结果**:浏览器保存对话框中的默认文件名为 `图文报告-{title}-{patient}-{hid}-{time}.pdf`而非「My Google AI Studio App.pdf」。
### TC-5导出 JSON 时间使用北京时间
**前置条件**:任意可导出 JSON 的页面。
**步骤**
1. 点击导出 JSON。
2. 查看文件名中的时间戳。
**预期结果**:时间戳为北京时间(如当前是北京时间 23:19文件名中应显示 23-19 而非 15-19
### TC-6模板批量删除
**前置条件**:模板列表中有多个模板。
**步骤**
1. 选中 2 个模板的复选框。
2. 点击「批量删除」。
3. 确认删除。
**预期结果**:选中的模板被删除,列表中不再显示。未选中的模板保留。
### TC-7模板批量导出
**前置条件**:模板列表中有多个模板。
**步骤**
1. 选中 2 个模板的复选框。
2. 点击「批量导出」。
**预期结果**:下载的 JSON 文件包含 2 个模板的完整数据(名称、描述、内容、字段配置)。
### TC-8允许空模板列表
**前置条件**:模板列表中有模板。
**步骤**
1. 选中所有模板并批量删除。
**预期结果**:列表显示为空,无报错。
## 回归测试
- 确保打印功能正常,样式无异常。
- 确保单个模板导出/导入功能正常。
- 确保报告编辑、保存、加载功能正常。
## 测试通过标准
所有用例均通过,无控制台报错,排版对齐准确,文件名正确。

View File

@@ -0,0 +1,64 @@
# 测试方案 —— 2026-04-18-23-39-35
## 测试目标
验证下划线修复、文件名统一、间距缩紧和双向联动的正确性。
## 测试用例
### TC-1基础字段默认不勾选下划线
**前置条件**:进入模板管理 → 字段管理。
**步骤**
1. 点击「患者姓名」或「住院号」的编辑按钮。
**预期结果**:「打印时显示下划线」复选框默认未勾选。
### TC-2勾选下划线后打印生效
**前置条件**:某个字段已勾选「打印时显示下划线」。
**步骤**
1. 在编辑器中插入该字段。
2. 点击打印预览。
**预期结果**:该字段显示下划线,且下划线紧贴文字底部。
### TC-3未勾选下划线打印不显示
**前置条件**:某个字段未勾选下划线。
**步骤**
1. 在编辑器中插入该字段。
2. 点击打印预览。
**预期结果**:该字段不显示下划线。
### TC-4PDF 与 JSON 文件名一致
**前置条件**:报告已填写完整信息。
**步骤**
1. 分别点击「导出 PDF」和「导出 JSON」。
**预期结果**:两个文件的文件名前缀完全一致(如 `图文报告-腹腔镜胆囊切除术报告-未知-无号-2026-04-18T23-28`)。
### TC-5field-value 间距缩紧
**前置条件**:模板中有 field-value 字段。
**步骤**
1. 观察字段框内文字与边框的距离。
2. 打印预览中观察间距。
**预期结果**:文字紧贴边框,左右无明显空白。
### TC-6表单逆向联动
**前置条件**ReportEditor 已加载默认模板。
**步骤**
1. 点击右侧「基本信息」中「手术名称」输入框。
2. 观察中间模板区域。
**预期结果**
- 中间模板中「手术名称」字段高亮显示(蓝色背景 + 蓝色描边)。
- 页面平滑滚动到该字段位置(视野中央)。
### TC-7正向联动仍正常
**前置条件**ReportEditor 已加载默认模板。
**步骤**
1. 点击中间模板中的「患者姓名」字段。
**预期结果**
- 右侧表单中高亮「患者姓名」输入框。
- 右侧滚动到该输入框位置。
## 回归测试
- 确保字段插入、编辑、删除功能正常。
- 确保打印样式正常,所有字段类型显示正确。
- 确保视频分析、图片占位符功能正常。
## 测试通过标准
所有用例均通过,无控制台报错,下划线逻辑正确,双向联动流畅。

View File

@@ -0,0 +1,48 @@
# 测试方案 —— 2026-04-19-00-01-50
## 测试目标
验证高亮样式修复、点击空白取消、打印纯净度、下划线同步的有效性。
## 测试用例
### TC-1高亮样式柔和
**前置条件**ReportEditor 已加载默认模板。
**步骤**
1. 点击中间模板中的任意字段。
**预期结果**:字段显示浅灰色背景和细灰边框(不再是刺眼的蓝色)。
### TC-2点击空白取消高亮
**前置条件**ReportEditor 中某个字段已被高亮。
**步骤**
1. 点击模板中的空白区域(非字段、非占位符)。
**预期结果**:字段高亮样式消失,恢复为默认状态。
### TC-3打印不带高亮框
**前置条件**ReportEditor 中某个字段处于高亮状态。
**步骤**
1. 点击打印预览。
**预期结果**打印内容中不显示任何高亮框、outline 或 box-shadow字段显示正常。
### TC-4勾选下划线后打印生效
**前置条件**TemplateManage 中某字段已插入模板,且未勾选下划线。
**步骤**
1. 在字段管理中勾选该字段的「打印时显示下划线」。
2. 保存字段编辑。
3. 在 ReportEditor 中打印预览。
**预期结果**:该字段显示下划线。
### TC-5取消下划线后打印不显示
**前置条件**TemplateManage 中某字段已勾选下划线并保存。
**步骤**
1. 取消勾选该字段的「打印时显示下划线」。
2. 保存字段编辑。
3. 在 ReportEditor 中打印预览。
**预期结果**:该字段不显示下划线。
## 回归测试
- 确保字段插入、编辑、删除功能正常。
- 确保双向联动(中间点击→右侧高亮、右侧点击→中间高亮)正常。
- 确保打印样式整体正常。
## 测试通过标准
所有用例均通过,无控制台报错,打印内容纯净无高亮残留。

View File

@@ -0,0 +1,26 @@
# 测试方案 —— 2026-04-19-00-13-20
## 测试目标
验证打印时下划线是否紧贴文字底部。
## 测试用例
### TC-1有下划线字段紧贴文字
**前置条件**ReportEditor 中某字段(如术前诊断)已勾选「打印时显示下划线」。
**步骤**
1. 点击打印预览。
**预期结果**:该字段的下划线(黑线)紧贴文字底部,无明显间距。
### TC-2无下划线字段不受影响
**前置条件**:某字段带有 `.no-underline` 类。
**步骤**
1. 点击打印预览。
**预期结果**:该字段不显示下划线,排版正常。
### TC-3屏幕编辑态不受影响
**步骤**
1. 在 ReportEditor 中查看字段。
**预期结果**:屏幕上的 `.field-value` 行高保持原样,未被压缩。
## 测试通过标准
打印内容中下划线紧贴文字,无多余留白,屏幕编辑态正常。

Some files were not shown because too many files have changed in this diff Show More