Compare commits
3 Commits
v1.1.0
...
f373520fa5
| Author | SHA1 | Date | |
|---|---|---|---|
| f373520fa5 | |||
| 915e169bd8 | |||
| c55a55a27b |
@@ -1,113 +1,195 @@
|
||||
---
|
||||
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 调整、需求变更等任务时,必须严格按此工作流执行。不适用于纯问答、查询信息、或不涉及代码修改的任务。
|
||||
---
|
||||
|
||||
# 手术图文病历报告系统 —— 修改需求五步工作流
|
||||
# 手术图文病历报告系统 — 项目修改工作流
|
||||
|
||||
当用户提出任何程序修改需求时,必须严格按照以下顺序执行。禁止跳过任何步骤,禁止在方案未经过用户二次人工审核确认前进入下一步。
|
||||
> 本文档是 AI 编码代理执行项目修改需求时的强制性规范。任何涉及代码变更的任务必须按以下步骤逐一执行,严禁跳过或合并步骤。
|
||||
|
||||
## 前置检查
|
||||
|
||||
- **确认任务类型**:如果用户请求不涉及代码/配置/文件修改(如纯提问、查资料、解释概念),则无需使用此工作流。
|
||||
- **确认当前工作目录**:所有路径均相对于 `C:\Users\Administrator\Downloads\Gemini-图文报告系统-V1.2`(项目根目录)。
|
||||
|
||||
---
|
||||
|
||||
## Step 0: Gitea 备份 + 记录时间戳
|
||||
## Step 0:记录时间戳
|
||||
|
||||
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
|
||||
```
|
||||
3. 若首次初始化仓库,执行 `git init` 和 `git checkout -b main` 后再提交。
|
||||
4. 此步骤中所有 git 操作若遇到报错(如 nothing to commit),通过调整文件内容或空提交确保能推送成功。
|
||||
**任务开始后的第一件事**:获取当前时间并格式化为 `YYYY-MM-DD-HH-MM-SS`(如 `2026-04-16-21-30-00`)。
|
||||
|
||||
- 将该时间戳保存到工作上下文中(如变量 `workflowTimestamp`)。
|
||||
- 所有后续文档的文件名必须以此时间戳结尾。
|
||||
|
||||
---
|
||||
|
||||
## Step 1: 工程分析目录检查
|
||||
## Step 1:创建工程分析目录
|
||||
|
||||
确保以下目录存在(如不存在则创建):
|
||||
在**项目根目录**下创建文件夹:
|
||||
```
|
||||
.\工程分析\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: 需求分析文档
|
||||
|
||||
将用户提出的修改需求整理分析,写入文件:
|
||||
```
|
||||
.\工程分析\需求分析-{时间戳}.md
|
||||
```
|
||||
|
||||
文档必须包含:
|
||||
- 需求背景
|
||||
- 功能目标(精确描述要实现什么)
|
||||
- 涉及页面/模块/文件
|
||||
- 验收标准
|
||||
如果该目录已存在,则无需重复创建。
|
||||
|
||||
---
|
||||
|
||||
## Step 3: 实现方案文档(必须经过用户二次人工审核)
|
||||
## Step 2:需求分析
|
||||
|
||||
基于需求分析,编写详细实现方案,写入文件:
|
||||
```
|
||||
.\工程分析\实现方案-{时间戳}.md
|
||||
```
|
||||
|
||||
文档必须包含:
|
||||
- 技术思路与实现路径
|
||||
- 需要修改/新增的文件清单
|
||||
- 关键代码变更说明
|
||||
- 潜在风险点及应对策略
|
||||
|
||||
**重要约束**:写完此文档后,必须立即停止并向用户汇报,等待用户二次人工审核确认。在得到明确的"确认"、"同意"、"可以执行"等回复前,禁止继续进入 Step 4 或开始任何代码修改。
|
||||
|
||||
---
|
||||
|
||||
## Step 4: 测试方案文档(必须经过用户二次人工审核)
|
||||
|
||||
实现方案经用户确认后,编写测试方案,写入文件:
|
||||
```
|
||||
.\工程分析\测试方案-{时间戳}.md
|
||||
```
|
||||
|
||||
文档必须包含:
|
||||
- 测试项清单
|
||||
- 测试步骤(操作路径)
|
||||
- 预期结果
|
||||
- 回归验证范围(确保没有破坏现有功能)
|
||||
|
||||
**重要约束**:写完此文档后,必须立即停止并向用户汇报,等待用户二次人工审核确认。在得到明确的"确认"、"同意"、"可以执行"等回复前,禁止继续进入 Step 5。
|
||||
|
||||
---
|
||||
|
||||
## Step 5: 执行修改 + 经验记录
|
||||
|
||||
测试方案经用户确认后,开始执行:
|
||||
1. 按实现方案修改代码。
|
||||
2. 运行 `npm run lint` 进行 TypeScript 类型检查,确保零错误。
|
||||
3. 运行 `npm run build` 验证生产构建是否通过。
|
||||
4. 修改完成后,在以下文档中追加本次执行过程中遇到的关键问题及解决方案:
|
||||
1. 仔细阅读用户提出的需求,拆解成功能点与非功能点。
|
||||
2. 分析影响范围(修改哪些文件、是否有风险点)。
|
||||
3. 将分析结果写入文件:
|
||||
```
|
||||
.\工程分析\经验记录.md
|
||||
.\工程分析\需求分析-{workflowTimestamp}.md
|
||||
```
|
||||
4. 文档结构模板:
|
||||
```markdown
|
||||
# 需求分析 — {workflowTimestamp}
|
||||
|
||||
## 原始需求摘要
|
||||
(用户原话或提炼后的核心诉求)
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
-
|
||||
|
||||
### 非功能点
|
||||
-
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| | | |
|
||||
|
||||
## 待确认问题
|
||||
(如有,列出需要用户确认的内容)
|
||||
```
|
||||
每条记录必须使用四段式格式:
|
||||
- **A. 具体问题**
|
||||
- **B. 产生问题原因**
|
||||
- **C. 解决问题方案**
|
||||
- **D. 后续如何避免问题**
|
||||
5. 向用户汇报最终执行结果和验证情况。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
## Step 3:实现方案
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Gitea 地址 | `http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git` |
|
||||
| 工程分析目录 | `.\工程分析\` |
|
||||
| 类型检查 | `npm run lint` |
|
||||
| 构建验证 | `npm run build` |
|
||||
| 开发服务器 | `npm run dev` |
|
||||
1. 基于需求分析,设计详细的实现方案:
|
||||
- 根因分析(如果是 Bug 修复)
|
||||
- 修改文件清单
|
||||
- 具体代码变更(含前后对比)
|
||||
- 风险点与应对措施
|
||||
- 回滚策略
|
||||
2. 将方案写入文件:
|
||||
```
|
||||
.\工程分析\实现方案-{workflowTimestamp}.md
|
||||
```
|
||||
3. **必须停止执行,向用户展示实现方案文档内容,并请求人工审核确认。**
|
||||
- 提示语:"实现方案已完成,请审核 `.\工程分析\实现方案-{workflowTimestamp}.md`。确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。"
|
||||
4. **在得到用户明确确认(如回复"确认"、"同意"、"OK")之前,禁止进入下一步。**
|
||||
|
||||
---
|
||||
|
||||
## Step 4:测试方案
|
||||
|
||||
1. 基于实现方案设计测试用例:
|
||||
- 测试目标
|
||||
- 测试环境
|
||||
- 测试用例(步骤、操作、预期结果)
|
||||
- 验收标准(勾选列表)
|
||||
- 测试方式(手工/自动化)
|
||||
2. 将方案写入文件:
|
||||
```
|
||||
.\工程分析\测试方案-{workflowTimestamp}.md
|
||||
```
|
||||
3. **必须停止执行,向用户展示测试方案文档内容,并请求人工审核确认。**
|
||||
- 提示语:"测试方案已完成,请审核 `.\工程分析\测试方案-{workflowTimestamp}.md`。确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。"
|
||||
4. **在得到用户明确确认之前,禁止进入下一步。**
|
||||
|
||||
---
|
||||
|
||||
## Step 5:执行修改
|
||||
|
||||
### 5.1 执行前必读
|
||||
|
||||
在动手修改任何代码之前,**必须阅读**:
|
||||
```
|
||||
.\工程分析\经验记录.md
|
||||
```
|
||||
|
||||
将该文档中的关键经验教训纳入本次执行的注意事项中,防止重复犯错。
|
||||
|
||||
### 5.2 执行修改
|
||||
|
||||
1. 严格按照已审核通过的实现方案修改代码。
|
||||
2. 每次修改后应进行必要的验证(如 `npm run lint`、本地编译检查、关键路径手工验证)。
|
||||
3. 如果在执行过程中遇到**任何未在实现方案中预料到的问题**(包括修改范围扩大、发现新的 Bug、遇到环境异常、实现方案中的假设不成立等),必须:
|
||||
- 先解决问题
|
||||
- 然后在 `经验记录.md` 中记录
|
||||
|
||||
### 5.3 更新经验记录
|
||||
|
||||
执行完成后,检查本次过程中是否出现了值得记录的关键问题。如果有,在 `.\工程分析\经验记录.md` 中追加记录,使用**统一的四段式格式**:
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
## 记录 N:标题
|
||||
|
||||
**A. 具体问题**
|
||||
(清晰描述现象)
|
||||
|
||||
**B. 产生问题原因**
|
||||
(根因分析,可多条)
|
||||
|
||||
**C. 解决问题方案**
|
||||
(具体修改了什么)
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
(可执行的建议)
|
||||
```
|
||||
|
||||
如果 `经验记录.md` 不存在,则创建它,并在顶部加入 `# 经验记录` 标题。
|
||||
|
||||
---
|
||||
|
||||
## Step 6:Gitea 备份
|
||||
|
||||
1. 在项目根目录执行以下 Git 操作:
|
||||
```bash
|
||||
git add .\工程分析\
|
||||
git commit -m "{workflowTimestamp} - {本次修改的简要描述}"
|
||||
git push origin main
|
||||
```
|
||||
- 简要描述应概括本次修改的核心内容(如 "修复路由切换后关键帧丢失问题"、"新增自动帧插入非阻塞优化" 等)。
|
||||
- 如果只修改了工程分析文档而没有改代码(理论上不应发生),也应提交这些文档。
|
||||
2. 备份完成后,**必须向用户明确提醒**:
|
||||
> "本次工作流相关文档已备份到 Gitea,commit 信息为:`{workflowTimestamp} - {简要描述}`。"
|
||||
|
||||
---
|
||||
|
||||
## 禁忌清单(严禁事项)
|
||||
|
||||
- ❌ 严禁跳过实现方案审核直接进入代码修改。
|
||||
- ❌ 严禁跳过测试方案审核直接执行测试。
|
||||
- ❌ 严禁未阅读 `经验记录.md` 就动手改代码。
|
||||
- ❌ 严禁执行完成后不更新 `经验记录.md`(如有新问题)。
|
||||
- ❌ 严禁执行完成后不进行 Gitea 备份。
|
||||
- ❌ 严禁在需求分析、实现方案、测试方案文档中使用不明确的描述,应具体到文件路径和函数名。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考:常用命令
|
||||
|
||||
```bash
|
||||
# 类型检查
|
||||
npm run lint
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
|
||||
# 启动预览服务
|
||||
npm run preview -- --host 0.0.0.0
|
||||
|
||||
# Git 备份
|
||||
git add .\工程分析\
|
||||
git commit -m "{timestamp} - {描述}"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -6,7 +6,7 @@
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
**手术图文病历报告系统**(Gemini-图文报告系统-V1.1)是一款基于 **React 19 + TypeScript + Vite + Tailwind CSS 4** 开发的纯前端医疗图文报告管理应用。所有数据持久化在浏览器 `localStorage` 中,无需后端服务即可独立运行。
|
||||
**手术图文病历报告系统**是一款基于 **React 19 + TypeScript + Vite + Tailwind CSS 4** 开发的纯前端医疗图文报告管理应用。所有数据持久化在浏览器 `localStorage` 中,无需后端服务即可独立运行。
|
||||
|
||||
### 核心功能
|
||||
- **图文报告生成**:基于 `contentEditable` 的富文本编辑器,支持插入表格、图片占位符,可从本地或手术视频中截取关键帧插入报告。
|
||||
@@ -34,6 +34,7 @@
|
||||
- **图标**:Lucide React
|
||||
- **动画**:Motion
|
||||
- **语言**:TypeScript 5.8(`tsconfig.json` 中 `jsx: "react-jsx"`、`moduleResolution: "bundler"`)
|
||||
- **其他依赖**:`@google/genai`(预留 AI 功能)
|
||||
|
||||
### 运行时架构
|
||||
- **纯前端 SPA**:无后端 API,所有业务逻辑在浏览器端执行。
|
||||
@@ -46,8 +47,7 @@
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yaml # Docker Compose 配置(端口 8080:80)
|
||||
├── docker-compose.qnap.yml # QNAP 专用 Docker Compose
|
||||
├── docker-compose.yaml # Docker Compose 配置(端口 4002:80)
|
||||
├── Dockerfile # 多阶段构建:node:20-alpine -> nginx:alpine
|
||||
├── nginx.conf # Nginx SPA 回退配置(try_files)
|
||||
├── package.json # 依赖与脚本
|
||||
@@ -65,7 +65,7 @@
|
||||
├── pages/
|
||||
│ ├── Login.tsx # 登录页(初始化默认数据)
|
||||
│ ├── Dashboard.tsx # 工作台概览(统计图表 + 快捷入口)
|
||||
│ ├── ReportEditor.tsx # 图文报告编辑器(最复杂的页面)
|
||||
│ ├── ReportEditor.tsx # 图文报告编辑器(最复杂的页面,约 1400+ 行)
|
||||
│ ├── ReportManage.tsx # 报告列表管理
|
||||
│ ├── ReportView.tsx # 报告查看/打印
|
||||
│ ├── TemplateManage.tsx # 模板管理
|
||||
@@ -106,7 +106,7 @@ npm run dev
|
||||
|
||||
### Docker 部署
|
||||
```bash
|
||||
# 构建并启动(访问 http://localhost:8080)
|
||||
# 构建并启动(访问 http://localhost:4002)
|
||||
docker-compose up -d --build
|
||||
|
||||
# 停止
|
||||
@@ -136,10 +136,13 @@ docker-compose down
|
||||
- 各页面在 `useEffect` 中读取 `storage.get('currentUser')`,未登录则 `navigate('/')`。
|
||||
- `Sidebar.tsx` 的 `navItems` 按 `roles` 数组过滤菜单。
|
||||
- `user` 角色只能查看/编辑自己创建的报告;`super`/`admin` 可查看全院报告。
|
||||
- `admin` 只能管理同部门(`department`)的 `user` 角色用户,且一个部门只能有一个管理员。
|
||||
- 用户对象包含 `visibleTemplates`(可视模板)和 `manageableTemplates`(可管理模板)数组,用于细粒度模板权限控制。
|
||||
|
||||
### 数据持久化约定
|
||||
- **禁止直接调用 `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)。
|
||||
- localStorage 中存储的 key 包括:`users`、`reports`、`templates`、`systemSettings`、`currentUser`、`multiSelectOptions`、`anesthesiaOptions`、`reportEditorDraft_{username}`。
|
||||
- sessionStorage 中存储的 key 包括:`restore_{reportId}`(用于历史版本恢复)。
|
||||
- 报告编辑器会在 `beforeunload` 和 `visibilitychange` 时自动保存草稿到 `reportEditorDraft_{username}`。
|
||||
|
||||
### 样式约定
|
||||
@@ -149,19 +152,20 @@ docker-compose down
|
||||
- A4 打印尺寸为 `210mm × 297mm`,打印时通过 `visibility` 控制只显示 `.print-content` 区域。
|
||||
|
||||
### 编辑器实现细节
|
||||
- `ReportEditor.tsx` 使用原生 `contentEditable` + `document.execCommand` 实现富文本,而非第三方编辑器。
|
||||
- `ReportEditor.tsx` 和 `TemplateManage.tsx` 使用原生 `contentEditable` + `document.execCommand` 实现富文本,而非第三方编辑器。
|
||||
- 图片通过 `.image-placeholder` 占位符插入,支持两种填充方式:
|
||||
1. 点击占位符上传本地图片(Base64 存入 HTML)。
|
||||
2. 从右侧“视频分析”面板拖拽自动抽帧的截图到占位符中。
|
||||
- 视频中上传后会根据 `systemSettings.framePositions` 自动抽帧(通过 `<video>` + `<canvas>` 实现)。
|
||||
- 自动抽帧支持“自动帧插入”功能:开启后,抽得的帧会按延迟顺序自动填入编辑器中的空图片占位符。
|
||||
|
||||
### TypeScript 类型
|
||||
核心类型定义在 `src/types.ts`:
|
||||
- `User`:用户,角色为 `'super' | 'admin' | 'user'`
|
||||
- `Report`:报告,状态为 `'draft' | 'completed'`,含 `content`(HTML 字符串)
|
||||
- `User`:用户,角色为 `'super' | 'admin' | 'user'`,含 `visibleTemplates` 和 `manageableTemplates`
|
||||
- `Report`:报告,状态为 `'draft' | 'completed'`,含 `content`(HTML 字符串)、`videos`、`capturedFrames`、`history`
|
||||
- `Template`:模板,结构与报告内容类似
|
||||
- `SystemSettings`:系统设置,含 `frameCount`、`framePositions`、`apiEndpoint` 等
|
||||
- `CapturedFrame`:视频抽帧结果
|
||||
- `SystemSettings`:系统设置,含 `frameCount`、`framePositions`、`apiEndpoint`、`apiKey`、`defaultTemplate`、`frameMode`、`autoInsertFrames`、`autoInsertFrameIndices`、`autoInsertDelay`
|
||||
- `CapturedFrame`:视频抽帧结果,含 `dataUrl`、`isManual` 等
|
||||
|
||||
### 路径别名
|
||||
- `vite.config.ts` 和 `tsconfig.json` 均配置了 `@/` 指向项目根目录(`.`)。
|
||||
@@ -201,3 +205,4 @@ docker-compose down
|
||||
- **打印逻辑**已封装在 `src/utils/print.ts`,需要打印 A4 报告时直接调用 `printDocument(htmlContent)`。
|
||||
- **修改样式时优先检查 `src/index.css`**,Tailwind v4 的主题变量和打印样式都在那里。
|
||||
- **添加新页面后**,记得在 `src/App.tsx` 注册路由,并在 `src/components/Sidebar.tsx` 的 `navItems` 中配置菜单和可见角色。
|
||||
- **修改 Docker 端口映射时**,同步更新 `docker-compose.yaml` 和本文件中的说明。
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -95,6 +95,31 @@
|
||||
.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 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.smart-field-wrapper .field-label {
|
||||
color: #64748b;
|
||||
user-select: none;
|
||||
}
|
||||
.smart-field-wrapper .field-value {
|
||||
min-width: 60px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
}
|
||||
.smart-field-wrapper .field-value:empty::before {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
@@ -124,4 +149,11 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +873,48 @@ export default function ReportEditor() {
|
||||
if (status === 'completed') navigate('/report-manage');
|
||||
};
|
||||
|
||||
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||
if (editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
updatePageHeight();
|
||||
saveDraftToStorage();
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && target.hasAttribute('data-bind')) {
|
||||
const fieldKey = target.getAttribute('data-bind')!;
|
||||
const newValue = target.innerText;
|
||||
|
||||
setReportData((prev) => {
|
||||
const next = { ...prev, [fieldKey]: newValue };
|
||||
stateRef.current = { ...stateRef.current, reportData: next };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync form state -> rich text field values
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const bindNodes = editorRef.current.querySelectorAll('[data-bind]');
|
||||
bindNodes.forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
const fieldKey = el.getAttribute('data-bind')!;
|
||||
const rawValue = (reportData as any)[fieldKey];
|
||||
|
||||
let newValue = '';
|
||||
if (Array.isArray(rawValue)) {
|
||||
newValue = rawValue.join(', ');
|
||||
} else if (rawValue !== undefined && rawValue !== null) {
|
||||
newValue = String(rawValue);
|
||||
}
|
||||
|
||||
if (el.innerText !== newValue) {
|
||||
el.innerText = newValue;
|
||||
}
|
||||
});
|
||||
}, [reportData]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
const hasVisibleTemplates = templates.length > 0;
|
||||
@@ -1006,7 +1048,7 @@ export default function ReportEditor() {
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={() => { contentRef.current = editorRef.current?.innerHTML || ''; updatePageHeight(); saveDraftToStorage(); }}
|
||||
onInput={handleEditorInput}
|
||||
onBlur={() => { contentRef.current = editorRef.current?.innerHTML || ''; saveDraftToStorage(); }}
|
||||
className="editor-content print-content"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { User, Template } from '../types';
|
||||
import { User, Template, BINDABLE_FIELDS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
import { printDocument } from '../utils/print';
|
||||
import { storage } from '../utils/storage';
|
||||
@@ -156,6 +156,22 @@ export default function TemplateManage() {
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
|
||||
editorRef.current?.focus();
|
||||
const html = `
|
||||
<span class="smart-field-wrapper" contenteditable="false">
|
||||
<span class="field-label">${field.label}:</span>
|
||||
<span class="field-value"
|
||||
data-bind="${field.key}"
|
||||
contenteditable="true"
|
||||
style="min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a;">
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const insertTable = () => {
|
||||
const rowsStr = prompt('请输入行数:', '2');
|
||||
const colsStr = prompt('请输入列数:', '3');
|
||||
@@ -396,58 +412,87 @@ export default function TemplateManage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
|
||||
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<select
|
||||
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
|
||||
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
|
||||
>
|
||||
<option value="">选择字体</option>
|
||||
<option value="SimSun">宋体</option>
|
||||
<option value="Microsoft YaHei">微软雅黑</option>
|
||||
<option value="SimHei">黑体</option>
|
||||
<option value="KaiTi">楷体</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
|
||||
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
|
||||
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="color"
|
||||
onChange={(e) => execCmd('foreColor', e.target.value)}
|
||||
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
|
||||
title="文字颜色"
|
||||
/>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Editor Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
|
||||
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<select
|
||||
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
|
||||
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
|
||||
>
|
||||
<option value="">选择字体</option>
|
||||
<option value="SimSun">宋体</option>
|
||||
<option value="Microsoft YaHei">微软雅黑</option>
|
||||
<option value="SimHei">黑体</option>
|
||||
<option value="KaiTi">楷体</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
|
||||
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
|
||||
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="color"
|
||||
onChange={(e) => execCmd('foreColor', e.target.value)}
|
||||
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
|
||||
title="文字颜色"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
|
||||
{/* Editor Area */}
|
||||
<div className="editor-content-wrapper print-wrapper">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="editor-content print-content"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Area */}
|
||||
<div className="editor-content-wrapper print-wrapper">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="editor-content print-content"
|
||||
>
|
||||
{/* Right: Field Library */}
|
||||
<aside className="w-[220px] bg-sidebar-bg border-l border-border flex flex-col shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<span className="text-sm font-bold text-text-main uppercase tracking-wider">字段库</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="card-minimal p-3">
|
||||
<h3 className="text-xs font-semibold text-primary mb-2">表单字段</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{BINDABLE_FIELDS.map((field) => (
|
||||
<button
|
||||
key={field.key}
|
||||
type="button"
|
||||
onClick={() => insertSmartField(field)}
|
||||
className="px-2 py-1 text-[11px] bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
|
||||
title={`插入 ${field.label}`}
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-2 leading-tight">点击插入智能占位方格,Label 锁定,Value 可输入并与报告基本信息联动。</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
25
src/types.ts
25
src/types.ts
@@ -75,4 +75,29 @@ 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: 'gender', label: '性别' },
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'hospitalId', label: '住院号' },
|
||||
{ key: 'bedNumber', label: '床号' },
|
||||
{ key: 'surgeryDate', label: '手术日期' },
|
||||
{ key: 'surgeryType', label: '手术类型' },
|
||||
{ key: 'surgeon', label: '手术者' },
|
||||
{ key: 'assistant', label: '助手' },
|
||||
{ key: 'anesthesiologist', label: '麻醉师' },
|
||||
{ key: 'anesthesiaType', label: '麻醉方式' },
|
||||
{ key: 'preoperativeDiagnosis', label: '术前诊断' },
|
||||
{ key: 'intraoperativeDiagnosis', label: '术中诊断' },
|
||||
{ key: 'surgicalProcedure', label: '手术经过' },
|
||||
];
|
||||
|
||||
@@ -34,6 +34,12 @@ export const printDocument = (htmlContent: string) => {
|
||||
.image-placeholder .delete-btn { display: none !important; }
|
||||
.image-placeholder:not(.has-image) { display: none !important; }
|
||||
.template-info-section { position: relative; margin-bottom: 16px; }
|
||||
.smart-field-wrapper { display: inline-flex; align-items: center; margin: 0 4px; vertical-align: middle; }
|
||||
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
|
||||
.smart-field-wrapper .field-value { min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a; outline: none; }
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value { border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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] 经验记录初始文档创建
|
||||
@@ -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`,不触及路由、存储封装或其他页面逻辑。
|
||||
- 不引入新依赖。
|
||||
@@ -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`,不触及其他文件。
|
||||
- 不引入新依赖。
|
||||
@@ -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 结构和样式的微调整,不修改任何逻辑函数。
|
||||
- 不引入新依赖。
|
||||
@@ -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 结构和样式的微调整,不修改任何逻辑函数。
|
||||
- 不引入新依赖。
|
||||
@@ -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 || ''
|
||||
};
|
||||
```
|
||||
|
||||
### 修改点 4:useLayoutEffect 安全网 — 从 draft 恢复已有报告(约第 677 行后)
|
||||
|
||||
在 `setLoadedTemplateId(draft.loadedTemplateId || '');` 之后,**追加同步**:
|
||||
```tsx
|
||||
stateRef.current = {
|
||||
...stateRef.current,
|
||||
reportData: draft.reportData,
|
||||
videos: draft.videos,
|
||||
capturedFrames: draft.capturedFrames,
|
||||
loadedTemplateId: draft.loadedTemplateId || ''
|
||||
};
|
||||
```
|
||||
|
||||
### 修改点 5:useLayoutEffect 安全网 — 从已保存报告(found)恢复(约第 692 行后)
|
||||
|
||||
在 `contentLoadedRef.current = true;` 之后,**追加同步**:
|
||||
```tsx
|
||||
stateRef.current = {
|
||||
...stateRef.current,
|
||||
reportData: found,
|
||||
videos: found.videos || [],
|
||||
capturedFrames: found.capturedFrames || []
|
||||
};
|
||||
```
|
||||
|
||||
### 修改点 6:useLayoutEffect 安全网 — 从 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` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
@@ -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` |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:useEffect — 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);
|
||||
}
|
||||
```
|
||||
|
||||
### 变更 2:useEffect — 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 行重复)。
|
||||
|
||||
### 变更 3:useEffect — 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 ...
|
||||
}
|
||||
```
|
||||
|
||||
### 变更 4:autoCaptureFrames 引入 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` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
@@ -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 再重新构建。
|
||||
@@ -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` 回滚。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**
|
||||
249
工程分析/实现方案-2026-04-16-22-23-02.md
Normal file
249
工程分析/实现方案-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 实现方案 — 2026-04-16-22-23-02
|
||||
|
||||
## 根因分析
|
||||
|
||||
当前 `TemplateManage.tsx` 和 `ReportEditor.tsx` 均使用原生 `contentEditable` 实现富文本编辑,但模板中的占位符是纯 HTML(如 `姓名:<span style="color: #ff0000;">*姓名*</span>`),存在以下问题:
|
||||
1. **固定文本无保护**:"姓名:" 等标签与普通文本无异,用户可随意删除或篡改。
|
||||
2. **无双向绑定**:模板中的占位符与右侧表单之间没有数据通道,模板内容不会随表单变化,表单也不会随模板输入自动填充。
|
||||
3. **打印样式混乱**:现有的红色占位文本在打印报告中显得不专业。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 修改 | 新增右侧"字段库"面板,支持点击插入智能占位控件 |
|
||||
| `src/pages/ReportEditor.tsx` | 修改 | 新增 `data-bind` DOM 的双向监听与同步逻辑 |
|
||||
| `src/utils/print.ts` | 修改 | 打印样式中增加 `.field-value` 的打印适配 |
|
||||
| `src/index.css` | 修改 | 新增 `.smart-field-wrapper` 系列样式 |
|
||||
| `src/types.ts` | 修改(可选) | 定义字段映射常量数组,供两端复用 |
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 变更 1:`src/types.ts` — 定义字段库常量
|
||||
|
||||
**新增内容(在文件末尾追加):**
|
||||
|
||||
```typescript
|
||||
export interface BindableField {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const BINDABLE_FIELDS: BindableField[] = [
|
||||
{ key: 'patientName', label: '姓名' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'hospitalId', label: '住院号' },
|
||||
{ key: 'bedNumber', label: '床号' },
|
||||
{ key: 'surgeryDate', label: '手术日期' },
|
||||
{ key: 'surgeryType', label: '手术类型' },
|
||||
{ key: 'surgeon', label: '手术者' },
|
||||
{ key: 'assistant', label: '助手' },
|
||||
{ key: 'anesthesiaType', label: '麻醉方式' },
|
||||
{ key: 'preoperativeDiagnosis', label: '术前诊断' },
|
||||
{ key: 'intraoperativeDiagnosis', label: '术中诊断' },
|
||||
{ key: 'surgicalProcedure', label: '手术经过' },
|
||||
];
|
||||
```
|
||||
|
||||
### 变更 2:`src/index.css` — 智能占位控件样式
|
||||
|
||||
**在 `@layer components` 或文件末尾新增:**
|
||||
|
||||
```css
|
||||
.smart-field-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.smart-field-wrapper .field-label {
|
||||
color: #64748b;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.smart-field-wrapper .field-value {
|
||||
min-width: 60px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.smart-field-wrapper .field-value:empty::before {
|
||||
content: '\200b'; /* zero-width space to keep min-height */
|
||||
}
|
||||
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value {
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 变更 3:`src/pages/TemplateManage.tsx` — 字段库面板与插入逻辑
|
||||
|
||||
**当前结构:** `TemplateManage.tsx` 右侧通常为操作按钮区(如保存、预览)。
|
||||
|
||||
**新增字段库面板(放在保存按钮下方或单独区域):**
|
||||
|
||||
```tsx
|
||||
import { BINDABLE_FIELDS } from '../types';
|
||||
|
||||
// 在组件内新增辅助函数
|
||||
const insertSmartField = (field: typeof BINDABLE_FIELDS[0]) => {
|
||||
const html = `
|
||||
<span class="smart-field-wrapper" contenteditable="false">
|
||||
<span class="field-label">${field.label}:</span>
|
||||
<span class="field-value"
|
||||
data-bind="${field.key}"
|
||||
contenteditable="true"
|
||||
style="min-width: 60px; padding: 0 4px; border: 1px solid #cbd5e1; border-radius: 4px; display: inline-block; background: #fff; color: #0f172a;">
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
};
|
||||
```
|
||||
|
||||
**UI 位置(在保存按钮下方新增卡片):**
|
||||
|
||||
```tsx
|
||||
<div className="card-minimal mt-4">
|
||||
<h3 className="text-sm font-semibold text-primary mb-2">表单字段库</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BINDABLE_FIELDS.map((field) => (
|
||||
<button
|
||||
key={field.key}
|
||||
type="button"
|
||||
onClick={() => insertSmartField(field)}
|
||||
className="px-2 py-1 text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 rounded border border-slate-300 transition-colors"
|
||||
title={`插入 ${field.label}`}
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-2">点击字段插入智能占位方格,Label 锁定,Value 可输入。</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
> 注意:`TemplateManage.tsx` 的具体行号需以实际文件为准,但插入逻辑与 UI 结构如上。
|
||||
|
||||
### 变更 4:`src/pages/ReportEditor.tsx` — 双向绑定逻辑
|
||||
|
||||
#### 4.1 富文本 → 表单(`handleEditorInput` 或 `onInput` 事件)
|
||||
|
||||
**当前代码**中 `ReportEditor.tsx` 的编辑器通常已有 `onInput` 处理(保存草稿)。
|
||||
|
||||
**在原有 `onInput` 处理器中追加:**
|
||||
|
||||
```tsx
|
||||
const handleEditorInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||
// 1. 原有逻辑:同步 contentRef 并保存草稿
|
||||
if (editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
}
|
||||
saveDraftToStorage();
|
||||
|
||||
// 2. 新增:双向绑定 — 方格内容变更时更新表单 State
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && target.hasAttribute('data-bind')) {
|
||||
const fieldKey = target.getAttribute('data-bind')!;
|
||||
const newValue = target.innerText;
|
||||
|
||||
setReportData((prev) => {
|
||||
const next = { ...prev, [fieldKey]: newValue };
|
||||
// 同步 stateRef
|
||||
stateRef.current.reportData = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 4.2 表单 → 富文本(`useEffect` 监听 `reportData`)
|
||||
|
||||
**在 `ReportEditor.tsx` 中新增一个 `useEffect`:**
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const bindNodes = editorRef.current.querySelectorAll('[data-bind]');
|
||||
bindNodes.forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
const fieldKey = el.getAttribute('data-bind')!;
|
||||
const rawValue = (reportData as any)[fieldKey];
|
||||
|
||||
// 处理数组类型(如 surgeon / assistant)
|
||||
let newValue = '';
|
||||
if (Array.isArray(rawValue)) {
|
||||
newValue = rawValue.join(', ');
|
||||
} else if (rawValue !== undefined && rawValue !== null) {
|
||||
newValue = String(rawValue);
|
||||
}
|
||||
|
||||
// 仅在差异时更新 DOM,防止光标跳动
|
||||
if (el.innerText !== newValue) {
|
||||
el.innerText = newValue;
|
||||
}
|
||||
});
|
||||
}, [reportData]);
|
||||
```
|
||||
|
||||
#### 4.3 光标/焦点保护(边界处理)
|
||||
|
||||
为避免 `useEffect` 在 `reportData` 变化时与用户的输入冲突,上述逻辑已通过 `if (el.innerText !== newValue)` 做短路保护。若用户当前正在该方格内输入(此时 `reportData` 已由 `handleEditorInput` 同步更新),`innerText` 通常等于 `newValue`,不会触发 DOM 重写,因此光标不会跳动。
|
||||
|
||||
### 变更 5:`src/utils/print.ts` — 打印样式适配
|
||||
|
||||
**当前 `print.ts` 会将 HTML 内容包裹后打印。**
|
||||
|
||||
**在注入的 `<style>` 中追加:**
|
||||
|
||||
```css
|
||||
@media print {
|
||||
/* 现有打印样式保留 ... */
|
||||
|
||||
.smart-field-wrapper .field-value {
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 2px !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 若 `print.ts` 本身会读取 `index.css` 中的打印样式,可确认是否重复。保险起见,两边同时维护或仅在 `index.css` 维护即可。此处优先在 `index.css` 中维护,`print.ts` 若已内联样式则同步追加。
|
||||
|
||||
---
|
||||
|
||||
## 风险点
|
||||
|
||||
| 风险 | 级别 | 应对措施 |
|
||||
|------|------|---------|
|
||||
| 光标跳动/输入中断 | 中 | `useEffect` 同步时严格判断 `innerText !== newValue`,仅在差异时重写 DOM |
|
||||
| `contenteditable="false"` 外层导致整个控件无法删除 | 低 | 这是预期行为,用户可通过选中整个控件后按 Delete 删除;若需要允许删除,可在外层增加 `tabindex` 或删除按钮 |
|
||||
| 数组字段(surgeon)同步时格式异常 | 低 | 在 `useEffect` 中加入 `Array.isArray(rawValue)` 分支,统一用 `join(', ')` |
|
||||
| 老模板未自动升级,用户反馈"联动不生效" | 低 | 在 `TemplateManage` 页面增加提示文案:"请重新编辑模板并插入字段库控件以激活联动" |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
本次改动仅涉及前端 UI 和 DOM 事件处理,不修改数据结构和存储接口。如出现异常,可直接执行以下任一方式:
|
||||
1. `git revert` 撤销相关提交;
|
||||
2. 手动注释掉 `ReportEditor.tsx` 中的 `useEffect` 双向绑定逻辑和 `handleEditorInput` 的新增分支,保留原有草稿保存逻辑即可恢复。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将继续编写测试方案。**
|
||||
@@ -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`
|
||||
@@ -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 读写保持正常。
|
||||
- [ ] 报告保存(草稿/完成)功能未被破坏。
|
||||
- [ ] 视频分析面板与编辑器交互保持正常。
|
||||
@@ -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` 未被修改,存储读写保持正常。
|
||||
- [ ] 编辑器打印、保存草稿、完成报告功能保持正常。
|
||||
- [ ] 关键帧的 "手动截取"、删除功能保持正常。
|
||||
@@ -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` 构建成功。
|
||||
|
||||
---
|
||||
|
||||
## 回归验证范围
|
||||
- [ ] 拖拽插入功能未被破坏。
|
||||
- [ ] 关键帧卡片的删除、视频跳转功能正常。
|
||||
- [ ] 编辑器保存、打印功能正常。
|
||||
@@ -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` 构建成功。
|
||||
|
||||
---
|
||||
|
||||
## 回归验证范围
|
||||
- [ ] 拖拽插入功能未被破坏。
|
||||
- [ ] 关键帧卡片的删除、手动徽章显示正常。
|
||||
- [ ] 编辑器保存、打印功能正常。
|
||||
@@ -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 备份)。**
|
||||
@@ -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 备份)。**
|
||||
@@ -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` 可正常加载
|
||||
@@ -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:模板切换行为未被意外改变,页面无崩溃。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
102
工程分析/测试方案-2026-04-16-22-23-02.md
Normal file
102
工程分析/测试方案-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 测试方案 — 2026-04-16-22-23-02
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 `TemplateManage` 端字段库插入功能、智能占位控件的保护特性,以及 `ReportEditor` 端富文本与基本信息表单之间的双向数据绑定是否稳定可靠。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 浏览器:Chrome / Edge
|
||||
- 前置条件:已登录系统(建议使用 `admin` 超级管理员账号)
|
||||
- 测试页面:`/template-manage`、`/report-editor`
|
||||
|
||||
---
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 用例 1:模板管理端 — 字段库插入与标签锁定
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1.1 | 进入 `/template-manage` | 页面正常加载,右侧出现"表单字段库"卡片 |
|
||||
| 1.2 | 将光标定位到编辑器任意位置,点击字段库中的「姓名」按钮 | 编辑器中插入一个带有"姓名:"标签和空方格的控件 |
|
||||
| 1.3 | 尝试用鼠标单独选中"姓名:"标签并删除 | **无法单独删除或修改**标签文本,只能整体选中或删除整个控件 |
|
||||
| 1.4 | 点击空方格并输入"张三" | 方格内正常显示"张三",光标不跳动 |
|
||||
| 1.5 | 再次点击「住院号」按钮插入第二个控件 | 两个控件在编辑器中并存,互不干扰 |
|
||||
|
||||
### 用例 2:报告编辑端 — 富文本 → 表单单向联动
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 2.1 | 进入 `/report-editor`(新建报告),确保模板已包含至少一个智能占位控件(如"姓名") | 编辑器加载默认模板,智能控件正确渲染 |
|
||||
| 2.2 | 点击"姓名"方格,输入"李四" | 右侧【基本信息】表单的"患者姓名"字段**自动同步为"李四"** |
|
||||
| 2.3 | 继续输入,追加"先生" | 表单字段同步更新为"李四先生",输入过程光标稳定不跳 |
|
||||
| 2.4 | 删除方格内部分文字(如删去"先生") | 表单字段同步回退为"李四" |
|
||||
|
||||
### 用例 3:报告编辑端 — 表单 → 富文本单向联动
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 3.1 | 在右侧【基本信息】表单的"患者姓名"字段中输入"王五" | 编辑器内"姓名"方格的内容**自动同步为"王五"** |
|
||||
| 3.2 | 清空表单中的"患者姓名" | 编辑器内方格内容同步清空,但方格本身保留 |
|
||||
| 3.3 | 修改"住院号"表单字段 | 仅对应"住院号"方格更新,其他控件不受影响 |
|
||||
|
||||
### 用例 4:双向联动并发编辑 — 光标防跳动
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 4.1 | 在方格内快速连续输入一段长文本 | 光标始终位于输入末尾,**不会出现跳到行首或方格外**的现象 |
|
||||
| 4.2 | 同时通过右侧表单修改同一字段的值 | 若当前焦点在方格内,表单修改不应打断当前输入;失焦后方格内容正确更新 |
|
||||
|
||||
### 用例 5:数组类型字段同步
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 5.1 | 在模板中插入「手术者」控件 | 控件正常插入,标签锁定 |
|
||||
| 5.2 | 在右侧表单中选择多个手术者(如"张医生"、"李医生") | 编辑器内"手术者"方格显示为"张医生, 李医生" |
|
||||
| 5.3 | 在右侧表单中取消部分选择 | 方格内容同步更新,逗号分隔格式保持正确 |
|
||||
|
||||
### 用例 6:打印样式适配
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 6.1 | 在编辑器中填充若干智能控件 | 编辑态下方格带有浅灰色边框和圆角 |
|
||||
| 6.2 | 点击"打印"按钮或进入打印预览 | 打印预览中,方格边框消失,变为黑色下划线填空风格 |
|
||||
|
||||
### 用例 7:路由切换与草稿保存
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 7.1 | 在 `/report-editor` 中编辑报告,填写带有智能控件的内容 | — |
|
||||
| 7.2 | 切换到 `/report-manage`,再返回 `/report-editor` | 编辑器内容、基本信息表单值、智能控件内的数据**全部完整保留** |
|
||||
| 7.3 | 检查 localStorage draft | `reportEditorDraft_{username}` 中 HTML 包含完整的 `data-bind` 属性 |
|
||||
|
||||
### 用例 8:老模板兼容性
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 8.1 | 打开一个未使用智能控件的老模板(纯文本占位符) | 报告编辑器正常加载,无报错 |
|
||||
| 8.2 | 在老模板中正常编辑文字 | 文字编辑和表单保存功能不受影响 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `TemplateManage` 右侧正确显示字段库面板,点击可插入控件。
|
||||
- [ ] 插入控件的 Label 不可单独编辑或删除,Value 方格可正常输入。
|
||||
- [ ] `ReportEditor` 中方格输入时,右侧表单字段实时同步更新。
|
||||
- [ ] 右侧表单字段修改时,编辑器内对应方格实时同步更新。
|
||||
- [ ] 快速输入过程中光标稳定,无跳动或焦点丢失。
|
||||
- [ ] 数组字段(手术者/助手)在方格中正确显示为逗号分隔字符串。
|
||||
- [ ] 打印预览中方格样式变为下划线风格,符合 A4 报告规范。
|
||||
- [ ] 路由切换后,智能控件内的数据不丢失。
|
||||
- [ ] 老模板无智能控件时,现有编辑功能不受影响。
|
||||
- [ ] `npm run lint` 类型检查通过,无编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工浏览器验证为主,结合 DevTools 观察 DOM 结构和 `data-bind` 属性。
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**
|
||||
25
工程分析/经验记录.md
25
工程分析/经验记录.md
@@ -284,3 +284,28 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
||||
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
||||
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
|
||||
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
||||
|
||||
---
|
||||
|
||||
## 记录 12:contentEditable 中实现标签锁定与输入方格的双向绑定
|
||||
|
||||
**A. 具体问题**
|
||||
需要在 `ReportEditor` 和 `TemplateManage` 的富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧【基本信息】表单双向联动。
|
||||
|
||||
**B. 产生问题原因**
|
||||
原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。若仅用样式区分的普通 `<span>`,用户仍可通过退格键将"姓名:"删掉一半或改乱。
|
||||
|
||||
**C. 解决问题方案**
|
||||
采用三层嵌套 HTML 结构:
|
||||
1. **外层** `<span class="smart-field-wrapper" contenteditable="false">`:作为不可编辑的框架,确保整个控件不会被内部逐字删除。
|
||||
2. **标签层** `<span class="field-label">`:显示固定文本如"姓名:",受外层保护。
|
||||
3. **输入层** `<span class="field-value" contenteditable="true" data-bind="patientName">`:允许用户输入,并通过 `data-bind` 属性建立与 `reportData` 的映射关系。
|
||||
|
||||
双向绑定逻辑:
|
||||
- **富文本 → 表单**:在 `handleEditorInput` 中通过 `e.target.hasAttribute('data-bind')` 判断输入源,实时更新 `reportData`。
|
||||
- **表单 → 富文本**:在 `useEffect` 中监听 `reportData` 变化,仅当 `el.innerText !== newValue` 时才重写 DOM,防止光标跳动。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 `<span>`。
|
||||
- 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。
|
||||
- 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-16-51-00
|
||||
|
||||
## 需求背景
|
||||
用户在进入 **report-editor**(图文报告生成)页面时,期望编辑器能够自动加载在 **system-settings** 中配置的"图文报告生成默认模板"。但目前存在以下异常现象:
|
||||
|
||||
1. 超级管理员进入 report-editor 时,顶部模板选择器显示 **"无"**,编辑区域(`editor-content-wrapper print-wrapper`)为纯白色空白,没有加载任何模板内容。
|
||||
2. 在 system-settings 中已正确设置默认模板为 **"腹腔镜胆囊切除术报告"**,但 report-editor 未按预期加载。
|
||||
3. 用户从 `/dashboard` 等其他页面返回 `/report-editor` 后,若之前未进行过有效编辑,也可能出现白色空白模板的情况。
|
||||
|
||||
## 功能目标
|
||||
修复 report-editor 页面初始化时的模板加载逻辑,确保:
|
||||
- 新建报告(无 `reportId`)时,优先加载系统设置中的默认模板。
|
||||
- 只有当用户确实在编辑器中有过有效编辑内容时,才从本地草稿(draft)恢复。
|
||||
- 模板选择器中的"当前模板(及重置模板):"应正确反映当前加载的模板名称,而不是显示"无"。
|
||||
- 从其他页面返回或重新进入 report-editor 时,未编辑状态下不应出现空白模板。
|
||||
|
||||
## 涉及页面/模块
|
||||
- `src/pages/ReportEditor.tsx` —— 核心问题所在,草稿恢复与默认模板加载逻辑
|
||||
- `src/pages/SystemSettings.tsx` —— 默认模板设置页面(验证配置读取逻辑)
|
||||
- `src/utils/storage.ts` —— localStorage 读写封装(辅助确认)
|
||||
|
||||
## 问题根因(预分析)
|
||||
通过代码审阅,发现以下导致空白的根因:
|
||||
|
||||
1. **空字符串草稿被当作有效内容加载**:`saveDraftToStorage` 在组件卸载时自动保存草稿。如果用户未在编辑器中输入任何内容,保存的 `content` 会是空字符串 `""`。在初始化 effect 中,判断条件 `typeof draft.content === 'string'` 对空字符串也返回 `true`,导致编辑器被填充为空白 HTML,跳过了后续默认模板加载逻辑。
|
||||
|
||||
2. **草稿中未记录模板 ID**:`loadedTemplateId` 没有被存入 draft。当从 draft 恢复时,即使内容非空,模板选择器也因缺少 `loadedTemplateId` 而显示"无"。
|
||||
|
||||
3. **默认模板加载逻辑被空白草稿截断**:由于空草稿提前将 `contentLoadedRef.current` 设为 `true`,真正的默认模板分支 (`settings.defaultTemplate`) 永远不会执行。
|
||||
|
||||
## 验收标准
|
||||
- [ ] 超级管理员进入 report-editor 时,编辑区域正确显示 system-settings 中设置的默认模板内容。
|
||||
- [ ] 模板选择器显示当前已加载的默认模板名称,而非"无"。
|
||||
- [ ] 从 dashboard 等其他页面返回 report-editor,未编辑情况下不显示空白模板。
|
||||
- [ ] `npm run lint` 类型检查零错误。
|
||||
- [ ] `npm run build` 构建通过。
|
||||
@@ -1,26 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-17-07-04
|
||||
|
||||
## 需求背景
|
||||
在 `report-editor` 页面的 **视频分析 - 关键帧摘取** 区域,目前用户需要通过鼠标拖拽的方式将截图插入到报告编辑器的 `image-placeholder` 中。当关键帧数量较多或占位符位置较远时,拖拽操作不够便捷,用户体验有待提升。
|
||||
|
||||
## 功能目标
|
||||
为每个关键帧截图增加一个 **"插入"** 按钮,实现一键自动插入到编辑器中第一个空置的 `image-placeholder` 中。
|
||||
|
||||
具体要求:
|
||||
1. "插入" 按钮放置在每张截图底部的时间格式(如 `00:15`)与 "可拖拽" 提示文字之间。
|
||||
2. "插入" 按钮的显隐行为与右侧的 "可拖拽" 文字保持一致:仅当鼠标聚焦/悬停到该截图卡片上时才显示(`opacity-0 group-hover:opacity-100`)。
|
||||
3. 点击 "插入" 后,系统在当前编辑器内容中查找第一个尚未填充图片的 `.image-placeholder` 元素(即不含 `has-image` class 的占位符)。
|
||||
4. 找到后,将该关键帧的图片数据自动填充到该占位符中(效果等价于拖拽放下)。
|
||||
5. 填充后同步更新 `contentRef.current` 并保存草稿。
|
||||
6. 若编辑器中不存在空置的 `image-placeholder`,则给出提示(如 `alert('没有可插入图片的空位')`)。
|
||||
|
||||
## 涉及页面/模块
|
||||
- `src/pages/ReportEditor.tsx` —— 关键帧列表渲染区域 + 新增插入逻辑
|
||||
|
||||
## 验收标准
|
||||
- [ ] 每个关键帧截图底部出现 "插入" 按钮,位置在时间文字和 "可拖拽" 之间。
|
||||
- [ ] "插入" 按钮只在鼠标悬停到该截图卡片上时显示,移开即隐藏。
|
||||
- [ ] 点击 "插入" 后,第一个空置的 `image-placeholder` 被自动填充为该帧图片。
|
||||
- [ ] 若无可插入的空占位符,弹出友好提示。
|
||||
- [ ] 插入后编辑器内容、草稿状态正确同步。
|
||||
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。
|
||||
@@ -1,25 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-17-15-37
|
||||
|
||||
## 需求背景
|
||||
上一个需求已为关键帧截图添加了 "插入" 按钮,但用户对当前按钮的呈现方式提出了优化诉求:按钮位置在底部文字区域不够醒目,且纯文字链接样式不够直观,蓝色与 "可拖拽" 提示颜色过于接近,辨识度不足。
|
||||
|
||||
## 功能目标
|
||||
调整关键帧截图卡片上 "插入" 按钮的位置和样式:
|
||||
1. **位置调整**:将 "插入" 按钮从卡片底部的文字区域移到**图片中央**(覆盖在图片之上)。
|
||||
2. **样式调整**:改为**实体按钮样式**(带背景色、圆角、内边距、阴影),而非纯文字链接。
|
||||
3. **颜色调整**:不再使用蓝色(`text-accent`),改用与 "可拖拽" 蓝色提示有**明显区分度**的颜色(如绿色系 `emerald-500`),hover 时才显示在图片上方。
|
||||
4. 保留原有行为:
|
||||
- 鼠标未悬停时按钮不可见。
|
||||
- 点击后仍按从前到后顺序自动填充第一个空置 `image-placeholder`。
|
||||
- 点击不触发卡片跳转到视频位置。
|
||||
|
||||
## 涉及页面/模块
|
||||
- `src/pages/ReportEditor.tsx` —— 关键帧卡片 JSX 结构调整
|
||||
|
||||
## 验收标准
|
||||
- [ ] "插入" 按钮位于关键帧图片的正中央。
|
||||
- [ ] 按钮为实体按钮样式(圆角、背景色、阴影)。
|
||||
- [ ] 按钮颜色不是蓝色,与 "可拖拽" 有明显区分。
|
||||
- [ ] 鼠标悬停到卡片上时按钮淡入显示,移开时淡出隐藏。
|
||||
- [ ] 点击按钮后插入逻辑正常工作。
|
||||
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。
|
||||
@@ -1,22 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-17-21-58
|
||||
|
||||
## 需求背景
|
||||
用户对上一个优化后的 "插入" 按钮位置提出了微调诉求:按钮覆盖在图片正中央会遮挡图片内容,不够理想。
|
||||
|
||||
## 功能目标
|
||||
调整关键帧截图卡片上 "插入" 按钮的位置和颜色:
|
||||
1. **位置调整**:将 "插入" 按钮从图片正中央移回卡片底部,位于**时间格式(如 `00:15`)**和 **"可拖拽"** 提示文字之间。
|
||||
2. **不遮盖图片**:按钮不再以 absolute 覆盖层形式存在于图片之上。
|
||||
3. **颜色调整**:按钮恢复为与 "可拖拽" 一致的蓝色(`bg-accent` / `text-white`)。
|
||||
4. **保留实体按钮样式**:保持 `px-3 py-1.5 rounded-full shadow-md` 的实体胶囊按钮外观,不再使用纯文字链接。
|
||||
|
||||
## 涉及页面/模块
|
||||
- `src/pages/ReportEditor.tsx` —— 关键帧卡片 JSX 结构调整
|
||||
|
||||
## 验收标准
|
||||
- [ ] "插入" 按钮位于关键帧卡片底部,时间文字与 "可拖拽" 之间。
|
||||
- [ ] 按钮不覆盖在图片上,不遮挡图片内容。
|
||||
- [ ] 按钮为蓝色实体胶囊按钮,与 "可拖拽" 蓝色一致。
|
||||
- [ ] 悬停时按钮和 "可拖拽" 同时显示,移开时同时隐藏。
|
||||
- [ ] 点击按钮后插入逻辑正常工作,不触发视频跳转。
|
||||
- [ ] `npm run lint` 零错误,`npm run build` 构建通过。
|
||||
@@ -1,36 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-18-51-06
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `/report-editor` 页面中进行操作后,离开该页面(例如进入 `/report-manage`),再返回 `/report-editor` 时,**视频分析相关数据丢失**,具体表现为:
|
||||
|
||||
1. 自动关键帧摘取的图片消失;
|
||||
2. 自动/手动拖拽到报告 `image-placeholder` 上的视频截图消失;
|
||||
3. 手动截取的视频截图消失。
|
||||
|
||||
报告的基本信息(患者姓名、住院号等)保存正常。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 修复路由切换后 `capturedFrames`(关键帧/截图)数据丢失的问题;
|
||||
- 修复路由切换后 `videos`(已上传视频列表)数据丢失的问题;
|
||||
- 确保 `stateRef.current` 与 React state 在数据恢复后保持同步;
|
||||
- 确保组件卸载时保存的 draft 包含完整的视频分析数据。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 localStorage 存储机制不变;
|
||||
- 最小化代码改动,避免引入新的状态管理库;
|
||||
- 不破坏现有报告保存/打印/模板切换等功能。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 初始化逻辑、`useLayoutEffect` 安全网、`stateRef` 同步 |
|
||||
| `src/utils/storage.ts` | 无 | 不涉及修改 |
|
||||
| 其他页面 | 低 | 仅受 `/report-editor` 数据恢复正确性影响 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。问题现象明确,根因已定位。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-19-06-18
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
### 问题 1:路由切换后所有内容丢失
|
||||
在 `/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再返回 `/report-editor` 时,**所有内容全部变空**:
|
||||
- 报告编辑器内容丢失;
|
||||
- 基本信息(患者姓名、住院号等)丢失;
|
||||
- 视频分析中的自动关键帧、手动截图、拖拽到 `image-placeholder` 的图片全部丢失。
|
||||
|
||||
### 问题 2:自动帧插入的 UI 刷新时序异常
|
||||
开启「自动帧插入」后,系统应在**每摘取到一张特定关键帧后、经过设定的延迟时间,立即将该帧插入到报告中**。但目前的实际表现是:用户需要等待所有关键帧全部摘取完成后,右侧关键帧列表和报告中的插入图片才会**一次性批量蹦出**,无法看到逐帧实时更新的效果。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- **问题 1**:修复路由切换后报告内容、基本信息、视频分析数据全部丢失的根因;
|
||||
- **问题 2**:修复 `autoCaptureFrames` 中 React 状态更新被批处理导致的 UI 延迟刷新问题,使关键帧在摘取过程中实时可见、并按延迟配置逐帧插入 placeholder。
|
||||
|
||||
### 非功能点
|
||||
- 保持现有 localStorage 草稿机制不变;
|
||||
- 最小化改动范围,不引入新的状态管理库;
|
||||
- 不破坏模板切换、保存草稿、打印等现有功能。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 初始化数据恢复逻辑 + autoCaptureFrames 的渲染同步 |
|
||||
| 其他页面 | 低 | 仅涉及 `/report-editor` 的数据持久化和恢复 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。问题现象明确,根因已定位。
|
||||
@@ -1,30 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-19-18-14
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求**重新部署当前应用**,使最新的代码修改(已修复的路由切换数据丢失、自动帧插入实时刷新等问题)在生产环境中生效。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 基于最新代码重新构建 Docker 镜像;
|
||||
- 停止并移除旧运行的容器;
|
||||
- 启动新容器并暴露服务;
|
||||
- 验证部署后的应用可正常访问。
|
||||
|
||||
### 非功能点
|
||||
- 尽量缩短服务中断时间;
|
||||
- 保留旧镜像以便快速回滚(可选);
|
||||
- 部署失败时需要有明确的错误信息。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| Docker 容器 `medical-report-app` | 高 | 需要重建并重启 |
|
||||
| 本地端口 `8080` | 中 | 重新绑定到新容器 |
|
||||
| 源代码 / Git 仓库 | 无 | 仅读取最新代码进行构建 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。部署流程明确。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 需求分析 — 2026-04-16-19-28-04
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `/report-editor` 页面进行操作(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder),离开该页面进入 `/report-manage` 等其他界面后,再次返回 `/report-editor` 时,**所有内容全部丢失**:
|
||||
- 报告编辑器中的文本和图片丢失;
|
||||
- 基本信息(患者姓名、住院号等)丢失;
|
||||
- 视频分析面板中的视频列表和关键帧截图全部丢失。
|
||||
|
||||
此前两次修复尝试未能解决该问题。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
- 彻底修复路由切换后报告内容、基本信息、视频分析数据全部丢失的问题;
|
||||
- 确保自动保存机制(草稿保存)在任何情况下都不会用空值覆盖已有的有效 draft;
|
||||
- 确保组件卸载时保存的 draft 100% 反映用户最新的操作状态。
|
||||
|
||||
### 非功能点
|
||||
- 最小化对现有 UI 和交互逻辑的侵入;
|
||||
- 保持现有 localStorage 存储机制不变;
|
||||
- 同时兼顾 React 18 `StrictMode` 在开发/预览环境下的双重挂载行为。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 自动保存逻辑 `saveDraftToStorage` 和自动保存 effect 需要重构 |
|
||||
| `useLayoutEffect` 安全网 | 中 | 需要添加依赖数组,避免重复执行 |
|
||||
| 其他组件 | 无 | 不涉及修改 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。根因已定位,修复方案明确。
|
||||
50
工程分析/需求分析-2026-04-16-22-23-02.md
Normal file
50
工程分析/需求分析-2026-04-16-22-23-02.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析 — 2026-04-16-22-23-02
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
在 `TemplateManage` 模块新增**字段库功能**,实现在手术记录模板中插入具备**"标签锁定、内容可调"**特性的智能占位方格。建立报告正文(`ReportEditor` 富文本编辑器)与右侧"基本信息"表单之间的**双向数据绑定映射**。确保模板固定文本(如"姓名:")在报告编辑端不被误删,同时实现文档内容与结构化表单的同步联动录入。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
1. **模板编辑端 (`TemplateManage.tsx`)**
|
||||
- 新增右侧"表单字段库"侧边栏,列出所有可映射字段(姓名、性别、年龄、住院号、手术者等)。
|
||||
- 点击字段库中的字段按钮,在编辑器光标处插入一个特殊 HTML 占位控件。
|
||||
- 占位控件结构要求:
|
||||
- `Label`(如"姓名:")**锁定不可编辑**,且不能被单独删除或篡改。
|
||||
- `Value`(方格区域)**允许用户输入**,并与右侧表单字段双向绑定。
|
||||
|
||||
2. **报告编辑端 (`ReportEditor.tsx`)**
|
||||
- **富文本 → 表单**:用户在编辑器占位方格内输入内容时,自动同步更新右侧【基本信息】对应字段的表单值。
|
||||
- **表单 → 富文本**:用户在右侧【基本信息】表单中修改内容时,自动同步更新编辑器内对应占位方格的内容。
|
||||
- 同步时必须处理光标跳动问题,保证输入体验流畅。
|
||||
|
||||
3. **打印适配**
|
||||
- 打印时占位方格的边框样式需要适配 A4 报告风格(如下划线填空或去边框)。
|
||||
|
||||
4. **老数据兼容**
|
||||
- 现有模板中的纯文本占位符(如 `姓名:<span style="color: #ff0000;">*姓名*</span>`)不会被自动转换。
|
||||
- 仅当管理员在 `TemplateManage` 中重新编辑模板并插入新控件后,才激活联动能力。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 不引入第三方富文本编辑器,继续基于现有 `contentEditable` + `document.execCommand` 方案。
|
||||
- 最小化对 `storage.ts` 数据结构的侵入。
|
||||
- 保持现有 `npm run lint` 类型检查通过。
|
||||
- 多选字段(如 `surgeon` 数组)在方格中展示为逗号分隔字符串。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 新增字段库侧边栏、插入控件逻辑 |
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 双向绑定监听(`onInput` + `useEffect`)、DOM 同步 |
|
||||
| `src/utils/print.ts` | 中 | 打印样式适配(`@media print` 下 `.field-value` 样式) |
|
||||
| `src/index.css` | 中 | 新增 `.smart-field-wrapper`、`.field-label`、`.field-value` 的编辑态/打印态样式 |
|
||||
| `src/types.ts` | 低 | 可能需要扩展字段映射常量/类型定义 |
|
||||
| `src/utils/defaultContent.ts` | 低 | 可选:将默认模板中的部分占位符替换为智能控件 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已提供详细需求描述及技术实现思路,可直接进入实现方案设计阶段。
|
||||
286
过往经验/经验记录.md
Normal file
286
过往经验/经验记录.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 经验记录
|
||||
|
||||
---
|
||||
|
||||
## 记录 1:report-editor 新建报告时显示空白模板
|
||||
|
||||
**A. 具体问题**
|
||||
超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。
|
||||
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。
|
||||
3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。
|
||||
4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。
|
||||
2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。
|
||||
3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。
|
||||
- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。
|
||||
- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。
|
||||
|
||||
---
|
||||
|
||||
## 记录 2:关键帧一键插入占位符功能实现
|
||||
|
||||
**A. 具体问题**
|
||||
用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。
|
||||
|
||||
**B. 产生问题原因**
|
||||
原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。
|
||||
2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。
|
||||
3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。
|
||||
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。
|
||||
- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。
|
||||
|
||||
---
|
||||
|
||||
## 记录 3:关键帧 "插入" 按钮位置与样式优化
|
||||
|
||||
**A. 具体问题**
|
||||
用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。
|
||||
|
||||
**B. 产生问题原因**
|
||||
初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。
|
||||
2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。
|
||||
3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。
|
||||
- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。
|
||||
- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。
|
||||
|
||||
---
|
||||
|
||||
## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部)
|
||||
|
||||
**A. 具体问题**
|
||||
用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。
|
||||
|
||||
**B. 产生问题原因**
|
||||
按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。
|
||||
2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。
|
||||
3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。
|
||||
4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
||||
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
||||
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 5:路由切换后视频分析图片丢失
|
||||
|
||||
**A. 具体问题**
|
||||
在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。
|
||||
2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。
|
||||
3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。
|
||||
4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。
|
||||
|
||||
**C. 解决问题方案**
|
||||
在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。
|
||||
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。
|
||||
- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 6:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
|
||||
|
||||
**A. 具体问题**
|
||||
1. 在 `/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`,**报告内容变空、基本信息清空、视频分析数据全部丢失**。
|
||||
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft,覆盖了用户已有的数据。
|
||||
2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。
|
||||
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
|
||||
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。
|
||||
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。
|
||||
- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview)
|
||||
|
||||
**A. 具体问题**
|
||||
用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI;
|
||||
2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署;
|
||||
3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程;
|
||||
2. 执行 `npm run build` 重新构建生产包;
|
||||
3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器;
|
||||
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本;
|
||||
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱;
|
||||
- 如需固定端口,可在 `package.json` 的 `preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 8:路由切换后所有内容仍然丢失——彻底重构自动保存机制
|
||||
|
||||
**A. 具体问题**
|
||||
在 `/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。
|
||||
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []`、`capturedFrames: []`、默认 `reportData`)。
|
||||
3. 组件卸载(cleanup)时调用保存,用这个空值**覆盖了 localStorage 中已有的正确 draft**。
|
||||
4. 重新挂载后,系统读取了被清空的 draft,导致所有数据全部丢失。
|
||||
5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current` 和 `stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId`、`reportId`,确保闭包永远绑定当前渲染周期的最新 state。
|
||||
2. **重构自动保存 effect**:将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`,effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载,cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。
|
||||
3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
|
||||
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
|
||||
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
|
||||
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 9:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
|
||||
|
||||
**A. 具体问题**
|
||||
在 `/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`:
|
||||
- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失;
|
||||
- 视频分析面板中的自动关键帧和手动截图全部丢失。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state(如 `capturedFrames`、`videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。
|
||||
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。
|
||||
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **重构 `saveDraftToStorage` 从 Ref 读取**:
|
||||
- `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`。
|
||||
- `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。
|
||||
- `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。
|
||||
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 对于需要在异步操作或组件卸载时读取的"最新状态",**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
|
||||
- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。
|
||||
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。
|
||||
- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景(React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
|
||||
|
||||
**A. 具体问题**
|
||||
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。
|
||||
|
||||
**B. 产生问题原因**
|
||||
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`:
|
||||
```tsx
|
||||
if ((settings.autoInsertDelay || 0) > 0) {
|
||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
||||
}
|
||||
```
|
||||
|
||||
`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。
|
||||
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。
|
||||
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。
|
||||
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
|
||||
- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
|
||||
|
||||
**A. 具体问题**
|
||||
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9:
|
||||
```tsx
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
```
|
||||
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。
|
||||
2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
|
||||
```typescript
|
||||
} catch {
|
||||
// ignore quota exceeded
|
||||
}
|
||||
```
|
||||
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **压缩关键帧分辨率与质量**:
|
||||
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
|
||||
```tsx
|
||||
const MAX_WIDTH = 800;
|
||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
||||
canvas.width = video.videoWidth * scale;
|
||||
canvas.height = video.videoHeight * scale;
|
||||
```
|
||||
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
|
||||
- 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。
|
||||
|
||||
2. **增加存储错误可见性**:
|
||||
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`:
|
||||
```typescript
|
||||
} catch (e) {
|
||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||
}
|
||||
```
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
|
||||
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
||||
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
|
||||
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
||||
Reference in New Issue
Block a user