17 Commits

Author SHA1 Message Date
Administrator
32f8b2a7ec 2026-04-18-19-23-31 - 修复视频分析模块空白、图片占位符尺寸自适应优化 2026-04-18 19:27:05 +08:00
Administrator
519cc6fc82 2026-04-18-19-08-43 - 追加经验记录31:六项UI优化实施记录 2026-04-18 19:15:00 +08:00
Administrator
4a7051b6db 2026-04-18-19-08-43 - 六项UI优化:基础字段无下划线、field-value联动高亮、视频按钮整合、视频间距紧凑、签名空行、图片占位符自适应高度 2026-04-18 19:14:23 +08:00
Administrator
5f4ae1ff29 2026-04-18-18-36-43 - 报告管理列名修正、字段下划线控制、下载导出功能、右对齐排版修复、签名默认右对齐 2026-04-18 18:48:30 +08:00
Administrator
db1c11f7eb 2026-04-18-18-08-37 - 编辑器工具栏新增字号行距选择、修复字体选择、模板排版间距调整 2026-04-18 18:13:07 +08:00
Administrator
55ce78d898 2026-04-18-17-48-59 - 模板排版微调:字段间距、Flex居中抬头、打印隐藏删除按钮、统一1.5行距、下划线贴底 2026-04-18 17:52:05 +08:00
Administrator
e1dc961ecf 2026-04-18-17-27-51 - 修复TemplateManage静态占位符插入、重构默认模板排版、修复Logo删除按钮交互 2026-04-18 17:33:07 +08:00
Administrator
67fb2c9080 2026-04-18-16-55-47 - 报告编辑器field-value点击联动、字段动态排序、默认模板手术图片表格替换 2026-04-18 17:01:18 +08:00
Administrator
a46ecffadf 2026-04-18-16-45-02 - 建立代码编纂工作流规范(含需求分析、实现方案、测试方案) 2026-04-18 16:50:55 +08:00
Administrator
034575e0a8 Merge remote V1.2.0 and add local V1.2.1 2026-04-18 16:31:38 +08:00
Administrator
4e24ee15a2 V1.2.1 2026-04-18 16:31:09 +08:00
0df27cbc73 2026-04-18-00-43-19 - 默认模板占位符补充data-mode属性与图片来源隔离对齐 2026-04-18 00:46:05 +08:00
1278f7282f 2026-04-18-00-23-14 - 补充自动帧插入时的占位符边框清除 2026-04-18 00:34:46 +08:00
8978b7a2de Merge remote: adopt upstream modal and placeholder isolation changes 2026-04-18 00:33:59 +08:00
6498ef6444 2026-04-18-00-23-14 - 修复拖拽关键帧边框残留、替换prompt为自定义弹窗、增加占位符图片来源隔离 2026-04-18 00:32:28 +08:00
b7a1ea457e 2026-04-18-00-02-08 - 拖拽关键帧样式修复、占位符分类隔离与Modal弹窗改造、表格插入Modal化 2026-04-18 00:09:33 +08:00
d05029838f 2026-04-17-23-38-34 - 时间格式自定义下拉组件、表格内图片占位符自适应、打印多页页边距修复 2026-04-17 23:44:03 +08:00
44 changed files with 4077 additions and 454 deletions

301
AGENTS.md
View File

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

View File

@@ -5,7 +5,7 @@ import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft
Video, Play, Pause, Plus, X, ChevronLeft, Download
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
@@ -48,11 +48,13 @@ export default function ReportEditor() {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isSaved, setIsSaved] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [loadedTemplateId, setLoadedTemplateId] = useState('');
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
surgeon: ['张医生', '李医生', '王医生'],
assistant: ['赵医生', '钱医生', '孙医生'],
@@ -66,6 +68,12 @@ export default function ReportEditor() {
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]);
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
const editorRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
@@ -371,6 +379,28 @@ export default function ReportEditor() {
const targetEl = node as HTMLElement | null;
if (!targetEl) return;
// Handle click on field-value: switch to info tab, highlight and focus corresponding input
const fieldValue = targetEl.closest('.field-value') as HTMLElement | null;
if (fieldValue) {
const bindKey = fieldValue.getAttribute('data-bind');
if (bindKey) {
setActiveTab('info');
stateRef.current = { ...stateRef.current, activeTab: 'info' };
setActiveFieldKey(bindKey);
setTimeout(() => {
const inputEl = document.getElementById(`input-${bindKey}`);
if (inputEl) {
inputEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = inputEl.querySelector('input, select') as HTMLElement | null;
if (focusable) {
focusable.focus();
}
}
}, 100);
}
return;
}
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
if (!placeholder) return;
@@ -459,13 +489,24 @@ export default function ReportEditor() {
}, []);
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
@@ -478,55 +519,27 @@ export default function ReportEditor() {
saveDraftToStorage();
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
if (rowsStr && colsStr) {
const rows = parseInt(rowsStr);
const cols = parseInt(colsStr);
if (isNaN(rows) || isNaN(cols)) return;
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
const changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.lineHeight = height;
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
};
const insertTable = () => {
editorRef.current?.focus();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
const insertImage = () => {
editorRef.current?.focus();
let width = 200;
let height = 200;
while (true) {
const input = prompt('请输入占位符的最大宽度和高度(px),用*号分隔(如: 100*50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
const trimmed = input.trim();
if (trimmed === '') break;
const parts = trimmed.split('*').map(s => s.trim());
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
width = parseInt(parts[0]) || 0;
height = parseInt(parts[1]) || 0;
break;
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插入图片' : '插入/点击放置图片';
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></span>&#8203;`;
execCmd('insertHTML', html);
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -655,13 +668,24 @@ export default function ReportEditor() {
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
<img src="${newFrame.dataUrl}" style="max-width:${emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'};max-height:${emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
emptyPlaceholder.style.width = 'auto';
emptyPlaceholder.style.height = 'auto';
emptyPlaceholder.style.lineHeight = 'normal';
emptyPlaceholder.style.maxWidth = emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px';
emptyPlaceholder.style.maxHeight = emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px';
emptyPlaceholder.style.textAlign = 'left';
emptyPlaceholder.style.verticalAlign = 'top';
emptyPlaceholder.style.justifyContent = 'flex-start';
emptyPlaceholder.style.alignItems = 'flex-start';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
@@ -686,17 +710,34 @@ export default function ReportEditor() {
};
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
<img src="${frame.dataUrl}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
if (placeholder.getAttribute('data-mode') === 'manual') {
alert('此处为静态图片占位符仅支持点击插入如Logo/签名),不支持拖入关键帧');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
@@ -709,7 +750,7 @@ export default function ReportEditor() {
alert('编辑器未准备好');
return;
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (!emptyPlaceholder) {
alert('没有可插入图片的空位');
return;
@@ -1299,6 +1340,13 @@ export default function ReportEditor() {
<Check size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
@@ -1319,6 +1367,7 @@ export default function ReportEditor() {
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<select
onMouseDown={(e) => e.preventDefault()}
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"
>
@@ -1328,6 +1377,27 @@ export default function ReportEditor() {
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', 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="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(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="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</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>
@@ -1393,14 +1463,30 @@ export default function ReportEditor() {
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{activeTab === 'info' && (
<div className="report-info-form space-y-4">
{formFields.filter(f => f.visibleInForm).map(field => {
{(() => {
const topKeys = ['patientName', 'hospitalId', 'title'];
const contentHtml = contentRef.current || editorRef.current?.innerHTML || '';
return [...formFields.filter(f => f.visibleInForm)].sort((a, b) => {
const aTop = topKeys.indexOf(a.key);
const bTop = topKeys.indexOf(b.key);
if (aTop !== -1 && bTop !== -1) return aTop - bTop;
if (aTop !== -1) return -1;
if (bTop !== -1) return 1;
const aIndex = contentHtml.indexOf(`data-bind="${a.key}"`);
const bIndex = contentHtml.indexOf(`data-bind="${b.key}"`);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
})().map(field => {
const isRequired = field.isSystemLocked;
const hasError = isRequired && touched[field.key] && !(reportData as any)[field.key];
if (field.type === 'text' || field.type === 'date') {
const inputType = field.type === 'date' ? 'date' : 'text';
return (
<div key={field.key} className={field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'}>
<div key={field.key} id={`input-${field.key}`} className={`${field.category === '填空' && formFields.filter(f2 => f2.visibleInForm && f2.type === 'text' && f2.isSystemLocked).length > 1 && (field.key === 'patientName' || field.key === 'hospitalId') ? 'flex-1 space-y-1' : 'space-y-1'} p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">
{field.label} {isRequired && <span className="text-red-500">*</span>}
</label>
@@ -1420,7 +1506,7 @@ export default function ReportEditor() {
const isOpen = openDropdown === field.key;
const opts = field.options || (field.key === 'anesthesiaType' ? anesthesiaOptions : []);
return (
<div key={field.key} className="space-y-1 select-dropdown-root relative">
<div key={field.key} id={`input-${field.key}`} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex items-center min-h-[42px] cursor-text"
@@ -1529,7 +1615,7 @@ export default function ReportEditor() {
const currentInputText = multiInputText[field.key] !== undefined ? multiInputText[field.key] : displayText;
return (
<div key={field.key} className="space-y-1 select-dropdown-root relative">
<div key={field.key} id={`input-${field.key}`} className={`space-y-1 select-dropdown-root relative p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div
className="w-full px-3 py-2 border border-border rounded-lg bg-white flex flex-wrap gap-1 items-center min-h-[42px] cursor-text"
@@ -1610,7 +1696,7 @@ export default function ReportEditor() {
const { h: h12, isPM } = from24h(h24val);
return (
<div key={field.key} className="space-y-1">
<div key={field.key} id={`input-${field.key}`} className={`space-y-1 p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`}>
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
@@ -1667,7 +1753,7 @@ export default function ReportEditor() {
const { h: h12g, isPM: isPMg } = from24h(h24);
return (
<div key={field.key} className="space-y-1">
<div key={field.key} id={`input-${field.key}`} className="space-y-1">
<label className="block text-xs font-bold text-text-main">{field.label}</label>
<div className="flex items-center gap-2">
<select
@@ -1724,7 +1810,7 @@ export default function ReportEditor() {
)}
{activeTab === 'video' && (
<div className="space-y-4">
<div className="space-y-2">
<input
ref={videoInputRef}
type="file"
@@ -1733,49 +1819,44 @@ export default function ReportEditor() {
className="hidden"
onChange={handleVideoUpload}
/>
<button
onClick={() => videoInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 p-3 border border-dashed border-border rounded-lg hover:border-accent hover:bg-slate-50 transition-all"
>
<Video size={18} />
<div className="text-left">
<p className="text-xs font-bold text-text-main"></p>
<p className="text-[10px] text-text-muted"> MP4, MOV </p>
</div>
</button>
{videos.length > 0 && (
<div className="space-y-4">
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
{videos.map((v, i) => (
<div
key={v.id}
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
}`}
>
<div
onClick={() => selectVideo(i)}
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
>
<Play size={16} />
</div>
<div
onClick={() => selectVideo(i)}
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
>{v.name}</div>
<button
onClick={() => removeVideo(v.id)}
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"
>
<X size={12} />
</button>
</div>
))}
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar items-center">
<button
onClick={() => videoInputRef.current?.click()}
className="shrink-0 w-24 h-[68px] flex flex-col items-center justify-center gap-1 border-2 border-dashed border-border rounded-xl hover:border-accent hover:bg-slate-50 transition-all text-text-muted hover:text-accent"
>
<Video size={18} />
<span className="text-[10px] font-bold"></span>
</button>
{videos.map((v, i) => (
<div
key={v.id}
className={`shrink-0 w-24 p-1.5 border-2 rounded-xl cursor-pointer transition-all relative group ${
currentVideoIndex === i ? 'border-accent bg-white shadow-sm' : 'border-transparent'
}`}
>
<div
onClick={() => selectVideo(i)}
className="aspect-video bg-slate-900 rounded-lg flex items-center justify-center text-white"
>
<Play size={16} />
</div>
<div
onClick={() => selectVideo(i)}
className="text-[9px] font-bold text-text-main truncate mt-1.5 px-1"
>{v.name}</div>
<button
onClick={() => removeVideo(v.id)}
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"
>
<X size={12} />
</button>
</div>
))}
</div>
{currentVideoIndex !== -1 && (
<div className="space-y-4">
{currentVideoIndex !== -1 && videos.length > 0 && (
<div className="space-y-2">
<div className="relative bg-slate-900 rounded-2xl overflow-hidden aspect-video shadow-lg">
<video
ref={videoRef}
@@ -1813,7 +1894,7 @@ export default function ReportEditor() {
</div>
</div>
<div className="flex justify-between items-center pt-2">
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-[10px] font-bold text-text-main uppercase tracking-wider"></span>
<button
onClick={captureFrame}
@@ -1864,8 +1945,6 @@ export default function ReportEditor() {
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -1874,6 +1953,150 @@ export default function ReportEditor() {
</div>
<canvas ref={canvasRef} className="hidden" />
{placeholderModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1"></label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80">/</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80"></span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
execCmd('insertHTML', html);
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{exportModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
printDocument(editorRef.current?.innerHTML || '', `图文报告-${title}-${patient}-${hid}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const title = reportData.title || '无标题';
const patient = reportData.patientName || '未知';
const hid = reportData.hospitalId || '无号';
const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `图文报告-${title}-${patient}-${hid}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</div>
</div>
)}
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">

View File

@@ -283,7 +283,7 @@ export default function ReportManage() {
</th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24"></th>

View File

@@ -1,7 +1,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 { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
@@ -13,6 +13,7 @@ export default function TemplateManage() {
const [templates, setTemplates] = useState<Template[]>([]);
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', desc: '' });
const [isSaved, setIsSaved] = useState(false);
@@ -34,10 +35,20 @@ export default function TemplateManage() {
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
const [editFieldHasUnderline, setEditFieldHasUnderline] = useState(true);
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(true);
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
const updatePageHeight = () => {
@@ -132,13 +143,24 @@ export default function TemplateManage() {
}, [currentUser]);
const fillPlaceholderSrc = (placeholder: HTMLElement, src: string) => {
const mw = placeholder.style.maxWidth || placeholder.style.width || '200px';
const mh = placeholder.style.maxHeight || placeholder.style.height || '200px';
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
<img src="${src}" style="max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
placeholder.style.width = 'auto';
placeholder.style.height = 'auto';
placeholder.style.lineHeight = 'normal';
placeholder.style.maxWidth = mw;
placeholder.style.maxHeight = mh;
placeholder.style.textAlign = 'left';
placeholder.style.verticalAlign = 'top';
placeholder.style.justifyContent = 'flex-start';
placeholder.style.alignItems = 'flex-start';
saveTemplateContent();
};
@@ -351,6 +373,18 @@ export default function TemplateManage() {
editorRef.current?.focus();
};
const changeLineHeight = (height: string) => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
if (block) {
(block as HTMLElement).style.lineHeight = height;
saveTemplateContent();
}
};
const saveTemplateContent = () => {
if (!currentTemplateId || !editorRef.current) return;
const allTemplates = storage.get<Template[]>('templates', []);
@@ -370,7 +404,8 @@ export default function TemplateManage() {
}
pushHistory();
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>&#8203;`;
const underlineClass = field.hasUnderline === false ? ' no-underline' : '';
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value${underlineClass}" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>&#8203;`;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
@@ -439,6 +474,7 @@ export default function TemplateManage() {
next.timeDefault = editFieldTimeDefault;
next.fixedTimeValue = editFieldFixedTimeValue;
}
next.hasUnderline = editFieldHasUnderline;
return next;
});
setFormFields(updated);
@@ -456,6 +492,7 @@ export default function TemplateManage() {
type: newFieldForm.type,
visibleInForm: true,
isSystemLocked: false,
hasUnderline: newFieldHasUnderline,
options: ['单选', '多选'].includes(newFieldForm.category) && newFieldOptions.trim()
? newFieldOptions.split(/[,]/).map(s => s.trim()).filter(Boolean)
: undefined
@@ -473,6 +510,7 @@ export default function TemplateManage() {
setNewFieldTimeFormat('YYYY年MM月DD日');
setNewFieldTimeDefault('specific');
setNewFieldFixedTimeValue('');
setNewFieldHasUnderline(true);
};
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -491,57 +529,17 @@ export default function TemplateManage() {
};
const insertTable = () => {
const rowsStr = prompt('请输入行数:', '2');
const colsStr = prompt('请输入列数:', '3');
if (rowsStr && colsStr) {
const rows = parseInt(rowsStr);
const cols = parseInt(colsStr);
if (isNaN(rows) || isNaN(cols)) return;
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
pushHistory();
execCmd('insertHTML', table);
}
editorRef.current?.focus();
restoreSelection();
pushHistory();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
let width = 200;
let height = 200;
while (true) {
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
const trimmed = input.trim();
if (trimmed === '') break;
const parts = trimmed.split('*').map(s => s.trim());
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
width = parseInt(parts[0]) || 0;
height = parseInt(parts[1]) || 0;
break;
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;
const showShortText = width > 0 && width < 80;
const hintText = showShortText ? '插图' : '插入/点击放置图片';
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${hintText}</span></span>&#8203;`;
pushHistory();
execCmd('insertHTML', html);
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const saveCurrentTemplate = () => {
@@ -740,6 +738,13 @@ export default function TemplateManage() {
<Save size={16} />
</button>
<button
onClick={() => setExportModalOpen(true)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
title="下载"
>
<Download size={18} />
</button>
<button
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
className="p-2.5 rounded-lg bg-slate-100 text-text-muted hover:bg-slate-200 transition-colors"
@@ -771,6 +776,27 @@ export default function TemplateManage() {
<option value="SimHei"></option>
<option value="KaiTi"></option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { execCmd('fontSize', 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="3">12pt</option>
<option value="4">14pt</option>
<option value="5">18pt</option>
<option value="6">24pt</option>
</select>
<select
onMouseDown={(e) => e.preventDefault()}
onChange={(e) => { if (e.target.value) { changeLineHeight(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="1">1.0</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
</select>
</div>
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
<button onMouseDown={(e) => e.preventDefault()} 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>
@@ -886,6 +912,7 @@ export default function TemplateManage() {
setEditFieldTimeFormat(field.timeFormat || '');
setEditFieldTimeDefault(field.timeDefault || 'specific');
setEditFieldFixedTimeValue(field.fixedTimeValue || '');
setEditFieldHasUnderline(field.hasUnderline !== false);
const target = e.currentTarget;
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -933,44 +960,67 @@ export default function TemplateManage() {
className="w-full px-1.5 py-1 text-xs border border-border rounded"
/>
)}
<input
list={`edit-format-list-${field.key}`}
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
<div className="relative">
<input
type="text"
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onFocus={() => setFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setFormatDropdownOpen(false), 200);
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id={`edit-format-list-${field.key}`}>
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (field.type === 'date') return isDateFormat;
if (field.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setFormatDropdownOpen(false);
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式或下拉选择"
/>
{formatDropdownOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (field.type === 'date') return isDateFormat;
if (field.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setEditFieldTimeFormat(fmt);
setFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
</div>
)}
<label className="flex items-center gap-1 text-[10px] text-slate-600 cursor-pointer">
<input type="checkbox" checked={editFieldHasUnderline} onChange={(e) => setEditFieldHasUnderline(e.target.checked)} />
线
</label>
<div className="flex gap-2">
<button
onClick={() => saveFieldEdit(field.key)}
@@ -1101,42 +1151,61 @@ export default function TemplateManage() {
className="w-full px-2 py-1.5 text-xs border border-border rounded"
/>
)}
<input
list="new-format-list"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
<div className="relative">
<input
type="text"
value={newFieldTimeFormat}
onChange={(e) => setNewFieldTimeFormat(e.target.value)}
onFocus={() => setNewFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setNewFormatDropdownOpen(false), 200);
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式,如 YYYY-MM-DD"
/>
<datalist id="new-format-list">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (newFieldForm.type === 'date') return isDateFormat;
if (newFieldForm.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => <option key={fmt} value={fmt} />)}
</datalist>
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = newFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setNewFormatDropdownOpen(false);
}
}}
className="w-full px-2 py-1.5 text-xs border border-border rounded"
placeholder="输入格式或下拉选择"
/>
{newFormatDropdownOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (newFieldForm.type === 'date') return isDateFormat;
if (newFieldForm.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setNewFieldTimeFormat(fmt);
setNewFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
</div>
)}
{['单选', '多选'].includes(newFieldForm.category) && (
@@ -1148,6 +1217,10 @@ export default function TemplateManage() {
className="w-full px-2 py-1.5 text-xs border border-border rounded focus:outline-hidden focus:border-accent"
/>
)}
<label className="flex items-center gap-1 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" checked={newFieldHasUnderline} onChange={(e) => setNewFieldHasUnderline(e.target.checked)} />
线
</label>
<button
onClick={addField}
className="w-full py-1.5 bg-accent text-white text-xs font-semibold rounded hover:opacity-90 transition-colors"
@@ -1162,6 +1235,45 @@ export default function TemplateManage() {
</div>
</div>
{exportModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const name = currentTemplate?.name || '模板';
printDocument(editorRef.current?.innerHTML || '', `${name}-${ts}`);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-accent text-white rounded text-sm font-semibold hover:opacity-90 transition-colors"
> PDF</button>
<button
onClick={() => {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const name = currentTemplate?.name || '模板';
const data = currentTemplate ? { ...currentTemplate, content: editorRef.current?.innerHTML } : { content: editorRef.current?.innerHTML };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
}}
className="w-full py-2.5 bg-slate-100 text-slate-700 rounded text-sm font-semibold hover:bg-slate-200 transition-colors"
> JSON</button>
<button
onClick={() => setExportModalOpen(false)}
className="w-full py-2.5 border border-border text-text-main rounded text-sm font-semibold hover:bg-slate-50 transition-colors"
></button>
</div>
</div>
</div>
)}
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-10 w-full max-w-[500px] shadow-2xl border border-border">
@@ -1208,6 +1320,130 @@ export default function TemplateManage() {
</div>
)}
{placeholderModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1"></label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80">/</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80"></span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
let html: string;
if (inTable) {
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-block;text-align:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;';
styleStr += `width:${w}px;height:${h}px;max-width:${w}px;max-height:${h}px;line-height:${h}px;`;
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;display:inline-block;vertical-align:middle;line-height:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const fragment = document.createDocumentFragment();
while (wrapper.firstChild) {
fragment.appendChild(wrapper.firstChild);
}
const sel2 = window.getSelection();
if (sel2 && sel2.rangeCount > 0) {
const range = sel2.getRangeAt(0);
range.deleteContents();
range.insertNode(fragment);
const lastNode = fragment.lastChild;
if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
sel2.removeAllRanges();
sel2.addRange(range);
}
} else if (editorRef.current) {
editorRef.current.appendChild(fragment);
}
editorRef.current?.focus();
saveTemplateContent();
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
{imagePickerOpen && imagePickerTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">

View File

@@ -116,11 +116,12 @@ export interface FormField {
timeFormat?: string;
timeDefault?: 'current' | 'specific';
fixedTimeValue?: string;
hasUnderline?: boolean;
}
export const DEFAULT_FORM_FIELDS: FormField[] = [
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true },
{ key: 'patientName', label: '患者姓名', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: true },
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: true },
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },

View File

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

View File

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

View File

@@ -0,0 +1,196 @@
# 实现方案 — 2026-04-17-23-38-34
## 根因分析
### 问题1原生 datalist 交互体验差
- 原生 `<input list>` + `<datalist>` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。
- 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。
### 问题2execCommand('insertHTML') 在表格中破坏结构
-`insertImage``<td>` 内执行 `execCommand('insertHTML', ...)`WebKit/Blink 会将复杂的 `inline-flex` 嵌套 `<span>` 结构自动"拍平"或重新排列。
- 外层 `<span class="image-placeholder">` 被浏览器移除,仅剩内部的 `.delete-btn``.placeholder-text` 散落为 `<td>` 的直接子元素。
- 表格单元格本身就是块级上下文,使用块级 `<div>` 作为占位符容器更符合浏览器预期,不会被强制修正。
### 问题3@page margin 与 body padding 的分页失效
- `@page { margin: 0 }` 将物理纸张边距设为 0。
- `body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。
- 当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding导致第二页顶部和底部紧贴纸张边缘。
- 正确做法是将边距交给 `@page` 规则,让打印引擎为每一张物理纸张独立留出边距。
## 修改文件清单
| 文件 | 修改内容 |
|------|---------|
| `src/pages/TemplateManage.tsx` | ① 引入 `formatDropdownOpen` / `newFormatDropdownOpen` 状态;② 将编辑/新增字段的格式 `input[list]+datalist` 替换为自定义下拉组件;③ `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
| `src/pages/ReportEditor.tsx` | `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
| `src/utils/print.ts` | `@page margin``body padding` 调整,`.content` width 改为 `100%` |
## 具体代码变更
### 1. TemplateManage.tsx
#### 1.1 新增状态(组件顶部)
```tsx
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
```
#### 1.2 编辑字段格式输入替换为自定义下拉
将原 `input[list]` + `datalist` 替换为:
```tsx
<div className="relative">
<input
type="text"
value={editFieldTimeFormat}
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
onFocus={() => setFormatDropdownOpen(true)}
onBlur={() => {
setTimeout(() => setFormatDropdownOpen(false), 200);
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = editFieldTimeFormat.trim();
if (val && !customTimeFormats.includes(val)) {
const next = [...customTimeFormats, val];
setCustomTimeFormats(next);
storage.set('customTimeFormats', next);
}
setFormatDropdownOpen(false);
}
}}
className="w-full px-1.5 py-1 text-xs border border-border rounded"
placeholder="输入格式或下拉选择"
/>
{formatDropdownOpen && (
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
{customTimeFormats
.filter(fmt => {
const isDateFormat = /YYYY|MM|DD/.test(fmt);
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
if (field.type === 'date') return isDateFormat;
if (field.type === 'time') return isTimeFormat;
return true;
})
.map(fmt => (
<div
key={fmt}
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
setEditFieldTimeFormat(fmt);
setFormatDropdownOpen(false);
}}
>
{fmt}
</div>
))}
</div>
)}
</div>
```
#### 1.3 新增字段格式输入同理替换
使用 `newFormatDropdownOpen` 状态,结构同上,过滤条件改为 `newFieldForm.type`
#### 1.4 insertImage 增加表格检测
```tsx
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
// 检测是否在表格单元格内
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
let width = 200;
let height = 200;
if (!inTable) {
while (true) {
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
if (input === null) return;
const trimmed = input.trim();
if (trimmed === '') break;
const parts = trimmed.split('*').map(s => s.trim());
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
width = parseInt(parts[0]) || 0;
height = parseInt(parts[1]) || 0;
break;
}
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
}
}
const id = 'ph_' + Date.now();
const hintText = '插入/点击放置图片';
let html: string;
if (inTable) {
// 表格内使用 div 块级容器,自适应单元格
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
// 普通文本中保持行内 span
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;
const showShortText = width > 0 && width < 80;
const text = showShortText ? '插图' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
pushHistory();
execCmd('insertHTML', html);
};
```
### 2. ReportEditor.tsx
`insertImage` 同理增加表格检测分支,与 TemplateManage 保持一致(去除 `restoreSelection()``pushHistory()` 调用)。
### 3. print.ts
```css
/* 修改前 */
@page { size: A4; margin: 0; }
body { margin: 0; padding: 10mm; ... }
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
/* 修改后 */
@page { size: A4; margin: 15mm 10mm; }
body { margin: 0; padding: 0; ... }
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
```
## 风险点与应对措施
| 风险 | 应对措施 |
|------|---------|
| 自定义下拉组件在滚动容器内可能被裁切 | 父容器设置 `relative`,下拉层设置 `absolute z-10`,并确保外层有适当的 overflow-visible 或足够空间 |
| 表格检测 `while (node)` 循环在编辑器外部可能遍历到 body/html | 以 `node.nodeName === 'TD' \|\| node.nodeName === 'TH'` 为终止条件,安全 |
| 表格内使用 div 后fillPlaceholderSrc 需要兼容 | fillPlaceholderSrc 通过 `querySelector('.image-placeholder')` 匹配 class不受标签名影响已验证兼容 |
| @page margin 增加后 .content width 190mm 会溢出 | 改为 `width: 100%`,让内容自然撑满可用区域 |
## 回滚策略
- TemplateManage.tsx 的修改:删除新增状态和替换的 JSX 条件块,恢复原有的 `input[list]` + `datalist`
- ReportEditor.tsx 的修改:删除 insertImage 中的表格检测分支。
- print.ts 的修改:恢复原始的 `@page``body``content` 样式。

View File

@@ -0,0 +1,256 @@
# 实现方案 — 2026-04-18-00-02-08
## 根因分析
### 问题1拖拽插入后边框不消失
- `fillPlaceholderSrc`(点击上传路径)设置了 `border='none'``background='transparent'`
- `fillPlaceholder`(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。
- 同时 `fillPlaceholder` 中图片 style 缺少 `max-height:100%;object-fit:contain;`,图片可能溢出占位符。
### 问题2prompt 弹窗体验差 + 自动帧插入无区分
- `insertImage` 使用浏览器原生 `prompt` 询问宽高,交互体验不佳。
- 所有 `.image-placeholder` 一视同仁,`autoCaptureFrames` 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
- 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
### 问题3insertTable 使用 prompt
- 与 insertImage 同理,原生 `prompt` 弹窗用户体验差,应替换为与项目风格一致的自定义 Modal。
## 修改文件清单
| 文件 | 修改内容 |
|------|---------|
| `src/pages/ReportEditor.tsx` | ① fillPlaceholder 补齐样式清除和图片约束;② insertImage 改为 placeholderModal③ insertTable 改为 tableModal④ autoCaptureFrames/insertFrameToPlaceholder 选择器增加 `:not([data-mode="manual"])`;⑤ handleDrop 拦截 manual 模式;⑥ JSX 底部新增 2 个 Modal |
| `src/pages/TemplateManage.tsx` | ① insertImage 改为 placeholderModal② insertTable 改为 tableModal③ JSX 底部新增 2 个 Modal |
## 具体代码变更
### 1. ReportEditor.tsx
#### 1.1 fillPlaceholder 修复
```ts
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
```
#### 1.2 新增状态
```ts
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
```
#### 1.3 insertImage 改为打开 Modal
```ts
const insertImage = () => {
editorRef.current?.focus();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
```
#### 1.4 insertTable 改为打开 Modal
```ts
const insertTable = () => {
editorRef.current?.focus();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
```
#### 1.5 autoCaptureFrames 中选择器修改
`setTimeout` 回调内的:
```ts
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
```
改为:
```ts
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
```
#### 1.6 insertFrameToPlaceholder 选择器修改
```ts
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
```
#### 1.7 handleDrop 拦截 manual 模式
```ts
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
if (placeholder.getAttribute('data-mode') === 'manual') {
alert('此处为静态图片占位符仅支持点击插入如Logo/签名),不支持拖入关键帧');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
fillPlaceholder(placeholder, frame);
}
};
```
#### 1.8 JSX 底部新增 Modal
**Placeholder Insert Modal**(在 `</div>` 关闭之前,与现有 `imagePickerOpen` Modal 并列):
```tsx
{placeholderModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1"></label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80">/</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}><br/><span className="text-[10px] opacity-80"></span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : '插入/点击放置图片';
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
styleStr += `width:${w}px;height:${h}px;`;
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
execCmd('insertHTML', html);
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
```
**Table Insert Modal**
```tsx
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1"></label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm"></button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm"></button>
</div>
</div>
</div>
)}
```
### 2. TemplateManage.tsx
结构与 ReportEditor.tsx 类似,但 `insertImage` 的 Modal 中也需要表格检测逻辑(已在上一轮修改中实现)。
#### 2.1 新增状态
```ts
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
```
#### 2.2 insertImage 改为打开 Modal
```ts
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
```
#### 2.3 insertTable 改为打开 Modal
```ts
const insertTable = () => {
editorRef.current?.focus();
restoreSelection();
pushHistory();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
```
#### 2.4 JSX 底部新增 Modal
与 ReportEditor.tsx 的 Modal 结构一致。TemplateManage.tsx 的 `insertImage` Modal 中,确认按钮需要执行表格检测(沿用上一轮修改的逻辑),然后调用 `execCmd('insertHTML', html)``pushHistory()`
## 风险点与应对措施
| 风险 | 应对措施 |
|------|---------|
| `data-mode="manual"` 的选择器 `:not([data-mode="manual"])` 可能不兼容旧浏览器 | 项目使用 Chrome/Edge完全支持属性选择器 |
| 新增 Modal 与现有 `imagePickerOpen` Modal 的 z-index 冲突 | 两者都使用 `z-50`,在同一时刻不会同时打开 |
| TemplateManage.tsx 的 insertImage 中 pushHistory() 调用位置 | 确认按钮中在 `execCmd` 之前调用 `pushHistory()` |
| 表格内的 insertImage上一轮修改与本次 Modal 的冲突 | 确认按钮中保留表格检测逻辑,在表格内时不使用 Modal 中的宽高值 |
## 回滚策略
- 删除新增的状态和 Modal JSX恢复 `insertImage``insertTable` 中的 `prompt` 弹窗逻辑。
- 恢复 `fillPlaceholder` 到修改前状态。
- 恢复 `autoCaptureFrames``insertFrameToPlaceholder``handleDrop` 中的选择器和拦截逻辑。

View File

@@ -0,0 +1,278 @@
# 实现方案 — 2026-04-18-00-23-14
## 根因分析
1. **拖拽后边框残留**`ReportEditor.tsx` 中存在两个填充函数:
- `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'``placeholder.style.background = 'transparent'`
- `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class未清除内联样式导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`
- 同理,`autoCaptureFrames``setTimeout` 回调中也直接操作 DOM未清除内联样式。
2. **原生 `prompt()` 体验差**`insertTable``insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。
3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
## 修改文件清单
- `src/pages/ReportEditor.tsx`
- `src/pages/TemplateManage.tsx`
## 具体代码变更
### 一、ReportEditor.tsx
#### 1. 新增弹窗状态
```typescript
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [savedRange, setSavedRange] = useState<Range | null>(null);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
```
#### 2. 修复 `fillPlaceholder`F1
`fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式:
```typescript
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
```
#### 3. 修复 `autoCaptureFrames` 中的自动插入F2
`setTimeout` 回调内,`classList.add('has-image')` 之后增加:
```typescript
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
```
#### 4. 替换 `insertTable` 为弹窗驱动F3
- **打开弹窗**
```typescript
const openTableModal = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
setTableRows('2');
setTableCols('3');
setTableModalOpen(true);
};
```
- **确认插入**
```typescript
const confirmInsertTable = () => {
const rows = parseInt(tableRows);
const cols = parseInt(tableCols);
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
setTableModalOpen(false);
return;
}
if (savedRange) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange);
}
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
for (let i = 0; i < rows; i++) {
table += '<tr>';
for (let j = 0; j < cols; j++) {
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
}
table += '</tr>';
}
table += '</table><p></p>';
execCmd('insertHTML', table);
setTableModalOpen(false);
setSavedRange(null);
};
```
- 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。
#### 5. 替换 `insertImage` 为弹窗驱动F4 / F5
- **打开弹窗**
```typescript
const openImageModal = () => {
editorRef.current?.focus();
const sel = window.getSelection();
let node: Node | null = sel?.anchorNode ?? null;
let inTable = false;
while (node) {
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
inTable = true;
break;
}
node = node.parentNode;
}
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
setImageModalInTable(inTable);
setImageModalWidth('200');
setImageModalHeight('200');
setImageModalAllowSource('all');
setImageModalOpen(true);
};
```
- **确认插入**
```typescript
const confirmInsertImage = () => {
if (savedRange) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange);
}
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
const allowSource = imageModalAllowSource;
const hintText = '插入/点击放置图片';
const id = 'ph_' + Date.now();
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
let html: string;
if (imageModalInTable) {
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
} else {
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
if (width > 0) styleStr += `width:${width}px;`;
if (height > 0) styleStr += `height:${height}px;`;
const showShortText = width > 0 && width < 80;
const text = showShortText ? '插入图片' : hintText;
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>&#8203;`;
}
execCmd('insertHTML', html);
setImageModalOpen(false);
setSavedRange(null);
};
```
- 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。
#### 6. 拦截拖拽F6
修改 `handleDrop`
```typescript
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) fillPlaceholder(placeholder, frame);
};
```
#### 7. 拦截点击空占位符F7
修改 `handleEditorClick` 中点击空占位符的分支:
```typescript
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'frame') {
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
return;
}
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
```
#### 8. 拦截一键插入关键帧F8
修改 `insertFrameToPlaceholder`
```typescript
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
if (!editorRef.current) {
alert('编辑器未准备好');
return;
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (!emptyPlaceholder) {
alert('没有可插入图片的空位');
return;
}
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
return;
}
fillPlaceholder(emptyPlaceholder, frame);
};
```
#### 9. 拦截自动帧插入F9
在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。
#### 10. 新增 JSX 弹窗(位于组件底部)
**Table Modal**
- 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm`
- 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl`
- 输入:行数、列数(`<input type="number">`
- 按钮:确认(`btn-accent` 样式)、取消
**Image Placeholder Modal**
- 同样遮罩层和卡片布局
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
- 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200提示文字「正文一行文字高度约为 20 像素左右」。
- 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
- 按钮:确认、取消
### 二、TemplateManage.tsx
#### 1. 新增弹窗状态
与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标:
```typescript
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
```
#### 2. 替换 `insertTable`
- `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。
- `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。
#### 3. 替换 `insertImage`
- `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。
- `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。
- HTML 中增加 `data-allow-source` 属性。
#### 4. 新增 JSX 弹窗
结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。
## 风险点与应对措施
| 风险 | 应对措施 |
|------|---------|
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 |
| `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 |
| 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 |
| TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 |
## 回滚策略
- 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。
- 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。

View File

@@ -0,0 +1,74 @@
# 实现方案 — 2026-04-18-00-43-19
## 根因分析
此前对「插入图片占位符」进行了弹窗改造,生成的占位符 HTML 新增了 `data-mode="frame|manual"` 属性,用于区分手术影像占位(允许拖拽/自动插入关键帧)和静态图片占位(仅允许点击上传/签名/素材)。
`defaultContent.ts` 中的默认模板仍使用旧版 `image-placeholder` 结构,**缺少 `data-mode` 属性**。这导致:
- 默认模板中的签名、Logo 等静态占位符在新建报告时,可被关键帧拖拽误填充。
- `autoCaptureFrames``insertFrameToPlaceholder` 等逻辑通过 `:not([data-mode="manual"])` 选择器过滤时,无该属性的占位符会被错误地当作手术影像占位处理。
## 修改文件清单
- `src/utils/defaultContent.ts`
## 具体代码变更
### 一、医院 Logo 占位符line 6
原结构:
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
</span>
```
新结构(仅添加 `data-mode="manual"`,宽高及布局不变):
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
</span>
```
### 二、表格内术中影像占位符lines 90/97/104/113/120/127共 6 处)
原结构:
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
```
新结构(仅添加 `data-mode="frame"`,宽高及布局不变):
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
<span class="delete-btn" contenteditable="false">×</span>
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
</span>
```
### 三、手术者签名占位符line 154
原结构:
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span></span>
```
新结构(添加 `data-mode="manual"`,并将提示文本改为「插入/点击放置图片」,因为 width=200px ≥ 80px
```html
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
```
## 风险点与应对措施
| 风险 | 应对措施 |
|------|---------|
| 修改默认模板后,新建报告的布局发生偏移 | 仅添加 `data-mode` 属性并修改文本,保持 `style``width/height/margin/display` 等所有布局属性绝对不变。 |
| 默认模板中占位符是 `<span>`,而新弹窗在表格内生成 `<div>` | 用户明确要求「只保存当前框的大小不变」,因此不改动标签类型,保持 `<span>` 以避免表格布局被破坏。 |
## 回滚策略
- 仅修改单个文件 `src/utils/defaultContent.ts`,回滚时直接还原该文件即可。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
# 测试方案 — 2026-04-17-23-38-34
## 测试目标
验证时间格式自定义下拉组件、表格内图片占位符插入、以及打印多页页边距三项修复。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome/Edge
- 测试账号admin / 123456超级管理员
## 测试用例
### TC-1时间格式自定义下拉编辑字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 登录 admin进入「模板管理」→「字段管理」 | — |
| 2 | 点击「手术日期」进入编辑模式,聚焦格式输入框 | 输入框下方弹出下拉列表,显示 `YYYY-MM-DD``YYYY年MM月DD日` 等日期格式 |
| 3 | 点击列表中 `YYYY年MM月DD日` | 输入框被填充为 `YYYY年MM月DD日`,下拉列表关闭 |
| 4 | 点击「手术开始时间」进入编辑模式,聚焦格式输入框 | 下拉列表只显示时间格式(`HH:mm``hh:mm A`**不出现**日期格式 |
| 5 | 在格式输入框中手写输入 `MM-DD HH:mm`,按 Enter | 新格式被保存,下拉列表中新增 `MM-DD HH:mm`;重新聚焦后可在列表中看到 |
### TC-2时间格式自定义下拉新增字段
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在字段管理底部点击「新增字段」 | — |
| 2 | category 选「时间」type 选「日期」,聚焦格式输入框 | 下拉列表只显示日期格式 |
| 3 | type 切换为「时分」,聚焦格式输入框 | 下拉列表只显示时间格式 |
| 4 | 手写输入新格式 `hh:mm:ss`,按 Enter | 新格式被保存到 `customTimeFormats` 并出现在列表中 |
### TC-3表格内插入图片占位符TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「模板管理」,编辑器中确保存在表格 | — |
| 2 | 将光标放入表格的某个 `<td>` 单元格内 | — |
| 3 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,占位符直接插入到单元格内 |
| 4 | 用 DevTools 检查插入的元素 | 外层标签为 `<div class="image-placeholder">`style 包含 `width:100%;height:100%;max-width:200px;max-height:200px;`内部结构完整delete-btn + placeholder-text |
| 5 | 点击图片占位符填充一张图片 | 图片正常显示,占位符添加 `has-image` class |
### TC-4普通文本中插入图片占位符TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 将光标放在表格外的普通文本段落中 | — |
| 2 | 点击「插入图片占位符」 | 弹出 prompt 要求输入宽高 |
| 3 | 输入 `100*80` | 插入行内 `<span class="image-placeholder">`style 包含 `width:100px;height:80px;`,与前后文字保持在同一行 |
### TC-5表格内插入图片占位符ReportEditor
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「报告编辑器」,确保报告内容中有表格 | — |
| 2 | 将光标放入表格单元格内,插入图片占位符 | 不弹出 prompt插入自适应 div 占位符 |
| 3 | 从视频分析面板拖拽关键帧到占位符中 | 图片正常填充,结构完整 |
### TC-6打印多页页边距
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在「报告编辑器」中创建一份内容较多的报告或复制粘贴大量文本使内容超过1页A4 | — |
| 2 | 点击「预览/打印」 | 弹出打印对话框 |
| 3 | 在浏览器打印预览中查看第2页 | 第二页顶部和底部均有约 15mm 的留白,不紧贴纸张边缘;左右留白约 10mm |
| 4 | 检查第一页 | 第一页同样有 15mm 上下 / 10mm 左右的均匀留白 |
### TC-7类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过0 errors |
## 验收标准
- [ ] `npm run lint` 无 TypeScript 编译错误
- [ ] 时间格式输入框支持点击展开下拉列表、选择选项、手写输入并记忆新格式
- [ ] date 字段下拉只显示日期格式time 字段只显示时间格式
- [ ] 表格内插入图片占位符不弹 prompt结构完整使用 div 块级容器
- [ ] 普通文本中插入图片占位符仍弹 prompt使用 span 行内容器
- [ ] 打印多页时每一页上下均有约 15mm 留白,左右约 10mm
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View File

@@ -0,0 +1,104 @@
# 测试方案 — 2026-04-18-00-02-08
## 测试目标
验证拖拽关键帧插入样式修复、图片占位符自定义弹窗与分类隔离、表格插入自定义弹窗三项修复。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome/Edge
- 测试账号admin / 123456超级管理员
## 测试用例
### TC-1拖拽关键帧后边框消失 + 图片约束
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「报告编辑器」,上传视频并自动摘取关键帧 | 右侧视频分析面板显示关键帧缩略图 |
| 2 | 编辑器中插入一个图片占位符 | 显示虚线框占位符 |
| 3 | 从右侧拖拽关键帧到占位符中 | 图片正常显示,**虚线边框和灰色背景消失**;图片不溢出占位符边界 |
| 4 | 用 DevTools 检查 `<img>` 元素 | style 包含 `max-width:100%;max-height:100%;object-fit:contain;` |
### TC-2图片占位符插入弹窗ReportEditor
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「报告编辑器」 | — |
| 2 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,而是弹出居中的自定义 Modal |
| 3 | Modal 中显示默认宽度 200、高度 200 | — |
| 4 | 修改宽度为 120高度为 80 | 输入框值正常变化 |
| 5 | 选择「静态图片占位」模式 | 模式按钮高亮切换 |
| 6 | 点击「确认插入」 | Modal 关闭,编辑器中插入行内 `<span>` 占位符,带有 `data-mode="manual"` 属性 |
| 7 | 用 DevTools 检查插入的元素 | `data-mode="manual"` 存在style 包含 `width:120px;height:80px;` |
### TC-3图片占位符插入弹窗TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「模板管理」 | — |
| 2 | 点击工具栏「插入图片占位符」 | 弹出自定义 Modal |
| 3 | 确认插入 | 占位符正常插入,结构完整 |
### TC-4自动帧插入跳过 manual 占位符
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 在编辑器中插入两个占位符:第一个是 frame 模式,第二个是 manual 模式 | — |
| 2 | 上传视频,开启「自动帧插入」,点击「自动关键帧摘取」 | — |
| 3 | 观察占位符填充情况 | 只有**第一个 frame 模式**的占位符被自动填入关键帧;第二个 manual 占位符**保持空白** |
### TC-5一键插入跳过 manual 占位符
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 编辑器中先插入一个 manual 占位符,再插入一个 frame 占位符 | — |
| 2 | 右侧关键帧卡片点击「插入」按钮 | 关键帧填入**第二个 frame 占位符**manual 占位符不受影响 |
### TC-6拖拽拦截 manual 占位符
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 编辑器中插入一个 manual 占位符 | — |
| 2 | 从右侧拖拽关键帧到该 manual 占位符上 | 弹出提示「此处为静态图片占位符,仅支持点击插入...」;占位符**不被填充** |
### TC-7表格插入弹窗ReportEditor
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「报告编辑器」 | — |
| 2 | 点击工具栏「插入表格」 | **不弹出 prompt**,弹出自定义 Modal |
| 3 | Modal 中显示默认行数 2、列数 3 | — |
| 4 | 修改行数为 4列数为 2 | 输入框值正常变化 |
| 5 | 点击「确认插入」 | Modal 关闭,编辑器中插入 4 行 2 列的表格 |
### TC-8表格插入弹窗TemplateManage
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 进入「模板管理」 | — |
| 2 | 点击工具栏「插入表格」 | 弹出自定义 Modal |
| 3 | 设置行数 3列数 3确认插入 | 表格正常插入 |
### TC-9类型检查
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过0 errors |
## 验收标准
- [ ] `npm run lint` 无 TypeScript 编译错误
- [ ] 拖拽关键帧后占位符边框和背景消失,图片不溢出
- [ ] 点击「插入图片占位符」弹出自定义 Modal非 prompt
- [ ] Modal 支持选择占位符模式frame/manual
- [ ] manual 占位符带有 `data-mode="manual"` 属性
- [ ] 自动帧插入和一键插入跳过 manual 占位符
- [ ] 拖拽到 manual 占位符被拦截并提示
- [ ] 点击「插入表格」弹出自定义 Modal非 prompt
- [ ] Modal 支持输入行数/列数并正常插入表格
## 测试方式
全部使用手工功能验证(项目无单元测试框架)。

View File

@@ -0,0 +1,47 @@
# 测试方案 — 2026-04-18-00-23-14
## 测试目标
验证以下 3 个需求是否按预期工作:
1. 拖拽关键帧到 `.image-placeholder` 后,占位符内联边框和背景被彻底清除。
2. 「插入表格」和「插入图片占位符」改为自定义居中弹窗,且插入位置准确。
3. 图片占位符支持「图片来源限制」,各限制类型在拖拽、点击、一键插入、自动插入场景下均被正确拦截或放行。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome / Edge推荐
- 测试账号:任意账号(建议 `admin` / `123456`
## 测试用例
| 编号 | 场景 | 操作步骤 | 预期结果 |
|------|------|---------|---------|
| TC-01 | 拖拽关键帧后边框清除 | 1. 进入「报告编辑」页。<br>2. 上传视频并自动/手动截取关键帧。<br>3. 插入一个图片占位符。<br>4. 从右侧视频分析面板拖拽关键帧到占位符。 | 占位符内的虚线框和浅灰背景完全消失,图片正常显示,无残留边框。 |
| TC-02 | 自动帧插入后边框清除 | 1. 开启「自动帧插入」。<br>2. 上传新视频触发自动摘帧。<br>3. 观察自动插入到占位符的关键帧。 | 自动插入的图片同样无残留边框和背景。 |
| TC-03 | ReportEditor 插入表格弹窗 | 1. 点击工具栏「表格」按钮。<br>2. 在弹窗中输入 3 行 4 列,点击确认。 | 页面中央弹出模态框;确认后表格正确插入到光标所在位置。 |
| TC-04 | ReportEditor 插入图片占位符弹窗 | 1. 点击工具栏「插入图片占位符」按钮。<br>2. 在弹窗中输入宽 150、高 100选择「所有来源」点击确认。 | 页面中央弹出模态框确认后行内占位符150×100插入到光标位置且可正常点击上传图片。 |
| TC-05 | TemplateManage 插入表格弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「表格」按钮,输入 2 行 2 列确认。 | 弹窗正常弹出,表格插入位置准确。 |
| TC-06 | TemplateManage 插入图片占位符弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「插入图片占位符」,输入宽 80、高 80确认。 | 弹窗正常弹出,占位符插入后显示「插图」缩写文本。 |
| TC-07 | 表格内插入占位符隐藏尺寸 | 1. 在表格单元格内点击。<br>2. 点击「插入图片占位符」。 | 弹窗中提示「表格内占位符将自动填满单元格,无需设置尺寸」,不显示宽高输入框。 |
| TC-08 | 仅限关键帧占位符 | 1. 插入占位符时选择「仅限关键帧」。<br>2. 点击该空占位符。 | 弹出提示「此区域仅限插入关键帧图片...」,不打开图片选择器。 |
| TC-09 | 仅限关键帧-拖拽放行 | 1. 对「仅限关键帧」占位符,从右侧拖拽关键帧放入。 | 关键帧正常插入,无报错。 |
| TC-10 | 仅限关键帧-上传拦截 | 1. 对「仅限关键帧」占位符,尝试点击打开图片选择器。 | 被拦截并提示。 |
| TC-11 | 仅限上传类占位符 | 1. 插入占位符时选择「仅限本地上传/签名/素材」。<br>2. 点击该空占位符。 | 正常弹出「本地上传/签名/素材」三选一弹窗。 |
| TC-12 | 仅限上传类-拖拽拦截 | 1. 对「仅限上传类」占位符,从右侧拖拽关键帧放入。 | 弹出提示「此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。」,拒绝插入。 |
| TC-13 | 一键插入拦截 | 1. 插入一个「仅限上传类」占位符作为第一个空位。<br>2. 在右侧关键帧卡片点击「插入」按钮。 | 弹出提示,拒绝插入。 |
| TC-14 | 自动帧插入跳过受限占位符 | 1. 插入一个「仅限上传类」占位符。<br>2. 开启自动帧插入,上传视频触发自动摘帧。 | 第一个空占位符因限制为 upload 而跳过,不插入关键帧。 |
| TC-15 | 向后兼容 | 1. 打开一份旧报告(无 `data-allow-source` 的占位符)。<br>2. 拖拽关键帧和点击上传。 | 旧占位符行为不变,两种操作均可正常执行。 |
## 验收标准
- [ ] TC-01 ~ TC-02拖拽/自动插入后占位符内联边框和背景完全清除。
- [ ] TC-03 ~ TC-07ReportEditor 和 TemplateManage 的表格/图片占位符弹窗正常工作,焦点恢复无误。
- [ ] TC-08 ~ TC-10「仅限关键帧」占位符正确拦截上传类操作放行关键帧操作。
- [ ] TC-11 ~ TC-14「仅限上传类」占位符正确拦截关键帧操作放行上传类操作。
- [ ] TC-15旧数据无 `data-allow-source` 时默认行为不受影响。
- [ ] `npm run lint` 无 TypeScript 编译错误。
## 测试方式
手工验证。本项目无自动化测试框架,所有用例通过浏览器交互逐项确认。

View File

@@ -0,0 +1,32 @@
# 测试方案 — 2026-04-18-00-43-19
## 测试目标
验证默认模板 `defaultContent.ts` 中的全部 `.image-placeholder` 已正确添加 `data-mode` 属性,且尺寸、布局与原有模板保持一致。
## 测试环境
- 本地开发服务器:`npm run dev`(端口 3000
- 浏览器Chrome / Edge
- 测试账号:`admin` / `123456`
## 测试用例
| 编号 | 场景 | 操作步骤 | 预期结果 |
|------|------|---------|---------|
| TC-01 | 默认模板 Logo 占位符 | 1. 登录后新建报告(不选择任何模板,加载默认模板)。<br>2. 查看编辑器顶部的医院 Logo 占位符。 | 占位符尺寸仍为 65×65pxDOM 中可见 `data-mode="manual"`;从右侧视频分析面板拖拽关键帧到 Logo 占位符时,弹出提示「此处为静态图片占位符...」并拒绝插入。 |
| TC-02 | 默认模板签名占位符 | 1. 新建报告,滚动到底部「手术者签名」处。<br>2. 查看占位符 DOM。 | 占位符尺寸仍为 200×40pxDOM 中可见 `data-mode="manual"`;提示文本为「插入/点击放置图片」;拖拽关键帧到签名区域时被拦截。 |
| TC-03 | 默认模板表格内影像占位符 | 1. 新建报告,查看「手术图片说明表格」中的 6 个占位符。<br>2. 检查 DOM。 | 每个占位符尺寸仍为 100%×150pxDOM 中可见 `data-mode="frame"`;从右侧拖拽关键帧到表格占位符时,可正常插入。 |
| TC-04 | 自动帧插入过滤 | 1. 新建报告,确保表格内和签名/Logo 占位符均为空。<br>2. 上传视频并开启「自动帧插入」。<br>3. 观察自动插入行为。 | 自动插入的关键帧只会填充表格内 `data-mode="frame"` 的占位符;不会填充 `data-mode="manual"` 的 Logo 和签名占位符。 |
| TC-05 | 布局无偏移 | 1. 对比修改前后的默认模板预览效果(或打印预览)。 | 所有占位符的位置、大小、边框、背景色与修改前完全一致,无可见差异。 |
## 验收标准
- [ ] TC-01 ~ TC-03默认模板中 8 个占位符均已正确添加 `data-mode`,尺寸未改变。
- [ ] TC-04自动帧插入和拖拽逻辑对 `manual` / `frame` 的隔离生效。
- [ ] TC-05视觉和排版与修改前完全一致。
- [ ] `npm run lint` 无 TypeScript 编译错误。
## 测试方式
手工验证。通过浏览器 DevTools 检查 DOM 属性,并通过拖拽/自动插入验证隔离逻辑。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -854,3 +854,120 @@ if ((settings.autoInsertDelay || 0) > 0) {
- `customTimeFormats` 这类用户可扩展的缓存数组,在初始化时应建立**无效值清理机制**,防止历史版本残留的数据污染后续 UI。
- `datalist` / `select` 的选项如果存在明显的类型分组(日期 vs 时间),应在渲染层做过滤,而不是将所有选项平铺展示。
- 任何在滚动容器内通过点击展开/折叠的交互组件,都应考虑增加 `scrollIntoView` 兜底,防止布局突变导致的点击失效问题。
---
## 记录 28原生 datalist 交互体验差、表格内 execCommand 插入破坏结构、打印分页边距失效
**A. 具体问题**
1. `template-manage` 字段管理中,时间字段的格式输入使用原生 `<input list>` + `<datalist>`,浏览器下拉体验差,部分浏览器不会自动展示全部选项。
2. 在 `template-manage` 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素散落为 `<td>` 的直接子元素。
3. `report-editor` / `report-view` 打印多页报告时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。
**B. 产生问题原因**
1. **原生 datalist 局限性**:不同浏览器对 `<datalist>` 的展示逻辑不一致Edge/Chrome 中聚焦时不会自动展开全部选项,且不支持样式自定义,无法提供一致的下拉选择体验。
2. **execCommand 在表格中的自动修正**`document.execCommand('insertHTML')` 在 `<td>` 内处理复杂的 `inline-flex` 嵌套 `<span>` 时WebKit/Blink 会将其自动"拍平"或重新排列。外层 `contenteditable="false"` 的 inline 容器被浏览器移除,仅剩内部子元素散落。
3. **@page margin 与 body padding 的分页陷阱**`@page { margin: 0 }` 将物理纸张边距设为 0`body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding导致第二页顶部和底部紧贴纸张边缘。`@page` 的 margin 才是为每一张物理纸张独立分配边距的正确方式。
**C. 解决问题方案**
1. **自定义下拉组件**:放弃原生 `input[list]` + `datalist`,改为手写 input + 绝对定位 div 列表组件:
- `onFocus` 时 `setDropdownOpen(true)` 展开列表
- `onMouseDown` + `e.preventDefault()` 阻止失焦,实现点击选项填充
- `onBlur`(延迟 200ms时保存手写的新格式到 `customTimeFormats`
- 列表项通过 `.filter` 按 `date`/`time` 类型过滤显示
2. **表格检测 + 块级容器**:在 `insertImage` 中通过 `window.getSelection().anchorNode` 向上遍历检测是否在 `<td>` / `<th>` 内:
- 若在表格内:不弹出 prompt使用 `<div>` 块级容器 + `width:100%;height:100%;max-width:200px;max-height:200px;`
- 若不在表格内:保持现有 `<span>` 行内容器 + prompt 输入自定义宽高
3. **打印边距修正**`print.ts` 中:
- `@page { margin: 15mm 10mm; }` 让打印引擎为每一页纸张独立分配上下 15mm / 左右 10mm 边距
- `body { padding: 0; }` 清除 body padding
- `.content { width: 100%; }` 让内容自然撑满可用区域
**D. 后续如何避免问题**
- 当 `<input list>` + `<datalist>` 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。
- `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `<td>` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `<div>`)作为外层容器,减少被浏览器重新排列的风险。
- 打印样式的边距控制必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。
---
## 记录 29拖拽关键帧样式遗漏、占位符分类隔离与 Modal 弹窗改造
**A. 具体问题**
1. 拖拽关键帧到 `.image-placeholder` 后,虚线边框和灰色背景未消失,且图片可能溢出占位符。
2. `insertImage` 和 `insertTable` 使用浏览器原生 `prompt` 弹窗,交互体验差。
3. 所有占位符一视同仁,自动帧插入和一键插入会把手术关键帧填入 Logo、签名等静态图片位置。
**B. 产生问题原因**
1. **`fillPlaceholder` 遗漏样式清除**`fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`,但 `fillPlaceholder`(拖拽路径)遗漏了这两行,且图片 style 缺少 `max-height:100%;object-fit:contain;`。
2. **原生 prompt 的限制**`prompt` 弹窗无法自定义样式,且在不同浏览器中表现不一致,用户体验差。
3. **占位符无分类机制**:所有 `.image-placeholder` 都接受关键帧填充,没有区分"接受自动插入"和"不接受自动插入"的占位符。
**C. 解决问题方案**
1. **补齐 `fillPlaceholder`**:增加 `placeholder.style.border = 'none'`、`placeholder.style.background = 'transparent'`,图片 style 改为 `max-width:100%;max-height:100%;object-fit:contain;`。
2. **自定义 Modal 替代 prompt**
- 新增 `placeholderModal` 状态isOpen, width, height, mode和 `tableModal` 状态isOpen, rows, cols
- `insertImage` 和 `insertTable` 改为打开 Modal。
- Modal 使用项目统一的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
3. **占位符分类隔离**
- Modal 中增加模式选择「手术影像占位frame」和「静态图片占位manual」。
- manual 模式生成的 placeholder 带有 `data-mode="manual"` 属性。
- `autoCaptureFrames` 和 `insertFrameToPlaceholder` 的选择器增加 `:not([data-mode="manual"])`。
- `handleDrop` 中拦截 manual 占位符的拖拽,弹出提示。
**D. 后续如何避免问题**
- 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。
- 原生 `prompt`/`confirm`/`alert` 在现代 Web 应用中应尽量避免使用,优先采用自定义 Modal 组件,以获得一致的视觉体验和更灵活的控制能力。
- 当系统中存在"自动填充"机制时,应考虑为被填充的容器增加分类标记(如 `data-mode`),并在自动填充逻辑中通过选择器过滤,防止无关区域被污染。
---
## 记录 30默认模板中 image-placeholder 缺少 data-mode 导致来源隔离失效
**A. 具体问题**
默认模板 `defaultContent.ts` 中的 8 个 `.image-placeholder`(医院 Logo、6 个表格内术中影像、手术者签名)使用的是旧版 HTML 结构,缺少 `data-mode="frame|manual"` 属性。新建报告加载默认模板后,签名和 Logo 区域可被关键帧拖拽误填充;自动帧插入时也会将术中截图插入签名位置。
**B. 产生问题原因**
此前对「插入图片占位符」进行弹窗改造时,仅在运行时插入逻辑中新增了 `data-mode` 属性,但未同步回刷默认模板 `defaultContent.ts`。导致默认模板产出的占位符与新插入的占位符结构不一致,图片来源隔离机制在默认模板场景下完全失效。
**C. 解决问题方案**
在 `defaultContent.ts` 中对 8 个占位符做最小化修补:
1. 医院 Logo65×65和手术者签名200×40添加 `data-mode="manual"`,标记为静态图片占位。
2. 表格内 6 个术中影像占位符100%×150添加 `data-mode="frame"`,标记为手术影像占位。
3. 签名占位符宽度 200px ≥ 80px按新弹窗规则将提示文本从「插入图片」更新为「插入/点击放置图片」。
4. 所有占位符的 `width/height/margin/display` 等布局属性绝对保持不变。
**D. 后续如何避免问题**
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode`、`data-allow-source`)时,必须同步检索 `defaultContent.ts` 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
- 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。
---
## 记录 31六项 UI/UX 优化集中实施
**A. 具体问题**
用户提出六项体验优化需求:基础信息字段打印无下划线、编辑器字段联动高亮、视频上传按钮整合、视频面板间距紧凑化、签名与日期之间空行、图片占位符填充后高度自适应。
**B. 产生问题原因**
均为长期使用中积累的交互和排版细节问题:
1. 默认模板的基础字段(姓名/性别/年龄/科别/床号/住院号)打印时默认带下划线,但临床场景中这些字段通常不需要下划线。
2. 编辑器中点击正文 `field-value` 后右侧没有视觉反馈,用户不知道对应哪个输入框。
3. 视频上传按钮独立占一行,浪费垂直空间。
4. 视频面板各区域间距过大,挤压了关键帧列表的展示空间。
5. 签名和日期之间缺少空行,排版拥挤。
6. 图片占位符填充后仍保留固定高度(如 200px导致图片下方出现大片空白。
**C. 解决问题方案**
1. **基础字段无下划线**:在 `defaultContent.ts` 的 `smartField()` 中硬编码 6 个 key`patientName`, `patientGender`, `patientAge`, `department`, `bedNumber`, `hospitalId`),自动注入 `.no-underline` 类;同时保留 `hasUnderline` 配置机制供 TemplateManage 自定义。
2. **字段联动高亮**:新增 `activeFieldKey` 状态;点击 `field-value` 时设置该状态并滚动到对应 `id={`input-${bindKey}`}` 元素为右侧所有字段类型text/date/single_select/multi_select/time的容器统一添加 `p-2 -mx-2 rounded-xl transition-all duration-300 ${activeFieldKey === field.key ? 'bg-blue-50 ring-1 ring-accent shadow-sm' : ''}`。
3. **视频按钮整合**:删除独立的大按钮,在缩略图滚动容器的首位插入缩小版按钮(`shrink-0 w-24 h-[68px]`),样式与视频卡片一致。
4. **视频间距紧凑**:将 `space-y-4` 逐层改为 `space-y-2`;关键帧摘取标题区域改为 `pt-1 border-t border-border`。
5. **签名空行**:在签名 `<p>` 和日期 `<p>` 之间插入 `<p style="margin:0;padding:0;line-height:1.5;">&nbsp;</p>`。
6. **占位符高度自适应**:在 `fillPlaceholderSrc`、`fillPlaceholder`、`autoCaptureFrames`ReportEditor以及 `fillPlaceholderSrc`TemplateManage填充图片后统一设置 `placeholder.style.height = 'auto'; placeholder.style.width = 'auto'; placeholder.style.lineHeight = 'normal';`,并将图片 style 中的 `max-height:100%;object-fit:contain` 改为 `height:auto`。
**D. 后续如何避免问题**
- 当为 `image-placeholder` 修改填充后的样式行为时,必须同步检索所有填充入口(`fillPlaceholderSrc`、`fillPlaceholder`、自动帧插入、拖拽填充等),并同步到 `TemplateManage.tsx`。
- 右侧表单字段容器样式如果统一(如高亮背景),应在所有字段类型的渲染分支中同步添加,避免某些类型遗漏。
- 默认模板修改后应通过「新建报告 → 检查 DOM 结构」快速验证。

View File

@@ -0,0 +1,35 @@
# 需求分析 — 2026-04-17-23-38-34
## 原始需求摘要
1. **时间格式输入框改造**`template-manage` 字段管理中,时间字段的格式输入当前使用原生 `<input list="...">` + `<datalist>`,浏览器兼容性和交互体验不佳。希望改造为类似单选下拉框的自定义组件,既能下拉选择已有格式,又能手写输入并自动记忆新格式。
2. **表格内插入图片占位符修复**:在 `template-manage` 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素被分散到 `<td>` 中。且表格内占位符应默认自适应单元格大小(最大不超过 200×200px
3. **打印第二页页边距太小**`report-editor` / `report-view` 点击打印时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。当前 `@page { margin: 0 }` + `body { padding: 10mm }` 的组合仅在文档首尾生效一次分页后无padding。
## 需求拆解
### 功能点
- **F1**`TemplateManage.tsx` 中编辑字段和新增字段的时间格式输入,从原生 `input[list]` + `datalist` 改造为自定义下拉组件input + 绝对定位 ul 列表。支持点击展开下拉、点击选项填充、手写输入、blur/Enter 时自动保存新格式到 `customTimeFormats`
- **F2**`TemplateManage.tsx``ReportEditor.tsx``insertImage` 函数,在插入前检测当前光标是否位于 `<td>` / `<th>` 内。若在表格内,使用块级 `<div>` 作为外层容器(避免浏览器 execCommand 修正破坏结构),并设置 `width:100%;height:100%;max-width:200px;max-height:200px;` 实现自适应。不在表格内时保持现有 `<span>` 行内结构。
- **F3**`src/utils/print.ts` 中,将 `@page { margin: 0 }` + `body { padding: 10mm }` 改为 `@page { margin: 15mm 10mm }` + `body { padding: 0 }`,使每一页物理纸张都有独立的上下 15mm / 左右 10mm 留白。同步调整 `.content` 的 width 为 `100%`
### 非功能点
- 向后兼容:已保存的模板/报告中已有的 `image-placeholder` 结构不受影响。
- 下拉组件的 `z-index` 需确保覆盖在滚动容器之上。
- 打印样式调整应同时兼顾 `.image-placeholder` 在打印时的隐藏逻辑。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/TemplateManage.tsx` | 高 | 时间格式自定义下拉组件(编辑+新增两处insertImage 表格检测与分支逻辑 |
| `src/pages/ReportEditor.tsx` | 中 | insertImage 表格检测与分支逻辑 |
| `src/utils/print.ts` | 低 | @page margin 与 body padding 调整 |
## 待确认问题
无(用户已明确需求,且本次无需人工确认)。

View File

@@ -0,0 +1,45 @@
# 需求分析 — 2026-04-18-00-02-08
## 原始需求摘要
1. **修复拖拽关键帧插入兼容性**:拖拽关键帧到 `.image-placeholder` 后,虚线边框和背景色未消失,且图片缺少 `max-height:100%;object-fit:contain;` 约束,可能溢出占位符。
2. **图片占位符插入改为自定义弹窗 + 分类隔离**
- 替换 `insertImage` 中的 `prompt` 弹窗为自定义 React Modal。
- 占位符分为两类:
- **手术影像占位frame 模式)**:支持自动帧插入、一键插入、拖拽插入。
- **静态图片占位manual 模式)**:仅支持点击后从弹窗选择图片来源(本地上传/签名/素材),防止系统自动将手术关键帧填入 Logo 或签名位置。
- 自动帧插入和一键插入逻辑需跳过 `data-mode="manual"` 的占位符。
- 拖拽到 manual 占位符时需拦截并提示。
3. **表格插入改为自定义弹窗**:替换 `insertTable` 中的 `prompt` 弹窗为自定义 React Modal中间弹出子窗口选择行数和列数。
## 需求拆解
### 功能点
- **F1**`ReportEditor.tsx``fillPlaceholder` 函数补齐 `border='none'``background='transparent'`,图片 style 增加 `max-height:100%;object-fit:contain;`
- **F2**`ReportEditor.tsx``TemplateManage.tsx``insertImage` 改为打开自定义 Modal替代 `prompt`。Modal 包含:
- 宽高输入框(默认 200*200
- 模式选择手术影像占位frame/ 静态图片占位manual
- 确认/取消按钮
- **F3**:生成 placeholder HTML 时manual 模式添加 `data-mode="manual"` 属性。
- **F4**`ReportEditor.tsx``autoCaptureFrames`setTimeout 回调内)、`insertFrameToPlaceholder` 的空占位符选择器,从 `.image-placeholder:not(.has-image)` 改为 `.image-placeholder:not(.has-image):not([data-mode="manual"])`
- **F5**`ReportEditor.tsx``handleDrop` 增加拦截:若目标 placeholder 的 `data-mode === 'manual'`,弹出提示并阻止填充。
- **F6**`ReportEditor.tsx``TemplateManage.tsx``insertTable` 改为打开自定义 Modal替代 `prompt`),包含行数/列数输入和确认/取消按钮。
### 非功能点
- 向后兼容:已有报告中已有的 placeholder 结构不受影响(没有 `data-mode` 属性的占位符默认为 frame 模式)。
- Modal 样式复用现有的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | fillPlaceholder 修复insertImage 改为 ModalinsertTable 改为 ModalautoCaptureFrames 选择器insertFrameToPlaceholder 选择器handleDrop 拦截;新增 3 个 Modal 的 JSX |
| `src/pages/TemplateManage.tsx` | 高 | insertImage 改为 ModalinsertTable 改为 Modal新增 2 个 Modal 的 JSX |
## 待确认问题
无(用户已明确需求,且本次无需人工确认)。

View File

@@ -0,0 +1,42 @@
# 需求分析 — 2026-04-18-00-23-14
## 原始需求摘要
用户提出 3 个关于富文本编辑器ReportEditor / TemplateManage的优化需求
1. **拖拽关键帧后占位符边框残留**:通过拖拽将视频关键帧放入 `.image-placeholder` 后,占位符原有的虚线框和背景色未完全清除,视觉兼容性差。
2. **废弃原生 `prompt()`,改为居中 UI 弹窗**:点击「插入表格」和「插入图片占位符」时,当前使用浏览器原生 `prompt()` 弹窗,希望改为屏幕中央的自定义 React 弹窗,以确认表格行列数或占位符最大长宽。
3. **占位符图片来源隔离与保护**:创建图片占位符时,可选择允许的图片来源类型(关键帧图片 / 本地上传/签名/素材),从而保护签名等特定区域不被误拖入术中截图。
## 需求拆解
### 功能点
- **F1**:修复 `fillPlaceholder`(拖拽填充)未清除内联 `border``background` 的问题,使图片完全撑满占位符。
- **F2**:修复 `autoCaptureFrames` 中自动插入关键帧时同样未清除内联样式的问题。
- **F3**:在 `ReportEditor.tsx``TemplateManage.tsx` 中,将 `insertTable` 的原生 `prompt()` 替换为自定义居中弹窗(输入行数、列数)。
- **F4**:在 `ReportEditor.tsx``TemplateManage.tsx` 中,将 `insertImage` 的原生 `prompt()` 替换为自定义居中弹窗(输入宽度、高度;表格内自动隐藏尺寸输入)。
- **F5**:在弹窗中增加「图片来源限制」下拉选项(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成占位符时写入 `data-allow-source` 属性。
- **F6**:在 `handleDrop`(拖拽关键帧)中拦截:若占位符限制为 `upload`,拒绝拖入并提示。
- **F7**:在 `handleEditorClick`(点击空占位符)中拦截:若占位符限制为 `frame`,拒绝打开图片选择器并提示。
- **F8**:在 `insertFrameToPlaceholder`(一键插入关键帧)中拦截:若目标占位符限制为 `upload`,拒绝插入并提示。
- **F9**:在 `autoCaptureFrames` 的自动帧插入 `setTimeout` 中拦截:若第一个空置占位符限制为 `upload`,跳过该帧不插入。
### 非功能点
- 向后兼容:未设置 `data-allow-source` 的旧占位符默认行为不变(视为 `all`)。
- 焦点管理:打开弹窗前保存当前 Selection/Range确认后恢复光标位置再执行 `insertHTML`,确保插入位置正确。
- 视觉一致性:弹窗样式与现有 `imagePickerOpen` 弹窗保持一致(固定遮罩 + 白色圆角卡片 + 居中布局)。
- 零新依赖:不引入第三方 UI 库,继续使用原生 React state + Tailwind CSS 实现。
## 影响范围
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | 修改 `fillPlaceholder``insertTable``insertImage``handleDrop``handleEditorClick``insertFrameToPlaceholder``autoCaptureFrames`;新增弹窗 state 与 JSX。 |
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertTable``insertImage`;新增弹窗 state 与 JSX复用 `savedRangeRef` 做光标恢复。 |
| `src/index.css` | 低 | 无需修改,`.image-placeholder.has-image` 的 Tailwind 样式已正确,只需在 JS 中清除内联样式。 |
## 待确认问题
无。用户已明确要求本次不经过人工二次确认直接执行。

View File

@@ -0,0 +1,32 @@
# 需求分析 — 2026-04-18-00-43-19
## 原始需求摘要
用户反馈默认模板里的 `class="image-placeholder"` 有问题,要求将默认模板中全部 `image-placeholder` 替换为「按动插入图片占位符之后的状态」,且**只保留当前框的大小不变**。
## 需求拆解
### 功能点
- **F1**:分析默认模板 `defaultContent.ts` 中所有 `.image-placeholder` 的当前结构与新弹窗插入逻辑生成结构的差异。
- **F2**:为默认模板中所有 `.image-placeholder` 补充 `data-mode` 属性,使其与新的图片来源隔离机制兼容:
- 医院 Logo、手术者签名 → `data-mode="manual"`(静态图片占位,仅支持点击插入,禁止拖入关键帧)
- 表格内术中影像占位符 → `data-mode="frame"`(手术影像占位,支持拖拽/自动关键帧插入)
- **F3**更新签名占位符的提示文本使其符合新弹窗的宽度阈值规则width ≥ 80 时显示「插入/点击放置图片」)。
- **F4**:保持所有占位符的现有 `width``height` 及外围布局标签类型、margin、容器结构绝对不变。
### 非功能点
- 向后兼容:默认模板仅影响新建报告,已有报告不受影响。
- 最小侵入:仅修改 `defaultContent.ts`,不动任何 TSX/JS 逻辑。
- `npm run lint` 零错误。
## 影响范围
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/utils/defaultContent.ts` | 高 | 修改 8 个 `image-placeholder` 的 HTML 结构,补充 `data-mode` 及文本。 |
## 待确认问题
无。用户已明确无需人工二次确认。

View File

@@ -0,0 +1,55 @@
# 需求分析 —— 2026-04-18-16-45-02
## 需求来源
用户明确要求建立一套标准化的代码编纂工作流,用于规范后续所有项目修改需求的处理流程。
## 需求概述
新建一个完整的代码编纂工作流。后续用户提出的任何项目修改相关需求,都必须严格按照该工作流执行。
## 工作流步骤定义
### 0. 时间记录
每次执行前,记录问题开始的时间,格式为 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
### 1. 工程分析文件夹
阅读或创建 `.\工程分析` 文件夹,用于存放对整个工程的整体分析文档。
### 2. 需求分析文档
每次用户提出的需求,都整理写入 `.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
### 3. 实现方案文档
每次将实现方案写入 `.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
**关键约束**:该文档写完后必须经过用户二次人工审核确认,方可进入下一步。
### 4. 测试方案文档
将测试方案写入 `.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md` 文档中。
**关键约束**:该文档写完后必须经过用户二次人工审核确认,方可进入下一步。
### 5. 经验记录与知识库
- **执行前**:阅读 `.\工程分析\经验记录.md`,防止重复犯错。
- **执行后**:在 `.\工程分析\经验记录.md` 中,将执行过程中遇到的关键问题及解决方案,按以下四段式格式追加记录:
- A. 具体问题
- B. 产生问题原因
- C. 解决问题方案
- D. 后续如何避免问题
### 6. Gitea 备份
最终执行完成后,使用 Gitea 对文档进行备份。Commit 信息需包含:
- `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}` 时间戳
- 对本次修改的简要描述
完成后提醒用户已完成文档备份。
### 7. 重新部署
最终执行完成后,重新执行 `npm` 部署本项目。
## 本次需求的特殊性
本次需求本身即为"建立工作流规范",不涉及具体业务代码的增删改。因此实现内容主要是:
1. 确认 `.\工程分析` 目录结构已就绪
2. 将工作流规范固化为可重复执行的流程文档
3. 确保用户理解每个步骤的审核节点
## 需求影响范围
- 工程分析文档体系
- 后续所有项目修改需求的执行方式
- Gitea 备份流程
- 无业务代码变更

View File

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

View File

@@ -0,0 +1,29 @@
# 需求分析 —— 2026-04-18-17-27-51
## 需求来源
用户提出 TemplateManage 功能修复与默认报告模板排版重构需求。
## 需求概述
### 需求 1修复 TemplateManage 静态图片占位符插入不显示
`template-manage` 中通过弹窗选择「静态图片占位」并点击「确认插入」后,编辑器中没有出现 `class="image-placeholder"` 的占位符框。经分析,原因是 `document.execCommand('insertHTML')` 对复杂嵌套 HTML`contenteditable="false"`)的自动修正/过滤行为不可靠。
### 需求 2重构默认报告模板顶部排版
根据用户提供的视觉参考图片,重写 `defaultContent.ts` 顶部排版:
- **抬头**:左侧 Logo65×65 静态占位),右侧 14 号字体的「西 安 交 通 大 学 第 一 附 属 医 院」(带下划线),下方 16 号字体「手术记录」。
- **基本信息栏**11 号字体、不加粗、带贯穿下划线的一行:姓名、性别、年龄、科别、床号、住院号。
- **诊断/手术信息**12 号字体、加粗的单行:手术日期、术前诊断、术中诊断、手术名称。
- **双列信息**12 号字体、不加粗、两项一行:手术开始/终止时间、手术者/助手、麻醉师/麻醉方式。
- **手术步骤标题**12 号字体、加粗的「手术步骤、术中出现的情况及处理:」。
### 需求 3修复顶部 Logo 占位符删除按钮无法点击
当前默认模板中 65px×65px 的 Logo 占位符右上角的「×」删除按钮无法点击。原因是占位符缺少 `position: relative`,导致绝对定位的删除按钮点击区域溢出或被遮挡。需保留其「静态图片占位 (`data-mode="manual"`)」逻辑。
## 涉及文件
- `src/pages/TemplateManage.tsx`(需求 1修复 insertImage 插入方式)
- `src/utils/defaultContent.ts`(需求 2、3重构模板排版 + Logo 修复)
## 需求影响范围
- 模板管理页面的图片占位符插入功能
- 新建报告时的默认模板视觉效果
- 打印输出时的顶部排版

View File

@@ -0,0 +1,30 @@
# 需求分析 —— 2026-04-18-17-48-59
## 需求来源
用户基于打印预览效果,提出默认模板排版微调和打印样式修复需求。
## 需求概述
### 需求 1缩减基本信息栏字段间空格
当前默认模板中「姓名:性别:年龄:科别:床号:住院号:」之间使用了 `&nbsp;&nbsp;&nbsp;`(三个不间断空格),间距过大。需缩减为单个空格 `&nbsp;`
### 需求 2Logo 与医院名/标题靠拢并整体居中
当前顶部使用 3 列 table20%-60%-20%Logo 固定在左侧 20% 区域,与中间标题距离过远。需改为 Flex 布局,使 Logo 与文字内容作为一个整体水平居中,且两者间距缩小。
### 需求 3打印时隐藏所有「×」删除按钮
打印预览中,`.smart-field-wrapper` 内的 `.delete-btn`(红色×)仍然可见。`print.ts` 中仅隐藏了 `.image-placeholder .delete-btn`,遗漏了文本字段中的删除按钮。需全局隐藏 `.delete-btn`
### 需求 4统一全文行距为 1.5,消除段前段后间距
当前模板中各 `<p>` 标签的 `line-height` 不统一(有 1.8、默认行高等),且部分段落有默认 margin/padding。需统一为 `line-height: 1.5; margin: 0; padding: 0;`
### 需求 5下划线紧贴文字底部
「西 安 交 通 大 学 第 一 附 属 医 院」下方的 `border-bottom` 和「姓名:」等基本信息栏下方的 `border-bottom` 与文字间距过大。需移除 `padding-bottom`,并通过 `line-height: 1` 或类似手段消除字体底部留白,使横线紧贴文字底部。
## 涉及文件
- `src/utils/defaultContent.ts`(需求 1、2、4、5
- `src/utils/print.ts`(需求 3、4
## 需求影响范围
- 默认报告模板的视觉效果
- 打印输出样式
- 无业务逻辑变更

View File

@@ -0,0 +1,34 @@
# 需求分析 —— 2026-04-18-18-08-37
## 需求来源
用户提出报告编辑器与模板管理器的工具栏功能增强,以及默认模板排版细节调整。
## 需求概述
### 需求 1修复字体选择并新增字号、行距功能
`report-editor``template-manage` 的工具栏中:
- **修复字体选择**:当前 `document.execCommand('fontName')` 可能因浏览器兼容性或 CSS 覆盖而失效,需确保字体选择能正确生效。
- **新增字号选择**:在工具栏字体选择旁边增加字号下拉框,支持 12pt/14pt/18pt 等常用字号。
- **新增行距选择**:在工具栏增加行距下拉框,支持 1.0/1.5/2.0 等行距。由于 `execCommand` 不原生支持行距,需通过直接修改 DOM 元素的 `style.lineHeight` 实现。
### 需求 2修复手术者签名右对齐时图片框换行
当「手术者签名」所在行设置 `text-align: right` 时,文字跑到最右侧,而图片占位符(`display: inline-flex`)换到了下一行。需确保文字和图片在同一行内保持连续。
### 需求 3缩减「手术记录」与「姓名」之间的距离
当前顶部 Flex 容器的 `margin-bottom: 16px` 导致标题与基本信息栏间距过大。需缩小该间距。
### 需求 4消除「手术名称」与「手术开始时间」之间的多余间距
「手术名称」是 `<p>` 标签,「手术开始时间」在 `<table>` 中。`<table>` 的默认 margin 或 `<p>` 的默认间距导致两者距离过远。需消除多余间距,保持 1.5 行距且无段前段后间距。
### 需求 5统一「手术日期」及以下内容为 12pt、1.5 行距、无段间距
当前手术步骤段落1~5缺少 `font-size: 12pt`,导致与上方诊断信息字体大小不一致。需统一从「手术日期」开始往下的所有正文内容为 12pt、1.5 行距、无段前段后间距。
## 涉及文件
- `src/pages/ReportEditor.tsx`(需求 1工具栏增强
- `src/pages/TemplateManage.tsx`(需求 1工具栏增强
- `src/utils/defaultContent.ts`(需求 2~5模板排版修复
## 需求影响范围
- 编辑器工具栏交互
- 默认报告模板视觉效果
- 打印输出样式

View File

@@ -0,0 +1,39 @@
# 需求分析 —— 2026-04-18-18-36-43
## 需求来源
用户提出报告管理列名修正、字段下划线控制、下载功能、右对齐排版修复及默认模板调整等五项改进需求。
## 需求概述
### 需求 1ReportManage 列名"患者号"改为"住院号"
报告管理列表中表头显示为"患者号",但实际数据对应的是住院号字段,需修正列名。
### 需求 2TemplateManage 字段管理增加"下划线"控制
在模板管理的字段管理面板中,每个字段增加"打印时显示下划线"单选框,默认勾选。若取消勾选,则该字段在打印输出时不显示底部下划线。需在 `FormField` 数据结构中增加 `hasUnderline` 属性,并在打印样式中支持 `.no-underline` 类。
### 需求 3ReportEditor / TemplateManage 新增下载按钮
在报告编辑器和模板管理页面的打印按钮旁新增"下载"按钮,点击弹出模态框,支持导出 PDF 和 JSON
- PDF复用现有 `printDocument()`,传入自定义文件名
- JSON通过 `Blob` + `URL.createObjectURL` 实现下载
- 默认文件名格式:`图文报告-{手术名称}-{患者}-{住院号}-{下载时间}.pdf/.json`
### 需求 4修复右对齐时签名与图片框分离
当编辑器中设置右对齐时,"手术者签名:"文字与 `class="image-placeholder"` 图片框被拆分为两行。根本原因是 `display: inline-flex` 在右对齐布局下容易触发换行。需将运行时插入的占位符以及默认模板中的占位符 `display` 属性从 `inline-flex` 改为 `inline-block`,并配合 `line-height` 垂直居中。
### 需求 5默认模板手术者签名右对齐
`defaultContent.ts` 中「手术者签名」行默认设为 `text-align: right`,并应用需求 4 中的 `inline-block` 修复。
## 涉及文件
- `src/pages/ReportManage.tsx`(需求 1
- `src/types.ts`(需求 2FormField 扩展)
- `src/utils/print.ts`(需求 2、3打印样式 + 文件名支持)
- `src/pages/TemplateManage.tsx`(需求 2、3、4
- `src/pages/ReportEditor.tsx`(需求 3、4
- `src/utils/defaultContent.ts`(需求 4、5
## 需求影响范围
- 报告管理列表展示
- 模板字段配置体系
- 编辑器/模板管理器工具栏交互
- 打印输出样式
- 文件导出功能

View File

@@ -0,0 +1,41 @@
# 需求分析 —— 2026-04-18-19-08-43
## 需求来源
用户基于实际使用体验,提出六项界面交互和排版优化需求。
## 需求概述
### 需求 1基础信息字段默认无下划线
默认模板中的「姓名、性别、年龄、科别、床号、住院号」6 个基础信息字段,在打印时默认不显示下划线。通过在 `smartField()` 函数中根据 key 自动注入 `.no-underline` 类实现。
### 需求 2点击 field-value 联动高亮并居中滚动
`report-editor` 中点击正文 `class="field-value"` 时:
- 右侧基本信息对应输入框高亮显示蓝色背景(类似 `template-manage` 中的字段高亮效果)
- 自动滚动到屏幕可视区域的 1/3~2/3 位置(使用 `block: 'center'`
### 需求 3视频上传按钮整合进缩略图列表
`report-editor` 右侧「视频分析」Tab 中原本独立占据一行的「上传视频」大按钮,缩小并移入水平滚动的视频缩略图列表首位(`flex gap-2 overflow-x-auto`),尺寸与视频卡片保持一致(约 `w-24 h-[68px]`),节省垂直空间。
### 需求 4视频模块间距紧凑化
缩减「视频分析」面板中以下间距:
- 视频缩略图列表与下方视频播放器之间的距离
- 视频播放器与播放控制按钮之间的距离
- 播放控制按钮与「关键帧摘取」标题之间的距离
为下方关键帧列表腾出更多展示空间。
### 需求 5签名与日期之间增加空行
`defaultContent.ts` 中,「手术者签名」行与「撰写时间」行之间插入一个空段落 `<p>&nbsp;</p>`,使排版更美观。
### 需求 6图片占位符填充后高度自适应
当图片占位符被填充图片后,若图片实际高度小于占位符预设高度(如 200px占位符仍保留固定高度导致下方出现大片空白。需在填充图片后将占位符的 `height``width``line-height` 重置为 `auto` / `normal`,让高度由图片实际尺寸决定。
## 涉及文件
- `src/utils/defaultContent.ts`(需求 1、5
- `src/pages/ReportEditor.tsx`(需求 2、3、4、6
- `src/pages/TemplateManage.tsx`(需求 6
## 需求影响范围
- 默认模板打印样式
- 编辑器交互体验
- 视频分析面板布局
- 图片占位符自适应行为

View File

@@ -0,0 +1,30 @@
# 需求分析 —— 2026-04-18-19-23-31
## 需求来源
用户在实际使用中发现两个问题,要求进行修复和优化。
## 需求概述
### 需求 1修复视频分析模块空白问题
`ReportEditor` 中,上一轮修改将「上传视频」按钮移入了 `videos.length > 0` 的条件渲染内部,导致当没有视频时,整个「视频分析」面板变为空白,用户无法上传第一个视频。
**预期行为**:无论是否有已上传视频,「上传视频」按钮和缩略图滚动列表都应始终可见。
### 需求 2图片占位符尺寸自适应与等比例缩放限制
当前图片占位符填充图片后,虽然高度变为 `auto`,但宽度仍保持预设值(如 200px导致图片在占位符内居中显示周围仍有大量空白。用户希望
- 预设的宽高仅作为**最大限制**`max-width` / `max-height`
- 如果图片超出限制,则等比例缩小
- 图片靠左上方放置(`object-position: left top`
- 占位符自身的虚线框大小要**紧缩包围shrink-wrap**成图片实际缩放后的尺寸
### 需求 3Logo 框大小保持 65px × 65px
默认模板中顶部医院 Logo 占位符的尺寸应保持 65px × 65px 不变。
## 涉及文件
- `src/pages/ReportEditor.tsx`(需求 1、2
- `src/pages/TemplateManage.tsx`(需求 2
## 需求影响范围
- 视频分析面板的可见性逻辑
- 图片占位符的填充后样式行为
- 打印/预览时的图片尺寸表现