Compare commits
33 Commits
d45e973255
...
v1.2.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a777aad85 | ||
|
|
e6cdfd84d4 | ||
|
|
3eb1b489f3 | ||
|
|
9ff2f5923a | ||
|
|
8ccb234a62 | ||
|
|
cfb3cb91f8 | ||
|
|
d5529a4998 | ||
|
|
7ab8c919e3 | ||
|
|
89bf60b4e1 | ||
|
|
888255ae6f | ||
|
|
48337c382c | ||
|
|
726bbc5bac | ||
|
|
c5648077e8 | ||
|
|
9c09e6cccc | ||
|
|
f98177938f | ||
|
|
8ffb9162d3 | ||
|
|
32f8b2a7ec | ||
|
|
519cc6fc82 | ||
|
|
4a7051b6db | ||
|
|
5f4ae1ff29 | ||
|
|
db1c11f7eb | ||
|
|
55ce78d898 | ||
|
|
e1dc961ecf | ||
|
|
67fb2c9080 | ||
|
|
a46ecffadf | ||
|
|
034575e0a8 | ||
|
|
4e24ee15a2 | ||
| 0df27cbc73 | |||
| 1278f7282f | |||
| 8978b7a2de | |||
| 6498ef6444 | |||
| b7a1ea457e | |||
| d05029838f |
301
AGENTS.md
301
AGENTS.md
@@ -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 / user(user 只能看自己的) |
|
||||
| `/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 运行时注入,通常无需手动修改。
|
||||
|
||||
@@ -8,15 +8,19 @@ import { storage } from '../utils/storage';
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState({
|
||||
reportCount: 0,
|
||||
totalCount: 0,
|
||||
monthCount: 0,
|
||||
templateCount: 0,
|
||||
userCount: 0,
|
||||
todayCount: 0,
|
||||
trend: [0,0,0,0,0,0,0],
|
||||
trendLabels: ['','','','','','',''],
|
||||
trendFullDates: ['','','','','','',''],
|
||||
maxTrend: 1
|
||||
});
|
||||
const [tooltip, setTooltip] = useState<{ visible: boolean; x: number; y: number; date: string; count: number }>({ visible: false, x: 0, y: 0, date: '', count: 0 });
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');
|
||||
|
||||
useEffect(() => {
|
||||
const user = storage.get<User | null>('currentUser', null);
|
||||
@@ -35,32 +39,42 @@ export default function Dashboard() {
|
||||
? reports.filter(r => r.author === user.username)
|
||||
: reports;
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const todayReports = userReports.filter(r => r.createdAt === today);
|
||||
|
||||
// 7-day trend data
|
||||
// 本月报告数
|
||||
const currentMonth = today.slice(0, 7);
|
||||
const thisMonthReports = userReports.filter(r => r.createdAt && r.createdAt.startsWith(currentMonth));
|
||||
|
||||
// 动态趋势数据
|
||||
const daysCount = timeRange === '7days' ? 7 : 30;
|
||||
const trend: number[] = [];
|
||||
const labels: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
const fullDates: string[] = [];
|
||||
for (let i = daysCount - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const label = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
const label = timeRange === '7days' ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getDate()}`;
|
||||
labels.push(label);
|
||||
fullDates.push(dateStr);
|
||||
trend.push(userReports.filter(r => r.createdAt === dateStr).length);
|
||||
}
|
||||
const maxTrend = Math.max(...trend, 1);
|
||||
|
||||
setStats({
|
||||
reportCount: userReports.length,
|
||||
totalCount: userReports.length,
|
||||
monthCount: thisMonthReports.length,
|
||||
templateCount: templates.length,
|
||||
userCount: users.length,
|
||||
todayCount: todayReports.length,
|
||||
trend,
|
||||
trendLabels: labels,
|
||||
trendFullDates: fullDates,
|
||||
maxTrend
|
||||
});
|
||||
}, [navigate]);
|
||||
}, [navigate, timeRange]);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
@@ -80,10 +94,15 @@ export default function Dashboard() {
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">全部报告总数</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.totalCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
<div className="text-[11px] text-text-muted mb-2 uppercase tracking-wider font-bold">本月报告总数</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.reportCount}</div>
|
||||
<div className="text-3xl font-bold text-text-main">{stats.monthCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-minimal">
|
||||
@@ -104,11 +123,44 @@ export default function Dashboard() {
|
||||
<TrendingUp size={16} className="text-accent" />
|
||||
报告增长趋势
|
||||
</span>
|
||||
<span className="text-[10px] text-accent font-bold uppercase tracking-wider">最近 7 天</span>
|
||||
<div className="flex bg-slate-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setTimeRange('7days')}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '7days' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
|
||||
>最近 7 天</button>
|
||||
<button
|
||||
onClick={() => setTimeRange('1month')}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${timeRange === '1month' ? 'bg-white text-accent shadow-sm' : 'text-text-muted hover:text-text-main'}`}
|
||||
>最近 30 天</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-50 rounded-xl p-6 min-h-[240px] relative">
|
||||
{/* SVG Area Chart */}
|
||||
<svg viewBox="0 0 300 120" className="w-full h-full overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 300 135"
|
||||
className="w-full h-full overflow-visible"
|
||||
onMouseMove={(e) => {
|
||||
const svg = e.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const mouseX = ((e.clientX - rect.left) / rect.width) * 300;
|
||||
const paddingX = 10;
|
||||
const chartW = 300 - paddingX * 2;
|
||||
const n = stats.trend.length;
|
||||
if (n <= 1) return;
|
||||
let idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1));
|
||||
idx = Math.max(0, Math.min(n - 1, idx));
|
||||
const ptX = paddingX + (idx / (n - 1)) * chartW;
|
||||
const ptY = 8 + (120 - 16) - (stats.maxTrend > 0 ? (stats.trend[idx] / stats.maxTrend) * (120 - 16) : 0);
|
||||
setTooltip({
|
||||
visible: true,
|
||||
x: (ptX / 300) * rect.width,
|
||||
y: (ptY / 135) * rect.height,
|
||||
date: stats.trendFullDates[idx] || '',
|
||||
count: stats.trend[idx]
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(prev => ({ ...prev, visible: false }))}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563EB" stopOpacity="0.35" />
|
||||
@@ -145,17 +197,37 @@ export default function Dashboard() {
|
||||
<g>
|
||||
<path d={areaPath} fill="url(#trendGradient)" />
|
||||
<path d={linePath} fill="none" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{/* Transparent capture layer for reliable mouse events */}
|
||||
<rect x="0" y="0" width="300" height="135" fill="transparent" />
|
||||
{points.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
|
||||
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
|
||||
<text x={p.x} y={120 - 2} textAnchor="middle" fontSize="8" fill="#94A3B8" fontWeight="bold">{p.label}</text>
|
||||
{/* 7天模式显示圆点和数值;30天模式隐藏 */}
|
||||
{stats.trend.length <= 10 && (
|
||||
<>
|
||||
<circle cx={p.x} cy={p.y} r="3.5" fill="#2563EB" stroke="#fff" strokeWidth="2" />
|
||||
<text x={p.x} y={p.y - 10} textAnchor="middle" fontSize="8" fill="#64748B" fontWeight="bold">{p.count}</text>
|
||||
</>
|
||||
)}
|
||||
{/* 标签稀疏化:7天每天显示,30天每隔5天显示 */}
|
||||
{(stats.trend.length <= 10 || i % 5 === 0) && (
|
||||
<text x={p.x} y={128} textAnchor="middle" fontSize={stats.trendLabels.length > 10 ? '7' : '8'} fill="#94A3B8" fontWeight="bold">{p.label}</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
{/* Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-slate-800 text-white text-xs rounded-lg px-3 py-2 shadow-lg z-10"
|
||||
style={{ left: tooltip.x, top: tooltip.y - 40, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
<div className="font-bold">{tooltip.date}</div>
|
||||
<div className="text-slate-300">报告数: {tooltip.count}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Login() {
|
||||
frameMode: 'uniform',
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
|
||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
|
||||
};
|
||||
storage.set('systemSettings', defaultSettings);
|
||||
}
|
||||
|
||||
@@ -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,33 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||
allFields.forEach(el => {
|
||||
(el as HTMLElement).style.backgroundColor = '';
|
||||
(el as HTMLElement).style.outline = '';
|
||||
(el as HTMLElement).style.outlineOffset = '';
|
||||
});
|
||||
if (activeFieldKey) {
|
||||
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="${activeFieldKey}"]`) as HTMLElement;
|
||||
if (targetEl) {
|
||||
targetEl.style.backgroundColor = '#f1f5f9';
|
||||
targetEl.style.outline = '1px solid #94a3b8';
|
||||
targetEl.style.outlineOffset = '1px';
|
||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [activeFieldKey]);
|
||||
|
||||
const [multiSelectOptions, setMultiSelectOptions] = useState<Record<string, string[]>>({
|
||||
surgeon: ['张医生', '李医生', '王医生'],
|
||||
assistant: ['赵医生', '钱医生', '孙医生'],
|
||||
@@ -66,6 +88,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);
|
||||
@@ -348,11 +376,24 @@ export default function ReportEditor() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const src = event.target?.result as 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%; height: auto; 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();
|
||||
};
|
||||
@@ -371,6 +412,31 @@ 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;
|
||||
}
|
||||
|
||||
// 点击空白处清除高亮
|
||||
setActiveFieldKey(null);
|
||||
|
||||
const placeholder = targetEl.closest('.image-placeholder') as HTMLElement | null;
|
||||
if (!placeholder) return;
|
||||
|
||||
@@ -379,12 +445,25 @@ export default function ReportEditor() {
|
||||
e.preventDefault();
|
||||
if (placeholder.classList.contains('has-image')) {
|
||||
placeholder.classList.remove('has-image');
|
||||
const w = parseInt(placeholder.style.maxWidth || placeholder.style.width || '0');
|
||||
const text = w > 0 && w < 80 ? '插图' : '插入/点击放置图片';
|
||||
placeholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
|
||||
`;
|
||||
placeholder.style.border = '1px dashed #cbd5e1';
|
||||
placeholder.style.background = '#f8fafc';
|
||||
const mw = placeholder.style.maxWidth;
|
||||
const mh = placeholder.style.maxHeight;
|
||||
if (mw) placeholder.style.width = mw;
|
||||
if (mh) {
|
||||
placeholder.style.height = mh;
|
||||
placeholder.style.lineHeight = mh;
|
||||
}
|
||||
placeholder.style.textAlign = 'center';
|
||||
placeholder.style.verticalAlign = 'middle';
|
||||
placeholder.style.justifyContent = 'center';
|
||||
placeholder.style.alignItems = 'center';
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
} else {
|
||||
@@ -459,13 +538,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 +568,40 @@ 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 changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
let node = sel.getRangeAt(0).commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
|
||||
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
|
||||
if (block) {
|
||||
(block as HTMLElement).style.textAlign = align;
|
||||
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>​`;
|
||||
execCmd('insertHTML', html);
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -655,13 +730,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 +772,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 +812,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 +1402,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 +1429,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 +1439,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>
|
||||
@@ -1343,9 +1475,9 @@ export default function ReportEditor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('left')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('center')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('right')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
@@ -1393,14 +1525,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}`} onClick={() => setActiveFieldKey(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 +1568,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}`} onClick={() => setActiveFieldKey(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 +1677,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}`} onClick={() => setActiveFieldKey(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 +1758,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}`} onClick={() => setActiveFieldKey(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 +1815,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}`} onClick={() => setActiveFieldKey(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
|
||||
@@ -1724,7 +1872,7 @@ export default function ReportEditor() {
|
||||
)}
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
@@ -1733,49 +1881,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">
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
</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 +1956,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 +2007,6 @@ export default function ReportEditor() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1874,6 +2015,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 = 'position:relative;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;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${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;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
execCmd('insertHTML', html);
|
||||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
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(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
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">
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function ReportManage() {
|
||||
const exportBulkJSON = () => {
|
||||
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
|
||||
const data = selectedReports.map(r => buildExportData(r));
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timestamp = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
downloadJSON(data, `reports_export_${timestamp}.json`);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -94,7 +94,10 @@ export default function SystemSettings() {
|
||||
apiEndpoint: '',
|
||||
apiKey: '',
|
||||
defaultTemplate: templates[0]?.id || '',
|
||||
frameMode: 'uniform'
|
||||
frameMode: 'uniform',
|
||||
autoInsertFrames: true,
|
||||
autoInsertDelay: 1,
|
||||
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
|
||||
};
|
||||
setSettings(defaultSettings);
|
||||
storage.set('systemSettings', defaultSettings);
|
||||
|
||||
@@ -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, Upload } from 'lucide-react';
|
||||
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
|
||||
import { defaultReportContent } from '../utils/defaultContent';
|
||||
import { printDocument } from '../utils/print';
|
||||
@@ -13,8 +13,11 @@ 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 [importedContent, setImportedContent] = useState<{content: string; fields: FormField[]} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const savedRangeRef = useRef<Range | null>(null);
|
||||
@@ -34,11 +37,22 @@ export default function TemplateManage() {
|
||||
const [editFieldTimeFormat, setEditFieldTimeFormat] = useState('');
|
||||
const [editFieldTimeDefault, setEditFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||
const [editFieldFixedTimeValue, setEditFieldFixedTimeValue] = useState('');
|
||||
const [editFieldHasUnderline, setEditFieldHasUnderline] = useState(false);
|
||||
const [newFieldTimeFormat, setNewFieldTimeFormat] = useState('YYYY年MM月DD日');
|
||||
const [newFieldTimeDefault, setNewFieldTimeDefault] = useState<'current' | 'specific'>('specific');
|
||||
const [newFieldFixedTimeValue, setNewFieldFixedTimeValue] = useState('');
|
||||
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(false);
|
||||
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 [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const updatePageHeight = () => {
|
||||
if (!editorRef.current) return;
|
||||
@@ -117,6 +131,10 @@ export default function TemplateManage() {
|
||||
const template = templates.find(t => t.id === currentTemplateId);
|
||||
if (template) {
|
||||
editorRef.current.innerHTML = template.content;
|
||||
if (template.fields && template.fields.length > 0) {
|
||||
setFormFields(template.fields);
|
||||
storage.set('formFieldsConfig', template.fields);
|
||||
}
|
||||
}
|
||||
setTimeout(() => updatePageHeight(), 0);
|
||||
}
|
||||
@@ -132,13 +150,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();
|
||||
};
|
||||
|
||||
@@ -191,12 +220,25 @@ export default function TemplateManage() {
|
||||
pushHistory();
|
||||
if (placeholder.classList.contains('has-image')) {
|
||||
placeholder.classList.remove('has-image');
|
||||
const w = parseInt(placeholder.style.maxWidth || placeholder.style.width || '0');
|
||||
const text = w > 0 && w < 80 ? '插图' : '插入/点击放置图片';
|
||||
placeholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span>
|
||||
`;
|
||||
placeholder.style.border = '1px dashed #cbd5e1';
|
||||
placeholder.style.background = '#f8fafc';
|
||||
const mw = placeholder.style.maxWidth;
|
||||
const mh = placeholder.style.maxHeight;
|
||||
if (mw) placeholder.style.width = mw;
|
||||
if (mh) {
|
||||
placeholder.style.height = mh;
|
||||
placeholder.style.lineHeight = mh;
|
||||
}
|
||||
placeholder.style.textAlign = 'center';
|
||||
placeholder.style.verticalAlign = 'middle';
|
||||
placeholder.style.justifyContent = 'center';
|
||||
placeholder.style.alignItems = 'center';
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNode(placeholder);
|
||||
@@ -351,6 +393,30 @@ 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 changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
let node = sel.getRangeAt(0).commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
|
||||
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, li');
|
||||
if (block) {
|
||||
(block as HTMLElement).style.textAlign = align;
|
||||
saveTemplateContent();
|
||||
}
|
||||
};
|
||||
|
||||
const saveTemplateContent = () => {
|
||||
if (!currentTemplateId || !editorRef.current) return;
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
@@ -370,7 +436,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>​`;
|
||||
const underlineClass = field.hasUnderline !== true ? ' 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>​`;
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
@@ -439,11 +506,25 @@ export default function TemplateManage() {
|
||||
next.timeDefault = editFieldTimeDefault;
|
||||
next.fixedTimeValue = editFieldFixedTimeValue;
|
||||
}
|
||||
next.hasUnderline = editFieldHasUnderline;
|
||||
return next;
|
||||
});
|
||||
setFormFields(updated);
|
||||
storage.set('formFieldsConfig', updated);
|
||||
setEditingFieldKey(null);
|
||||
|
||||
// 同步更新编辑器中已插入字段的 classList
|
||||
if (editorRef.current) {
|
||||
const els = editorRef.current.querySelectorAll(`.field-value[data-bind="${key}"]`);
|
||||
els.forEach(el => {
|
||||
if (editFieldHasUnderline) {
|
||||
el.classList.remove('no-underline');
|
||||
} else {
|
||||
el.classList.add('no-underline');
|
||||
}
|
||||
});
|
||||
saveTemplateContent();
|
||||
}
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
@@ -456,6 +537,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 +555,7 @@ export default function TemplateManage() {
|
||||
setNewFieldTimeFormat('YYYY年MM月DD日');
|
||||
setNewFieldTimeDefault('specific');
|
||||
setNewFieldFixedTimeValue('');
|
||||
setNewFieldHasUnderline(true);
|
||||
};
|
||||
|
||||
const handleAssetUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -491,57 +574,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>​`;
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const saveCurrentTemplate = () => {
|
||||
@@ -572,22 +615,92 @@ export default function TemplateManage() {
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = (id: string) => {
|
||||
if (templates.length <= 1) {
|
||||
alert('至少需要保留一个模板');
|
||||
return;
|
||||
}
|
||||
if (window.confirm('确定要删除此模板吗?')) {
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
const updated = allTemplates.filter(t => t.id !== id);
|
||||
setTemplates(updated.filter(t => templates.some(x => x.id === t.id)));
|
||||
setTemplates(updated);
|
||||
storage.set('templates', updated);
|
||||
if (currentTemplateId === id) {
|
||||
const visible = updated.filter(t => templates.some(x => x.id === t.id));
|
||||
setCurrentTemplateId(visible[0]?.id || null);
|
||||
setCurrentTemplateId(updated[0]?.id || null);
|
||||
}
|
||||
setSelectedIds(prev => prev.filter(sid => sid !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 个模板吗?`)) return;
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
const updated = allTemplates.filter(t => !selectedIds.includes(t.id));
|
||||
setTemplates(updated);
|
||||
storage.set('templates', updated);
|
||||
if (currentTemplateId && selectedIds.includes(currentTemplateId)) {
|
||||
setCurrentTemplateId(updated[0]?.id || null);
|
||||
}
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const handleBatchExport = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const targets = templates.filter(t => selectedIds.includes(t.id));
|
||||
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
type: 'surclaw_template_package_batch',
|
||||
templates: targets
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `模板批量导出-${ts}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const json = JSON.parse(event.target?.result as string);
|
||||
if (json.type !== 'surclaw_template_package') {
|
||||
alert('无效的模板包文件');
|
||||
return;
|
||||
}
|
||||
setFormData({ name: json.title || '', desc: json.description || '' });
|
||||
setImportedContent({
|
||||
content: json.content || '',
|
||||
fields: Array.isArray(json.fields) ? json.fields : []
|
||||
});
|
||||
} catch {
|
||||
alert('文件解析失败,请检查 JSON 格式');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
if (e.target) e.target.value = '';
|
||||
};
|
||||
|
||||
const handleExportTemplate = (template: Template) => {
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
type: 'surclaw_template_package',
|
||||
title: template.name,
|
||||
description: template.desc || '',
|
||||
content: template.content,
|
||||
fields: template.fields || formFields
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
a.download = `模板导出-${template.name}-${ts}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleModalSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const allTemplates = storage.get<Template[]>('templates', []);
|
||||
@@ -605,14 +718,19 @@ export default function TemplateManage() {
|
||||
id: 'tpl_' + Date.now(),
|
||||
name: formData.name,
|
||||
desc: formData.desc,
|
||||
content: defaultReportContent,
|
||||
content: importedContent?.content || defaultReportContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: currentUser?.username || 'admin'
|
||||
author: currentUser?.username || 'admin',
|
||||
fields: importedContent?.fields || formFields
|
||||
};
|
||||
const updated = [...allTemplates, newTpl];
|
||||
setTemplates([...templates, newTpl]);
|
||||
storage.set('templates', updated);
|
||||
setCurrentTemplateId(newTpl.id);
|
||||
if (importedContent?.fields && importedContent.fields.length > 0) {
|
||||
setFormFields(importedContent.fields);
|
||||
storage.set('formFieldsConfig', importedContent.fields);
|
||||
}
|
||||
|
||||
const savedUsers = storage.get<User[]>('users', []);
|
||||
let updatedUsers = savedUsers;
|
||||
@@ -653,6 +771,7 @@ export default function TemplateManage() {
|
||||
}
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
setImportedContent(null);
|
||||
};
|
||||
|
||||
if (!currentUser) return null;
|
||||
@@ -674,39 +793,76 @@ export default function TemplateManage() {
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="px-4 pt-3 pb-1 flex items-center justify-between bg-slate-50 border-b border-border">
|
||||
<span className="text-xs text-text-muted font-bold">已选中 {selectedIds.length} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleBatchExport}
|
||||
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
批量导出
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchDelete}
|
||||
className="px-2 py-1 rounded-md bg-red-50 text-red-600 text-[10px] font-bold hover:bg-red-100 transition-colors"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{templates.map(tpl => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
onClick={() => setCurrentTemplateId(tpl.id)}
|
||||
className={`p-4 rounded-xl border transition-all group ${
|
||||
className={`p-4 rounded-xl border transition-all group cursor-pointer ${
|
||||
currentTemplateId === tpl.id
|
||||
? 'bg-white border-accent shadow-sm'
|
||||
: 'bg-transparent border-transparent hover:bg-white hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className={`text-sm font-bold ${currentTemplateId === tpl.id ? 'text-accent' : 'text-text-main'}`}>
|
||||
{tpl.name}
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(tpl.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIds(prev => e.target.checked ? [...prev, tpl.id] : prev.filter(id => id !== tpl.id));
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className={`text-sm font-bold ${currentTemplateId === tpl.id ? 'text-accent' : 'text-text-main'}`}>
|
||||
{tpl.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted line-clamp-1 mb-2">{tpl.desc || '无描述'}</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditInfo(tpl); }}
|
||||
className="px-2 py-1 rounded-md bg-slate-100 text-slate-600 text-[10px] font-bold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleExportTemplate(tpl); }}
|
||||
className="px-2 py-1 rounded-md bg-blue-50 text-blue-600 text-[10px] font-bold hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
|
||||
className="px-2 py-1 rounded-md bg-red-50 text-red-600 text-[10px] font-bold hover:bg-red-100 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted line-clamp-1 mb-2">{tpl.desc || '无描述'}</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditInfo(tpl); }}
|
||||
className="px-2 py-1 rounded-md bg-slate-100 text-slate-600 text-[10px] font-bold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
{templates.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteTemplate(tpl.id); }}
|
||||
className="px-2 py-1 rounded-md bg-red-50 text-red-600 text-[10px] font-bold hover:bg-red-100 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
@@ -740,6 +896,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 +934,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>
|
||||
@@ -787,9 +971,9 @@ export default function TemplateManage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('left')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('center')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => changeAlignment('right')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
@@ -886,6 +1070,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 +1118,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 +1309,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 +1375,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,11 +1393,63 @@ 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(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
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(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
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">
|
||||
<h3 className="text-xl font-bold text-text-main mb-2">{isEditing ? '编辑模板信息' : '新增模板'}</h3>
|
||||
<p className="text-sm text-text-muted mb-8">设置模板的基本名称和描述</p>
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-3 mb-6 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleModalSubmit} className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模板名称 *</label>
|
||||
@@ -1191,7 +1474,7 @@ export default function TemplateManage() {
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
onClick={() => { setIsModalOpen(false); setImportedContent(null); }}
|
||||
className="px-6 py-2.5 bg-slate-100 text-text-muted rounded-lg text-sm font-semibold hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
@@ -1208,6 +1491,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 = 'position:relative;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;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${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;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
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">
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface Template {
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
author: string;
|
||||
fields?: FormField[];
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
@@ -116,11 +117,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: false },
|
||||
{ key: 'hospitalId', label: '住院号', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: true, hasUnderline: false },
|
||||
{ key: 'title', label: '手术名称', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
{ key: 'patientGender', label: '患者性别', category: '单选', type: 'single_select', visibleInForm: true, isSystemLocked: false, options: ['男', '女'] },
|
||||
{ key: 'patientAge', label: '患者年龄', category: '填空', type: 'text', visibleInForm: true, isSystemLocked: false },
|
||||
|
||||
@@ -1,85 +1,77 @@
|
||||
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>​`;
|
||||
const smartField = (key: string) => {
|
||||
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value no-underline" data-bind="${key}" contenteditable="true" style="min-width:24px;padding:0 2px;margin:0;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:inherit;font-size:inherit;vertical-align:baseline;box-sizing:border-box;outline:none;text-align:center;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
};
|
||||
|
||||
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="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;transform:translate(-5px,-5px);">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
|
||||
</span>
|
||||
</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: 1px; margin-bottom: 2px; display: inline-block; line-height: 1;">西 安 交 通 大 学 第 一 附 属 医 院</div>
|
||||
<div style="font-size: 16pt; font-family: SimSun;">手术记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-family: SimSun;">
|
||||
<p style="font-family: SimSun; font-size: 11pt; font-weight: normal; margin: 0; padding: 0; line-height: 1; border-bottom: 1px solid #000;">
|
||||
姓名:${smartField('patientName')}
|
||||
性别:${smartField('patientGender')}
|
||||
年龄:${smartField('patientAge')}
|
||||
科别:${smartField('department')}
|
||||
床号:${smartField('bedNumber')}
|
||||
住院号:${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 +79,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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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="position:relative;border: 1px dashed #cbd5e1; background: #f8fafc; width: 100%; height: 100%; max-width: 200px; max-height: 200px; min-height: 60px; margin: 0px auto; display: flex; align-items: center; justify-content: center; cursor: pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<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>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0; 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;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
|
||||
</p>
|
||||
|
||||
<p style="text-align: right; font-family: SimSun;">
|
||||
<p style="margin: 0; padding: 0; line-height: 1.5;"> </p>
|
||||
|
||||
<p style="text-align: right; font-family: SimSun; line-height: 1.5; margin: 0; padding: 0;">
|
||||
${smartField('reportDate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const printDocument = (htmlContent: string) => {
|
||||
export const printDocument = (htmlContent: string, docTitle: string = '图文报告') => {
|
||||
const originalTitle = document.title;
|
||||
document.title = docTitle;
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
@@ -17,13 +19,14 @@ export const printDocument = (htmlContent: string) => {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${docTitle}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
@page { size: A4; margin: 15mm 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 10mm; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
||||
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
|
||||
body { margin: 0; padding: 0; font-family: SimSun, "Microsoft YaHei", serif; color: #1E293B; background: white; }
|
||||
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
||||
img { max-width: 100%; height: auto; display: block; margin: 8px auto; }
|
||||
p { margin: 0; padding: 4px 0; line-height: 1.6; }
|
||||
p { margin: 0; padding: 0; line-height: 1.5; }
|
||||
h1 { font-size: 20px; margin: 16px 0 12px; font-weight: 600; text-align: center; }
|
||||
strong, b { font-weight: 600; }
|
||||
u { text-decoration: underline; }
|
||||
@@ -31,15 +34,16 @@ 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; }
|
||||
.smart-field-wrapper { display: inline-flex; align-items: baseline; margin: 0; vertical-align: baseline; }
|
||||
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
|
||||
.smart-field-wrapper .field-value { min-width: 32px; padding: 0 4px; margin: 0 2px; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: 1.2; font-size: inherit; vertical-align: text-bottom; box-sizing: border-box; min-height: 1.2em; outline: none; }
|
||||
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
|
||||
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value { 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 { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px 0px 2px !important; line-height: 1 !important; }
|
||||
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -52,6 +56,7 @@ export const printDocument = (htmlContent: string) => {
|
||||
win.focus();
|
||||
setTimeout(() => {
|
||||
win.print();
|
||||
document.title = originalTitle;
|
||||
setTimeout(() => {
|
||||
if (iframe.parentNode) document.body.removeChild(iframe);
|
||||
}, 1000);
|
||||
|
||||
196
工程分析/实现方案-2026-04-17-23-38-34.md
Normal file
196
工程分析/实现方案-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 实现方案 — 2026-04-17-23-38-34
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题1:原生 datalist 交互体验差
|
||||
- 原生 `<input list>` + `<datalist>` 在不同浏览器中表现不一致,部分浏览器不会自动展开全部选项,且不支持样式自定义。
|
||||
- 用户已习惯 `ReportEditor.tsx` 中单选下拉框的交互模式,期望统一体验。
|
||||
|
||||
### 问题2:execCommand('insertHTML') 在表格中破坏结构
|
||||
- 当 `insertImage` 在 `<td>` 内执行 `execCommand('insertHTML', ...)` 时,WebKit/Blink 会将复杂的 `inline-flex` 嵌套 `<span>` 结构自动"拍平"或重新排列。
|
||||
- 外层 `<span class="image-placeholder">` 被浏览器移除,仅剩内部的 `.delete-btn` 和 `.placeholder-text` 散落为 `<td>` 的直接子元素。
|
||||
- 表格单元格本身就是块级上下文,使用块级 `<div>` 作为占位符容器更符合浏览器预期,不会被强制修正。
|
||||
|
||||
### 问题3:@page margin 与 body padding 的分页失效
|
||||
- `@page { margin: 0 }` 将物理纸张边距设为 0。
|
||||
- `body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。
|
||||
- 当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding,导致第二页顶部和底部紧贴纸张边缘。
|
||||
- 正确做法是将边距交给 `@page` 规则,让打印引擎为每一张物理纸张独立留出边距。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/pages/TemplateManage.tsx` | ① 引入 `formatDropdownOpen` / `newFormatDropdownOpen` 状态;② 将编辑/新增字段的格式 `input[list]+datalist` 替换为自定义下拉组件;③ `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
|
||||
| `src/pages/ReportEditor.tsx` | `insertImage` 增加表格检测,表格内使用 `<div>` 块级容器+自适应尺寸 |
|
||||
| `src/utils/print.ts` | `@page margin` 与 `body padding` 调整,`.content` width 改为 `100%` |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. TemplateManage.tsx
|
||||
|
||||
#### 1.1 新增状态(组件顶部)
|
||||
|
||||
```tsx
|
||||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||||
```
|
||||
|
||||
#### 1.2 编辑字段格式输入替换为自定义下拉
|
||||
|
||||
将原 `input[list]` + `datalist` 替换为:
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={editFieldTimeFormat}
|
||||
onChange={(e) => setEditFieldTimeFormat(e.target.value)}
|
||||
onFocus={() => setFormatDropdownOpen(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setFormatDropdownOpen(false), 200);
|
||||
const val = editFieldTimeFormat.trim();
|
||||
if (val && !customTimeFormats.includes(val)) {
|
||||
const next = [...customTimeFormats, val];
|
||||
setCustomTimeFormats(next);
|
||||
storage.set('customTimeFormats', next);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = editFieldTimeFormat.trim();
|
||||
if (val && !customTimeFormats.includes(val)) {
|
||||
const next = [...customTimeFormats, val];
|
||||
setCustomTimeFormats(next);
|
||||
storage.set('customTimeFormats', next);
|
||||
}
|
||||
setFormatDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
className="w-full px-1.5 py-1 text-xs border border-border rounded"
|
||||
placeholder="输入格式或下拉选择"
|
||||
/>
|
||||
{formatDropdownOpen && (
|
||||
<div className="absolute z-10 left-0 right-0 top-full mt-1 bg-white border border-border rounded shadow-lg max-h-32 overflow-y-auto">
|
||||
{customTimeFormats
|
||||
.filter(fmt => {
|
||||
const isDateFormat = /YYYY|MM|DD/.test(fmt);
|
||||
const isTimeFormat = /HH|hh|mm|A/.test(fmt);
|
||||
if (field.type === 'date') return isDateFormat;
|
||||
if (field.type === 'time') return isTimeFormat;
|
||||
return true;
|
||||
})
|
||||
.map(fmt => (
|
||||
<div
|
||||
key={fmt}
|
||||
className="px-2 py-1 text-xs hover:bg-slate-100 cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setEditFieldTimeFormat(fmt);
|
||||
setFormatDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{fmt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 1.3 新增字段格式输入同理替换
|
||||
|
||||
使用 `newFormatDropdownOpen` 状态,结构同上,过滤条件改为 `newFieldForm.type`。
|
||||
|
||||
#### 1.4 insertImage 增加表格检测
|
||||
|
||||
```tsx
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
|
||||
// 检测是否在表格单元格内
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
while (node) {
|
||||
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
|
||||
inTable = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
let width = 200;
|
||||
let height = 200;
|
||||
if (!inTable) {
|
||||
while (true) {
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
if (input === null) return;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') break;
|
||||
const parts = trimmed.split('*').map(s => s.trim());
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
||||
width = parseInt(parts[0]) || 0;
|
||||
height = parseInt(parts[1]) || 0;
|
||||
break;
|
||||
}
|
||||
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
|
||||
}
|
||||
}
|
||||
|
||||
const id = 'ph_' + Date.now();
|
||||
const hintText = '插入/点击放置图片';
|
||||
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
// 表格内使用 div 块级容器,自适应单元格
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
// 普通文本中保持行内 span
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插图' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. ReportEditor.tsx
|
||||
|
||||
`insertImage` 同理增加表格检测分支,与 TemplateManage 保持一致(去除 `restoreSelection()` 和 `pushHistory()` 调用)。
|
||||
|
||||
### 3. print.ts
|
||||
|
||||
```css
|
||||
/* 修改前 */
|
||||
@page { size: A4; margin: 0; }
|
||||
body { margin: 0; padding: 10mm; ... }
|
||||
.content { width: 190mm; min-height: 277mm; margin: 0 auto; }
|
||||
|
||||
/* 修改后 */
|
||||
@page { size: A4; margin: 15mm 10mm; }
|
||||
body { margin: 0; padding: 0; ... }
|
||||
.content { width: 100%; min-height: 277mm; margin: 0 auto; }
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 自定义下拉组件在滚动容器内可能被裁切 | 父容器设置 `relative`,下拉层设置 `absolute z-10`,并确保外层有适当的 overflow-visible 或足够空间 |
|
||||
| 表格检测 `while (node)` 循环在编辑器外部可能遍历到 body/html | 以 `node.nodeName === 'TD' \|\| node.nodeName === 'TH'` 为终止条件,安全 |
|
||||
| 表格内使用 div 后,fillPlaceholderSrc 需要兼容 | fillPlaceholderSrc 通过 `querySelector('.image-placeholder')` 匹配 class,不受标签名影响,已验证兼容 |
|
||||
| @page margin 增加后 .content width 190mm 会溢出 | 改为 `width: 100%`,让内容自然撑满可用区域 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- TemplateManage.tsx 的修改:删除新增状态和替换的 JSX 条件块,恢复原有的 `input[list]` + `datalist`。
|
||||
- ReportEditor.tsx 的修改:删除 insertImage 中的表格检测分支。
|
||||
- print.ts 的修改:恢复原始的 `@page`、`body`、`content` 样式。
|
||||
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 实现方案 — 2026-04-18-00-02-08
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 问题1:拖拽插入后边框不消失
|
||||
- `fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`。
|
||||
- `fillPlaceholder`(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。
|
||||
- 同时 `fillPlaceholder` 中图片 style 缺少 `max-height:100%;object-fit:contain;`,图片可能溢出占位符。
|
||||
|
||||
### 问题2:prompt 弹窗体验差 + 自动帧插入无区分
|
||||
- `insertImage` 使用浏览器原生 `prompt` 询问宽高,交互体验不佳。
|
||||
- 所有 `.image-placeholder` 一视同仁,`autoCaptureFrames` 会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。
|
||||
- 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
|
||||
|
||||
### 问题3:insertTable 使用 prompt
|
||||
- 与 insertImage 同理,原生 `prompt` 弹窗用户体验差,应替换为与项目风格一致的自定义 Modal。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/pages/ReportEditor.tsx` | ① fillPlaceholder 补齐样式清除和图片约束;② insertImage 改为 placeholderModal;③ insertTable 改为 tableModal;④ autoCaptureFrames/insertFrameToPlaceholder 选择器增加 `:not([data-mode="manual"])`;⑤ handleDrop 拦截 manual 模式;⑥ JSX 底部新增 2 个 Modal |
|
||||
| `src/pages/TemplateManage.tsx` | ① insertImage 改为 placeholderModal;② insertTable 改为 tableModal;③ JSX 底部新增 2 个 Modal |
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 1. ReportEditor.tsx
|
||||
|
||||
#### 1.1 fillPlaceholder 修复
|
||||
|
||||
```ts
|
||||
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
|
||||
placeholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
|
||||
`;
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.2 新增状态
|
||||
|
||||
```ts
|
||||
const [placeholderModal, setPlaceholderModal] = useState({
|
||||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||||
});
|
||||
const [tableModal, setTableModal] = useState({
|
||||
isOpen: false, rows: '2', cols: '3'
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.3 insertImage 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.4 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.5 autoCaptureFrames 中选择器修改
|
||||
|
||||
将 `setTimeout` 回调内的:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
```
|
||||
改为:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.6 insertFrameToPlaceholder 选择器修改
|
||||
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.7 handleDrop 拦截 manual 模式
|
||||
|
||||
```ts
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
if (placeholder.getAttribute('data-mode') === 'manual') {
|
||||
alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) {
|
||||
fillPlaceholder(placeholder, frame);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.8 JSX 底部新增 Modal
|
||||
|
||||
**Placeholder Insert Modal**(在 `</div>` 关闭之前,与现有 `imagePickerOpen` Modal 并列):
|
||||
|
||||
```tsx
|
||||
{placeholderModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">宽度(px)</label>
|
||||
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">高度(px)</label>
|
||||
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1">占位符类型</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>手术影像占位<br/><span className="text-[10px] opacity-80">(支持自动/拖拽插入)</span></button>
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>静态图片占位<br/><span className="text-[10px] opacity-80">(仅支持点击插入)</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={() => {
|
||||
const w = parseInt(placeholderModal.width) || 200;
|
||||
const h = parseInt(placeholderModal.height) || 200;
|
||||
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
|
||||
const showShortText = w > 0 && w < 80;
|
||||
const text = showShortText ? '插图' : '插入/点击放置图片';
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
styleStr += `width:${w}px;height:${h}px;`;
|
||||
const id = 'ph_' + Date.now();
|
||||
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
execCmd('insertHTML', html);
|
||||
setPlaceholderModal({...placeholderModal, isOpen: false});
|
||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Table Insert Modal**:
|
||||
|
||||
```tsx
|
||||
{tableModal.isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">行数</label>
|
||||
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs mb-1">列数</label>
|
||||
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={() => {
|
||||
const rows = parseInt(tableModal.rows);
|
||||
const cols = parseInt(tableModal.cols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
|
||||
setTableModal({...tableModal, isOpen: false});
|
||||
return;
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModal({...tableModal, isOpen: false});
|
||||
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2. TemplateManage.tsx
|
||||
|
||||
结构与 ReportEditor.tsx 类似,但 `insertImage` 的 Modal 中也需要表格检测逻辑(已在上一轮修改中实现)。
|
||||
|
||||
#### 2.1 新增状态
|
||||
|
||||
```ts
|
||||
const [placeholderModal, setPlaceholderModal] = useState({
|
||||
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
|
||||
});
|
||||
const [tableModal, setTableModal] = useState({
|
||||
isOpen: false, rows: '2', cols: '3'
|
||||
});
|
||||
```
|
||||
|
||||
#### 2.2 insertImage 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.3 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
pushHistory();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.4 JSX 底部新增 Modal
|
||||
|
||||
与 ReportEditor.tsx 的 Modal 结构一致。TemplateManage.tsx 的 `insertImage` Modal 中,确认按钮需要执行表格检测(沿用上一轮修改的逻辑),然后调用 `execCmd('insertHTML', html)` 和 `pushHistory()`。
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| `data-mode="manual"` 的选择器 `:not([data-mode="manual"])` 可能不兼容旧浏览器 | 项目使用 Chrome/Edge,完全支持属性选择器 |
|
||||
| 新增 Modal 与现有 `imagePickerOpen` Modal 的 z-index 冲突 | 两者都使用 `z-50`,在同一时刻不会同时打开 |
|
||||
| TemplateManage.tsx 的 insertImage 中 pushHistory() 调用位置 | 确认按钮中在 `execCmd` 之前调用 `pushHistory()` |
|
||||
| 表格内的 insertImage(上一轮修改)与本次 Modal 的冲突 | 确认按钮中保留表格检测逻辑,在表格内时不使用 Modal 中的宽高值 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 删除新增的状态和 Modal JSX,恢复 `insertImage` 和 `insertTable` 中的 `prompt` 弹窗逻辑。
|
||||
- 恢复 `fillPlaceholder` 到修改前状态。
|
||||
- 恢复 `autoCaptureFrames`、`insertFrameToPlaceholder`、`handleDrop` 中的选择器和拦截逻辑。
|
||||
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 实现方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **拖拽后边框残留**:`ReportEditor.tsx` 中存在两个填充函数:
|
||||
- `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。
|
||||
- `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class,未清除内联样式,导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`。
|
||||
- 同理,`autoCaptureFrames` 的 `setTimeout` 回调中也直接操作 DOM,未清除内联样式。
|
||||
|
||||
2. **原生 `prompt()` 体验差**:`insertTable` 和 `insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。
|
||||
|
||||
3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
- `src/pages/ReportEditor.tsx`
|
||||
- `src/pages/TemplateManage.tsx`
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 一、ReportEditor.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [savedRange, setSavedRange] = useState<Range | null>(null);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 修复 `fillPlaceholder`(F1)
|
||||
|
||||
在 `fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式:
|
||||
|
||||
```typescript
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 3. 修复 `autoCaptureFrames` 中的自动插入(F2)
|
||||
|
||||
在 `setTimeout` 回调内,`classList.add('has-image')` 之后增加:
|
||||
|
||||
```typescript
|
||||
emptyPlaceholder.style.border = 'none';
|
||||
emptyPlaceholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 4. 替换 `insertTable` 为弹窗驱动(F3)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setTableRows('2');
|
||||
setTableCols('3');
|
||||
setTableModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertTable = () => {
|
||||
const rows = parseInt(tableRows);
|
||||
const cols = parseInt(tableCols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
|
||||
setTableModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。
|
||||
|
||||
#### 5. 替换 `insertImage` 为弹窗驱动(F4 / F5)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openImageModal = () => {
|
||||
editorRef.current?.focus();
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
while (node) {
|
||||
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
|
||||
inTable = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertImage = () => {
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
|
||||
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
|
||||
const allowSource = imageModalAllowSource;
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
|
||||
let html: string;
|
||||
if (imageModalInTable) {
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插入图片' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。
|
||||
|
||||
#### 6. 拦截拖拽(F6)
|
||||
|
||||
修改 `handleDrop`:
|
||||
|
||||
```typescript
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) fillPlaceholder(placeholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 7. 拦截点击空占位符(F7)
|
||||
|
||||
修改 `handleEditorClick` 中点击空占位符的分支:
|
||||
|
||||
```typescript
|
||||
if (!placeholder.classList.contains('has-image')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'frame') {
|
||||
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
|
||||
return;
|
||||
}
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. 拦截一键插入关键帧(F8)
|
||||
|
||||
修改 `insertFrameToPlaceholder`:
|
||||
|
||||
```typescript
|
||||
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
|
||||
if (!editorRef.current) {
|
||||
alert('编辑器未准备好');
|
||||
return;
|
||||
}
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (!emptyPlaceholder) {
|
||||
alert('没有可插入图片的空位');
|
||||
return;
|
||||
}
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
|
||||
return;
|
||||
}
|
||||
fillPlaceholder(emptyPlaceholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 9. 拦截自动帧插入(F9)
|
||||
|
||||
在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。
|
||||
|
||||
#### 10. 新增 JSX 弹窗(位于组件底部)
|
||||
|
||||
**Table Modal**:
|
||||
- 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm`
|
||||
- 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl`
|
||||
- 输入:行数、列数(`<input type="number">`)
|
||||
- 按钮:确认(`btn-accent` 样式)、取消
|
||||
|
||||
**Image Placeholder Modal**:
|
||||
- 同样遮罩层和卡片布局
|
||||
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
|
||||
- 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200),提示文字「正文一行文字高度约为 20 像素左右」。
|
||||
- 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
|
||||
- 按钮:确认、取消
|
||||
|
||||
### 二、TemplateManage.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标:
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 替换 `insertTable`
|
||||
|
||||
- `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。
|
||||
|
||||
#### 3. 替换 `insertImage`
|
||||
|
||||
- `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。
|
||||
- HTML 中增加 `data-allow-source` 属性。
|
||||
|
||||
#### 4. 新增 JSX 弹窗
|
||||
|
||||
结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 |
|
||||
| `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 |
|
||||
| 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 |
|
||||
| TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在,ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。
|
||||
- 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。
|
||||
74
工程分析/实现方案-2026-04-18-00-43-19.md
Normal file
74
工程分析/实现方案-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 实现方案 — 2026-04-18-00-43-19
|
||||
|
||||
## 根因分析
|
||||
|
||||
此前对「插入图片占位符」进行了弹窗改造,生成的占位符 HTML 新增了 `data-mode="frame|manual"` 属性,用于区分手术影像占位(允许拖拽/自动插入关键帧)和静态图片占位(仅允许点击上传/签名/素材)。
|
||||
|
||||
但 `defaultContent.ts` 中的默认模板仍使用旧版 `image-placeholder` 结构,**缺少 `data-mode` 属性**。这导致:
|
||||
- 默认模板中的签名、Logo 等静态占位符在新建报告时,可被关键帧拖拽误填充。
|
||||
- `autoCaptureFrames`、`insertFrameToPlaceholder` 等逻辑通过 `:not([data-mode="manual"])` 选择器过滤时,无该属性的占位符会被错误地当作手术影像占位处理。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
- `src/utils/defaultContent.ts`
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 一、医院 Logo 占位符(line 6)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
新结构(仅添加 `data-mode="manual"`,宽高及布局不变):
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:65px;height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 auto;cursor:pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
### 二、表格内术中影像占位符(lines 90/97/104/113/120/127,共 6 处)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
新结构(仅添加 `data-mode="frame"`,宽高及布局不变):
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="frame" style="display:inline-flex;align-items:center;justify-content:center;width:100%;height:150px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;cursor:pointer;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
### 三、手术者签名占位符(line 154)
|
||||
|
||||
原结构:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入图片</span></span>
|
||||
```
|
||||
|
||||
新结构(添加 `data-mode="manual"`,并将提示文本改为「插入/点击放置图片」,因为 width=200px ≥ 80px):
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-flex;align-items:center;justify-content:center;width:200px;height:40px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">插入/点击放置图片</span></span>
|
||||
```
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 修改默认模板后,新建报告的布局发生偏移 | 仅添加 `data-mode` 属性并修改文本,保持 `style` 中 `width/height/margin/display` 等所有布局属性绝对不变。 |
|
||||
| 默认模板中占位符是 `<span>`,而新弹窗在表格内生成 `<div>` | 用户明确要求「只保存当前框的大小不变」,因此不改动标签类型,保持 `<span>` 以避免表格布局被破坏。 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 仅修改单个文件 `src/utils/defaultContent.ts`,回滚时直接还原该文件即可。
|
||||
83
工程分析/实现方案-2026-04-18-16-45-02.md
Normal file
83
工程分析/实现方案-2026-04-18-16-45-02.md
Normal 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. 经验记录文档需持续维护,成为项目知识库的核心资产。
|
||||
56
工程分析/实现方案-2026-04-18-16-55-47.md
Normal file
56
工程分析/实现方案-2026-04-18-16-55-47.md
Normal 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` 中严格一致。
|
||||
82
工程分析/实现方案-2026-04-18-17-27-51.md
Normal file
82
工程分析/实现方案-2026-04-18-17-27-51.md
Normal 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,不加粗
|
||||
- 姓名、性别、年龄、科别、床号、住院号,用 ` ` 间隔
|
||||
|
||||
#### 诊断/手术信息(单行加粗)
|
||||
每项独立 `<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. ` ` 分隔的基本信息栏在打印时可能换行,需测试实际打印效果。
|
||||
100
工程分析/实现方案-2026-04-18-17-48-59.md
Normal file
100
工程分析/实现方案-2026-04-18-17-48-59.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 实现方案 —— 2026-04-18-17-48-59
|
||||
|
||||
## 方案目标
|
||||
修复默认模板排版细节和打印样式问题,提升报告的视觉一致性和打印输出质量。
|
||||
|
||||
## 需求 1:缩减基本信息栏字段间空格
|
||||
|
||||
### 修改文件
|
||||
`src/utils/defaultContent.ts`
|
||||
|
||||
### 修改内容
|
||||
将基本信息栏 `<p>` 中字段之间的 ` ` 替换为单个 ` `。
|
||||
|
||||
**修改前**:
|
||||
```html
|
||||
姓名:${smartField('patientName')}
|
||||
性别:${smartField('patientGender')}
|
||||
年龄:${smartField('patientAge')}
|
||||
科别:${smartField('department')}
|
||||
床号:${smartField('bedNumber')}
|
||||
住院号:${smartField('hospitalId')}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```html
|
||||
姓名:${smartField('patientName')}
|
||||
性别:${smartField('patientGender')}
|
||||
年龄:${smartField('patientAge')}
|
||||
科别:${smartField('department')}
|
||||
床号:${smartField('bedNumber')}
|
||||
住院号:${smartField('hospitalId')}
|
||||
```
|
||||
|
||||
## 需求 2:Logo 与医院名/标题靠拢并整体居中
|
||||
|
||||
### 修改文件
|
||||
`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 中)不会自动更新。这是预期行为。
|
||||
91
工程分析/实现方案-2026-04-18-18-08-37.md
Normal file
91
工程分析/实现方案-2026-04-18-18-08-37.md
Normal 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 对应 12pt,4 对应 14pt,5 对应 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` 在签名行可能导致超长内容溢出,但考虑到签名行通常较短,风险可控。
|
||||
91
工程分析/实现方案-2026-04-18-18-36-43.md
Normal file
91
工程分析/实现方案-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 实现方案 —— 2026-04-18-18-36-43
|
||||
|
||||
## 方案目标
|
||||
实现五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
|
||||
|
||||
## 需求 1:ReportManage 列名修正
|
||||
|
||||
### 修改文件
|
||||
`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; }`
|
||||
|
||||
## 需求 3:ReportEditor / 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 增加下划线 checkbox;insertSmartField 注入 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 报告),需分别处理。
|
||||
97
工程分析/实现方案-2026-04-18-19-08-43.md
Normal file
97
工程分析/实现方案-2026-04-18-19-08-43.md
Normal 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;"> </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)和正文中的显示是否正常。
|
||||
82
工程分析/实现方案-2026-04-18-19-23-31.md
Normal file
82
工程分析/实现方案-2026-04-18-19-23-31.md
Normal 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';
|
||||
```
|
||||
|
||||
## 需求 3:Logo 框大小保持 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` 规则覆盖。
|
||||
101
工程分析/实现方案-2026-04-18-19-37-56.md
Normal file
101
工程分析/实现方案-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 实现方案 —— 2026-04-18-19-37-56
|
||||
|
||||
## 方案目标
|
||||
修复编辑器中的 4 个体验问题,提升视频面板、图片占位符和对齐功能的稳定性。
|
||||
|
||||
## 需求 1:视频上传按钮位置调整
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`
|
||||
|
||||
### 修改内容
|
||||
在「视频分析」面板的缩略图滚动容器中,将 `<button>上传视频</button>` 从 `videos.map()` 之前移至之后。保持按钮样式和点击逻辑不变。
|
||||
|
||||
## 需求 2:图片占位符提示文字绝对居中
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`、`src/utils/defaultContent.ts`
|
||||
|
||||
### 修改内容
|
||||
将 `.placeholder-text` 的样式改为绝对定位居中:
|
||||
```css
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
width: 100%;
|
||||
```
|
||||
|
||||
需要确保 `.image-placeholder` 父容器带有 `position: relative;`(默认模板和运行时插入逻辑中已具备)。
|
||||
|
||||
修改位置:
|
||||
1. `defaultContent.ts` 中 8 个占位符的 `.placeholder-text` style
|
||||
2. `ReportEditor.tsx` 中 `placeholderModal` 确认插入时的 `.placeholder-text` style
|
||||
3. `TemplateManage.tsx` 中 `placeholderModal` 确认插入时的 `.placeholder-text` style
|
||||
4. `ReportEditor.tsx` 和 `TemplateManage.tsx` 中 `handleEditorClick` 删除图片后重建 `.placeholder-text` 的 innerHTML
|
||||
|
||||
## 需求 3:删除图片后占位符恢复原始大小
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
在 `handleEditorClick` 中处理 `.delete-btn` 点击、恢复占位符为空的逻辑中,增加尺寸恢复:
|
||||
```ts
|
||||
const mw = placeholder.style.maxWidth;
|
||||
const mh = placeholder.style.maxHeight;
|
||||
if (mw) placeholder.style.width = mw;
|
||||
if (mh) {
|
||||
placeholder.style.height = mh;
|
||||
placeholder.style.lineHeight = mh;
|
||||
}
|
||||
placeholder.style.textAlign = 'center';
|
||||
```
|
||||
|
||||
同时需要恢复其他被修改的样式:
|
||||
- `border: 1px dashed #cbd5e1`
|
||||
- `background: #f8fafc`
|
||||
- `vertical-align: middle`(inline-block 占位符)
|
||||
- `justify-content: center; align-items: center`(flex 占位符)
|
||||
|
||||
由于无法直接区分 flex 和 inline-block,可以通过检查 `placeholder.style.display` 或简单地将 `justifyContent` 和 `alignItems` 重置为 `center`(对 inline-block 无影响)。
|
||||
|
||||
## 需求 4:对齐按钮改用安全的 DOM 操作
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
1. **新增 `changeAlignment` 方法**:
|
||||
```ts
|
||||
const changeAlignment = (align: 'left' | 'center' | 'right' | 'justify') => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
let node = sel.getRangeAt(0).commonAncestorContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode as Node;
|
||||
const block = (node as HTMLElement).closest('p, div, td, h1, h2, h3, h4, h5, h6, li');
|
||||
if (block) {
|
||||
(block as HTMLElement).style.textAlign = align;
|
||||
if (editorRef.current) {
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage(); // ReportEditor
|
||||
// saveTemplateContent(); // TemplateManage
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **替换工具栏按钮**:将三个对齐按钮的 `onClick={() => execCmd('justifyLeft')}` 等替换为 `onClick={() => changeAlignment('left')}` 等。保留 `onMouseDown={(e) => e.preventDefault()}` 以防止编辑器失焦。
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/ReportEditor.tsx` | 视频按钮位置;placeholder-text 样式(3 处:插入、删除恢复、Modal);删除恢复时尺寸复原;新增 changeAlignment;替换对齐按钮 |
|
||||
| `src/pages/TemplateManage.tsx` | placeholder-text 样式(3 处);删除恢复时尺寸复原;新增 changeAlignment;替换对齐按钮 |
|
||||
| `src/utils/defaultContent.ts` | 8 个占位符的 placeholder-text 样式更新为绝对居中 |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `changeAlignment` 中 `closest('p, div, ...')` 如果选中了编辑器根容器(`contenteditable` div),可能会对齐整个文档。但由于工具栏按钮要求编辑器已聚焦,通常选区在正文内部,风险较低。
|
||||
2. 占位符删除恢复时,`maxWidth`/`maxHeight` 的回退逻辑需确保在所有场景下(默认模板、运行时插入)都能正确读取。
|
||||
3. 绝对居中的 `position:absolute` 需要父容器 `position:relative`,需验证所有占位符均满足。
|
||||
130
工程分析/实现方案-2026-04-18-20-03-44.md
Normal file
130
工程分析/实现方案-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 实现方案 —— 2026-04-18-20-03-44
|
||||
|
||||
## 方案目标
|
||||
实现模板的导入/导出迁移能力,统一默认模板 Logo 的交互行为。
|
||||
|
||||
## 需求 1:模板导出功能
|
||||
|
||||
### 修改文件
|
||||
`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
在模板列表的每个模板行操作列中增加「导出」按钮(使用 Download 图标)。点击时:
|
||||
```ts
|
||||
const handleExportTemplate = (template: Template) => {
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
type: 'surclaw_template_package',
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
fields: template.fields || []
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `模板导出-${template.title}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
```
|
||||
|
||||
## 需求 2:模板导入功能
|
||||
|
||||
### 修改文件
|
||||
`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
1. **新增状态**:
|
||||
```ts
|
||||
const [importedContent, setImportedContent] = useState<{content: string, fields: FormField[]} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
```
|
||||
|
||||
2. **新增导入处理函数**:
|
||||
```ts
|
||||
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const json = JSON.parse(event.target?.result as string);
|
||||
if (json.type !== 'surclaw_template_package') {
|
||||
alert('无效的模板包文件');
|
||||
return;
|
||||
}
|
||||
setNewTemplateTitle(json.title || '');
|
||||
setNewTemplateDescription(json.description || '');
|
||||
setImportedContent({
|
||||
content: json.content || '',
|
||||
fields: Array.isArray(json.fields) ? json.fields : []
|
||||
});
|
||||
} catch {
|
||||
alert('文件解析失败,请检查 JSON 格式');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
```
|
||||
|
||||
3. **修改创建逻辑**:在 `handleCreateTemplate` 中,如果有 `importedContent`,优先使用导入的内容和字段:
|
||||
```ts
|
||||
const newTemplate: Template = {
|
||||
id: 'tmpl_' + Date.now(),
|
||||
title: newTemplateTitle,
|
||||
description: newTemplateDescription,
|
||||
content: importedContent?.content || `<div style="font-size:12pt;line-height:1.5;"><p>请输入模板内容...</p></div>`,
|
||||
fields: importedContent?.fields || [],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
```
|
||||
|
||||
4. **UI 调整**:在新增模板 Modal 中标题下方加入导入区域:
|
||||
```tsx
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||
<div className="text-xs text-text-muted flex-1">已有模板文件?点击右侧图标导入</div>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImportFile} />
|
||||
</div>
|
||||
```
|
||||
|
||||
5. **关闭 Modal 时重置**:`setImportedContent(null)`
|
||||
|
||||
## 需求 3:Logo 替换为可交互占位符
|
||||
|
||||
### 修改文件
|
||||
`src/utils/defaultContent.ts`
|
||||
|
||||
### 修改内容
|
||||
将默认模板顶部的 Logo HTML 替换为标准 `image-placeholder`:
|
||||
```html
|
||||
<span class="image-placeholder" data-placeholder="true" contenteditable="false" data-mode="manual" style="display:inline-block;text-align:center;width:65px;height:65px;line-height:65px;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;position:relative;">
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">LOGO</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
关键点:
|
||||
- `class="image-placeholder"`:触发编辑器中的占位符交互逻辑
|
||||
- `data-mode="manual"`:标记为静态图片占位,不支持自动帧插入
|
||||
- `position:relative` + `position:absolute` 居中:确保提示文字绝对居中
|
||||
- `delete-btn`:支持点击右上方的「×」删除
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/TemplateManage.tsx` | 新增 `handleExportTemplate`;新增 `importedContent` 状态和 `handleImportFile`;修改 `handleCreateTemplate` 使用导入数据;新增模板 Modal 中增加导入 UI;模板列表操作列增加导出按钮 |
|
||||
| `src/utils/defaultContent.ts` | 顶部 Logo 替换为标准 `image-placeholder` |
|
||||
|
||||
## 风险与注意事项
|
||||
1. 导入的 JSON 中 `fields` 数组需要与 `FormField` 类型结构兼容。由于 JSON 导入的是纯数据,直接赋值给 `template.fields` 即可(TypeScript 编译时类型校验通过)。
|
||||
2. 导出文件名中包含模板标题,需注意标题中的特殊字符可能影响文件名(但浏览器通常会自动处理)。
|
||||
3. Logo 占位符替换后,原有「西安交通大学第一附属医院」的样式应保持不变,仅替换 Logo 部分。
|
||||
4. 新增模板弹窗关闭时,需同步重置 `importedContent` 为 `null`,避免影响下一次创建。
|
||||
66
工程分析/实现方案-2026-04-18-22-59-10.md
Normal file
66
工程分析/实现方案-2026-04-18-22-59-10.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 实现方案 —— 2026-04-18-22-59-10
|
||||
|
||||
## 方案目标
|
||||
将字段下划线默认行为改为「默认不显示」,修复占位符提示文字居中问题。
|
||||
|
||||
## 需求 1:所有字段默认打印时不显示下划线
|
||||
|
||||
### 修改文件 1:`src/pages/TemplateManage.tsx`
|
||||
|
||||
1. **新增字段默认状态**:
|
||||
```ts
|
||||
const [newFieldHasUnderline, setNewFieldHasUnderline] = useState(false);
|
||||
```
|
||||
|
||||
2. **编辑字段回显默认值**:在 `startEditField` 或等效函数中:
|
||||
```ts
|
||||
setEditFieldHasUnderline(field.hasUnderline ?? false);
|
||||
```
|
||||
|
||||
3. **插入字段类名判断**:在 `insertSmartField` 中:
|
||||
```ts
|
||||
const underlineClass = field.hasUnderline !== true ? ' no-underline' : '';
|
||||
```
|
||||
|
||||
### 修改文件 2:`src/utils/defaultContent.ts`
|
||||
|
||||
移除 `noUnderlineKeys` 数组,直接在 `smartField()` 中给所有字段加 `.no-underline`:
|
||||
```ts
|
||||
const smartField = (key: string) => {
|
||||
return `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value no-underline" data-bind="${key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
};
|
||||
```
|
||||
|
||||
## 需求 2:修复占位符文字偏左
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`、`src/pages/TemplateManage.tsx`、`src/utils/defaultContent.ts`
|
||||
|
||||
### 修改内容
|
||||
在所有 `.placeholder-text` 的 `style` 属性中追加 `text-align:center;`。
|
||||
|
||||
需要修改的位置:
|
||||
1. `defaultContent.ts`:Logo 占位符 + 6 个表格占位符 + 签名占位符
|
||||
2. `ReportEditor.tsx`:
|
||||
- `handleEditorClick` 删除恢复逻辑中的 `.placeholder-text`
|
||||
- `placeholderModal` 确认插入时的 `.placeholder-text`(table 内 + inline-block)
|
||||
3. `TemplateManage.tsx`:
|
||||
- `handleEditorClick` 删除恢复逻辑中的 `.placeholder-text`
|
||||
- `placeholderModal` 确认插入时的 `.placeholder-text`(table 内 + inline-block)
|
||||
|
||||
统一的新样式:
|
||||
```
|
||||
color:#94a3b8;font-size:11px;pointer-events:none;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);display:block;width:100%;text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
|
||||
```
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/TemplateManage.tsx` | `newFieldHasUnderline` 默认 `false`;编辑回显默认 `false`;`insertSmartField` 判断逻辑;placeholder-text 样式 |
|
||||
| `src/utils/defaultContent.ts` | `smartField()` 直接加 `.no-underline`;所有 placeholder-text 加 `text-align:center` |
|
||||
| `src/pages/ReportEditor.tsx` | 所有 placeholder-text 加 `text-align:center` |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `smartField()` 中移除 `noUnderlineKeys` 后,所有默认模板字段将统一无下划线。此前通过 `hasUnderline` 配置自定义下划线的机制仍然保留(`field.hasUnderline === true` 时不加 `.no-underline`),只是默认值变为 `false`。
|
||||
2. `text-align:center` 追加时需注意不破坏已有的其他样式属性顺序。
|
||||
3. 批量替换 `placeholder-text` 样式时,应使用精确的字符串匹配,避免误伤其他元素。
|
||||
90
工程分析/实现方案-2026-04-18-23-19-44.md
Normal file
90
工程分析/实现方案-2026-04-18-23-19-44.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 实现方案 —— 2026-04-18-23-19-44
|
||||
|
||||
## 方案目标
|
||||
修复排版对齐问题,优化导出文件名,实现模板批量操作。
|
||||
|
||||
## 需求 1:修复 field-value 输入内容往上飘
|
||||
|
||||
### 修改文件
|
||||
`src/utils/defaultContent.ts`、`src/utils/print.ts`
|
||||
|
||||
### 修改内容
|
||||
- `defaultContent.ts` 中 `smartField()`:
|
||||
- `vertical-align:text-bottom` → `vertical-align:baseline`
|
||||
- `line-height:1.2;min-height:1.2em;` → `line-height:inherit;`
|
||||
- `print.ts` 中 `.field-value` 打印样式同步修改 `vertical-align:baseline; line-height:inherit;`
|
||||
- 打印时下划线 `padding-bottom` 改为 `1px` 以紧贴文字
|
||||
|
||||
## 需求 2、3、4:微调排版间距和 Logo 位置
|
||||
|
||||
### 修改文件
|
||||
`src/utils/defaultContent.ts`
|
||||
|
||||
### 修改内容
|
||||
- 姓名栏横线:`padding-bottom: 1px;`(原来是 `padding: 0 0 1px 0`,可能需要调整)
|
||||
- 手术记录标题:`margin-top: 2px;`(原来是 `margin-bottom: 8px` 等,需要精确调整)
|
||||
- Logo:使用 `position:absolute` 向左上偏移 5px,或调整父容器 `gap`/`margin`
|
||||
|
||||
## 需求 5:导出 PDF 文件名修正
|
||||
|
||||
### 修改文件
|
||||
`src/utils/print.ts`
|
||||
|
||||
### 修改内容
|
||||
在 `printDocument` 函数中:
|
||||
1. 保存原始 `document.title`
|
||||
2. 设置 `document.title = docTitle`
|
||||
3. 打印完成后恢复 `document.title = originalTitle`
|
||||
|
||||
这样浏览器在 `window.print()` 时会使用正确的文件名。
|
||||
|
||||
## 需求 6:导出 JSON 时间使用北京时间
|
||||
|
||||
### 修改文件
|
||||
`src/pages/ReportEditor.tsx`、`src/pages/ReportManage.tsx`、`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
定义一个全局格式化函数 `getBeijingTimeStr()`:
|
||||
```ts
|
||||
const getBeijingTimeStr = () => {
|
||||
const d = new Date();
|
||||
const bjTime = new Date(d.getTime() + (8 * 60 * 60 * 1000));
|
||||
return bjTime.toISOString().replace(/T/, '-').replace(/:/g, '-').slice(0, 16);
|
||||
};
|
||||
```
|
||||
|
||||
替换所有 `new Date().toISOString().replace(/[:.]/g, '-')` 的调用。
|
||||
|
||||
## 需求 7:模板管理批量操作
|
||||
|
||||
### 修改文件
|
||||
`src/pages/TemplateManage.tsx`
|
||||
|
||||
### 修改内容
|
||||
1. **新增状态**:`const [selectedIds, setSelectedIds] = useState<string[]>([]);`
|
||||
2. **批量删除**:`handleBatchDelete()` 过滤掉选中 ID,清空 `selectedIds`
|
||||
3. **批量导出**:`handleBatchExport()` 将选中模板打包为 JSON 数组下载
|
||||
4. **UI 调整**:
|
||||
- 模板列表每行前增加复选框
|
||||
- 当有选中项时,显示批量操作工具栏(批量删除 + 批量导出)
|
||||
5. **允许空列表**:移除 `templates.length > 1` 对删除按钮的限制(改为只在批量删除时确认)
|
||||
|
||||
### 冲突检查
|
||||
- 现有 `handleDeleteTemplate` 单个删除逻辑可复用
|
||||
- `Login.tsx` 中的默认模板初始化逻辑需要检查:如果用户删除了所有模板,系统是否会在登录时强制创建默认模板
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/utils/defaultContent.ts` | smartField 基线对齐;姓名栏间距;手术记录间距;Logo 位置 |
|
||||
| `src/utils/print.ts` | field-value 打印样式;document.title 动态设置 |
|
||||
| `src/pages/ReportEditor.tsx` | 导出文件名使用北京时间 |
|
||||
| `src/pages/ReportManage.tsx` | 导出文件名使用北京时间 |
|
||||
| `src/pages/TemplateManage.tsx` | 导出文件名使用北京时间;批量操作状态和 UI |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `vertical-align:baseline` 后,需要验证不同字号混合时(如 11pt 正文 + 12pt 字段)的对齐效果。
|
||||
2. Logo 使用 `position:absolute` 时需要确保父容器有 `position:relative`,且不会遮挡其他元素。
|
||||
3. 修改 `document.title` 后需确保在打印失败或用户取消时也能恢复。
|
||||
4. 批量删除后如果 `currentTemplateId` 被删除,需要重置为 `null` 或自动选中其他模板。
|
||||
5. 北京时间计算 `new Date(d.getTime() + (8 * 60 * 60 * 1000))` 在夏令时转换时可能有 1 小时偏差,但中国大陆不使用夏令时,所以安全。
|
||||
108
工程分析/实现方案-2026-04-18-23-39-35.md
Normal file
108
工程分析/实现方案-2026-04-18-23-39-35.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 实现方案 —— 2026-04-18-23-39-35
|
||||
|
||||
## 方案目标
|
||||
修复下划线功能、统一导出文件名、缩紧输入框间距、实现表单逆向联动。
|
||||
|
||||
## 需求 1:修复下划线勾选状态异常及打印失效
|
||||
|
||||
### 修改文件 1:`src/types.ts`
|
||||
在 `DEFAULT_FORM_FIELDS` 数组中,为所有字段显式设置 `hasUnderline: false`(如果当前为 `true` 或未指定)。
|
||||
|
||||
### 修改文件 2:`src/pages/TemplateManage.tsx`
|
||||
在编辑字段的回显逻辑中:
|
||||
```ts
|
||||
setEditFieldHasUnderline(field.hasUnderline === true);
|
||||
```
|
||||
确保 `undefined` 时默认不勾选。
|
||||
|
||||
### 修改文件 3:`src/utils/print.ts`
|
||||
恢复默认显示下划线的白名单机制:
|
||||
```css
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value {
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 2px 0px 2px !important;
|
||||
}
|
||||
.smart-field-wrapper .field-value.no-underline {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 需求 2:统一 PDF 和 JSON 导出文件名
|
||||
|
||||
### 修改文件:`src/utils/print.ts`
|
||||
确保 `printDocument` 中:
|
||||
1. 保存原始 `document.title`
|
||||
2. 设置 `document.title = docTitle`
|
||||
3. iframe HTML 中也写入 `<title>${docTitle}</title>`
|
||||
4. 打印完成后恢复 `document.title`
|
||||
|
||||
同时检查 `ReportEditor.tsx` 和 `ReportManage.tsx` 中调用 `printDocument` 时传入的 `docTitle` 是否与 JSON 文件名一致。
|
||||
|
||||
## 需求 3:缩紧 field-value 内文字间距
|
||||
|
||||
### 修改文件 1:`src/utils/defaultContent.ts`
|
||||
```ts
|
||||
// padding:0 4px → padding:0 2px
|
||||
// margin:0 2px → margin:0
|
||||
// min-width:32px → min-width:24px
|
||||
// 增加 text-align:center 让文字居中
|
||||
```
|
||||
|
||||
### 修改文件 2:`src/utils/print.ts`
|
||||
同步修改打印样式中的 `.field-value`:
|
||||
```css
|
||||
.smart-field-wrapper .field-value {
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 需求 4:ReportEditor 表单逆向联动
|
||||
|
||||
### 修改文件:`src/pages/ReportEditor.tsx`
|
||||
|
||||
1. **新增 useEffect 监听 activeFieldKey**:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||
allFields.forEach(el => {
|
||||
(el as HTMLElement).style.backgroundColor = '#f8fafc';
|
||||
(el as HTMLElement).style.boxShadow = 'none';
|
||||
});
|
||||
if (activeFieldKey) {
|
||||
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="${activeFieldKey}"]`) as HTMLElement;
|
||||
if (targetEl) {
|
||||
targetEl.style.backgroundColor = '#eff6ff';
|
||||
targetEl.style.boxShadow = '0 0 0 2px #3b82f6';
|
||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [activeFieldKey]);
|
||||
```
|
||||
|
||||
2. **右侧表单添加 onFocus/onClick**:
|
||||
在右侧表单字段容器的 `onClick` 中增加 `setActiveFieldKey(field.key)`,在 input/select 的 `onFocus` 中也增加 `setActiveFieldKey(field.key)`。
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/types.ts` | DEFAULT_FORM_FIELDS 中 hasUnderline 设为 false |
|
||||
| `src/pages/TemplateManage.tsx` | 编辑字段回显逻辑 |
|
||||
| `src/utils/print.ts` | 打印下划线白名单机制;document.title 设置;field-value 间距 |
|
||||
| `src/utils/defaultContent.ts` | smartField padding/margin 缩小;text-align:center |
|
||||
| `src/pages/ReportEditor.tsx` | activeFieldKey useEffect 高亮滚动;表单 onFocus 联动 |
|
||||
| `src/pages/ReportManage.tsx` | 检查导出文件名一致性 |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `DEFAULT_FORM_FIELDS` 修改后,现有用户的 localStorage 中已保存的字段配置不会自动更新,需要手动编辑或清除 `formFieldsConfig` 才能看到效果。
|
||||
2. `activeFieldKey` 的 useEffect 直接操作 DOM style,需要确保在组件卸载或切换 tab 时清除高亮。
|
||||
3. 缩小 padding/margin 后,需要验证在表格单元格(td)内的显示是否正常。
|
||||
4. 打印样式中 `.field-value.no-underline` 的优先级必须高于基础 `.field-value` 规则。
|
||||
65
工程分析/实现方案-2026-04-19-00-01-50.md
Normal file
65
工程分析/实现方案-2026-04-19-00-01-50.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 实现方案 —— 2026-04-19-00-01-50
|
||||
|
||||
## 方案目标
|
||||
修复高亮样式、实现点击空白取消、阻断打印高亮、同步字段下划线配置到已插入的 DOM。
|
||||
|
||||
## 需求 1 & 2:优化高亮样式、点击空白取消、阻断打印
|
||||
|
||||
### 修改文件 1:`src/pages/ReportEditor.tsx`
|
||||
|
||||
1. **点击空白取消高亮**:在 `handleEditorClick` 中,如果点击目标不是 `.field-value`,则设置 `setActiveFieldKey(null)`。
|
||||
|
||||
2. **柔和高亮样式**:修改 `activeFieldKey` 的 `useEffect`:
|
||||
- 清除样式时:恢复为 `''`(空字符串)而非硬编码颜色,让 CSS 类重新接管
|
||||
- 高亮时:`backgroundColor: '#f1f5f9'`(浅灰)、`outline: '1px solid #94a3b8'`(细灰边框)、`outlineOffset: '1px'`
|
||||
- 不再使用 `box-shadow`
|
||||
|
||||
### 修改文件 2:`src/utils/print.ts`
|
||||
|
||||
在 `@media print` 中强制抹除 `outline` 和 `box-shadow`:
|
||||
```css
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 2px 1px 2px !important;
|
||||
}
|
||||
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
## 需求 3:修复下划线勾选无效
|
||||
|
||||
### 修改文件:`src/pages/TemplateManage.tsx`
|
||||
|
||||
在 `saveFieldEdit` 函数中,保存字段配置后,扫描编辑器中所有 `data-bind` 匹配的 `.field-value`,根据新的 `hasUnderline` 值动态添加/移除 `.no-underline` 类:
|
||||
```ts
|
||||
if (editorRef.current) {
|
||||
const els = editorRef.current.querySelectorAll(`.field-value[data-bind="${editingFieldId}"]`);
|
||||
els.forEach(el => {
|
||||
if (editFieldHasUnderline) {
|
||||
el.classList.remove('no-underline');
|
||||
} else {
|
||||
el.classList.add('no-underline');
|
||||
}
|
||||
});
|
||||
saveTemplateContent();
|
||||
}
|
||||
```
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/ReportEditor.tsx` | handleEditorClick 点击空白取消高亮;useEffect 柔和高亮样式 |
|
||||
| `src/utils/print.ts` | @media print 强制抹除 outline/box-shadow |
|
||||
| `src/pages/TemplateManage.tsx` | saveFieldEdit 同步更新已插入字段的 classList |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `handleEditorClick` 中增加 `setActiveFieldKey(null)` 时,需确保不会影响 `.image-placeholder` 的点击处理逻辑(placeholder 点击在 field-value 判断之后)。
|
||||
2. `useEffect` 中清除样式时使用 `style.backgroundColor = ''` 而非 `= '#f8fafc'`,这样可以让元素的 CSS 类样式重新生效,避免硬编码颜色与 CSS 类冲突。
|
||||
3. `saveFieldEdit` 中扫描 DOM 并修改 classList 后,必须调用 `saveTemplateContent()` 将变更持久化到 localStorage。
|
||||
4. 打印样式中 `outline: none !important` 和 `box-shadow: none !important` 的优先级需确保高于任何内联样式。
|
||||
37
工程分析/实现方案-2026-04-19-00-13-20.md
Normal file
37
工程分析/实现方案-2026-04-19-00-13-20.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 实现方案 —— 2026-04-19-00-13-20
|
||||
|
||||
## 方案目标
|
||||
使打印/PDF导出时 `.field-value` 的下划线紧贴文字底部。
|
||||
|
||||
## 修改点
|
||||
|
||||
### 修改文件:`src/utils/print.ts`
|
||||
|
||||
在 `@media print` 的 `.smart-field-wrapper .field-value` 样式中增加 `line-height: 1 !important;`。
|
||||
|
||||
**原因**:即使 `padding-bottom` 已设为 `0px`,父级文档的 `line-height: 1.5` 仍会在文字下方保留不可见的行高留白。通过强制压缩行高到 `1`,可以消除底部留白,使 `border-bottom` 紧贴文字。
|
||||
|
||||
```css
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid #000 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 2px 0px 2px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/utils/print.ts` | @media print 中 .field-value 增加 line-height: 1 !important |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `line-height: 1` 会显著压缩行高,但由于 `.field-value` 在打印时已经是 `inline-block` 且独立显示,不会影响周围段落的整体行距。
|
||||
2. `!important` 确保优先级高于任何内联样式。
|
||||
41
工程分析/实现方案-2026-04-19-00-24-02.md
Normal file
41
工程分析/实现方案-2026-04-19-00-24-02.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 实现方案 —— 2026-04-19-00-24-02
|
||||
|
||||
## 方案目标
|
||||
完善 Dashboard 数据概览:新增全部报告卡片、修复图表重叠、增加时间维度切换。
|
||||
|
||||
## 需求 1:新增"全部报告总数"卡片
|
||||
|
||||
### 修改文件:`src/pages/Dashboard.tsx`
|
||||
|
||||
1. **扩展 stats 结构**:增加 `totalCount` 字段,表示全部报告总数(原 `reportCount` 改为仅统计本月)。
|
||||
2. **更新计算逻辑**:在 useEffect 中计算 `userReports.length` 作为 `totalCount`,原 `reportCount` 保留为当月数量。
|
||||
3. **调整卡片布局**:将原来的 4 卡片网格改为包含 5 个统计项,或保持 4 卡片但替换/调整内容。根据用户要求,在"本月报告总数"左侧插入"全部报告总数",并将网格列数从 4 改为 5(`lg:grid-cols-5`)或保持 4 列但替换其中一个卡片。
|
||||
|
||||
## 需求 2:修复图表日期文字与 X 轴重叠
|
||||
|
||||
### 修改文件:`src/pages/Dashboard.tsx`
|
||||
|
||||
1. **增大底部留白**:将 Canvas 的 `padding` 或图表绘制区域的高度计算中加入更大的底部偏移(如 `bottomPadding = 30` 而非原来的 10)。
|
||||
2. **调整文字 Y 坐标**:将 `ctx.fillText(label, x, h - 10)` 改为 `ctx.fillText(label, x, h - 5)` 或更下方,确保文字不会与 X 轴线(通常在 `h - padding` 位置绘制)重叠。
|
||||
3. **调整字体大小**:30 天模式下缩小字体到 9px,避免文字过密。
|
||||
|
||||
## 需求 3:时间维度切换
|
||||
|
||||
### 修改文件:`src/pages/Dashboard.tsx`
|
||||
|
||||
1. **增加状态**:`const [timeRange, setTimeRange] = useState<'7days' | '1month'>('7days');`
|
||||
2. **响应式计算**:将 `useEffect` 的依赖数组增加 `timeRange`,当切换时重新计算 `trend` 和 `trendLabels`。
|
||||
3. **标签格式化**:
|
||||
- 7 天模式:显示 `MM-DD`(如 04-13)
|
||||
- 30 天模式:显示 `DD`(如 13),避免过密
|
||||
4. **UI 控件**:在图表标题右侧增加切换按钮组(最近 7 天 / 最近 30 天)。
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/Dashboard.tsx` | stats 结构扩展、totalCount 计算、卡片布局调整、timeRange 状态、趋势数据响应式计算、Canvas 绘制坐标修复、时间切换 UI |
|
||||
|
||||
## 风险与注意事项
|
||||
1. 原代码中 `reportCount` 可能表示的是全部报告数,需要确认其原意。如果原意是全部报告数,则需要新增 `monthCount` 而非修改 `reportCount`。根据用户方案,将 `reportCount` 改为当月数,`totalCount` 为全部数。
|
||||
2. Canvas 绘制中 `padding`、`chartH` 的计算需要同步调整,确保数据线不会画到文字区域。
|
||||
3. 30 天模式下数据点密集,需要考虑是否跳点显示标签(如只显示奇数天)。
|
||||
34
工程分析/实现方案-2026-04-19-00-33-44.md
Normal file
34
工程分析/实现方案-2026-04-19-00-33-44.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 实现方案 —— 2026-04-19-00-33-44
|
||||
|
||||
## 方案目标
|
||||
解决 30 天趋势图表过密问题,通过稀疏化标签 + Tooltip 提升可读性。
|
||||
|
||||
## 修改点
|
||||
|
||||
### 修改文件:`src/pages/Dashboard.tsx`
|
||||
|
||||
1. **条件渲染数据点和数值**:在 SVG 的 `points.map()` 中,对圆点和数值文本增加条件判断:
|
||||
- 7 天模式:正常显示圆点和数值
|
||||
- 30 天模式:不显示圆点和数值文本(仅保留折线和面积图)
|
||||
|
||||
2. **稀疏化 X 轴标签**:
|
||||
- 7 天模式:每天显示标签
|
||||
- 30 天模式:每隔 5 天显示一次标签(`i % 5 === 0`)
|
||||
|
||||
3. **SVG 鼠标事件与 Tooltip**:
|
||||
- 在图表容器上绑定 `onMouseMove` 和 `onMouseLeave`
|
||||
- 计算鼠标在 SVG 坐标系中的相对位置,映射到最近的数据点索引
|
||||
- 用 React state 管理 `tooltipData: { index, x, y, visible }`
|
||||
- 用绝对定位的 div 渲染 Tooltip,显示日期和数值
|
||||
|
||||
4. **透明捕获层**:在 SVG 中增加一个覆盖整个图表区域的透明 `<rect>`,确保鼠标在空白区域也能触发事件。
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/Dashboard.tsx` | 条件渲染 circle/text、标签稀疏化、Tooltip state、SVG 鼠标事件、透明捕获层 |
|
||||
|
||||
## 风险与注意事项
|
||||
1. Tooltip 坐标计算需考虑 SVG 的 viewBox 到屏幕像素的映射比例。
|
||||
2. 鼠标移出 SVG 区域时必须隐藏 Tooltip。
|
||||
3. 7 天模式的显示效果必须保持完全不变。
|
||||
42
工程分析/实现方案-2026-04-19-01-03-37.md
Normal file
42
工程分析/实现方案-2026-04-19-01-03-37.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 实现方案 —— 2026-04-19-01-03-37
|
||||
|
||||
## 方案目标
|
||||
调整默认自动插入帧索引为间隔抽取、模板导出文件名加时间戳、修复占位符删除恢复居中。
|
||||
|
||||
## 需求 1:默认 autoInsertFrameIndices 改为 [0,2,4,6,8,10]
|
||||
|
||||
### 修改文件 1:`src/pages/Login.tsx`
|
||||
在 `initData()` 的 `defaultSettings` 中,将 `autoInsertFrameIndices` 从 `[0, 1, 2, 3, 4, 5]` 改为 `[0, 2, 4, 6, 8, 10]`。frameCount 保持 12,framePositions 保持原有均匀分布逻辑。
|
||||
|
||||
### 修改文件 2:`src/pages/SystemSettings.tsx`
|
||||
在 `resetToDefault` 函数中,补全缺失的 `autoInsertFrames`、`autoInsertDelay`、`autoInsertFrameIndices` 字段,并将 `autoInsertFrameIndices` 设为 `[0, 2, 4, 6, 8, 10]`。
|
||||
|
||||
## 需求 2:模板导出 JSON 文件名加时间戳
|
||||
|
||||
### 修改文件:`src/pages/TemplateManage.tsx`
|
||||
在 `handleExportTemplate` 中,生成下载前追加北京时间戳:
|
||||
```ts
|
||||
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
a.download = `模板导出-${template.name}-${ts}.json`;
|
||||
```
|
||||
|
||||
## 需求 3:TemplateManage 占位符删除恢复居中
|
||||
|
||||
### 修改文件:`src/pages/TemplateManage.tsx`
|
||||
在 `handleEditorClick` 的删除恢复分支中,补齐与 ReportEditor 一致的逻辑:
|
||||
1. `.placeholder-text` 使用 `position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;`
|
||||
2. 恢复 `width`(从 `maxWidth`)、`height`/`lineHeight`(从 `maxHeight`)
|
||||
3. 设置 `textAlign='center'`、`verticalAlign='middle'`、`justifyContent='center'`、`alignItems='center'`
|
||||
4. 根据宽度判断显示"插图"(<80px)或"插入/点击放置图片"
|
||||
|
||||
## 涉及文件及修改点
|
||||
| 文件 | 修改点 |
|
||||
|------|--------|
|
||||
| `src/pages/Login.tsx` | `defaultSettings.autoInsertFrameIndices` 改为 `[0,2,4,6,8,10]` |
|
||||
| `src/pages/SystemSettings.tsx` | `resetToDefault` 补全 autoInsert 相关字段,索引改为间隔抽取 |
|
||||
| `src/pages/TemplateManage.tsx` | 导出文件名加时间戳;删除恢复补齐居中样式 |
|
||||
|
||||
## 风险与注意事项
|
||||
1. `Login.tsx` 的修改只影响首次初始化(无 systemSettings 时),已有用户的 localStorage 不会被覆盖。
|
||||
2. `SystemSettings.tsx` 的 `resetToDefault` 是用户主动触发的重置操作,会覆盖现有设置。
|
||||
3. 两端编辑器的占位符删除恢复逻辑需要保持同步,后续修改时应同时检查 ReportEditor 和 TemplateManage。
|
||||
86
工程分析/测试方案-2026-04-17-23-38-34.md
Normal file
86
工程分析/测试方案-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 测试方案 — 2026-04-17-23-38-34
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证时间格式自定义下拉组件、表格内图片占位符插入、以及打印多页页边距三项修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:时间格式自定义下拉(编辑字段)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 登录 admin,进入「模板管理」→「字段管理」 | — |
|
||||
| 2 | 点击「手术日期」进入编辑模式,聚焦格式输入框 | 输入框下方弹出下拉列表,显示 `YYYY-MM-DD`、`YYYY年MM月DD日` 等日期格式 |
|
||||
| 3 | 点击列表中 `YYYY年MM月DD日` | 输入框被填充为 `YYYY年MM月DD日`,下拉列表关闭 |
|
||||
| 4 | 点击「手术开始时间」进入编辑模式,聚焦格式输入框 | 下拉列表只显示时间格式(`HH:mm`、`hh:mm A`),**不出现**日期格式 |
|
||||
| 5 | 在格式输入框中手写输入 `MM-DD HH:mm`,按 Enter | 新格式被保存,下拉列表中新增 `MM-DD HH:mm`;重新聚焦后可在列表中看到 |
|
||||
|
||||
### TC-2:时间格式自定义下拉(新增字段)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在字段管理底部点击「新增字段」 | — |
|
||||
| 2 | category 选「时间」,type 选「日期」,聚焦格式输入框 | 下拉列表只显示日期格式 |
|
||||
| 3 | type 切换为「时分」,聚焦格式输入框 | 下拉列表只显示时间格式 |
|
||||
| 4 | 手写输入新格式 `hh:mm:ss`,按 Enter | 新格式被保存到 `customTimeFormats` 并出现在列表中 |
|
||||
|
||||
### TC-3:表格内插入图片占位符(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」,编辑器中确保存在表格 | — |
|
||||
| 2 | 将光标放入表格的某个 `<td>` 单元格内 | — |
|
||||
| 3 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,占位符直接插入到单元格内 |
|
||||
| 4 | 用 DevTools 检查插入的元素 | 外层标签为 `<div class="image-placeholder">`,style 包含 `width:100%;height:100%;max-width:200px;max-height:200px;`,内部结构完整(delete-btn + placeholder-text) |
|
||||
| 5 | 点击图片占位符填充一张图片 | 图片正常显示,占位符添加 `has-image` class |
|
||||
|
||||
### TC-4:普通文本中插入图片占位符(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 将光标放在表格外的普通文本段落中 | — |
|
||||
| 2 | 点击「插入图片占位符」 | 弹出 prompt 要求输入宽高 |
|
||||
| 3 | 输入 `100*80` | 插入行内 `<span class="image-placeholder">`,style 包含 `width:100px;height:80px;`,与前后文字保持在同一行 |
|
||||
|
||||
### TC-5:表格内插入图片占位符(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」,确保报告内容中有表格 | — |
|
||||
| 2 | 将光标放入表格单元格内,插入图片占位符 | 不弹出 prompt,插入自适应 div 占位符 |
|
||||
| 3 | 从视频分析面板拖拽关键帧到占位符中 | 图片正常填充,结构完整 |
|
||||
|
||||
### TC-6:打印多页页边距
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在「报告编辑器」中创建一份内容较多的报告(或复制粘贴大量文本使内容超过1页A4) | — |
|
||||
| 2 | 点击「预览/打印」 | 弹出打印对话框 |
|
||||
| 3 | 在浏览器打印预览中查看第2页 | 第二页顶部和底部均有约 15mm 的留白,不紧贴纸张边缘;左右留白约 10mm |
|
||||
| 4 | 检查第一页 | 第一页同样有 15mm 上下 / 10mm 左右的均匀留白 |
|
||||
|
||||
### TC-7:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 时间格式输入框支持点击展开下拉列表、选择选项、手写输入并记忆新格式
|
||||
- [ ] date 字段下拉只显示日期格式,time 字段只显示时间格式
|
||||
- [ ] 表格内插入图片占位符不弹 prompt,结构完整,使用 div 块级容器
|
||||
- [ ] 普通文本中插入图片占位符仍弹 prompt,使用 span 行内容器
|
||||
- [ ] 打印多页时每一页上下均有约 15mm 留白,左右约 10mm
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 测试方案 — 2026-04-18-00-02-08
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证拖拽关键帧插入样式修复、图片占位符自定义弹窗与分类隔离、表格插入自定义弹窗三项修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:拖拽关键帧后边框消失 + 图片约束
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」,上传视频并自动摘取关键帧 | 右侧视频分析面板显示关键帧缩略图 |
|
||||
| 2 | 编辑器中插入一个图片占位符 | 显示虚线框占位符 |
|
||||
| 3 | 从右侧拖拽关键帧到占位符中 | 图片正常显示,**虚线边框和灰色背景消失**;图片不溢出占位符边界 |
|
||||
| 4 | 用 DevTools 检查 `<img>` 元素 | style 包含 `max-width:100%;max-height:100%;object-fit:contain;` |
|
||||
|
||||
### TC-2:图片占位符插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,而是弹出居中的自定义 Modal |
|
||||
| 3 | Modal 中显示默认宽度 200、高度 200 | — |
|
||||
| 4 | 修改宽度为 120,高度为 80 | 输入框值正常变化 |
|
||||
| 5 | 选择「静态图片占位」模式 | 模式按钮高亮切换 |
|
||||
| 6 | 点击「确认插入」 | Modal 关闭,编辑器中插入行内 `<span>` 占位符,带有 `data-mode="manual"` 属性 |
|
||||
| 7 | 用 DevTools 检查插入的元素 | `data-mode="manual"` 存在,style 包含 `width:120px;height:80px;` |
|
||||
|
||||
### TC-3:图片占位符插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | 弹出自定义 Modal |
|
||||
| 3 | 确认插入 | 占位符正常插入,结构完整 |
|
||||
|
||||
### TC-4:自动帧插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在编辑器中插入两个占位符:第一个是 frame 模式,第二个是 manual 模式 | — |
|
||||
| 2 | 上传视频,开启「自动帧插入」,点击「自动关键帧摘取」 | — |
|
||||
| 3 | 观察占位符填充情况 | 只有**第一个 frame 模式**的占位符被自动填入关键帧;第二个 manual 占位符**保持空白** |
|
||||
|
||||
### TC-5:一键插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中先插入一个 manual 占位符,再插入一个 frame 占位符 | — |
|
||||
| 2 | 右侧关键帧卡片点击「插入」按钮 | 关键帧填入**第二个 frame 占位符**;manual 占位符不受影响 |
|
||||
|
||||
### TC-6:拖拽拦截 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中插入一个 manual 占位符 | — |
|
||||
| 2 | 从右侧拖拽关键帧到该 manual 占位符上 | 弹出提示「此处为静态图片占位符,仅支持点击插入...」;占位符**不被填充** |
|
||||
|
||||
### TC-7:表格插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | **不弹出 prompt**,弹出自定义 Modal |
|
||||
| 3 | Modal 中显示默认行数 2、列数 3 | — |
|
||||
| 4 | 修改行数为 4,列数为 2 | 输入框值正常变化 |
|
||||
| 5 | 点击「确认插入」 | Modal 关闭,编辑器中插入 4 行 2 列的表格 |
|
||||
|
||||
### TC-8:表格插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | 弹出自定义 Modal |
|
||||
| 3 | 设置行数 3,列数 3,确认插入 | 表格正常插入 |
|
||||
|
||||
### TC-9:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 拖拽关键帧后占位符边框和背景消失,图片不溢出
|
||||
- [ ] 点击「插入图片占位符」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持选择占位符模式(frame/manual)
|
||||
- [ ] manual 占位符带有 `data-mode="manual"` 属性
|
||||
- [ ] 自动帧插入和一键插入跳过 manual 占位符
|
||||
- [ ] 拖拽到 manual 占位符被拦截并提示
|
||||
- [ ] 点击「插入表格」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持输入行数/列数并正常插入表格
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证以下 3 个需求是否按预期工作:
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,占位符内联边框和背景被彻底清除。
|
||||
2. 「插入表格」和「插入图片占位符」改为自定义居中弹窗,且插入位置准确。
|
||||
3. 图片占位符支持「图片来源限制」,各限制类型在拖拽、点击、一键插入、自动插入场景下均被正确拦截或放行。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome / Edge(推荐)
|
||||
- 测试账号:任意账号(建议 `admin` / `123456`)
|
||||
|
||||
## 测试用例
|
||||
|
||||
| 编号 | 场景 | 操作步骤 | 预期结果 |
|
||||
|------|------|---------|---------|
|
||||
| TC-01 | 拖拽关键帧后边框清除 | 1. 进入「报告编辑」页。<br>2. 上传视频并自动/手动截取关键帧。<br>3. 插入一个图片占位符。<br>4. 从右侧视频分析面板拖拽关键帧到占位符。 | 占位符内的虚线框和浅灰背景完全消失,图片正常显示,无残留边框。 |
|
||||
| TC-02 | 自动帧插入后边框清除 | 1. 开启「自动帧插入」。<br>2. 上传新视频触发自动摘帧。<br>3. 观察自动插入到占位符的关键帧。 | 自动插入的图片同样无残留边框和背景。 |
|
||||
| TC-03 | ReportEditor 插入表格弹窗 | 1. 点击工具栏「表格」按钮。<br>2. 在弹窗中输入 3 行 4 列,点击确认。 | 页面中央弹出模态框;确认后表格正确插入到光标所在位置。 |
|
||||
| TC-04 | ReportEditor 插入图片占位符弹窗 | 1. 点击工具栏「插入图片占位符」按钮。<br>2. 在弹窗中输入宽 150、高 100,选择「所有来源」,点击确认。 | 页面中央弹出模态框;确认后行内占位符(150×100)插入到光标位置,且可正常点击上传图片。 |
|
||||
| TC-05 | TemplateManage 插入表格弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「表格」按钮,输入 2 行 2 列确认。 | 弹窗正常弹出,表格插入位置准确。 |
|
||||
| TC-06 | TemplateManage 插入图片占位符弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「插入图片占位符」,输入宽 80、高 80,确认。 | 弹窗正常弹出,占位符插入后显示「插图」缩写文本。 |
|
||||
| TC-07 | 表格内插入占位符隐藏尺寸 | 1. 在表格单元格内点击。<br>2. 点击「插入图片占位符」。 | 弹窗中提示「表格内占位符将自动填满单元格,无需设置尺寸」,不显示宽高输入框。 |
|
||||
| TC-08 | 仅限关键帧占位符 | 1. 插入占位符时选择「仅限关键帧」。<br>2. 点击该空占位符。 | 弹出提示「此区域仅限插入关键帧图片...」,不打开图片选择器。 |
|
||||
| TC-09 | 仅限关键帧-拖拽放行 | 1. 对「仅限关键帧」占位符,从右侧拖拽关键帧放入。 | 关键帧正常插入,无报错。 |
|
||||
| TC-10 | 仅限关键帧-上传拦截 | 1. 对「仅限关键帧」占位符,尝试点击打开图片选择器。 | 被拦截并提示。 |
|
||||
| TC-11 | 仅限上传类占位符 | 1. 插入占位符时选择「仅限本地上传/签名/素材」。<br>2. 点击该空占位符。 | 正常弹出「本地上传/签名/素材」三选一弹窗。 |
|
||||
| TC-12 | 仅限上传类-拖拽拦截 | 1. 对「仅限上传类」占位符,从右侧拖拽关键帧放入。 | 弹出提示「此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。」,拒绝插入。 |
|
||||
| TC-13 | 一键插入拦截 | 1. 插入一个「仅限上传类」占位符作为第一个空位。<br>2. 在右侧关键帧卡片点击「插入」按钮。 | 弹出提示,拒绝插入。 |
|
||||
| TC-14 | 自动帧插入跳过受限占位符 | 1. 插入一个「仅限上传类」占位符。<br>2. 开启自动帧插入,上传视频触发自动摘帧。 | 第一个空占位符因限制为 upload 而跳过,不插入关键帧。 |
|
||||
| TC-15 | 向后兼容 | 1. 打开一份旧报告(无 `data-allow-source` 的占位符)。<br>2. 拖拽关键帧和点击上传。 | 旧占位符行为不变,两种操作均可正常执行。 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] TC-01 ~ TC-02:拖拽/自动插入后占位符内联边框和背景完全清除。
|
||||
- [ ] TC-03 ~ TC-07:ReportEditor 和 TemplateManage 的表格/图片占位符弹窗正常工作,焦点恢复无误。
|
||||
- [ ] TC-08 ~ TC-10:「仅限关键帧」占位符正确拦截上传类操作,放行关键帧操作。
|
||||
- [ ] TC-11 ~ TC-14:「仅限上传类」占位符正确拦截关键帧操作,放行上传类操作。
|
||||
- [ ] TC-15:旧数据无 `data-allow-source` 时默认行为不受影响。
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工验证。本项目无自动化测试框架,所有用例通过浏览器交互逐项确认。
|
||||
32
工程分析/测试方案-2026-04-18-00-43-19.md
Normal file
32
工程分析/测试方案-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 测试方案 — 2026-04-18-00-43-19
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证默认模板 `defaultContent.ts` 中的全部 `.image-placeholder` 已正确添加 `data-mode` 属性,且尺寸、布局与原有模板保持一致。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome / Edge
|
||||
- 测试账号:`admin` / `123456`
|
||||
|
||||
## 测试用例
|
||||
|
||||
| 编号 | 场景 | 操作步骤 | 预期结果 |
|
||||
|------|------|---------|---------|
|
||||
| TC-01 | 默认模板 Logo 占位符 | 1. 登录后新建报告(不选择任何模板,加载默认模板)。<br>2. 查看编辑器顶部的医院 Logo 占位符。 | 占位符尺寸仍为 65×65px;DOM 中可见 `data-mode="manual"`;从右侧视频分析面板拖拽关键帧到 Logo 占位符时,弹出提示「此处为静态图片占位符...」并拒绝插入。 |
|
||||
| TC-02 | 默认模板签名占位符 | 1. 新建报告,滚动到底部「手术者签名」处。<br>2. 查看占位符 DOM。 | 占位符尺寸仍为 200×40px;DOM 中可见 `data-mode="manual"`;提示文本为「插入/点击放置图片」;拖拽关键帧到签名区域时被拦截。 |
|
||||
| TC-03 | 默认模板表格内影像占位符 | 1. 新建报告,查看「手术图片说明表格」中的 6 个占位符。<br>2. 检查 DOM。 | 每个占位符尺寸仍为 100%×150px;DOM 中可见 `data-mode="frame"`;从右侧拖拽关键帧到表格占位符时,可正常插入。 |
|
||||
| TC-04 | 自动帧插入过滤 | 1. 新建报告,确保表格内和签名/Logo 占位符均为空。<br>2. 上传视频并开启「自动帧插入」。<br>3. 观察自动插入行为。 | 自动插入的关键帧只会填充表格内 `data-mode="frame"` 的占位符;不会填充 `data-mode="manual"` 的 Logo 和签名占位符。 |
|
||||
| TC-05 | 布局无偏移 | 1. 对比修改前后的默认模板预览效果(或打印预览)。 | 所有占位符的位置、大小、边框、背景色与修改前完全一致,无可见差异。 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] TC-01 ~ TC-03:默认模板中 8 个占位符均已正确添加 `data-mode`,尺寸未改变。
|
||||
- [ ] TC-04:自动帧插入和拖拽逻辑对 `manual` / `frame` 的隔离生效。
|
||||
- [ ] TC-05:视觉和排版与修改前完全一致。
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工验证。通过浏览器 DevTools 检查 DOM 属性,并通过拖拽/自动插入验证隔离逻辑。
|
||||
96
工程分析/测试方案-2026-04-18-16-45-02.md
Normal file
96
工程分析/测试方案-2026-04-18-16-45-02.md
Normal 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-05:Gitea 备份验证(后续真实需求执行时)
|
||||
**前置条件**:代码修改已完成
|
||||
**操作步骤**:
|
||||
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 将在后续真实需求中实际执行并验证。
|
||||
123
工程分析/测试方案-2026-04-18-16-55-47.md
Normal file
123
工程分析/测试方案-2026-04-18-16-55-47.md
Normal 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 全部通过,即可确认三项需求均正确实现。
|
||||
155
工程分析/测试方案-2026-04-18-17-27-51.md
Normal file
155
工程分析/测试方案-2026-04-18-17-27-51.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 测试方案 —— 2026-04-18-17-27-51
|
||||
|
||||
## 测试目标
|
||||
验证 TemplateManage 静态占位符插入修复、默认模板排版重构、Logo 删除按钮修复。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-01:TemplateManage 插入静态图片占位符
|
||||
**前置条件**:进入 /template-manage,编辑器有焦点
|
||||
**操作步骤**:
|
||||
1. 点击工具栏「插入图片占位符」
|
||||
2. 在弹窗中选择「静态图片占位」
|
||||
3. 输入宽度 200,高度 200
|
||||
4. 点击「确认插入」
|
||||
|
||||
**预期结果**:
|
||||
- 编辑器中出现虚线边框的占位符框
|
||||
- 占位符带有 `class="image-placeholder"` 和 `data-mode="manual"`
|
||||
- 占位符内部显示「插入/点击放置图片」文字
|
||||
- 占位符右上角显示红色「×」删除按钮
|
||||
|
||||
---
|
||||
|
||||
### TC-02:TemplateManage 插入手术影像占位符
|
||||
**前置条件**:进入 /template-manage
|
||||
**操作步骤**:
|
||||
1. 点击工具栏「插入图片占位符」
|
||||
2. 选择「手术影像占位」
|
||||
3. 点击「确认插入」
|
||||
|
||||
**预期结果**:
|
||||
- 占位符正常显示
|
||||
- 带有 `data-mode="frame"`
|
||||
- 可接受关键帧拖拽填充
|
||||
|
||||
---
|
||||
|
||||
### TC-03:TemplateManage 占位符删除按钮
|
||||
**前置条件**:已插入占位符
|
||||
**操作步骤**:
|
||||
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-09:Logo 占位符删除按钮可点击
|
||||
**前置条件**:新建报告已加载默认模板
|
||||
**操作步骤**:
|
||||
1. 鼠标悬浮在顶部 Logo 占位符上
|
||||
2. 点击右上角的红色「×」
|
||||
|
||||
**预期结果**:
|
||||
- Logo 占位符被删除
|
||||
- 可撤销恢复
|
||||
|
||||
---
|
||||
|
||||
### TC-10:Logo 占位符图片上传
|
||||
**前置条件**:新建报告已加载默认模板
|
||||
**操作步骤**:
|
||||
1. 点击顶部 Logo 占位符
|
||||
2. 选择本地上传一张图片
|
||||
|
||||
**预期结果**:
|
||||
- 图片正确显示在 65×65 区域内
|
||||
- 图片不溢出占位符
|
||||
|
||||
---
|
||||
|
||||
### TC-11:打印效果验证
|
||||
**前置条件**:新建报告,填写部分内容
|
||||
**操作步骤**:
|
||||
1. 点击打印按钮
|
||||
2. 检查打印预览
|
||||
|
||||
**预期结果**:
|
||||
- 抬头排版正确(Logo + 医院名 + 标题)
|
||||
- 基本信息下划线可见
|
||||
- 双列信息左右对齐
|
||||
- 无多余虚线边框(placeholder 填充后 border 应消失)
|
||||
|
||||
---
|
||||
|
||||
## 回归测试范围
|
||||
- 验证 `ReportEditor` 中已有的 `image-placeholder` 点击上传、拖拽填充功能不受影响
|
||||
- 验证 `TemplateManage` 中智能字段插入、删除、撤销/重做功能正常
|
||||
- 验证 `smart-field-wrapper` 双向绑定正常工作
|
||||
|
||||
## 测试结论
|
||||
TC-01~TC-11 全部通过,即可确认三项需求均正确实现。
|
||||
111
工程分析/测试方案-2026-04-18-17-48-59.md
Normal file
111
工程分析/测试方案-2026-04-18-17-48-59.md
Normal 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 全部通过,即可确认五项排版优化均正确实现。
|
||||
134
工程分析/测试方案-2026-04-18-18-08-37.md
Normal file
134
工程分析/测试方案-2026-04-18-18-08-37.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 测试方案 —— 2026-04-18-18-08-37
|
||||
|
||||
## 测试目标
|
||||
验证编辑器工具栏字号/行距功能、字体选择修复,以及默认模板排版调整。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-01:ReportEditor 字体选择修复
|
||||
**前置条件**:进入 /report-editor,编辑器中有文字
|
||||
**操作步骤**:
|
||||
1. 选中一段文字
|
||||
2. 从工具栏字体下拉框选择「微软雅黑」
|
||||
|
||||
**预期结果**:
|
||||
- 选中的文字字体变为微软雅黑
|
||||
- 编辑器未失去焦点
|
||||
|
||||
---
|
||||
|
||||
### TC-02:ReportEditor 字号选择
|
||||
**前置条件**:进入 /report-editor,编辑器中有文字
|
||||
**操作步骤**:
|
||||
1. 选中一段文字
|
||||
2. 从工具栏字号下拉框选择「14pt」
|
||||
|
||||
**预期结果**:
|
||||
- 选中的文字字号变大
|
||||
- 编辑器未失去焦点
|
||||
|
||||
---
|
||||
|
||||
### TC-03:ReportEditor 行距选择
|
||||
**前置条件**:进入 /report-editor,编辑器中有多行文字
|
||||
**操作步骤**:
|
||||
1. 将光标放在某一段落内
|
||||
2. 从工具栏行距下拉框选择「2.0」
|
||||
|
||||
**预期结果**:
|
||||
- 当前段落行距变为 2.0
|
||||
- 其他段落不受影响
|
||||
- 草稿自动保存
|
||||
|
||||
---
|
||||
|
||||
### TC-04:TemplateManage 工具栏功能
|
||||
**前置条件**:进入 /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 全部通过,即可确认所有需求均正确实现。
|
||||
117
工程分析/测试方案-2026-04-18-18-36-43.md
Normal file
117
工程分析/测试方案-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 测试方案 —— 2026-04-18-18-36-43
|
||||
|
||||
## 测试目标
|
||||
验证五项系统改进:列名修正、字段下划线控制、下载导出、右对齐排版修复、默认模板签名右对齐。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-01:ReportManage 列名显示
|
||||
**前置条件**:进入 /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-04:ReportEditor 下载按钮
|
||||
**前置条件**:进入 /report-editor,有内容的报告
|
||||
**操作步骤**:
|
||||
1. 点击顶部下载按钮
|
||||
2. 在弹窗中选择「导出 PDF」
|
||||
3. 在弹窗中选择「导出 JSON」
|
||||
|
||||
**预期结果**:
|
||||
- 弹窗正常显示两个导出选项
|
||||
- PDF 导出时浏览器保存对话框的文件名包含「图文报告-{手术名称}-{患者}-{住院号}-{时间}」
|
||||
- JSON 导出时下载的文件名格式同上,内容包含 reportData
|
||||
|
||||
---
|
||||
|
||||
### TC-05:TemplateManage 下载按钮
|
||||
**前置条件**:进入 /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 全部通过,即可确认所有需求均正确实现。
|
||||
64
工程分析/测试方案-2026-04-18-19-08-43.md
Normal file
64
工程分析/测试方案-2026-04-18-19-08-43.md
Normal 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` 中的图片占位符同样支持高度自适应。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,打印样式正常。
|
||||
54
工程分析/测试方案-2026-04-18-19-23-31.md
Normal file
54
工程分析/测试方案-2026-04-18-19-23-31.md
Normal 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-5:Logo 占位符大小保持 65px × 65px
|
||||
**前置条件**:默认模板已加载。
|
||||
**步骤**:
|
||||
1. 检查顶部 Logo 占位符。
|
||||
**预期结果**:占位符尺寸为 65px × 65px,不受本次修改影响。
|
||||
|
||||
## 回归测试
|
||||
- 确保视频播放、关键帧摘取、拖拽插入功能正常。
|
||||
- 确保 `template-manage` 中的图片占位符同样支持尺寸自适应。
|
||||
- 确保打印样式正常,图片不会被截断。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,视频模块和图片占位符行为符合预期。
|
||||
48
工程分析/测试方案-2026-04-18-19-37-56.md
Normal file
48
工程分析/测试方案-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 测试方案 —— 2026-04-18-19-37-56
|
||||
|
||||
## 测试目标
|
||||
验证 4 项编辑器体验修复的正确性和稳定性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:视频上传按钮位于列表末尾
|
||||
**前置条件**:已上传至少一个视频。
|
||||
**步骤**:
|
||||
1. 切换到「视频分析」Tab。
|
||||
**预期结果**:
|
||||
- 视频缩略图列表中,已有视频在前,「上传视频」按钮在最后。
|
||||
- 点击上传按钮可正常打开文件选择器。
|
||||
|
||||
### TC-2:图片占位符提示文字绝对居中
|
||||
**前置条件**:默认模板已加载。
|
||||
**步骤**:
|
||||
1. 查看顶部 Logo 占位符和表格内图片占位符。
|
||||
**预期结果**:
|
||||
- 「插入图片」或「插入/点击放置图片」文字在占位框正中心,不偏上也不偏下。
|
||||
- 占位符高度不同时(65px vs 200px),文字始终居中。
|
||||
|
||||
### TC-3:删除图片后占位符恢复原始大小
|
||||
**前置条件**:模板中有 200×200 的图片占位符,已插入图片。
|
||||
**步骤**:
|
||||
1. 点击图片上的「×」删除按钮。
|
||||
**预期结果**:
|
||||
- 占位符恢复为虚线框,宽度恢复为 200px,高度恢复为 200px。
|
||||
- 提示文字居中显示。
|
||||
- 占位符仍可重新插入图片。
|
||||
|
||||
### TC-4:对齐按钮不破坏混合排版
|
||||
**前置条件**:默认模板已加载,「手术者签名:」行包含文字和签名占位符。
|
||||
**步骤**:
|
||||
1. 将光标放在「手术者签名:」这一行。
|
||||
2. 分别点击「左对齐」「居中」「右对齐」按钮。
|
||||
**预期结果**:
|
||||
- 整行(文字 + 占位符)作为一个整体对齐,不会换行分离。
|
||||
- `.field-value` 所在行同样适用,对齐时不破坏字段与文字的同行关系。
|
||||
|
||||
## 回归测试
|
||||
- 确保视频上传、播放、关键帧摘取功能正常。
|
||||
- 确保图片占位符的插入、拖拽、自动帧填充功能正常。
|
||||
- 确保打印样式正常,图片和字段显示正确。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,排版结构完整。
|
||||
62
工程分析/测试方案-2026-04-18-20-03-44.md
Normal file
62
工程分析/测试方案-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 测试方案 —— 2026-04-18-20-03-44
|
||||
|
||||
## 测试目标
|
||||
验证模板导入/导出功能和默认模板 Logo 替换的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:模板导出
|
||||
**前置条件**:模板列表中已有至少一个模板,且该模板有内容和字段配置。
|
||||
**步骤**:
|
||||
1. 在模板列表中找到目标模板。
|
||||
2. 点击操作列的「导出」按钮。
|
||||
**预期结果**:
|
||||
- 浏览器下载一个 JSON 文件,文件名为 `模板导出-{模板名称}.json`。
|
||||
- JSON 内容包含 `version`、`type`、`title`、`description`、`content`、`fields` 字段。
|
||||
- `fields` 数组与模板原有的字段配置一致。
|
||||
|
||||
### TC-2:模板导入(自动填充名称和描述)
|
||||
**前置条件**:已有一个有效的模板导出 JSON 文件。
|
||||
**步骤**:
|
||||
1. 点击「新增模板」按钮。
|
||||
2. 在弹窗中点击导入图标,选择 JSON 文件。
|
||||
**预期结果**:
|
||||
- 模板名称输入框自动填充为 JSON 中的 `title`。
|
||||
- 模板描述输入框自动填充为 JSON 中的 `description`。
|
||||
- 无控制台报错。
|
||||
|
||||
### TC-3:模板导入后创建
|
||||
**前置条件**:已完成 TC-2 的导入操作。
|
||||
**步骤**:
|
||||
1. 点击「创建」按钮。
|
||||
2. 在新创建的模板中点击「编辑模板」。
|
||||
**预期结果**:
|
||||
- 编辑器中显示的内容与导入 JSON 中的 `content` 一致。
|
||||
- 字段管理中的配置与导入 JSON 中的 `fields` 一致。
|
||||
|
||||
### TC-4:导入无效文件
|
||||
**前置条件**:准备一个非 JSON 文件或格式错误的 JSON。
|
||||
**步骤**:
|
||||
1. 在新增模板弹窗中选择无效文件。
|
||||
**预期结果**:
|
||||
- 弹出提示「文件解析失败,请检查 JSON 格式」或「无效的模板包文件」。
|
||||
- 表单保持空白,不填充任何数据。
|
||||
|
||||
### TC-5:Logo 占位符交互
|
||||
**前置条件**:新建报告,默认模板已加载。
|
||||
**步骤**:
|
||||
1. 查看顶部 Logo 区域。
|
||||
2. 点击 Logo 占位符右上方的「×」。
|
||||
3. 再次点击 Logo 区域。
|
||||
**预期结果**:
|
||||
- Logo 区域显示为虚线框,提示文字「LOGO」居中显示。
|
||||
- 点击「×」后 Logo 占位符被删除。
|
||||
- 再次点击可打开图片选择器插入图片。
|
||||
|
||||
## 回归测试
|
||||
- 确保模板列表的加载、编辑、删除功能正常。
|
||||
- 确保默认模板的其他部分(基础信息、手术步骤、图片表格等)不受影响。
|
||||
- 确保打印样式正常。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,导入/导出数据完整准确。
|
||||
54
工程分析/测试方案-2026-04-18-22-59-10.md
Normal file
54
工程分析/测试方案-2026-04-18-22-59-10.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 测试方案 —— 2026-04-18-22-59-10
|
||||
|
||||
## 测试目标
|
||||
验证字段下划线默认行为和占位符文字居中修复。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:新增字段默认不下划线
|
||||
**前置条件**:进入模板管理 → 字段管理 → 新增字段。
|
||||
**步骤**:
|
||||
1. 点击「添加字段」。
|
||||
2. 观察「打印时显示下划线」复选框状态。
|
||||
**预期结果**:复选框默认未勾选。
|
||||
|
||||
### TC-2:插入字段默认带 no-underline 类
|
||||
**前置条件**:模板管理中已有字段(默认或新增)。
|
||||
**步骤**:
|
||||
1. 在编辑器中插入任意字段。
|
||||
2. 检查生成的 HTML。
|
||||
**预期结果**:`.field-value` 带有 `.no-underline` 类。
|
||||
|
||||
### TC-3:显式勾选下划线后打印正常显示
|
||||
**前置条件**:某个字段的「打印时显示下划线」已勾选。
|
||||
**步骤**:
|
||||
1. 插入该字段。
|
||||
2. 点击打印预览。
|
||||
**预期结果**:该字段显示下划线,其他未勾选字段不显示。
|
||||
|
||||
### TC-4:默认模板所有字段打印无下划线
|
||||
**前置条件**:新建报告,加载默认模板。
|
||||
**步骤**:
|
||||
1. 点击打印预览。
|
||||
2. 检查「姓名、性别、年龄、科别、床号、住院号」等字段。
|
||||
**预期结果**:所有字段均不显示下划线。
|
||||
|
||||
### TC-5:删除图片后占位符文字居中
|
||||
**前置条件**:模板中有图片占位符,已插入图片。
|
||||
**步骤**:
|
||||
1. 点击图片右上角的「×」删除。
|
||||
**预期结果**:提示文字(如「插入/点击放置图片」或「LOGO」)在虚线框正中心,不偏左。
|
||||
|
||||
### TC-6:不同尺寸占位符文字均居中
|
||||
**前置条件**:模板中有不同尺寸的占位符(65px Logo、200px 表格占位符)。
|
||||
**步骤**:
|
||||
1. 分别检查各占位符的文字位置。
|
||||
**预期结果**:所有占位符文字均绝对居中。
|
||||
|
||||
## 回归测试
|
||||
- 确保字段插入、编辑、删除功能正常。
|
||||
- 确保图片占位符的插入、删除、拖拽功能正常。
|
||||
- 确保打印样式正常。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,排版居中对齐准确。
|
||||
73
工程分析/测试方案-2026-04-18-23-19-44.md
Normal file
73
工程分析/测试方案-2026-04-18-23-19-44.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 测试方案 —— 2026-04-18-23-19-44
|
||||
|
||||
## 测试目标
|
||||
验证排版修复、导出文件名优化和模板批量操作的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:field-value 文字与正文齐平
|
||||
**前置条件**:新建报告,加载默认模板。
|
||||
**步骤**:
|
||||
1. 在「姓名」字段中输入文字。
|
||||
2. 观察文字与「姓名:」的基线对齐情况。
|
||||
**预期结果**:字段中的文字与周围正文在同一水平线上,无明显上浮。
|
||||
|
||||
### TC-2:打印时下划线紧贴文字
|
||||
**前置条件**:模板中有带下划线的字段。
|
||||
**步骤**:
|
||||
1. 点击打印预览。
|
||||
2. 观察下划线与文字的距离。
|
||||
**预期结果**:下划线与文字底部距离约 1px,不悬空。
|
||||
|
||||
### TC-3:排版间距微调
|
||||
**前置条件**:默认模板已加载。
|
||||
**步骤**:
|
||||
1. 观察「姓名:」与下方横线的距离。
|
||||
2. 观察「手术记录」与上方横线的距离。
|
||||
3. 观察 Logo 与医院名称的相对位置。
|
||||
**预期结果**:
|
||||
- 姓名栏横线紧贴文字下方(约 1px)
|
||||
- 手术记录距上方横线约 2px
|
||||
- Logo 比原来偏左上约 5px
|
||||
|
||||
### TC-4:导出 PDF 文件名正确
|
||||
**前置条件**:报告已填写完整信息。
|
||||
**步骤**:
|
||||
1. 点击「导出报告」→「导出 PDF」。
|
||||
**预期结果**:浏览器保存对话框中的默认文件名为 `图文报告-{title}-{patient}-{hid}-{time}.pdf`,而非「My Google AI Studio App.pdf」。
|
||||
|
||||
### TC-5:导出 JSON 时间使用北京时间
|
||||
**前置条件**:任意可导出 JSON 的页面。
|
||||
**步骤**:
|
||||
1. 点击导出 JSON。
|
||||
2. 查看文件名中的时间戳。
|
||||
**预期结果**:时间戳为北京时间(如当前是北京时间 23:19,文件名中应显示 23-19 而非 15-19)。
|
||||
|
||||
### TC-6:模板批量删除
|
||||
**前置条件**:模板列表中有多个模板。
|
||||
**步骤**:
|
||||
1. 选中 2 个模板的复选框。
|
||||
2. 点击「批量删除」。
|
||||
3. 确认删除。
|
||||
**预期结果**:选中的模板被删除,列表中不再显示。未选中的模板保留。
|
||||
|
||||
### TC-7:模板批量导出
|
||||
**前置条件**:模板列表中有多个模板。
|
||||
**步骤**:
|
||||
1. 选中 2 个模板的复选框。
|
||||
2. 点击「批量导出」。
|
||||
**预期结果**:下载的 JSON 文件包含 2 个模板的完整数据(名称、描述、内容、字段配置)。
|
||||
|
||||
### TC-8:允许空模板列表
|
||||
**前置条件**:模板列表中有模板。
|
||||
**步骤**:
|
||||
1. 选中所有模板并批量删除。
|
||||
**预期结果**:列表显示为空,无报错。
|
||||
|
||||
## 回归测试
|
||||
- 确保打印功能正常,样式无异常。
|
||||
- 确保单个模板导出/导入功能正常。
|
||||
- 确保报告编辑、保存、加载功能正常。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,排版对齐准确,文件名正确。
|
||||
64
工程分析/测试方案-2026-04-18-23-39-35.md
Normal file
64
工程分析/测试方案-2026-04-18-23-39-35.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 测试方案 —— 2026-04-18-23-39-35
|
||||
|
||||
## 测试目标
|
||||
验证下划线修复、文件名统一、间距缩紧和双向联动的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:基础字段默认不勾选下划线
|
||||
**前置条件**:进入模板管理 → 字段管理。
|
||||
**步骤**:
|
||||
1. 点击「患者姓名」或「住院号」的编辑按钮。
|
||||
**预期结果**:「打印时显示下划线」复选框默认未勾选。
|
||||
|
||||
### TC-2:勾选下划线后打印生效
|
||||
**前置条件**:某个字段已勾选「打印时显示下划线」。
|
||||
**步骤**:
|
||||
1. 在编辑器中插入该字段。
|
||||
2. 点击打印预览。
|
||||
**预期结果**:该字段显示下划线,且下划线紧贴文字底部。
|
||||
|
||||
### TC-3:未勾选下划线打印不显示
|
||||
**前置条件**:某个字段未勾选下划线。
|
||||
**步骤**:
|
||||
1. 在编辑器中插入该字段。
|
||||
2. 点击打印预览。
|
||||
**预期结果**:该字段不显示下划线。
|
||||
|
||||
### TC-4:PDF 与 JSON 文件名一致
|
||||
**前置条件**:报告已填写完整信息。
|
||||
**步骤**:
|
||||
1. 分别点击「导出 PDF」和「导出 JSON」。
|
||||
**预期结果**:两个文件的文件名前缀完全一致(如 `图文报告-腹腔镜胆囊切除术报告-未知-无号-2026-04-18T23-28`)。
|
||||
|
||||
### TC-5:field-value 间距缩紧
|
||||
**前置条件**:模板中有 field-value 字段。
|
||||
**步骤**:
|
||||
1. 观察字段框内文字与边框的距离。
|
||||
2. 打印预览中观察间距。
|
||||
**预期结果**:文字紧贴边框,左右无明显空白。
|
||||
|
||||
### TC-6:表单逆向联动
|
||||
**前置条件**:ReportEditor 已加载默认模板。
|
||||
**步骤**:
|
||||
1. 点击右侧「基本信息」中「手术名称」输入框。
|
||||
2. 观察中间模板区域。
|
||||
**预期结果**:
|
||||
- 中间模板中「手术名称」字段高亮显示(蓝色背景 + 蓝色描边)。
|
||||
- 页面平滑滚动到该字段位置(视野中央)。
|
||||
|
||||
### TC-7:正向联动仍正常
|
||||
**前置条件**:ReportEditor 已加载默认模板。
|
||||
**步骤**:
|
||||
1. 点击中间模板中的「患者姓名」字段。
|
||||
**预期结果**:
|
||||
- 右侧表单中高亮「患者姓名」输入框。
|
||||
- 右侧滚动到该输入框位置。
|
||||
|
||||
## 回归测试
|
||||
- 确保字段插入、编辑、删除功能正常。
|
||||
- 确保打印样式正常,所有字段类型显示正确。
|
||||
- 确保视频分析、图片占位符功能正常。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,下划线逻辑正确,双向联动流畅。
|
||||
48
工程分析/测试方案-2026-04-19-00-01-50.md
Normal file
48
工程分析/测试方案-2026-04-19-00-01-50.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 测试方案 —— 2026-04-19-00-01-50
|
||||
|
||||
## 测试目标
|
||||
验证高亮样式修复、点击空白取消、打印纯净度、下划线同步的有效性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:高亮样式柔和
|
||||
**前置条件**:ReportEditor 已加载默认模板。
|
||||
**步骤**:
|
||||
1. 点击中间模板中的任意字段。
|
||||
**预期结果**:字段显示浅灰色背景和细灰边框(不再是刺眼的蓝色)。
|
||||
|
||||
### TC-2:点击空白取消高亮
|
||||
**前置条件**:ReportEditor 中某个字段已被高亮。
|
||||
**步骤**:
|
||||
1. 点击模板中的空白区域(非字段、非占位符)。
|
||||
**预期结果**:字段高亮样式消失,恢复为默认状态。
|
||||
|
||||
### TC-3:打印不带高亮框
|
||||
**前置条件**:ReportEditor 中某个字段处于高亮状态。
|
||||
**步骤**:
|
||||
1. 点击打印预览。
|
||||
**预期结果**:打印内容中不显示任何高亮框、outline 或 box-shadow,字段显示正常。
|
||||
|
||||
### TC-4:勾选下划线后打印生效
|
||||
**前置条件**:TemplateManage 中某字段已插入模板,且未勾选下划线。
|
||||
**步骤**:
|
||||
1. 在字段管理中勾选该字段的「打印时显示下划线」。
|
||||
2. 保存字段编辑。
|
||||
3. 在 ReportEditor 中打印预览。
|
||||
**预期结果**:该字段显示下划线。
|
||||
|
||||
### TC-5:取消下划线后打印不显示
|
||||
**前置条件**:TemplateManage 中某字段已勾选下划线并保存。
|
||||
**步骤**:
|
||||
1. 取消勾选该字段的「打印时显示下划线」。
|
||||
2. 保存字段编辑。
|
||||
3. 在 ReportEditor 中打印预览。
|
||||
**预期结果**:该字段不显示下划线。
|
||||
|
||||
## 回归测试
|
||||
- 确保字段插入、编辑、删除功能正常。
|
||||
- 确保双向联动(中间点击→右侧高亮、右侧点击→中间高亮)正常。
|
||||
- 确保打印样式整体正常。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例均通过,无控制台报错,打印内容纯净无高亮残留。
|
||||
26
工程分析/测试方案-2026-04-19-00-13-20.md
Normal file
26
工程分析/测试方案-2026-04-19-00-13-20.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 测试方案 —— 2026-04-19-00-13-20
|
||||
|
||||
## 测试目标
|
||||
验证打印时下划线是否紧贴文字底部。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:有下划线字段紧贴文字
|
||||
**前置条件**:ReportEditor 中某字段(如术前诊断)已勾选「打印时显示下划线」。
|
||||
**步骤**:
|
||||
1. 点击打印预览。
|
||||
**预期结果**:该字段的下划线(黑线)紧贴文字底部,无明显间距。
|
||||
|
||||
### TC-2:无下划线字段不受影响
|
||||
**前置条件**:某字段带有 `.no-underline` 类。
|
||||
**步骤**:
|
||||
1. 点击打印预览。
|
||||
**预期结果**:该字段不显示下划线,排版正常。
|
||||
|
||||
### TC-3:屏幕编辑态不受影响
|
||||
**步骤**:
|
||||
1. 在 ReportEditor 中查看字段。
|
||||
**预期结果**:屏幕上的 `.field-value` 行高保持原样,未被压缩。
|
||||
|
||||
## 测试通过标准
|
||||
打印内容中下划线紧贴文字,无多余留白,屏幕编辑态正常。
|
||||
38
工程分析/测试方案-2026-04-19-00-24-02.md
Normal file
38
工程分析/测试方案-2026-04-19-00-24-02.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 测试方案 —— 2026-04-19-00-24-02
|
||||
|
||||
## 测试目标
|
||||
验证 Dashboard 新增卡片、图表修复、时间切换功能的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:全部报告总数卡片显示正确
|
||||
**步骤**:
|
||||
1. 登录后进入 Dashboard。
|
||||
**预期结果**:顶部统计卡片区域显示"全部报告总数",数值等于当前用户可见的所有报告数量。
|
||||
|
||||
### TC-2:本月报告总数不受全部报告卡片影响
|
||||
**步骤**:
|
||||
1. 查看"本月报告总数"卡片。
|
||||
**预期结果**:数值仅统计当月(YYYY-MM)创建的报告,非全部报告。
|
||||
|
||||
### TC-3:7 天趋势图表正常
|
||||
**步骤**:
|
||||
1. 默认进入 Dashboard,图表显示"最近 7 天"。
|
||||
**预期结果**:X 轴显示 7 个日期标签(MM-DD 格式),无重叠,数据点与折线正确对应。
|
||||
|
||||
### TC-4:30 天趋势图表正常
|
||||
**步骤**:
|
||||
1. 点击"最近 30 天"按钮。
|
||||
**预期结果**:图表重新渲染,X 轴显示 30 个日期标签(DD 格式),无重叠,趋势数据正确。
|
||||
|
||||
### TC-5:日期文字不与轴线重叠
|
||||
**步骤**:
|
||||
1. 在 7 天和 30 天两种模式下查看图表底部。
|
||||
**预期结果**:日期文字清晰可见,不与 X 轴线或数据点重叠。
|
||||
|
||||
## 回归测试
|
||||
- 确保今日新增报告、模板数、用户/部门数等其他统计卡片正常显示。
|
||||
- 确保页面无控制台报错。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例通过,图表在不同时间维度下均正常渲染,无文字重叠。
|
||||
29
工程分析/测试方案-2026-04-19-00-33-44.md
Normal file
29
工程分析/测试方案-2026-04-19-00-33-44.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 测试方案 —— 2026-04-19-00-33-44
|
||||
|
||||
## 测试目标
|
||||
验证 30 天趋势图表稀疏化显示和 Tooltip 交互的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:30 天模式不显示圆点和数值
|
||||
**步骤**:
|
||||
1. 进入 Dashboard,点击"最近 30 天"。
|
||||
**预期结果**:图表中仅显示面积图和折线,无蓝色圆点和数字 0/1/2... 等数值文本。
|
||||
|
||||
### TC-2:30 天模式标签稀疏化
|
||||
**步骤**:
|
||||
1. 查看 30 天模式 X 轴。
|
||||
**预期结果**:仅显示约 6 个日期标签(每隔 5 天一个),标签不重叠。
|
||||
|
||||
### TC-3:Tooltip 悬停显示
|
||||
**步骤**:
|
||||
1. 在 30 天模式图表上移动鼠标。
|
||||
**预期结果**:鼠标旁出现 Tooltip,显示当前位置的日期和报告数量;移出图表区域后 Tooltip 消失。
|
||||
|
||||
### TC-4:7 天模式不受影响
|
||||
**步骤**:
|
||||
1. 点击"最近 7 天"。
|
||||
**预期结果**:每天显示圆点、数值和标签,无 Tooltip 行为(或 Tooltip 可选),与修改前完全一致。
|
||||
|
||||
## 测试通过标准
|
||||
30 天模式图表清爽可读,Tooltip 交互流畅,7 天模式无变化。
|
||||
36
工程分析/测试方案-2026-04-19-01-03-37.md
Normal file
36
工程分析/测试方案-2026-04-19-01-03-37.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 测试方案 —— 2026-04-19-01-03-37
|
||||
|
||||
## 测试目标
|
||||
验证默认帧索引、导出文件名、占位符删除恢复的正确性。
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:默认自动插入帧为间隔抽取
|
||||
**步骤**:
|
||||
1. 清空 localStorage 或重置系统,重新登录。
|
||||
2. 上传视频并自动摘取关键帧,观察自动插入的帧。
|
||||
**预期结果**:自动插入的是第 1、3、5、7、9、11 帧(索引 0、2、4、6、8、10)。
|
||||
|
||||
### TC-2:系统设置重置后帧索引正确
|
||||
**步骤**:
|
||||
1. 进入系统设置,点击"恢复默认设置"。
|
||||
2. 上传视频测试自动插入。
|
||||
**预期结果**:同样插入间隔帧(第 1、3、5、7、9、11 帧)。
|
||||
|
||||
### TC-3:模板导出文件名带时间戳
|
||||
**步骤**:
|
||||
1. 进入 TemplateManage,点击任意模板的"导出"按钮。
|
||||
**预期结果**:文件名为 `模板导出-模板名称-YYYY-MM-DD-HH-mm.json`。
|
||||
|
||||
### TC-4:TemplateManage 占位符删除后文字居中
|
||||
**步骤**:
|
||||
1. 进入 TemplateManage,插入图片占位符,上传图片,再点击删除。
|
||||
**预期结果**:提示文字在虚线框内居中显示,不偏向左侧。
|
||||
|
||||
### TC-5:ReportEditor 占位符不受影响
|
||||
**步骤**:
|
||||
1. 进入 ReportEditor,重复 TC-4 操作。
|
||||
**预期结果**:文字仍然居中,无变化。
|
||||
|
||||
## 测试通过标准
|
||||
所有用例通过,无控制台报错,两端编辑器行为一致。
|
||||
485
工程分析/经验记录.md
485
工程分析/经验记录.md
@@ -854,3 +854,488 @@ 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. 医院 Logo(65×65)和手术者签名(200×40)添加 `data-mode="manual"`,标记为静态图片占位。
|
||||
2. 表格内 6 个术中影像占位符(100%×150)添加 `data-mode="frame"`,标记为手术影像占位。
|
||||
3. 签名占位符宽度 200px ≥ 80px,按新弹窗规则将提示文本从「插入图片」更新为「插入/点击放置图片」。
|
||||
4. 所有占位符的 `width/height/margin/display` 等布局属性绝对保持不变。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode`、`data-allow-source`)时,必须同步检索 `defaultContent.ts` 和任何预置模板文件,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
|
||||
- 默认模板修改后,应通过「新建报告 → 检查 DOM」快速验证所有占位符是否携带了最新属性。
|
||||
|
||||
---
|
||||
|
||||
## 记录 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;"> </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 结构」快速验证。
|
||||
|
||||
---
|
||||
|
||||
## 记录 32:视频分析模块空白修复与图片占位符自适应逻辑重构
|
||||
|
||||
**A. 具体问题**
|
||||
1. 上一轮优化中将「上传视频」按钮移入了 `videos.length > 0` 条件渲染内部,导致无视频时整个「视频分析」面板空白,用户无法上传第一个视频。
|
||||
2. 图片占位符填充后仅将 `height` 设为 `auto`,但宽度仍保持预设值(如 200px),导致图片周围有大量空白,用户希望占位符能紧缩包围图片。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. **视频按钮位置错误**:重构视频面板时,将上传按钮和缩略图列表全部包裹在 `{videos.length > 0 && (...)}` 中,未意识到上传按钮必须始终可见。
|
||||
2. **占位符尺寸逻辑不完整**:此前仅将 `height` 改为 `auto`,未同步处理 `width`,也未利用 `max-width`/`max-height` 作为硬限制来实现等比例缩放。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **修复视频面板**:将上传按钮和缩略图列表移出 `videos.length > 0` 条件,使其始终渲染;仅保留视频播放器和关键帧网格在 `{currentVideoIndex !== -1 && videos.length > 0 && (...)}` 中条件渲染。注意:移出后需同步删除对应的 `</div>)}` 关闭标签,否则会导致 JSX 结构不匹配(esbuild 报错「Unexpected closing tag」)。
|
||||
2. **重构占位符尺寸逻辑**:
|
||||
- **插入时**:在 `placeholderModal` 确认插入的 `styleStr` 中,为 inline-block 占位符追加 `max-width:${w}px;max-height:${h}px;`(表格内占位符原本就有)。
|
||||
- **填充时**:在 `fillPlaceholderSrc`、`fillPlaceholder`、`autoCaptureFrames`(ReportEditor)和 `fillPlaceholderSrc`(TemplateManage)中统一执行以下步骤:
|
||||
- 读取 `placeholder.style.maxWidth || placeholder.style.width` 和 `placeholder.style.maxHeight || placeholder.style.height` 作为硬限制值 `mw` / `mh`
|
||||
- 将 `<img>` 的 style 设为 `max-width:${mw};max-height:${mh};display:block;object-fit:contain;object-position:left top;`
|
||||
- 将占位符外壳设为 `width:auto;height:auto;line-height:normal;max-width:${mw};max-height:${mh};text-align:left;vertical-align:top;justify-content:flex-start;align-items:flex-start;`
|
||||
- 这样,小图片会 shrink-wrap 到实际尺寸,大图片会等比例缩小到限制范围内,且靠左上方放置。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 重构条件渲染的 JSX 结构时,必须仔细核对打开和关闭标签的数量和层级。建议使用编辑器格式化或 build 工具(如 esbuild)立即验证。
|
||||
- `image-placeholder` 的尺寸逻辑涉及「创建时预设」和「填充后自适应」两个阶段,修改时必须同时考虑:
|
||||
- 创建时是否写入了 `max-width`/`max-height`
|
||||
- 填充时是否正确读取并应用这些限制值
|
||||
- 所有填充入口(本地上传、签名插入、系统素材、自动帧插入、拖拽填充)是否同步更新
|
||||
- 默认模板中的占位符如果没有 `max-width`/`max-height`,回退逻辑 `|| placeholder.style.width` 仍能正确获取限制值,但后续修改默认模板时应注意统一添加 `max-width`/`max-height` 以显式声明意图。
|
||||
|
||||
---
|
||||
|
||||
## 记录 33:四项编辑器体验优化集中实施
|
||||
|
||||
**A. 具体问题**
|
||||
1. 视频分析面板中「上传视频」按钮位于视频缩略图列表首位,不符合「先列出现有项,最后提供添加操作」的操作直觉。
|
||||
2. 图片占位符内的提示文字未在框中绝对居中,当占位符高度较大时文字明显偏上。
|
||||
3. 删除占位符内已插入的图片后,占位符保持收缩后的 `width:auto; height:auto` 尺寸,未恢复为原始预设大小。
|
||||
4. 点击「左对齐/居中/右对齐」按钮时,浏览器原生 `execCommand('justifyLeft')` 会用 `<div align="left">` 包裹选区,导致包含 `.field-value` 或 `.image-placeholder` 的段落被肢解,文字与输入框/图片强制换行分离。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 上一轮重构视频面板时,将上传按钮移入了缩略图列表,但放在了首位而非末尾。
|
||||
2. 占位符提示文字使用默认的行内流布局居中,依赖于 `line-height` 和父容器的 `align-items: center`,在填充后 `line-height` 被改为 `normal`,导致文字不再居中。
|
||||
3. 删除恢复逻辑仅重置了 `border` 和 `background`,未恢复 `width`、`height`、`lineHeight` 等尺寸属性。
|
||||
4. `execCommand` 的对齐命令实现过于粗暴,会直接修改 DOM 树结构以创建对齐容器,无法安全地处理混合排版(文字 + 交互元素)。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **视频按钮位置**:将上传按钮从 `videos.map()` 之前移至之后,保持所有样式和点击逻辑不变。
|
||||
2. **占位符文字绝对居中**:
|
||||
- 将 `.placeholder-text` 的样式统一改为 `position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); display:block; width:100%;`
|
||||
- 给所有表格内的 `.image-placeholder` 父容器添加 `position:relative;`(inline-block 和签名占位符原本已有)
|
||||
- 修改范围覆盖 `defaultContent.ts`(8 个占位符)、`ReportEditor.tsx`(Modal 插入 + 删除恢复)、`TemplateManage.tsx`(Modal 插入 + 删除恢复)
|
||||
3. **删除后恢复尺寸**:在删除恢复逻辑中增加:
|
||||
```ts
|
||||
const mw = placeholder.style.maxWidth;
|
||||
const mh = placeholder.style.maxHeight;
|
||||
if (mw) placeholder.style.width = mw;
|
||||
if (mh) { placeholder.style.height = mh; placeholder.style.lineHeight = mh; }
|
||||
placeholder.style.textAlign = 'center';
|
||||
placeholder.style.verticalAlign = 'middle';
|
||||
placeholder.style.justifyContent = 'center';
|
||||
placeholder.style.alignItems = 'center';
|
||||
```
|
||||
同时根据占位符原始宽度(`maxWidth || width`)判断显示「插图」(<80px)或「插入/点击放置图片」。
|
||||
4. **安全对齐**:弃用 `execCommand('justifyLeft'/'justifyCenter'/'justifyRight')`,新增 `changeAlignment(align)` 方法:
|
||||
- 通过 `window.getSelection()` 获取选区
|
||||
- 使用 `closest('p, div, td, h1, h2, h3, li')` 找到最近的块级祖先
|
||||
- 直接设置 `(block as HTMLElement).style.textAlign = align`
|
||||
- 同步保存内容快照
|
||||
- 对齐按钮增加 `onMouseDown={(e) => e.preventDefault()}` 防止编辑器失焦
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当修改 `image-placeholder` 的创建或恢复逻辑时,必须在所有入口同步更新:`defaultContent.ts`(静态模板)、`ReportEditor.tsx`(运行时插入/填充/删除恢复)、`TemplateManage.tsx`(模板管理)。
|
||||
- 任何涉及 `execCommand` 的富文本操作都应评估其安全性,优先使用直接 DOM 样式操作(如 `style.textAlign`、`style.lineHeight`)替代,避免浏览器原生命令对复杂 DOM 结构的不可控修改。
|
||||
- 绝对定位的居中方案(`transform: translate(-50%, -50%)`)虽然效果稳定,但要求父容器必须带有 `position: relative`,修改时需同步检查所有父容器的样式。
|
||||
|
||||
---
|
||||
|
||||
## 记录 34:模板导入导出迁移与 Logo 占位符替换
|
||||
|
||||
**A. 具体问题**
|
||||
1. 模板管理模块缺乏数据迁移能力:用户无法将配置好的模板(含字段管理配置)导出为文件,也无法在新建模板时通过文件导入已有配置。
|
||||
2. 默认模板顶部 Logo 虽然已是 `image-placeholder`,但使用的是 `display:inline-flex` 布局,与运行时插入的占位符(`display:inline-block`)样式不一致,导致交互体验不统一。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. 系统设计初期未考虑模板迁移场景,Template 类型缺少 `fields` 属性,字段配置仅保存在全局 `formFieldsConfig` 中。
|
||||
2. Logo 占位符在默认模板中独立硬编码,未与运行时插入逻辑保持一致的标准结构。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **Template 类型扩展**:在 `src/types.ts` 的 `Template` 接口中新增 `fields?: FormField[]`。
|
||||
2. **模板导出功能**:在 `TemplateManage.tsx` 中新增 `handleExportTemplate` 函数,导出 JSON 结构包含 `version`、`type`、`title`、`description`、`content`、`fields`。
|
||||
3. **模板导入功能**:
|
||||
- 新增 `importedContent` 状态(`{content: string; fields: FormField[]}`)和 `fileInputRef`
|
||||
- 新增 `handleImportFile` 函数:解析 JSON,验证 `type === 'surclaw_template_package'`,自动填充名称和描述,暂存内容和字段
|
||||
- 在新增模板 Modal 中增加导入 UI(使用用户指定的 `w-8 h-8 bg-accent...` 样式类名)
|
||||
- 修改 `handleModalSubmit`:新建模板时优先使用 `importedContent.content` 和 `importedContent.fields`,并同步保存到全局 `formFieldsConfig`
|
||||
- 切换模板时(`currentTemplateId` 变化),如果模板有 `fields` 则加载到编辑器并同步保存到全局配置
|
||||
4. **Logo 占位符标准化**:将 `defaultContent.ts` 中 Logo 的 `display:inline-flex` 改为 `display:inline-block`,统一使用 `text-align:center` + `line-height:65px` 的垂直居中方式,提示文字改为「LOGO」。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当扩展数据类型(如 Template 接口)时,应评估是否需要同步修改所有使用该类型的持久化/序列化逻辑(如 storage 读写、导入/导出)。
|
||||
- 默认模板中的占位符结构必须与运行时插入逻辑保持完全一致(`display`、居中方式、`data-mode` 等),任何差异都可能导致交互体验不一致。
|
||||
- 新增文件上传/导入功能时,必须在 onChange 事件末尾清空 `e.target.value = ''`,否则同一文件无法重复选择。
|
||||
|
||||
---
|
||||
|
||||
## 记录 35:字段默认不下划线与占位符文字居中修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. 模板管理中新增字段时,「打印时显示下划线」复选框默认勾选,用户希望改为默认不勾选。
|
||||
2. 删除图片占位符中的图片后,提示文字(如「插入/点击放置图片」)在虚线框内偏左,未真正居中。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `newFieldHasUnderline` 和 `editFieldHasUnderline` 的 `useState` 默认值为 `true`;`insertSmartField` 中的判断逻辑是 `field.hasUnderline === false ? ' no-underline' : ''`,导致只有显式关闭时才无下划线。
|
||||
2. 虽然给 `.placeholder-text` 使用了 `position:absolute + transform:translate(-50%, -50%)` 实现居中,但元素本身设置了 `display:block; width:100%`,其内部文本流默认 `text-align:left`,导致文字靠左。
|
||||
3. 上一轮对 `TemplateManage.tsx` 中 `handleEditorClick` 删除恢复逻辑的修改未完全生效,该文件中的删除恢复逻辑仍使用旧代码(无 absolute 定位、无尺寸恢复)。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **字段默认不下划线**:
|
||||
- `src/pages/TemplateManage.tsx`:`newFieldHasUnderline` 和 `editFieldHasUnderline` 默认值从 `true` 改为 `false`
|
||||
- `src/pages/TemplateManage.tsx`:`insertSmartField` 中判断改为 `field.hasUnderline !== true ? ' no-underline' : ''`
|
||||
- `src/pages/TemplateManage.tsx`:编辑字段回显改为 `field.hasUnderline ?? false`
|
||||
- `src/utils/defaultContent.ts`:移除 `noUnderlineKeys` 数组,`smartField()` 直接给所有字段加 `.no-underline`
|
||||
2. **占位符文字居中**:
|
||||
- 在所有 `.placeholder-text` 的 style 中追加 `text-align:center;`
|
||||
- 修改范围覆盖 `src/utils/defaultContent.ts`(8 个占位符)、`src/pages/ReportEditor.tsx`(3 处)、`src/pages/TemplateManage.tsx`(3 处)
|
||||
- 补全 `TemplateManage.tsx` 中 `handleEditorClick` 删除恢复逻辑的旧代码,添加 absolute 居中、尺寸恢复、`text-align:center`
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当修改默认值(如 `useState(true)` → `useState(false)`)时,应同时检查所有回显/回退逻辑(如 `field.hasUnderline !== false` → `field.hasUnderline ?? false`),确保数据兼容性。
|
||||
- 使用 `display:block; width:100%` 的绝对居中元素,必须显式设置 `text-align:center;` 以控制内部文本流的对齐方向。
|
||||
- 批量替换字符串时,应通过 grep 验证所有匹配位置是否都已更新,避免遗漏(如此次 `TemplateManage.tsx` 中 handleEditorClick 的旧代码)。
|
||||
|
||||
---
|
||||
|
||||
## 记录 36:七项排版与功能优化集中实施
|
||||
|
||||
**A. 具体问题**
|
||||
1. `.field-value` 输入框中的文字与正文不在同一基线上,视觉上向上偏移。
|
||||
2. 「姓名:」下方横线与文字之间距离过大。
|
||||
3. 「手术记录」标题与上方医院名称横线之间距离过大。
|
||||
4. Logo 占位符相对于医院名称文字整体偏右下。
|
||||
5. 导出 PDF 时浏览器默认文件名为「My Google AI Studio App.pdf」,而非自定义名称。
|
||||
6. 导出 JSON 文件名中的时间戳使用 UTC 时间,不符合国内用户习惯。
|
||||
7. 模板管理模块缺乏批量操作能力,只能逐个删除/导出。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `smartField()` 中使用了 `vertical-align:text-bottom` 和 `line-height:1.2;min-height:1.2em`,导致内联块元素基线计算偏移。
|
||||
2. 姓名栏 `<p>` 的 `padding-bottom:1px` 叠加 `line-height:1.2`,导致 border-bottom 距文字约 2-3px。
|
||||
3. 医院名称的 `margin-bottom:8px` 过大。
|
||||
4. Logo 位于 flex 容器中,使用默认的 `gap:12px` 和 `align-items:center`,位置不够精确。
|
||||
5. `printDocument()` 虽接受 `docTitle` 参数并写入 iframe 的 `<title>`,但浏览器打印时优先使用父窗口的 `document.title`。
|
||||
6. `new Date().toISOString()` 返回 UTC 时间字符串。
|
||||
7. 模板列表 UI 仅设计了单条操作按钮,未设计复选框和批量操作状态。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **基线对齐修复**:
|
||||
- `defaultContent.ts`:`vertical-align:text-bottom` → `vertical-align:baseline`;`line-height:1.2;min-height:1.2em` → `line-height:inherit;`
|
||||
- `print.ts`:同步修改 `.smart-field-wrapper` 和 `.field-value` 的 `vertical-align:baseline` 和 `line-height:inherit`
|
||||
2. **姓名栏间距**:`<p>` 的 `padding:0 0 1px 0` → `padding:0`;`line-height:1.2` → `line-height:1`,使 border-bottom 紧贴文字
|
||||
3. **手术记录间距**:医院名称 `margin-bottom:8px` → `margin-bottom:2px`;`padding-bottom:0` → `padding-bottom:1px`
|
||||
4. **Logo 微调**:给 Logo 的 `<span>` 添加 `transform:translate(-5px,-5px)`
|
||||
5. **PDF 文件名**:在 `printDocument()` 中保存并临时设置 `document.title = docTitle`,打印完成后恢复
|
||||
6. **北京时间**:统一替换所有 `new Date().toISOString()` 为 `new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 16)`,并保留原有的 `replace(/[:.]/g, '-')`
|
||||
7. **模板批量操作**:
|
||||
- 新增 `selectedIds` 状态
|
||||
- 新增 `handleBatchDelete` 和 `handleBatchExport`
|
||||
- 模板卡片内增加复选框(阻止冒泡避免触发选中)
|
||||
- 选中时显示批量操作浮动工具栏
|
||||
- 移除 `templates.length <= 1` 的单条删除限制,允许列表为空
|
||||
- 删除后自动同步 `currentTemplateId` 和 `selectedIds`
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 排版微调时,应同时检查编辑器显示、打印预览两处的表现,因为 `print.ts` 中有独立的样式覆盖。
|
||||
- `vertical-align` 属性对内联块元素的基线影响显著,混合使用 `text-bottom`、`middle`、`baseline` 时需谨慎测试。
|
||||
- 浏览器打印的文件名行为不一致(有的用 iframe title,有的用父窗口 title),最稳妥的方案是在打印前后动态修改 `document.title`。
|
||||
- 批量操作 UI 中,复选框的点击事件必须 `stopPropagation()`,否则会触发卡片点击导致状态混乱。
|
||||
- 批量删除后必须同步清理 `selectedIds` 和 `currentTemplateId`,避免出现「选中已删除项」或「当前模板不存在」的异常状态。
|
||||
|
||||
---
|
||||
|
||||
## 记录 37:下划线默认修复、PDF 文件名、间距缩紧、表单逆向联动
|
||||
|
||||
**A. 具体问题**
|
||||
1. 模板管理中「患者姓名」「住院号」的「打印时显示下划线」默认仍为勾选状态,且勾选与否在打印时都失去下划线效果。
|
||||
2. 导出 PDF 时浏览器默认文件名为「My Google AI Studio App.pdf」,与 JSON 文件名不一致。
|
||||
3. `.field-value` 内文字偏右,打印时左右间距过大。
|
||||
4. ReportEditor 中点击右侧表单输入框时,中间模板内的对应字段不会高亮,也不会滚动定位。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `DEFAULT_FORM_FIELDS` 中 `patientName` 和 `hospitalId` 硬编码了 `hasUnderline: true`;`defaultContent.ts` 中 `smartField()` 直接给所有字段加 `.no-underline`;`print.ts` 中 `@media print` 的 `.field-value` 默认显示下划线、`.no-underline` 时隐藏,逻辑正确但默认模板中的字段全部带有 `.no-underline`。
|
||||
2. `printDocument()` 虽设置了 `document.title = docTitle`,但 iframe 内部的 HTML 缺少 `<title>` 标签,某些浏览器优先使用父窗口的原始 title。
|
||||
3. `smartField()` 中 `padding:0 4px; margin:0 2px` 撑开了左右间距。
|
||||
4. 之前只实现了「点击中间模板 → 右侧表单高亮滚动」的单向联动,右侧表单缺少触发 `activeFieldKey` 的事件绑定。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **下划线修复**:
|
||||
- `src/types.ts`:`DEFAULT_FORM_FIELDS` 中 `patientName` 和 `hospitalId` 的 `hasUnderline: true` → `false`
|
||||
- `src/utils/print.ts`:`@media print` 下 `.field-value` 的 `padding-bottom:1px` → `0px`,使下划线紧贴文字
|
||||
2. **PDF 文件名**:在 iframe HTML 的 `<head>` 中注入 `<title>${docTitle}</title>`,确保浏览器打印对话框识别正确的默认文件名
|
||||
3. **间距缩紧**:
|
||||
- `src/utils/defaultContent.ts`:`padding:0 4px;margin:0 2px;min-width:32px` → `padding:0 2px;margin:0;min-width:24px;text-align:center`
|
||||
- `src/utils/print.ts`:同步缩小非打印和打印样式中的 padding/margin
|
||||
4. **表单逆向联动**:
|
||||
- `src/pages/ReportEditor.tsx`:新增 `useEffect` 监听 `activeFieldKey`,实时修改中间模板中对应 `.field-value` 的 `backgroundColor` 和 `boxShadow`,并调用 `scrollIntoView({ block: 'center' })`
|
||||
- 给右侧所有字段类型(text/date/single_select/multi_select/time)的容器 `div` 添加 `onClick={() => setActiveFieldKey(field.key)}`
|
||||
- 给之前缺少高亮样式的通用 time 字段容器补充了 `activeFieldKey` 高亮类名
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当修改 `DEFAULT_FORM_FIELDS` 的默认值时,需意识到已有用户的 `localStorage` 中保存的旧配置不会自动更新。如果默认值变更影响核心功能,应考虑在应用启动时做配置迁移或版本校验。
|
||||
- iframe 打印的文件名行为在不同浏览器间存在差异(Chrome 用父窗口 title,Safari 可能用 iframe title),最稳妥的方案是同时设置父窗口 `document.title` 和 iframe 内部 `<title>` 标签。
|
||||
- 双向联动时,`useEffect` 中的 DOM style 操作需要在组件卸载或 `activeFieldKey` 清空时清除,避免残留高亮。当前实现中 `activeFieldKey` 为 `null` 时会遍历清除所有高亮,逻辑已覆盖。
|
||||
- 给容器 div 添加 `onClick` 时需注意事件冒泡:容器内的子元素(如 input、button)的点击事件会自然冒泡到容器,如果子元素有自己的 onClick 处理(如 dropdown 选项),需确保已调用 `stopPropagation()`。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 38:高亮样式柔化、点击空白取消、打印高亮隔离、下划线配置同步
|
||||
|
||||
**A. 具体问题**
|
||||
1. ReportEditor 中 `.field-value` 激活高亮使用蓝色 box-shadow(`0 0 0 2px #3b82f6`)+ `#eff6ff` 背景,视觉过于刺眼。
|
||||
2. 点击编辑器空白区域时,高亮样式不会自动清除,用户必须点击另一个字段才能切换高亮。
|
||||
3. 打印/PDF 导出时,高亮的内联样式(box-shadow、backgroundColor)会带入打印件,导致打印内容出现蓝框。
|
||||
4. TemplateManage 中编辑字段的「打印时显示下划线」勾选后,已插入到模板中的 `.field-value` 仍然保留旧的 `.no-underline` 类,打印时不显示下划线。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `activeFieldKey` 的 `useEffect` 中使用了高对比度的蓝色阴影和背景色,未考虑柔和视觉体验。
|
||||
2. `handleEditorClick` 的 capture 事件处理器仅在点击 `.field-value` 时设置 `activeFieldKey`,没有处理「点击非字段区域时清空」的逻辑。
|
||||
3. `print.ts` 的 `@media print` 中只重置了 `border` 和 `background`,遗漏了 `outline` 和 `box-shadow`。
|
||||
4. `saveFieldEdit` 仅更新了 JSON state 和 `localStorage` 中的字段配置,没有同步扫描 `editorRef.current` 中已存在的 DOM 元素并更新其 `classList`。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **柔和高亮**:将 `useEffect` 中的高亮样式改为 `backgroundColor: '#f1f5f9'`(浅灰背景)、`outline: '1px solid #94a3b8'`(细灰边框)、`outlineOffset: '1px'`;清除样式时用 `''` 而非硬编码颜色,让 CSS 类重新接管。
|
||||
2. **点击空白取消高亮**:在 `handleEditorClick` 中,`.field-value` 判断分支结束后增加 `setActiveFieldKey(null)`,使点击任何非字段区域都会清除高亮。
|
||||
3. **打印隔离高亮**:`print.ts` 的 `@media print` 中强制添加 `outline: none !important; box-shadow: none !important;`,确保打印输出不受任何高亮内联样式影响。
|
||||
4. **下划线配置同步**:`saveFieldEdit` 末尾增加 DOM 扫描逻辑:
|
||||
```ts
|
||||
if (editorRef.current) {
|
||||
const els = editorRef.current.querySelectorAll(`.field-value[data-bind="${key}"]`);
|
||||
els.forEach(el => {
|
||||
if (editFieldHasUnderline) el.classList.remove('no-underline');
|
||||
else el.classList.add('no-underline');
|
||||
});
|
||||
saveTemplateContent();
|
||||
}
|
||||
```
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 任何通过 JS 直接操作 DOM 添加的内联样式(如高亮),都必须在 `@media print` 中通过 `!important` 强制抹除,防止打印件被屏幕交互样式污染。
|
||||
- 当字段配置(如 `hasUnderline`)同时影响「未来插入的元素」和「已存在的 DOM 元素」时,保存逻辑必须包含对已插入 DOM 的同步更新,不能只更新 state。
|
||||
- `contentEditable` 中的 capture 阶段点击事件是处理全局点击行为(如点击空白取消)的理想位置,但需注意不要阻断其他正常交互路径(如 placeholder 点击)。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 39:打印下划线紧贴文字——行高压缩
|
||||
|
||||
**A. 具体问题**
|
||||
打印/PDF 导出时,`.field-value` 的文字与下方 `border-bottom`(下划线)之间存在明显间距,视觉上不够紧凑。
|
||||
|
||||
**B. 产生问题原因**
|
||||
即使 `padding-bottom` 已设为 `0px`,父级文档设置了 `line-height: 1.5`(第 29 行),`inline-block` 元素内部仍保留了行高带来的底部留白空间。`border-bottom` 渲染在元素的盒模型底部边界,而非文字字形的实际基线/降部底部,因此出现了「文字与横线之间有间隙」的视觉效果。
|
||||
|
||||
**C. 解决问题方案**
|
||||
在 `src/utils/print.ts` 的 `@media print` 中,为 `.smart-field-wrapper .field-value` 增加 `line-height: 1 !important;`。将行高压缩到文字本身的绝对高度,彻底消除底部行高留白,使 `border-bottom` 紧贴文字正下方。
|
||||
|
||||
```css
|
||||
@media print {
|
||||
.smart-field-wrapper .field-value {
|
||||
/* ... 其他属性 ... */
|
||||
line-height: 1 !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当调整 `border-bottom` 与文字的距离时,如果 `padding-bottom` 已归零仍有间隙,应优先检查 `line-height` 的影响。
|
||||
- `inline-block` 元素的 `border-bottom` 位置受其内部行高影响显著,打印样式中可考虑显式设置 `line-height: 1` 以获得最紧凑的下划线效果。
|
||||
- 修改打印样式后,务必同时检查「有下划线」和「无下划线」两种字段的打印效果,避免 `line-height` 压缩导致其他排版异常。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 40:Dashboard 统计卡片扩展、图表时间切换与 X 轴重叠修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. Dashboard 首页缺少"全部报告总数"统计卡片,用户无法一眼看到系统内所有报告数量。
|
||||
2. 报告增长趋势图表中 X 轴日期文字与数据点/轴线发生重叠,影响可读性。
|
||||
3. 趋势图表仅支持固定 7 天数据,用户希望增加 30 天维度查看更长周期趋势。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `stats` 数据结构中只有 `reportCount`(实际表示全部报告数),没有区分"全部"和"本月"两个维度。
|
||||
2. SVG 的 viewBox 高度为 120,X 轴标签绘制在 `y=118`,文字底部超出 viewBox 并与数据点(count=0 时 y=112)只有 6px 间距,导致视觉重叠。
|
||||
3. 趋势计算逻辑固定为 `for (let i = 6; i >= 0; i--)` 的 7 天硬编码,缺少动态时间范围控制。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **扩展 stats 结构**:增加 `totalCount`(全部报告)和 `monthCount`(本月报告),将原 `reportCount` 拆分为两个维度。
|
||||
2. **新增统计卡片**:将顶部网格从 3 列改为 4 列(`lg:grid-cols-4`),在"本月报告总数"左侧新增"全部报告总数"卡片。
|
||||
3. **时间维度切换**:引入 `timeRange` 状态(`'7days' | '1month'`),useEffect 依赖中加入 `timeRange`,动态计算 7 天或 30 天的趋势数据和标签。
|
||||
4. **修复 X 轴重叠**:
|
||||
- 将 SVG viewBox 从 `0 0 300 120` 扩展为 `0 0 300 135`,增加底部 15px 空间。
|
||||
- 将日期标签的 y 坐标从 `120 - 2 = 118` 下移到 `128`,与数据点保持 16px 安全间距。
|
||||
- 30 天模式下字体缩小到 7px,避免过密。
|
||||
5. **标签格式化**:7 天模式显示 `M/D`(如 4/13),30 天模式显示 `DD`(如 13),减少 30 天模式下的文字宽度。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 在使用 SVG 绘制图表时,务必为 X 轴标签预留足够的底部空间(至少文字高度 + 安全间距),不能仅依赖 `overflow-visible`。
|
||||
- 当图表需要支持多时间维度时,应在数据计算层(useEffect)统一处理,而非在渲染层做条件分支,确保数据与标签同步。
|
||||
- 增加 grid 列数时,需同步检查响应式断点(`md:`、`lg:`),避免在小屏幕上卡片过度挤压。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 41:Dashboard 30 天趋势图表稀疏化与 Tooltip 交互
|
||||
|
||||
**A. 具体问题**
|
||||
Dashboard 中"最近 30 天"模式的趋势图表过于密集:30 个蓝色圆点、30 个数值文本、30 个日期标签全部挤在底部,完全无法阅读。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. SVG 图表对 7 天和 30 天采用完全相同的渲染策略,每天都绘制圆点、数值和标签。
|
||||
2. 30 天模式下数据点密度是 7 天的 4 倍以上,在固定宽度的 SVG 中必然导致严重重叠。
|
||||
3. 缺少悬停交互机制,用户无法在不显示所有数值的情况下查看具体某天的数据。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **条件渲染圆点和数值**:在 `points.map()` 中增加判断:`stats.trend.length <= 10` 时显示圆点和数值,否则隐藏。7 天模式(7 个点)正常显示,30 天模式(30 个点)只保留折线和面积图。
|
||||
2. **稀疏化 X 轴标签**:`stats.trend.length <= 10 || i % 5 === 0`,30 天模式每隔 5 天显示一个标签,从 30 个减少到约 6 个。
|
||||
3. **SVG 鼠标事件与 Tooltip**:
|
||||
- 在 `<svg>` 上绑定 `onMouseMove` 和 `onMouseLeave`
|
||||
- 通过 `getBoundingClientRect()` 将鼠标屏幕坐标映射到 SVG viewBox 坐标
|
||||
- 计算最近的数据点索引:`idx = Math.round(((mouseX - paddingX) / chartW) * (n - 1))`
|
||||
- 用 React state 管理 tooltip 的 `visible/x/y/date/count`
|
||||
- 用绝对定位的 `div` 渲染 Tooltip,显示完整日期和报告数
|
||||
4. **透明捕获层**:在 SVG 中增加覆盖全区域的 `<rect fill="transparent" />`,确保鼠标在空白区域也能触发事件。
|
||||
5. **完整日期存储**:`stats` 中新增 `trendFullDates` 数组,存储 `YYYY-MM-DD` 格式完整日期,供 Tooltip 显示使用。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当图表需要支持多时间维度时,必须考虑不同密度下的渲染策略差异,不能对所有维度一视同仁。
|
||||
- SVG 的鼠标事件坐标映射需要注意 `viewBox` 与实际显示尺寸的缩放比例,通过 `getBoundingClientRect()` 做比例换算是可靠方案。
|
||||
- Tooltip 等浮动层应使用 `pointer-events-none` 避免干扰下层交互,同时确保在容器 `relative` 定位下正确计算偏移。
|
||||
- 透明捕获层是解决 SVG 内部元素间隙导致事件丢失的有效手段,特别是在只有线条/路径的图表中。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 43:默认自动插入帧间隔抽取、导出文件名加时间戳、占位符删除恢复居中(修正版)
|
||||
|
||||
**A. 具体问题**
|
||||
1. 系统初始化时默认自动插入的帧为连续前 6 帧(索引 0~5),用户希望改为间隔抽取(第 1、3、5、7、9、11 帧,对应索引 0、2、4、6、8、10),同时保持 frameCount 为 12 不变。
|
||||
2. TemplateManage 单模板导出 JSON 文件名缺少时间戳。
|
||||
3. TemplateManage 编辑器中 `.image-placeholder` 删除图片后提示文字靠左,与 ReportEditor 不一致。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `Login.tsx` 的 `defaultSettings.autoInsertFrameIndices` 为 `[0, 1, 2, 3, 4, 5]`;`SystemSettings.tsx` 的 `resetToDefault` 完全缺失 `autoInsertFrames`、`autoInsertDelay`、`autoInsertFrameIndices` 字段。
|
||||
2. `handleExportTemplate` 直接拼接固定文件名,未加入时间戳。
|
||||
3. TemplateManage 的删除恢复逻辑缺少 `absolute` 居中样式和容器尺寸恢复。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **Login.tsx**:`autoInsertFrameIndices` 从 `[0, 1, 2, 3, 4, 5]` 改为 `[0, 2, 4, 6, 8, 10]`,frameCount 保持 12,framePositions 保持均匀分布。
|
||||
2. **SystemSettings.tsx**:`resetToDefault` 中补全 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]`。
|
||||
3. **TemplateManage.tsx**:
|
||||
- 导出文件名加入北京时间戳:`模板导出-模板名称-YYYY-MM-DD-HH-mm.json`
|
||||
- 删除恢复逻辑补齐 `absolute` 居中样式、尺寸恢复、`textAlign/verticalAlign/justifyContent/alignItems`
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- `resetToDefault` 函数中必须包含所有 `SystemSettings` 字段,不能遗漏任何新增配置项,否则重置后功能异常。
|
||||
- 两端编辑器共享控件时,应建立统一的"创建/填充/删除恢复"工具函数,避免在各自文件中维护重复且容易 diverge 的逻辑。
|
||||
- `autoInsertFrameIndices` 的默认值变更不影响已有用户数据,但重置操作会覆盖用户自定义的索引选择,需在重置提示中明确告知。
|
||||
|
||||
35
工程分析/需求分析-2026-04-17-23-38-34.md
Normal file
35
工程分析/需求分析-2026-04-17-23-38-34.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 需求分析 — 2026-04-17-23-38-34
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **时间格式输入框改造**:`template-manage` 字段管理中,时间字段的格式输入当前使用原生 `<input list="...">` + `<datalist>`,浏览器兼容性和交互体验不佳。希望改造为类似单选下拉框的自定义组件,既能下拉选择已有格式,又能手写输入并自动记忆新格式。
|
||||
|
||||
2. **表格内插入图片占位符修复**:在 `template-manage` 编辑器表格中点击"插入图片占位符"后,HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素被分散到 `<td>` 中。且表格内占位符应默认自适应单元格大小(最大不超过 200×200px)。
|
||||
|
||||
3. **打印第二页页边距太小**:`report-editor` / `report-view` 点击打印时,第二页及后续页面的上下边距几乎为 0,内容紧贴纸张边缘。当前 `@page { margin: 0 }` + `body { padding: 10mm }` 的组合仅在文档首尾生效一次,分页后无padding。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:`TemplateManage.tsx` 中编辑字段和新增字段的时间格式输入,从原生 `input[list]` + `datalist` 改造为自定义下拉组件(input + 绝对定位 ul 列表)。支持点击展开下拉、点击选项填充、手写输入、blur/Enter 时自动保存新格式到 `customTimeFormats`。
|
||||
- **F2**:`TemplateManage.tsx` 和 `ReportEditor.tsx` 的 `insertImage` 函数,在插入前检测当前光标是否位于 `<td>` / `<th>` 内。若在表格内,使用块级 `<div>` 作为外层容器(避免浏览器 execCommand 修正破坏结构),并设置 `width:100%;height:100%;max-width:200px;max-height:200px;` 实现自适应。不在表格内时保持现有 `<span>` 行内结构。
|
||||
- **F3**:`src/utils/print.ts` 中,将 `@page { margin: 0 }` + `body { padding: 10mm }` 改为 `@page { margin: 15mm 10mm }` + `body { padding: 0 }`,使每一页物理纸张都有独立的上下 15mm / 左右 10mm 留白。同步调整 `.content` 的 width 为 `100%`。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已保存的模板/报告中已有的 `image-placeholder` 结构不受影响。
|
||||
- 下拉组件的 `z-index` 需确保覆盖在滚动容器之上。
|
||||
- 打印样式调整应同时兼顾 `.image-placeholder` 在打印时的隐藏逻辑。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 时间格式自定义下拉组件(编辑+新增两处);insertImage 表格检测与分支逻辑 |
|
||||
| `src/pages/ReportEditor.tsx` | 中 | insertImage 表格检测与分支逻辑 |
|
||||
| `src/utils/print.ts` | 低 | @page margin 与 body padding 调整 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求分析 — 2026-04-18-00-02-08
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **修复拖拽关键帧插入兼容性**:拖拽关键帧到 `.image-placeholder` 后,虚线边框和背景色未消失,且图片缺少 `max-height:100%;object-fit:contain;` 约束,可能溢出占位符。
|
||||
|
||||
2. **图片占位符插入改为自定义弹窗 + 分类隔离**:
|
||||
- 替换 `insertImage` 中的 `prompt` 弹窗为自定义 React Modal。
|
||||
- 占位符分为两类:
|
||||
- **手术影像占位(frame 模式)**:支持自动帧插入、一键插入、拖拽插入。
|
||||
- **静态图片占位(manual 模式)**:仅支持点击后从弹窗选择图片来源(本地上传/签名/素材),防止系统自动将手术关键帧填入 Logo 或签名位置。
|
||||
- 自动帧插入和一键插入逻辑需跳过 `data-mode="manual"` 的占位符。
|
||||
- 拖拽到 manual 占位符时需拦截并提示。
|
||||
|
||||
3. **表格插入改为自定义弹窗**:替换 `insertTable` 中的 `prompt` 弹窗为自定义 React Modal,中间弹出子窗口选择行数和列数。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:`ReportEditor.tsx` 的 `fillPlaceholder` 函数补齐 `border='none'`、`background='transparent'`,图片 style 增加 `max-height:100%;object-fit:contain;`。
|
||||
- **F2**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertImage` 改为打开自定义 Modal(替代 `prompt`)。Modal 包含:
|
||||
- 宽高输入框(默认 200*200)
|
||||
- 模式选择:手术影像占位(frame)/ 静态图片占位(manual)
|
||||
- 确认/取消按钮
|
||||
- **F3**:生成 placeholder HTML 时,manual 模式添加 `data-mode="manual"` 属性。
|
||||
- **F4**:`ReportEditor.tsx` 的 `autoCaptureFrames`(setTimeout 回调内)、`insertFrameToPlaceholder` 的空占位符选择器,从 `.image-placeholder:not(.has-image)` 改为 `.image-placeholder:not(.has-image):not([data-mode="manual"])`。
|
||||
- **F5**:`ReportEditor.tsx` 的 `handleDrop` 增加拦截:若目标 placeholder 的 `data-mode === 'manual'`,弹出提示并阻止填充。
|
||||
- **F6**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertTable` 改为打开自定义 Modal(替代 `prompt`),包含行数/列数输入和确认/取消按钮。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已有报告中已有的 placeholder 结构不受影响(没有 `data-mode` 属性的占位符默认为 frame 模式)。
|
||||
- Modal 样式复用现有的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | fillPlaceholder 修复;insertImage 改为 Modal;insertTable 改为 Modal;autoCaptureFrames 选择器;insertFrameToPlaceholder 选择器;handleDrop 拦截;新增 3 个 Modal 的 JSX |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | insertImage 改为 Modal;insertTable 改为 Modal;新增 2 个 Modal 的 JSX |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析 — 2026-04-18-00-23-14
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户提出 3 个关于富文本编辑器(ReportEditor / TemplateManage)的优化需求:
|
||||
|
||||
1. **拖拽关键帧后占位符边框残留**:通过拖拽将视频关键帧放入 `.image-placeholder` 后,占位符原有的虚线框和背景色未完全清除,视觉兼容性差。
|
||||
2. **废弃原生 `prompt()`,改为居中 UI 弹窗**:点击「插入表格」和「插入图片占位符」时,当前使用浏览器原生 `prompt()` 弹窗,希望改为屏幕中央的自定义 React 弹窗,以确认表格行列数或占位符最大长宽。
|
||||
3. **占位符图片来源隔离与保护**:创建图片占位符时,可选择允许的图片来源类型(关键帧图片 / 本地上传/签名/素材),从而保护签名等特定区域不被误拖入术中截图。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:修复 `fillPlaceholder`(拖拽填充)未清除内联 `border` 和 `background` 的问题,使图片完全撑满占位符。
|
||||
- **F2**:修复 `autoCaptureFrames` 中自动插入关键帧时同样未清除内联样式的问题。
|
||||
- **F3**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertTable` 的原生 `prompt()` 替换为自定义居中弹窗(输入行数、列数)。
|
||||
- **F4**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertImage` 的原生 `prompt()` 替换为自定义居中弹窗(输入宽度、高度;表格内自动隐藏尺寸输入)。
|
||||
- **F5**:在弹窗中增加「图片来源限制」下拉选项(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成占位符时写入 `data-allow-source` 属性。
|
||||
- **F6**:在 `handleDrop`(拖拽关键帧)中拦截:若占位符限制为 `upload`,拒绝拖入并提示。
|
||||
- **F7**:在 `handleEditorClick`(点击空占位符)中拦截:若占位符限制为 `frame`,拒绝打开图片选择器并提示。
|
||||
- **F8**:在 `insertFrameToPlaceholder`(一键插入关键帧)中拦截:若目标占位符限制为 `upload`,拒绝插入并提示。
|
||||
- **F9**:在 `autoCaptureFrames` 的自动帧插入 `setTimeout` 中拦截:若第一个空置占位符限制为 `upload`,跳过该帧不插入。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:未设置 `data-allow-source` 的旧占位符默认行为不变(视为 `all`)。
|
||||
- 焦点管理:打开弹窗前保存当前 Selection/Range,确认后恢复光标位置再执行 `insertHTML`,确保插入位置正确。
|
||||
- 视觉一致性:弹窗样式与现有 `imagePickerOpen` 弹窗保持一致(固定遮罩 + 白色圆角卡片 + 居中布局)。
|
||||
- 零新依赖:不引入第三方 UI 库,继续使用原生 React state + Tailwind CSS 实现。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 修改 `fillPlaceholder`、`insertTable`、`insertImage`、`handleDrop`、`handleEditorClick`、`insertFrameToPlaceholder`、`autoCaptureFrames`;新增弹窗 state 与 JSX。 |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertTable`、`insertImage`;新增弹窗 state 与 JSX;复用 `savedRangeRef` 做光标恢复。 |
|
||||
| `src/index.css` | 低 | 无需修改,`.image-placeholder.has-image` 的 Tailwind 样式已正确,只需在 JS 中清除内联样式。 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已明确要求本次不经过人工二次确认直接执行。
|
||||
32
工程分析/需求分析-2026-04-18-00-43-19.md
Normal file
32
工程分析/需求分析-2026-04-18-00-43-19.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 需求分析 — 2026-04-18-00-43-19
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户反馈默认模板里的 `class="image-placeholder"` 有问题,要求将默认模板中全部 `image-placeholder` 替换为「按动插入图片占位符之后的状态」,且**只保留当前框的大小不变**。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:分析默认模板 `defaultContent.ts` 中所有 `.image-placeholder` 的当前结构与新弹窗插入逻辑生成结构的差异。
|
||||
- **F2**:为默认模板中所有 `.image-placeholder` 补充 `data-mode` 属性,使其与新的图片来源隔离机制兼容:
|
||||
- 医院 Logo、手术者签名 → `data-mode="manual"`(静态图片占位,仅支持点击插入,禁止拖入关键帧)
|
||||
- 表格内术中影像占位符 → `data-mode="frame"`(手术影像占位,支持拖拽/自动关键帧插入)
|
||||
- **F3**:更新签名占位符的提示文本,使其符合新弹窗的宽度阈值规则(width ≥ 80 时显示「插入/点击放置图片」)。
|
||||
- **F4**:保持所有占位符的现有 `width`、`height` 及外围布局(标签类型、margin、容器结构)绝对不变。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:默认模板仅影响新建报告,已有报告不受影响。
|
||||
- 最小侵入:仅修改 `defaultContent.ts`,不动任何 TSX/JS 逻辑。
|
||||
- `npm run lint` 零错误。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/utils/defaultContent.ts` | 高 | 修改 8 个 `image-placeholder` 的 HTML 结构,补充 `data-mode` 及文本。 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已明确无需人工二次确认。
|
||||
55
工程分析/需求分析-2026-04-18-16-45-02.md
Normal file
55
工程分析/需求分析-2026-04-18-16-45-02.md
Normal 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 备份流程
|
||||
- 无业务代码变更
|
||||
27
工程分析/需求分析-2026-04-18-16-55-47.md
Normal file
27
工程分析/需求分析-2026-04-18-16-55-47.md
Normal 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)
|
||||
|
||||
## 需求影响范围
|
||||
- 报告编辑器交互体验
|
||||
- 右侧基本信息面板渲染逻辑
|
||||
- 默认报告模板内容
|
||||
29
工程分析/需求分析-2026-04-18-17-27-51.md
Normal file
29
工程分析/需求分析-2026-04-18-17-27-51.md
Normal 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` 顶部排版:
|
||||
- **抬头**:左侧 Logo(65×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 修复)
|
||||
|
||||
## 需求影响范围
|
||||
- 模板管理页面的图片占位符插入功能
|
||||
- 新建报告时的默认模板视觉效果
|
||||
- 打印输出时的顶部排版
|
||||
30
工程分析/需求分析-2026-04-18-17-48-59.md
Normal file
30
工程分析/需求分析-2026-04-18-17-48-59.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 需求分析 —— 2026-04-18-17-48-59
|
||||
|
||||
## 需求来源
|
||||
用户基于打印预览效果,提出默认模板排版微调和打印样式修复需求。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:缩减基本信息栏字段间空格
|
||||
当前默认模板中「姓名:性别:年龄:科别:床号:住院号:」之间使用了 ` `(三个不间断空格),间距过大。需缩减为单个空格 ` `。
|
||||
|
||||
### 需求 2:Logo 与医院名/标题靠拢并整体居中
|
||||
当前顶部使用 3 列 table(20%-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)
|
||||
|
||||
## 需求影响范围
|
||||
- 默认报告模板的视觉效果
|
||||
- 打印输出样式
|
||||
- 无业务逻辑变更
|
||||
34
工程分析/需求分析-2026-04-18-18-08-37.md
Normal file
34
工程分析/需求分析-2026-04-18-18-08-37.md
Normal 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:模板排版修复)
|
||||
|
||||
## 需求影响范围
|
||||
- 编辑器工具栏交互
|
||||
- 默认报告模板视觉效果
|
||||
- 打印输出样式
|
||||
39
工程分析/需求分析-2026-04-18-18-36-43.md
Normal file
39
工程分析/需求分析-2026-04-18-18-36-43.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 需求分析 —— 2026-04-18-18-36-43
|
||||
|
||||
## 需求来源
|
||||
用户提出报告管理列名修正、字段下划线控制、下载功能、右对齐排版修复及默认模板调整等五项改进需求。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:ReportManage 列名"患者号"改为"住院号"
|
||||
报告管理列表中表头显示为"患者号",但实际数据对应的是住院号字段,需修正列名。
|
||||
|
||||
### 需求 2:TemplateManage 字段管理增加"下划线"控制
|
||||
在模板管理的字段管理面板中,每个字段增加"打印时显示下划线"单选框,默认勾选。若取消勾选,则该字段在打印输出时不显示底部下划线。需在 `FormField` 数据结构中增加 `hasUnderline` 属性,并在打印样式中支持 `.no-underline` 类。
|
||||
|
||||
### 需求 3:ReportEditor / 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`(需求 2:FormField 扩展)
|
||||
- `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)
|
||||
|
||||
## 需求影响范围
|
||||
- 报告管理列表展示
|
||||
- 模板字段配置体系
|
||||
- 编辑器/模板管理器工具栏交互
|
||||
- 打印输出样式
|
||||
- 文件导出功能
|
||||
41
工程分析/需求分析-2026-04-18-19-08-43.md
Normal file
41
工程分析/需求分析-2026-04-18-19-08-43.md
Normal 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> </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)
|
||||
|
||||
## 需求影响范围
|
||||
- 默认模板打印样式
|
||||
- 编辑器交互体验
|
||||
- 视频分析面板布局
|
||||
- 图片占位符自适应行为
|
||||
30
工程分析/需求分析-2026-04-18-19-23-31.md
Normal file
30
工程分析/需求分析-2026-04-18-19-23-31.md
Normal 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)**成图片实际缩放后的尺寸
|
||||
|
||||
### 需求 3:Logo 框大小保持 65px × 65px
|
||||
默认模板中顶部医院 Logo 占位符的尺寸应保持 65px × 65px 不变。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/ReportEditor.tsx`(需求 1、2)
|
||||
- `src/pages/TemplateManage.tsx`(需求 2)
|
||||
|
||||
## 需求影响范围
|
||||
- 视频分析面板的可见性逻辑
|
||||
- 图片占位符的填充后样式行为
|
||||
- 打印/预览时的图片尺寸表现
|
||||
29
工程分析/需求分析-2026-04-18-19-37-56.md
Normal file
29
工程分析/需求分析-2026-04-18-19-37-56.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 需求分析 —— 2026-04-18-19-37-56
|
||||
|
||||
## 需求来源
|
||||
用户在实际使用中发现 4 个编辑器体验问题,要求进行修复和优化。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:视频上传按钮位置调整
|
||||
在 `ReportEditor` 的「视频分析」面板中,「上传视频」按钮当前位于视频缩略图列表的首位。用户希望将其移至列表末尾,以符合「先列出已有视频,最后提供添加操作」的操作直觉。
|
||||
|
||||
### 需求 2:图片占位符提示文字绝对居中
|
||||
图片占位符(`.image-placeholder`)内的提示文字(如「插入/点击放置图片」)目前未在框中绝对居中。当占位符高度较大或行高不一时,文字会偏上或偏下。用户希望文字在占位符内绝对居中显示。
|
||||
|
||||
### 需求 3:删除图片后占位符恢复原始大小
|
||||
当向图片占位符插入图片后,占位符会收缩到图片实际尺寸(`width:auto; height:auto`)。但点击「×」删除图片后,占位符不会恢复为原始预设大小,而是保持收缩后的尺寸。用户希望删除后占位符能恢复为最初创建时的宽度和高度。
|
||||
|
||||
### 需求 4:对齐按钮导致混合排版换行
|
||||
点击富文本工具栏的「左对齐/居中/右对齐」按钮时,浏览器原生的 `document.execCommand('justifyLeft')` 等命令会粗暴地用 `<div align="left">` 包裹选区,导致包含 `.field-value` 或 `.image-placeholder` 的段落被肢解,文字与输入框/图片强制换行分离。用户希望对齐操作安全地作用于整个段落,不破坏混合排版结构。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/ReportEditor.tsx`(需求 1、2、3、4)
|
||||
- `src/pages/TemplateManage.tsx`(需求 2、3、4)
|
||||
- `src/utils/defaultContent.ts`(需求 2、3)
|
||||
|
||||
## 需求影响范围
|
||||
- 视频分析面板布局
|
||||
- 图片占位符的视觉表现和交互反馈
|
||||
- 富文本对齐功能的实现方式
|
||||
- 默认模板中占位符的 HTML 结构
|
||||
52
工程分析/需求分析-2026-04-18-20-03-44.md
Normal file
52
工程分析/需求分析-2026-04-18-20-03-44.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 需求分析 —— 2026-04-18-20-03-44
|
||||
|
||||
## 需求来源
|
||||
用户希望增强模板管理模块的数据迁移能力和默认模板的交互一致性。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:模板导出功能
|
||||
在 `TemplateManage` 的模板列表中,新增「导出」按钮。导出内容需包含:
|
||||
- 模板名称(`title`)
|
||||
- 模板描述(`description`)
|
||||
- 模板内容(`content`)
|
||||
- 字段管理配置(`fields`)
|
||||
|
||||
导出格式为 JSON,结构如下:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"type": "surclaw_template_package",
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"content": "...",
|
||||
"fields": []
|
||||
}
|
||||
```
|
||||
|
||||
### 需求 2:模板导入功能
|
||||
在「新增模板」弹窗中,新增「导入本地模板」选项。用户选择 JSON 文件后:
|
||||
- 自动解析并填充模板名称和描述到表单
|
||||
- 暂存模板内容和字段配置
|
||||
- 点击「创建」时,将暂存的内容和字段一并写入新模板
|
||||
|
||||
导入 UI 使用指定的样式类名:`w-8 h-8 bg-accent text-white rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors shadow-sm`
|
||||
|
||||
### 需求 3:Logo 替换为可交互占位符
|
||||
默认模板 `defaultContent.ts` 中顶部医院 Logo 当前为硬编码的 `<span>` 结构(非标准 `image-placeholder`),导致:
|
||||
- 无法点击右上方的「×」删除
|
||||
- 无法触发图片上传/选择逻辑
|
||||
- 与编辑器中其他图片占位符的交互不一致
|
||||
|
||||
需将其替换为标准的 65×65 `image-placeholder`(`data-mode="manual"`),使其支持删除、点击插入等完整交互。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/TemplateManage.tsx`(需求 1、2)
|
||||
- `src/utils/defaultContent.ts`(需求 3)
|
||||
- `src/types.ts`(确认 Template 类型结构)
|
||||
|
||||
## 需求影响范围
|
||||
- 模板列表操作列新增导出按钮
|
||||
- 新增模板弹窗新增导入 UI 和逻辑
|
||||
- 默认模板头部 Logo 的 HTML 结构
|
||||
- 模板创建流程需支持字段配置写入
|
||||
32
工程分析/需求分析-2026-04-18-22-59-10.md
Normal file
32
工程分析/需求分析-2026-04-18-22-59-10.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 需求分析 —— 2026-04-18-22-59-10
|
||||
|
||||
## 需求来源
|
||||
用户希望调整字段默认下划线行为,并修复占位符文字居中的样式问题。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:所有字段默认打印时不显示下划线
|
||||
当前字段管理中,新增字段的「打印时显示下划线」复选框默认勾选(`hasUnderline` 默认为 `true`)。用户希望改为默认不勾选,即所有现有字段和新增字段在打印时默认不显示下划线。
|
||||
|
||||
具体改动点:
|
||||
- `newFieldHasUnderline` 状态默认值从 `true` 改为 `false`
|
||||
- 编辑字段回显时,`hasUnderline` 回退值从 `true` 改为 `false`
|
||||
- `insertSmartField` 中类名判断逻辑改为:只要 `hasUnderline !== true` 就加 `.no-underline`
|
||||
- `defaultContent.ts` 中 `smartField()` 直接给所有字段加 `.no-underline`
|
||||
|
||||
### 需求 2:修复删除图片后占位符文字偏左
|
||||
删除图片后,占位符恢复为默认状态,但提示文字(如「插入/点击放置图片」)在虚线框内偏左,未真正居中。
|
||||
|
||||
原因分析:虽然使用了 `position:absolute + transform:translate(-50%, -50%)`,但 `placeholder-text` 是 `display:block; width:100%` 的块级元素,其内部文本流默认 `text-align:left`,导致文字靠左。
|
||||
|
||||
修复方案:在所有 `.placeholder-text` 的 style 中追加 `text-align:center;`。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/TemplateManage.tsx`(需求 1、2)
|
||||
- `src/utils/defaultContent.ts`(需求 1、2)
|
||||
- `src/pages/ReportEditor.tsx`(需求 2)
|
||||
|
||||
## 需求影响范围
|
||||
- 字段管理的默认值和插入逻辑
|
||||
- 默认模板中所有 smartField 的下划线行为
|
||||
- 所有图片占位符的提示文字对齐方式
|
||||
43
工程分析/需求分析-2026-04-18-23-19-44.md
Normal file
43
工程分析/需求分析-2026-04-18-23-19-44.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 需求分析 —— 2026-04-18-23-19-44
|
||||
|
||||
## 需求来源
|
||||
用户在实际使用和打印预览中发现多项排版和功能优化点。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:修复 field-value 输入内容往上飘
|
||||
`.field-value` 输入框中的文字与模板正文不在同一基线上,总是向上偏移。即使去掉下划线,也希望文字内容与周围正文齐平。
|
||||
|
||||
### 需求 2:姓名栏下方横线距离过远
|
||||
「姓名:」下方的横线(`border-bottom`)与「姓名:」文字之间的距离太远,希望缩小到约 1px。
|
||||
|
||||
### 需求 3:手术记录标题距上方横线过远
|
||||
「手术记录」标题与上方医院名称的横线之间距离过大,希望缩小到约 2px。
|
||||
|
||||
### 需求 4:Logo 插图位置微调
|
||||
Logo 占位符相对于「西安交通大学第一附属医院 手术记录」的文字整体偏右下,希望向左移动 5px,向上移动 5px。
|
||||
|
||||
### 需求 5:导出 PDF 文件名修正
|
||||
点击「导出报告」导出 PDF 时,浏览器默认文件名为「My Google AI Studio App.pdf」,希望改为与报告内容相关的自定义文件名(如 `图文报告-{title}-{patient}-{hid}-{time}.pdf`)。
|
||||
|
||||
### 需求 6:导出 JSON 文件名时间使用北京时间
|
||||
导出 JSON 时文件名中的时间戳使用 `new Date().toISOString()`(UTC 时间),希望改为北京时间(UTC+8)。
|
||||
|
||||
### 需求 7:模板管理批量操作
|
||||
在模板列表中为每个模板增加复选框,支持:
|
||||
- 批量导出(将选中的多个模板打包为一个 JSON 文件)
|
||||
- 批量删除(删除选中的多个模板)
|
||||
- 允许列表中不留任何模板
|
||||
|
||||
## 涉及文件
|
||||
- `src/utils/defaultContent.ts`(需求 1、2、3、4)
|
||||
- `src/utils/print.ts`(需求 1、5)
|
||||
- `src/pages/ReportEditor.tsx`(需求 5、6)
|
||||
- `src/pages/ReportManage.tsx`(需求 6)
|
||||
- `src/pages/TemplateManage.tsx`(需求 6、7)
|
||||
|
||||
## 需求影响范围
|
||||
- 默认模板排版细节(基线对齐、间距、Logo 位置)
|
||||
- 打印样式(下划线紧贴文字)
|
||||
- 导出文件名生成逻辑
|
||||
- 模板列表交互(复选框、批量操作)
|
||||
34
工程分析/需求分析-2026-04-18-23-39-35.md
Normal file
34
工程分析/需求分析-2026-04-18-23-39-35.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 需求分析 —— 2026-04-18-23-39-35
|
||||
|
||||
## 需求来源
|
||||
用户在实际使用中发现下划线功能异常、导出文件名不一致、输入框间距过大、以及表单缺乏逆向联动等问题。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:修复下划线勾选状态异常及打印失效
|
||||
1. **默认勾选未取消**:`DEFAULT_FORM_FIELDS` 中的基础字段(如患者姓名、住院号)默认 `hasUnderline` 仍为 `true` 或未指定,导致编辑弹窗中仍显示为勾选状态。
|
||||
2. **打印失效**:`print.ts` 中 `@media print` 的样式逻辑有问题,导致无论是否勾选「打印时显示下划线」,打印时都不显示下划线。
|
||||
3. **下划线紧贴文字**:用户希望勾选后的下划线紧贴文字底部。
|
||||
|
||||
### 需求 2:统一 PDF 和 JSON 导出文件名
|
||||
当前 PDF 导出文件名与 JSON 不一致(缺少时间后缀或格式不同),希望两者完全一致。
|
||||
|
||||
### 需求 3:缩紧 field-value 内文字间距
|
||||
`.field-value` 当前有 `padding:0 4px; margin:0 2px`,导致框内文字偏右,打印时左右间距过大。希望缩小 padding 和 margin。
|
||||
|
||||
### 需求 4:ReportEditor 表单逆向联动
|
||||
当前实现了「点击中间模板字段 → 右侧表单高亮滚动」,但反向逻辑缺失:点击右侧表单输入框时,中间模板内的对应 `.field-value` 不会高亮,也不会滚动到对应位置。
|
||||
|
||||
## 涉及文件
|
||||
- `src/types.ts`(需求 1)
|
||||
- `src/pages/TemplateManage.tsx`(需求 1)
|
||||
- `src/utils/print.ts`(需求 1、2、3)
|
||||
- `src/utils/defaultContent.ts`(需求 3)
|
||||
- `src/pages/ReportEditor.tsx`(需求 2、4)
|
||||
- `src/pages/ReportManage.tsx`(需求 2)
|
||||
|
||||
## 需求影响范围
|
||||
- 字段默认配置数据
|
||||
- 打印样式逻辑
|
||||
- 输入框内边距/外边距
|
||||
- 编辑器双向联动交互
|
||||
26
工程分析/需求分析-2026-04-19-00-01-50.md
Normal file
26
工程分析/需求分析-2026-04-19-00-01-50.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 需求分析 —— 2026-04-19-00-01-50
|
||||
|
||||
## 需求来源
|
||||
用户在实际使用中发现高亮样式刺眼、点击空白不取消高亮、以及下划线勾选无效的问题。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:高亮蓝框太明显
|
||||
ReportEditor 中 `.field-value` 激活时的蓝框(`box-shadow: 0 0 0 2px #3b82f6` + `#eff6ff` 背景)过于刺眼,希望改为更柔和的选中效果(类似 TemplateManage 中的淡色高亮)。
|
||||
|
||||
### 需求 2:点击空白处高亮不消失 + 打印带蓝框
|
||||
1. 点击模板空白区域时,`.field-value` 的高亮样式不会自动清除。
|
||||
2. 打印/PDF 导出时,高亮的内联样式(box-shadow、backgroundColor)会带入打印件,导致打印内容出现蓝框。
|
||||
|
||||
### 需求 3:下划线勾选无效
|
||||
在 TemplateManage 的字段管理中勾选「打印时显示下划线」并保存后,已插入到模板中的 `.field-value` 仍然带有 `.no-underline` 类,导致打印时不显示下划线。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/ReportEditor.tsx`(需求 1、2)
|
||||
- `src/utils/print.ts`(需求 2)
|
||||
- `src/pages/TemplateManage.tsx`(需求 3)
|
||||
|
||||
## 需求影响范围
|
||||
- 编辑器高亮交互体验
|
||||
- 打印样式纯净度
|
||||
- 字段配置与 DOM 的同步机制
|
||||
13
工程分析/需求分析-2026-04-19-00-13-20.md
Normal file
13
工程分析/需求分析-2026-04-19-00-13-20.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 需求分析 —— 2026-04-19-00-13-20
|
||||
|
||||
## 需求来源
|
||||
用户反馈打印时字段下划线与文字之间距离过大,视觉上不够紧凑。
|
||||
|
||||
## 需求概述
|
||||
在打印/PDF导出时,`.field-value` 的 `border-bottom`(下划线)与文字之间存在行高留白,导致横线没有紧贴文字底部。需要压缩行高以消除底部留白。
|
||||
|
||||
## 涉及文件
|
||||
- `src/utils/print.ts`
|
||||
|
||||
## 需求影响范围
|
||||
仅影响打印/导出PDF时的下划线视觉效果,不影响屏幕编辑态。
|
||||
23
工程分析/需求分析-2026-04-19-00-24-02.md
Normal file
23
工程分析/需求分析-2026-04-19-00-24-02.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 需求分析 —— 2026-04-19-00-24-02
|
||||
|
||||
## 需求来源
|
||||
用户在使用 Dashboard 首页时发现统计卡片缺失全部报告数,且图表存在日期文字与轴线重叠的视觉问题,同时希望增加时间维度切换能力。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:新增"全部报告总数"卡片
|
||||
在 Dashboard 统计卡片区域,紧邻"本月报告总数"左侧,新增一个"全部报告总数"数据卡片,显示当前用户可见的所有报告数量。
|
||||
|
||||
### 需求 2:修复图表 X 轴日期文字与轴线重叠
|
||||
报告增长趋势图表中,底部 X 轴日期文字(如 4/13、4/14 等)与轴线/数据线发生重叠,影响可读性。需要调整 Canvas 绘制坐标,增大底部留白并下移文字。
|
||||
|
||||
### 需求 3:图表时间维度切换
|
||||
为报告增长趋势图表增加"最近 7 天"和"最近 30 天"的切换按钮,动态重新计算趋势数据和标签。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/Dashboard.tsx`
|
||||
|
||||
## 需求影响范围
|
||||
- Dashboard 首页统计卡片布局
|
||||
- Canvas 趋势图表绘制逻辑
|
||||
- 统计数据计算逻辑
|
||||
22
工程分析/需求分析-2026-04-19-00-33-44.md
Normal file
22
工程分析/需求分析-2026-04-19-00-33-44.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 需求分析 —— 2026-04-19-00-33-44
|
||||
|
||||
## 需求来源
|
||||
用户发现 Dashboard 中"最近 30 天"模式下的趋势图表过于密集:30 个数据圆点、30 个数值文本、30 个日期标签挤在一起,完全无法阅读。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:30 天模式稀疏化显示
|
||||
在"最近 30 天"模式下:
|
||||
- 不绘制每一天的数据圆点 `<circle>` 和数值文本 `<text>`
|
||||
- 仅保留平滑的面积图轮廓和折线
|
||||
- X 轴日期标签每隔 5 天显示一次(稀疏化)
|
||||
|
||||
### 需求 2:Tooltip 悬停交互
|
||||
- 平时隐藏具体数值
|
||||
- 鼠标悬停在曲线区域时,显示浮动 Tooltip,展示该位置对应的日期和报告数量
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/Dashboard.tsx`
|
||||
|
||||
## 需求影响范围
|
||||
仅影响 Dashboard 趋势图表的 SVG 渲染和交互逻辑,7 天模式保持不变。
|
||||
26
工程分析/需求分析-2026-04-19-01-03-37.md
Normal file
26
工程分析/需求分析-2026-04-19-01-03-37.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 需求分析 —— 2026-04-19-01-03-37
|
||||
|
||||
## 需求来源
|
||||
用户在上次回退后重新提出三个优化需求,并明确要求 frameCount 保持 12 不变,仅调整 autoInsertFrameIndices 为间隔抽取。
|
||||
|
||||
## 需求概述
|
||||
|
||||
### 需求 1:默认自动插入帧改为间隔抽取(第1、3、5、7、9、11帧)
|
||||
系统初始化或重置时,frameCount 保持 12 不变,但 autoInsertFrameIndices(自动插入的帧索引)从 `[0,1,2,3,4,5]`(连续前6帧)改为 `[0,2,4,6,8,10]`(间隔抽取,对应第1、3、5、7、9、11帧)。
|
||||
|
||||
### 需求 2:模板导出 JSON 文件名加时间戳
|
||||
TemplateManage 单模板导出时,文件名从 `模板导出-模板名称.json` 改为 `模板导出-模板名称-时间戳.json`。
|
||||
|
||||
### 需求 3:TemplateManage 占位符删除后文字居中
|
||||
对齐 ReportEditor 和 TemplateManage 的 `.image-placeholder` 删除恢复逻辑,补齐居中样式和尺寸恢复。
|
||||
|
||||
## 涉及文件
|
||||
- `src/pages/Login.tsx` — 需求 1
|
||||
- `src/pages/SystemSettings.tsx` — 需求 1(resetToDefault 补全)
|
||||
- `src/pages/TemplateManage.tsx` — 需求 2、3
|
||||
|
||||
## 需求影响范围
|
||||
- 系统默认配置(新用户/重置后)
|
||||
- 系统设置重置功能
|
||||
- 模板导出文件名格式
|
||||
- TemplateManage 编辑器占位符交互体验
|
||||
Reference in New Issue
Block a user