6 Commits

10 changed files with 688 additions and 158 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

@@ -627,6 +627,8 @@ export default function ReportEditor() {
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}

View File

@@ -3,7 +3,7 @@ const smartField = (key: string) => `<span class="smart-field-wrapper" contented
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;">
<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>
@@ -87,21 +87,21 @@ 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;">
<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>
<p style="color: #64748b; font-size: 13px; margin: 0;">图A 腹腔镜探查</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" 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="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>
<p style="color: #64748b; font-size: 13px; margin: 0;">图B 胆囊管夹闭与离断</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" 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="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>
@@ -110,21 +110,21 @@ export const defaultReportContent = `
</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;">
<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>
<p style="color: #64748b; font-size: 13px; margin: 0;">图D 胆囊剥离与床面止血</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" 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="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>
<p style="color: #64748b; font-size: 13px; margin: 0;">图E 胆囊取出与钛夹确认</p>
</td>
<td style="width: 33%; text-align: center; padding: 10px; vertical-align: top; border: 1px solid #e2e8f0;">
<span class="image-placeholder" data-placeholder="true" contenteditable="false" 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="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>
@@ -151,7 +151,7 @@ export const defaultReportContent = `
</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>
手术者签名:<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>
</p>
<p style="text-align: right; font-family: SimSun;">

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,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

@@ -920,3 +920,25 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。
- 原生 `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」快速验证所有占位符是否携带了最新属性。

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` 及文本。 |
## 待确认问题
无。用户已明确无需人工二次确认。