diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..50ccb4f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,289 @@ +# AGENTS.md + +> 本文档面向 AI 编程助手。阅读者应假定对项目一无所知。所有信息均基于项目实际代码和开发历史,不做假设性推断。 + +--- + +## 项目概述 + +**手术图文病历报告系统**(版本 1.3)是一款纯前端、单页应用(SPA),面向医院手术室场景,用于: + +- 通过富文本编辑器撰写结构化手术图文报告 +- 上传手术视频并自动/手动抽取关键帧,拖拽插入报告 +- 管理报告模板、用户权限、系统设置 +- 导出 PDF / JSON 格式的报告和模板 + +**核心特征**: +- **无后端服务器**:所有数据(用户、报告、模板、设置、素材)持久化在浏览器 `localStorage` 中 +- **离线可用**:部署后即为静态文件,无需网络 API +- **A4 打印优先**:编辑器按 A4 尺寸(210mm × 297mm)排版,支持浏览器打印转 PDF +- **角色权限控制**:三级角色 `super`(超级管理员)/`admin`(科室管理员)/`user`(医生) + +--- + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 框架 | React 19 + TypeScript 5.8 | +| 构建工具 | Vite 6 | +| 样式 | Tailwind CSS v4(使用 `@theme` 和 `@import "tailwindcss"` 新语法) | +| 路由 | React Router DOM v7 | +| 图标 | lucide-react | +| 动画 | motion | +| AI SDK | `@google/genai`(依赖已安装,但当前源码中**未实际调用**任何 LLM API) | +| 运行时 | 纯浏览器客户端;`express` 仅在依赖列表中,未被源码使用 | + +--- + +## 项目结构 + +``` +├── public/ # 静态资源(favicon、logo_square.png) +├── src/ +│ ├── components/ +│ │ └── Sidebar.tsx # 左侧导航栏(角色过滤、自动折叠) +│ ├── pages/ +│ │ ├── Login.tsx # 登录页 + 全局初始化(默认用户/模板/字段/素材) +│ │ ├── Dashboard.tsx # 工作台(统计卡片、SVG 趋势图) +│ │ ├── ReportEditor.tsx# 核心:报告编辑器(2,200+ 行,最大文件) +│ │ ├── ReportManage.tsx# 报告列表(搜索、筛选、批量操作、历史回溯) +│ │ ├── ReportView.tsx # 报告只读查看 + 打印 +│ │ ├── TemplateManage.tsx # 模板编辑器(1,600+ 行,自定义 Undo/Redo) +│ │ ├── UserManage.tsx # 用户管理(RBAC、签名上传、模板权限) +│ │ └── SystemSettings.tsx # 系统设置(抽帧配置、AI API、默认模板) +│ ├── utils/ +│ │ ├── storage.ts # localStorage / sessionStorage 封装 +│ │ ├── print.ts # iframe 打印工具(A4 样式、@page 边距) +│ │ └── defaultContent.ts # 默认模板 HTML(腹腔镜胆囊切除术报告) +│ ├── App.tsx # BrowserRouter + 路由表 +│ ├── main.tsx # React 根挂载(StrictMode) +│ ├── types.ts # 核心 TypeScript 类型(User/Report/Template/FormField 等) +│ └── index.css # Tailwind 入口 + @theme 变量 + 打印媒体查询 +├── Dockerfile # 多阶段构建:node builder → nginx alpine +├── docker-compose.yaml # 映射宿主机 4002 → 容器 80 +├── nginx.conf # SPA 回退、Gzip、静态缓存 +├── vite.config.ts # Vite + Tailwind 插件、GEMINI_API_KEY 注入 +├── tsconfig.json # ES2022、react-jsx、路径别名 `@/*` +├── package.json +├── .env.example # GEMINI_API_KEY、APP_URL +├── index.html # 入口 HTML(标题 "My Google AI Studio App") +└── 过往经验/ # 开发经验记录(经验记录-1.md / 经验记录-2.md) +``` + +--- + +## 构建与部署命令 + +```bash +# 开发 +npm run dev # vite --port=3000 --host=0.0.0.0 + +# 生产构建 +npm run build # vite build → dist/ +npm run preview # vite preview(默认端口 4173) + +# 清理 +npm run clean # rm -rf dist + +# 类型检查(唯一 lint 手段,无 ESLint) +npm run lint # tsc --noEmit +``` + +**Docker 部署**: +```bash +docker-compose up -d --build +# 宿主机访问 http://localhost:4002 +``` + +**无 Docker 环境部署**(Windows 等): +```bash +npm run build +npm run preview +# 或任何静态文件服务器托管 dist/ +``` + +--- + +## 数据持久化与状态管理 + +- **无全局状态库**(无 Redux、Zustand、Context API) +- 每个页面独立通过 `useState` + `useEffect` 管理状态 +- **`localStorage` 即数据库**。关键键名: + - `users` — 用户列表 + - `currentUser` — 当前登录用户 + - `reports` — 报告列表 + - `templates` — 模板列表 + - `systemSettings` — 系统设置 + - `formFieldsConfig` — 动态字段配置 + - `imageAssets` — 系统素材库(Base64 图片) + - `reportEditorDraft_${username}` — 每用户报告草稿 + - `customTimeFormats` — 用户自定义时间格式缓存 + +⚠️ **localStorage 容量约 5MB**。关键帧图片采用 Canvas 压缩(最大宽度 800px、JPEG 质量 0.6)以避免超限。`storage.ts` 中的异常已改为 `console.error` 输出,不再静默吞掉。 + +--- + +## 核心模块说明 + +### 1. 富文本编辑器(ReportEditor.tsx / TemplateManage.tsx) + +- **底层**:原生 `contentEditable` + `document.execCommand` +- **智能字段(Smart Field)**:三层嵌套结构 + ```html + + 标签: + + ​ + ``` + - 外层 `contenteditable="false"` 保护标签不被逐字删除 + - 输入层 `data-bind` 实现与右侧表单的双向绑定 + - 末尾追加 `​`(零宽空格)防止排版换行异常 +- **图片占位符**:``,支持 `data-mode="frame|manual"` 分类隔离 +- **自定义 Undo/Redo**:TemplateManage 中已实现基于 HTML 字符串快照的自定义历史栈(`undoStack`/`redoStack`),取代不可靠的浏览器原生 undo + +### 2. 视频分析(ReportEditor.tsx) + +- 上传本地视频 → 生成 object URL +- 自动抽帧:按 `systemSettings.framePositions` 百分比位置逐帧截图 +- 手动截图:点击按钮从当前播放时间捕获 +- 图片压缩:Canvas 等比缩放至最大 800px 宽,JPEG 质量 0.6 +- 拖拽/一键插入:填充到第一个空置的 `image-placeholder:not([data-mode="manual"])` +- 自动帧插入:非阻塞 `setTimeout` 队列式插入,避免阻塞抽帧循环 + +### 3. 打印系统(src/utils/print.ts) + +- 创建隐藏 iframe,写入带 A4 打印样式的 HTML +- `@page { margin: 15mm 10mm; }` 为**每一页**纸张独立分配边距 +- `body { padding: 0 }` — 不可用 body padding 代替 @page margin,否则第二页及后续页边距失效 +- 打印前临时设置 `document.title` 并注入 iframe ``,确保 PDF 默认文件名正确 +- 打印后恢复原始标题 + +### 4. 角色权限(RBAC) + +| 角色 | 权限 | +|------|------| +| `super` | 全部页面、全部数据 | +| `admin` | 仅管理本科室用户;可管理模板;不能看系统设置中的 AI 配置 | +| `user` | 仅创建/查看/编辑自己的报告;可见被分配模板 | + +- `Sidebar.tsx` 根据 `currentUser.role` 过滤导航项 +- `UserManage.tsx` 中 admin 只能管理 `department` 与自己相同的用户 + +--- + +## 开发规范与约定 + +### 代码风格 +- TypeScript 严格模式未开启;`skipLibCheck: true` +- 路径别名 `@/` 映射到项目根目录(`src/` 同级) +- **无 ESLint、无 Prettier、无格式化配置** +- 所有字符串插值、UI 文案、注释均以**中文**为主 + +### 关键开发教训(必读) + +以下经验来自 `过往经验/` 中的 40+ 条记录,是修改本项目时**最容易踩的坑**: + +#### A. contentEditable 与 DOM 操作 +1. **插入 HTML 必须为紧凑单行**:使用 `document.execCommand('insertHTML', ...)` 或 `Range.insertNode()` 时,多行模板字符串中的缩进/换行会被浏览器解析为额外文本节点,破坏排版和光标行为。 +2. **删除字段用 `Range.selectNode + execCommand('delete')`**:直接 `target.remove()` 会绕过浏览器撤销栈,且可能在 WebKit 中误删父级 `<p>`。 +3. **任何直接操作 DOM 修改编辑器内容后,必须紧跟**: + ```ts + contentRef.current = editorRef.current.innerHTML; + ``` +4. **在表格 `<td>` 内插入复杂 inline 元素时**:优先使用块级 `<div>` 作为外层容器,`execCommand('insertHTML')` 对 `<td>` 内的 inline-flex 嵌套会自动"拍平"结构。 +5. **对齐操作弃用 `execCommand('justifyLeft'...)`**:改用直接设置 `block.style.textAlign = align`,避免浏览器对混合排版(文字 + 智能字段/占位符)的肢解。 + +#### B. 自动保存与 Ref/State 同步 +1. **永远不要将 `useRef` 作为自动保存的唯一数据源**:React 18 `StrictMode` 的"挂载 → 卸载 → 重挂载"会导致 ref 在首次卸载时仍保持初始空值,从而用空数据覆盖有效的 localStorage draft。 +2. **自动保存函数应直接从最新的 React state 和 DOM 读取数据**,通过 `useCallback` + 完整 dependency 数组保证闭包新鲜;或从 `stateRef` / `contentRef` 读取稳定快照,但必须在**所有**数据恢复路径中同步 ref。 +3. **`setState` 是异步的**:`setCapturedFrames(next); saveDraftToStorage();` 的写法会导致闭包读到旧值。若需即时保存,应在 `setState` 回调中触发保存,或从 ref 读取。 +4. **组件卸载时 DOM 可能已失效**:`editorRef.current?.innerHTML` 在卸载阶段可能为空,应优先使用 `contentRef.current`(内存引用)。 +5. **异步循环中不要 `await` 阻塞主流程**:自动帧插入使用 `setTimeout` 推入事件队列,而非 `await new Promise(...)`。 + +#### C. 图片占位符体系 +1. 占位符涉及**三处必须同步修改**:`defaultContent.ts`(静态模板)、`ReportEditor.tsx`(运行时插入/填充/删除恢复)、`TemplateManage.tsx`(模板管理)。 +2. 占位符创建时需写入 `max-width` / `max-height`;填充后改为 `width:auto; height:auto`,让图片 shrink-wrap;删除恢复时需回读 `maxWidth/maxHeight` 重置尺寸。 +3. 提示文字使用 `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center;`,要求父容器带 `position:relative`。 +4. 占位符分类隔离:通过 `data-mode="frame|manual"` 区分;自动插入和拖拽填充时必须用 `:not([data-mode="manual"])` 过滤。 + +#### D. 时间/日期字段格式系统 +1. 存储格式与显示格式分离:`YYYY-MM-DD` / `HH:mm` 存储;`YYYY年MM月DD日` / `hh:mm A` 显示。 +2. 必须同时实现**正向格式化**(存储 → 显示)和**反向解析**(显示 → 存储),否则编辑器内直接编辑 smart field 会导致数据混乱。 +3. 12h/24h 判断使用包含性判断:`field.timeFormat.includes('hh') || field.timeFormat.includes('A')`,避免精确匹配无法覆盖自定义格式。 +4. 默认值策略:「固定时间」/`specific` 与「当前时间」/`current`。自动填充必须加「仅当值为空时触发」保护,防止编辑已有报告时覆盖用户数据。 +5. 时间格式 token 体系:`YYYY`、`MM`、`DD`、`HH`、`hh`、`mm`、`A`。避免使用简写别名如 `'24h'`、`'12h'` 作为存储值。 + +#### E. 事件与交互 +1. **工具栏/字段库按钮必须加 `onMouseDown={(e) => e.preventDefault()}`**:防止点击时编辑器失焦导致 `Selection/Range` 丢失。 +2. **插入操作前恢复 `savedRangeRef`**:作为焦点流失后的兜底保险。 +3. **双向联动高亮**:通过 `activeFieldKey` 状态 + `useEffect` 直接操作 DOM style(`backgroundColor`、`outline`),避免触发组件重渲染导致光标丢失。点击非字段区域时清空高亮。 +4. 打印样式必须通过 `@media print` 强制抹除所有交互高亮内联样式(`outline: none !important; box-shadow: none !important;`)。 + +#### F. 数据初始化与默认值 +1. `Login.tsx` 的 `initData()` 是全局唯一初始化入口:默认用户、默认模板、默认字段配置、默认设置、素材预加载(logo)均应在此处完成。 +2. 新增 `localStorage` key 时需提供合理的默认值或降级处理。 +3. `resetToDefault` / 恢复出厂设置函数必须**包含所有** `SystemSettings` 字段,不能遗漏新增配置项。 +4. 修改 `DEFAULT_FORM_FIELDS` 默认值后,已有用户的 `localStorage` 中旧配置不会自动更新;若变更影响核心功能,应考虑启动时做配置迁移或版本校验。 +5. 批量操作后必须同步清理 `selectedIds` 和当前选中状态,避免选中已删除项。 + +--- + +## 测试策略 + +**当前状态:零自动化测试。** + +- 无单元测试、无集成测试、无 E2E 测试 +- 无 Jest、Vitest、Playwright、Cypress 配置 +- 唯一类型检查:`npm run lint`(`tsc --noEmit`) + +**建议补充方向**(如用户要求): +- `storage.ts` 的 JSON 序列化/反序列化 +- `types.ts` 中日期/时间格式化与解析函数的正反向一致性 +- 报告编辑器的草稿保存/恢复逻辑 + +--- + +## 安全注意事项 + +1. **密码明文存储**:用户密码以明文形式保存在 `localStorage` 的 `users` 数组中。这是纯前端架构的固有限制,**不适合生产环境处理真实敏感数据**。 +2. **无 HTTPS 强制**:Docker 部署默认 HTTP 80 端口。 +3. **无 API 鉴权**:无后端,因此无 Token、Session、CSRF 防护概念。 +4. **XSS 风险**:报告和模板内容直接以 HTML 字符串存储并在 `innerHTML` 中渲染。当前通过 `contentEditable` 限制输入来源,但若导入外部 JSON 模板/报告,需警惕恶意脚本。 +5. **Gemini API Key**:通过 Vite `define` 注入客户端,构建后 key 会暴露在静态 JS 中(当前源码未实际调用)。 + +--- + +## 部署环境变量 + +复制 `.env.example` 为 `.env`: + +``` +GEMINI_API_KEY="YOUR_KEY" # Google Gemini API Key(当前未在业务代码中使用) +APP_URL="YOUR_APP_URL" # 应用托管 URL +``` + +Vite 构建时仅将 `GEMINI_API_KEY` 注入 `process.env.GEMINI_API_KEY`,其余变量不自动暴露给客户端。 + +--- + +## 默认账号(首次登录或清空数据后) + +| 账号 | 密码 | 角色 | +|------|------|------| +| admin | 123456 | super | +| manager | 123456 | admin | +| doctor / 0001 | 123456 | user | + +--- + +## 修改前必读检查清单 + +在修改任何涉及以下内容的功能前,请先搜索并同步检查所有相关文件: + +- [ ] **智能字段结构** → `types.ts`、`defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`、`index.css`、`print.ts` +- [ ] **图片占位符(创建/填充/删除恢复)** → `defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx` +- [ ] **打印样式** → `print.ts`、`index.css`(`@media print`) +- [ ] **时间/日期格式** → `types.ts`、`ReportEditor.tsx`、`TemplateManage.tsx` +- [ ] **数据初始化/默认值** → `Login.tsx`、`SystemSettings.tsx` +- [ ] **自动保存/草稿** → `ReportEditor.tsx` 中的 `saveDraftToStorage`、`stateRef`、`contentRef` diff --git a/过往经验/经验记录-1.md b/过往经验/经验记录-1.md new file mode 100644 index 0000000..d0ec5d6 --- /dev/null +++ b/过往经验/经验记录-1.md @@ -0,0 +1,1360 @@ +# 经验记录 + +> 本文档为项目统一知识库,记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读,防止重复踩坑。 +> 记录格式:A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题 + +--- + +## 记录 1:report-editor 新建报告时显示空白模板 + +**A. 具体问题** +超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。 + +**B. 产生问题原因** +1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。 +2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。 +3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。 +4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。 + +**C. 解决问题方案** +1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。 +2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。 +3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。 + +**D. 后续如何避免问题** +- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。 +- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。 +- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。 + +--- + +## 记录 2:关键帧一键插入占位符功能实现 + +**A. 具体问题** +用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。 + +**B. 产生问题原因** +原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。 + +**C. 解决问题方案** +1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。 +2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。 +3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。 + +**D. 后续如何避免问题** +- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。 +- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。 +- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。 + +--- + +## 记录 3:关键帧 "插入" 按钮位置与样式优化 + +**A. 具体问题** +用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。 + +**B. 产生问题原因** +初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。 + +**C. 解决问题方案** +1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。 +2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。 +3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。 + +**D. 后续如何避免问题** +- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。 +- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。 +- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。 + +--- + +## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部) + +**A. 具体问题** +用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。 + +**B. 产生问题原因** +按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。 + +**C. 解决问题方案** +1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。 +2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。 +3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。 +4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。 + +**D. 后续如何避免问题** +- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。 +- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。 +- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。 + +--- + +## 记录 5:路由切换后视频分析图片丢失 + +**A. 具体问题** +在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。 + +**B. 产生问题原因** +1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。 +2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。 +3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。 +4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。 + +**C. 解决问题方案** +在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。 + +**D. 后续如何避免问题** +- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。 +- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。 +- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。 + +--- + +## 记录 6:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新 + +**A. 具体问题** +1. 在 `/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`,**报告内容变空、基本信息清空、视频分析数据全部丢失**。 +2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。 + +**B. 产生问题原因** +1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft,覆盖了用户已有的数据。 +2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。 + +**C. 解决问题方案** +1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draf t 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。 +2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。 +3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。 + +**D. 后续如何避免问题** +- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。 +- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。 +- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。 + +--- + +## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview) + +**A. 具体问题** +用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。 + +**B. 产生问题原因** +1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI; +2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署; +3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。 + +**C. 解决问题方案** +1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程; +2. 执行 `npm run build` 重新构建生产包; +3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器; +4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。 + +**D. 后续如何避免问题** +- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本; +- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱; +- 如需固定端口,可在 `package.json` 的 `preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。 + +--- + +## 记录 8:路由切换后所有内容仍然丢失——彻底重构自动保存机制 + +**A. 具体问题** +在 `/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。 + +**B. 产生问题原因** +1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。 +2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []`、`capturedFrames: []`、默认 `reportData`)。 +3. 组件卸载(cleanup)时调用保存,用这个空值**覆盖了 localStorage 中已有的正确 draft**。 +4. 重新挂载后,系统读取了被清空的 draft,导致所有数据全部丢失。 +5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。 + +**C. 解决问题方案** +1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current` 和 `stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId`、`reportId`,确保闭包永远绑定当前渲染周期的最新 state。 +2. **重构自动保存 effect**:将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`,effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载,cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。 +3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。 + +**D. 后续如何避免问题** +- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。 +- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。 +- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。 +- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。 + +--- + +## 记录 9:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效 + +**A. 具体问题** +在 `/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`: +- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失; +- 视频分析面板中的自动关键帧和手动截图全部丢失。 + +**B. 产生问题原因** +1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state(如 `capturedFrames`、`videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。 +2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。 +3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。 + +**C. 解决问题方案** +1. **重构 `saveDraftToStorage` 从 Ref 读取**: + - `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`。 + - `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。 + - `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。 +2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。 + +**D. 后续如何避免问题** +- 对于需要在异步操作或组件卸载时读取的"最新状态",**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。 +- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。 +- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。 +- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景(React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。 + +--- + +## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入 + +**A. 具体问题** +开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。 + +**B. 产生问题原因** +`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`: +```tsx +if ((settings.autoInsertDelay || 0) > 0) { + await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000)); +} +``` + +`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。 + +**C. 解决问题方案** +1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。 +2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。 +3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。 + +**D. 后续如何避免问题** +- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。 +- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。 +- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。 + +--- + +## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志 + +**A. 具体问题** +报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。 + +**B. 产生问题原因** +1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9: + ```tsx + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const dataUrl = canvas.toDataURL('image/jpeg', 0.9); + ``` + 对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。 +2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志: + ```typescript + } catch { + // ignore quota exceeded + } + ``` + 当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。 + +**C. 解决问题方案** +1. **压缩关键帧分辨率与质量**: + - 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放: + ```tsx + const MAX_WIDTH = 800; + const scale = Math.min(1, MAX_WIDTH / video.videoWidth); + canvas.width = video.videoWidth * scale; + canvas.height = video.videoHeight * scale; + ``` + - 将 JPEG 导出质量从 `0.9` 降到 `0.6`。 + - 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。 + +2. **增加存储错误可见性**: + - 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`: + ```typescript + } catch (e) { + console.error('Storage save failed (possibly quota exceeded):', e); + } + ``` + +**D. 后续如何避免问题** +- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。 +- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。 +- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。 +- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。 + +--- + +## 记录 12:contentEditable 中实现标签锁定与输入方格的双向绑定 + +**A. 具体问题** +需要在 `ReportEditor` 和 `TemplateManage` 的富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧【基本信息】表单双向联动。 + +**B. 产生问题原因** +原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。若仅用样式区分的普通 `<span>`,用户仍可通过退格键将"姓名:"删掉一半或改乱。 + +**C. 解决问题方案** +采用三层嵌套 HTML 结构: +1. **外层** `<span class="smart-field-wrapper" contenteditable="false">`:作为不可编辑的框架,确保整个控件不会被内部逐字删除。 +2. **标签层** `<span class="field-label">`:显示固定文本如"姓名:",受外层保护。 +3. **输入层** `<span class="field-value" contenteditable="true" data-bind="patientName">`:允许用户输入,并通过 `data-bind` 属性建立与 `reportData` 的映射关系。 + +双向绑定逻辑: +- **富文本 → 表单**:在 `handleEditorInput` 中通过 `e.target.hasAttribute('data-bind')` 判断输入源,实时更新 `reportData`。 +- **表单 → 富文本**:在 `useEffect` 中监听 `reportData` 变化,仅当 `el.innerText !== newValue` 时才重写 DOM,防止光标跳动。 + +**D. 后续如何避免问题** +- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器,而不是仅靠样式区分的普通 `<span>`。 +- 在 `State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。 +- 数组类型字段(如 `surgeon`)在同步到方格前应先 `join(', ')` 转换为字符串,保持显示一致性。 + +--- + +## 记录 13:手术时间方框化、动态字段分类体系与 UI 紧凑化 + +**A. 具体问题** +1. 手术开始/终止时间在模板中是纯文本"时 分",无法与右侧表单联动。 +2. `TemplateManage` 的字段库是静态列表,无法按医院需求自定义字段;`ReportEditor` 的右侧表单全部硬编码,每新增一个字段就要改代码。 +3. `field-value` 方格使用了 `min-width: 60px` 和上下 `padding`,导致行间距被撑大,排版松散。 + +**B. 产生问题原因** +1. 时间字段在 `defaultContent.ts` 中没有使用 `data-bind` 智能控件,且右侧表单将时间拆分为 `startHour`/`startMinute` 两个独立字段,缺少与方格的双向转换层。 +2. 早期设计采用了"硬编码表单"思路,字段名、类型、选项全部写死在 `ReportEditor.tsx` 的 JSX 中,不具备扩展性。 +3. `inline-block` 元素自带上下 `padding` 和 `border`,超出了默认行高,浏览器不得不增大整行高度以容纳它。 + +**C. 解决问题方案** +1. **时间方框联动**: + - 在 `defaultContent.ts` 中替换为 `data-bind="startTime"` 和 `data-bind="endTime"` 的方格。 + - 在 `ReportEditor.tsx` 的 `handleEditorInput` 中,对 `startTime`/`endTime` 使用 `split(':')` 解析,反向更新 `startHour`/`startMinute`;在 `useEffect(reportData)` 中拼接 `HH:mm` 同步回方格。 +2. **动态字段体系**: + - 在 `types.ts` 中新增 `FieldType`、`FormField`、`DEFAULT_FORM_FIELDS`,定义字段的 key/label/分类/类型/显隐/锁定状态/选项。 + - 使用 `localStorage` 的 `formFieldsConfig` 持久化字段配置。 + - `TemplateManage.tsx` 右侧字段库重构为 Tab 结构:【插入字段】按"填空/单选/多选/时间"分组;【字段管理】支持新增、删除(非锁定字段)、显隐开关。 + - `ReportEditor.tsx` 右侧基本信息表单改为遍历 `formFieldsConfig`、按 `type` switch-case 动态渲染(文本框/下拉框/多选标签/时间拆分下拉框)。 +3. **UI 紧凑化**: + - 将 `min-width` 从 `60px` 缩至 `32px`。 + - 去除上下 `padding`,使用 `line-height: 1.2`、`font-size: inherit`、`vertical-align: text-bottom`。 + - 背景色改为 `#f8fafc`(编辑态更明显),打印时恢复透明并只保留下划线。 + +**D. 后续如何避免问题** +- 对于需要将多个子字段映射到单一 UI 控件的场景,应在事件处理器和 `useEffect` 中各维护一层"拼接/解析"转换逻辑,保持底层数据结构不变。 +- 当表单字段超过 5 个且存在频繁变更需求时,应尽早从硬编码 JSX 转向"配置驱动渲染"(Config-Driven UI),降低后续维护成本。 +- 在 `contentEditable` 中插入 `inline-block` 元素时,务必通过 `line-height`、`vertical-align` 和最小化 `padding` 控制其对行高的影响,避免破坏段落排版的紧凑性。 + +--- + +## 记录 14:智能字段插入间距修复与 Backspace 防误删 + +**A. 具体问题** +1. `TemplateManage.tsx` 中使用 `insertSmartField` 插入智能字段后,字段后方会出现一个可见的空格(由 ` ` 和多行模板字符串中的换行/缩进空白引起)。 +2. 在 `contenteditable` 中,光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace,WebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。 +3. `defaultContent.ts` 中的 `smartField` 辅助函数同样存在多行缩进导致的模板 HTML 中夹杂空白文本节点问题。 + +**B. 产生问题原因** +1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 ` `,导致插入后字段与后续文字之间总有一个不必要的空格。 +2. `contenteditable="false"` 的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除,而不是只删除该不可编辑元素。 +3. `defaultContent.ts` 中的 `smartField` 为了可读性也使用了多行缩进模板字面量,导致默认模板里每个 `smartField` 调用前后都引入了额外的空白文本节点。 + +**C. 解决问题方案** +1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 ` `。 +2. **防止内部折行**:给 `.smart-field-wrapper` 增加 `white-space: nowrap;`(内联样式 + CSS 类双保险),确保标签和输入框不会在行中间被拆开。 +3. **拦截 Backspace/Delete**:在 `TemplateManage.tsx` 的编辑器上增加 `keydown` 事件监听(capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace,或光标在文本节点末尾且后一个兄弟节点是 `.smart-field-wrapper` 时按 Delete,主动 `preventDefault()` 并手动移除该字段节点,随后同步更新 `localStorage` 中的模板内容。 + +**D. 后续如何避免问题** +- 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。 +- 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `<p>` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。 +- 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。 + +--- + +## 记录 15:5 项交互与默认值优化(占位符尺寸、签名状态、素材预加载) + +**A. 具体问题** +用户提出 5 个 UI/UX 改进需求: +1. 插入图片占位符时两次 `prompt` 弹窗合并为一次,用英文逗号分隔宽高; +2. 占位符未指定尺寸时默认显示为 `200×200px`,且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`; +3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`; +4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列,根据 `user.signature` 显示「已上传」/「未上传」; +5. 修复系统重置后 `ReportEditor` 的素材库为空的问题,将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。 + +**B. 产生问题原因** +1. `insertImage()` 在两个编辑器(`TemplateManage`、`ReportEditor`)中均使用两次独立的 `prompt()`,操作冗余且中断感强。 +2. 旧占位符样式使用 `max-width`/`max-height`,当内容区域大于占位符时,边框和背景不会收缩到指定尺寸,视觉尺寸不可控;且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化,不统一。 +3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段,导致新系统首次进入 `/system-settings` 时相关开关为空。 +4. `UserManage.tsx` 表格缺少签名可视化列,管理员无法一眼辨别哪些医生已上传电子签名。 +5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`,素材库为空,图片选择器无法使用系统默认 logo。 + +**C. 解决问题方案** +1. **合并 prompt**: + ```ts + const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', ''); + const parts = input.split(',').map(s => s.trim()); + ``` + 按逗号分割,第一部分为宽度,第二部分为高度。留空或单侧留空时,另一侧自动回退到 `200`。 +2. **固定尺寸样式**: + - 移除 `max-width`/`max-height`,改用 `width:${width}px;` / `height:${height}px;`。 + - 默认值逻辑:`!widthStr && !heightStr` → `200×200`;`widthStr && !heightStr` → 宽自定义、高 `200`;`!widthStr && heightStr` → 宽 `200`、高自定义。 +3. **默认设置补全**:在 `Login.tsx` 的 `defaultSettings` 中显式加入: + ```ts + autoInsertFrames: true, + autoInsertDelay: 1, + autoInsertFrameIndices: [0, 1, 2, 3, 4, 5] + ``` +4. **签名状态列**:在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中,于「部门」之后、「状态」之前插入签名状态标签。 +5. **素材预加载前置**:将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中,并增加 `savedAssets.length === 0` 的判空保护,避免覆盖用户后续上传的素材。 + +**D. 后续如何避免问题** +- 对于成对的数值输入(如宽高、行列),优先考虑单输入框 + 分隔符,减少弹窗次数;同时做好格式解析和容错(空值、单侧空值、非数字)。 +- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控,避免 `inline-flex` 内容撑大容器。 +- 任何需要在多个页面共享的初始化数据(如素材库、默认配置),应放在全局初始化入口(如登录页的 `initData`),而不是分散在各个页面的 `useEffect` 中。 +- 表格字段变更时,注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致,避免列错位。 + +--- + +## 记录 16:模板字段唯一性、删除按钮与报告批量导出 + +**A. 具体问题** +1. `TemplateManage` 中智能字段可以重复插入多次,导致模板混乱。 +2. 智能字段在某些边界位置(如段落开头/结尾)无法通过 Backspace/Delete 删除。 +3. `ReportManage` 缺少报告导出功能和批量操作能力。 + +**B. 产生问题原因** +1. `insertSmartField` 没有检测 DOM 中是否已存在相同 `data-bind` 的字段节点。 +2. 之前的 `keydown` 拦截逻辑只处理了光标在文本节点内的情况,没有处理光标直接在块级父节点边界(`startContainer` 为 `<p>` 等块元素)的场景。 +3. `ReportManage` 的设计只支持单条查看/编辑/删除,没有设计多选状态和导出逻辑。 + +**C. 解决问题方案** +1. **唯一性校验**:在 `insertSmartField` 中通过 `editorRef.current?.querySelector([data-bind="..."])` 预检查,若已存在则 `alert` 并终止插入。 +2. **删除按钮**:给 `.smart-field-wrapper` 内部增加一个红色圆形的 `<span class="delete-btn">×</span>`,点击即可删除整个字段节点。同时在 `index.css` 和 `print` 媒体查询中分别定义显示/隐藏样式。 +3. **键盘删除增强**:重写 `keydown` 处理器,同时处理 `startContainer` 为 `TEXT_NODE` 和 `ELEMENT_NODE` 两种情况。当光标位于块级父节点的子节点边界时,通过 `el.childNodes[offset - 1]` 或 `el.childNodes[offset]` 定位字段节点并安全删除。 +4. **报告批量操作**: + - 在 `ReportManage.tsx` 中引入 `selectedIds` 状态,表格每行增加 Checkbox,表头支持全选/反选。 + - 增加浮动批量操作栏,支持"批量删除"、"批量导出 PDF"、"批量导出 JSON"、"取消选择"。 + - 单报告操作列增加"导出"按钮,点击弹出模态框选择 PDF 或 JSON。 + - PDF 导出复用现有的 `printDocument(content)`;JSON 导出通过 `Blob` + `URL.createObjectURL` 实现下载,数据结构包含 `meta`(报告元信息)和 `fields`(所有 `DEFAULT_FORM_FIELDS` 对应值)。 + - 批量 PDF 将多份报告的 HTML 用 `<div style="page-break-after: always;"></div>` 拼接后统一打印。 + - 批量 JSON 将多份报告导出为数组形式的单个 `.json` 文件。 + +**D. 后续如何避免问题** +- 在 `contentEditable` 中插入的任何可复用控件,都应考虑增加唯一性校验和明确的删除入口(可视化按钮 + 键盘事件拦截)。 +- 键盘事件处理不能假设 `startContainer` 一定是文本节点,必须覆盖块级元素边界的情况。 +- 当列表页需要增加批量操作时,建议将"选择状态"和"批量动作"封装为独立逻辑,保持单条操作按钮的可维护性。 +- 导出功能应尽量复用现有的 `printDocument` 等工具函数,减少新依赖引入。 + +--- + +## 记录 17:字段聚焦高亮、删除按钮显隐隔离与 multi_select 脏数据崩溃修复 + +**A. 具体问题** +1. `TemplateManage` 中编辑智能字段时缺少视觉焦点反馈,用户体验不够直观。 +2. 红色 × 删除按钮始终显示在字段内部左侧,且在任何包含 `smart-field-wrapper` 的页面(包括 `ReportEditor`)都会显示。 +3. `ReportEditor` 加载某些历史报告时崩溃,报错 `(y[x.key] || []).map is not a function`。 + +**B. 产生问题原因** +1. 之前没有为 `.field-value` 定义 `:focus` 状态的 CSS 样式。 +2. `delete-btn` 使用 `display: inline-flex` 默认常驻显示,且没有针对页面做显隐隔离。 +3. `multi_select` 字段(如 `surgeon`、`assistant`)的渲染直接对值调用 `.map()`,但旧数据或异常存储可能将其保存为字符串(如 `"张医生"` 而非 `["张医生"]`),导致 `.map` 在字符串上调用时抛出 `TypeError`。 + +**C. 解决问题方案** +1. **聚焦高亮**:在 `index.css` 中为 `.smart-field-wrapper .field-value:focus` 增加背景色加深(`#e2e8f0`)、边框变深(`#94a3b8`)和蓝色外发光(`box-shadow: 0 0 0 2px rgba(59,130,246,0.25)`)的样式,配合 `transition` 实现平滑反馈。 +2. **删除按钮定位与显隐隔离**: + - 将 `delete-btn` 从字段内部移到 `.field-value` 之后,并给 `.smart-field-wrapper` 增加 `position:relative`,使 `delete-btn` 可绝对定位到右上角(`top: -8px; right: -8px`)。 + - 默认 `display: none`;在 `TemplateManage` 的编辑器容器上增加 `template-editor-mode` class,通过 `.template-editor-mode .smart-field-wrapper:hover .delete-btn` 和 `:focus-within .delete-btn` 控制仅在 TemplateManage 中悬浮/聚焦时显示。 + - `ReportEditor` 的编辑器容器没有 `template-editor-mode`,因此删除按钮不会显示。 +3. **类型安全修复**:在 `ReportEditor.tsx` 的 `multi_select` 渲染分支中,增加 `Array.isArray` 检查: + ```ts + const rawValue = (reportData as any)[field.key]; + const tags = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []); + ``` + 确保无论旧数据是数组、字符串还是空值,都能安全渲染为标签列表。 + +**D. 后续如何避免问题** +- 任何需要在不同页面显隐不同的 UI 元素,应通过容器级 class 做样式隔离,而不是依赖全局显示/隐藏。 +- `contentEditable` 控件的焦点状态必须有明确的视觉反馈(背景/边框/阴影变化),否则用户难以感知当前编辑位置。 +- 对从持久化存储读取的数组类型数据,在 React 渲染前务必做 `Array.isArray` 校验,防止历史脏数据导致整页崩溃。 + +--- + +## 记录 18:字段悬浮高亮、电子签上传与手术者签名联动 + +**A. 具体问题** +1. `TemplateManage` 中右侧字段库按钮与编辑器中的字段缺乏视觉关联,用户难以快速定位字段位置。 +2. `UserManage` 缺少电子签名上传功能,无法为医生绑定个人签名图。 +3. 模板中缺少"手术者签名"字段,报告编辑时无法自动带入医生签名。 +4. 签名图片若直接放入 `.field-value` 中,容易撑大行高,影响排版和打印效果。 + +**B. 产生问题原因** +1. 字段库按钮没有任何与编辑器 DOM 联动的交互反馈机制。 +2. 早期设计未考虑医疗文书中的电子签需求,`User` 模型和 `DEFAULT_FORM_FIELDS` 均缺少签名相关定义。 +3. 没有针对签名图片设计专门的 CSS 尺寸约束,导致浏览器按原图尺寸渲染,破坏行高。 + +**C. 解决问题方案** +1. **悬浮高亮**:在 `TemplateManage.tsx` 的字段库按钮上增加 `onMouseEnter` / `onMouseLeave`,直接操作编辑器中对应 `data-bind` 的 `.field-value` 的 `style.boxShadow` 和 `style.backgroundColor`,实现蓝色外发光/背景变浅蓝色的即时高亮反馈。 +2. **电子签上传与压缩**: + - 在 `UserManage.tsx` 中增加 `compressImage(file, maxSize=500)` 工具函数,利用 Canvas 等比例缩放并填充白色背景,输出 JPEG base64(质量 0.8)。 + - 在用户编辑/新增弹窗中增加"电子签名"区块:预览图、上传按钮、清除按钮。 + - 编辑当前登录用户时同步更新 `storage.set('currentUser', ...)`,确保 ReportEditor 能读取最新签名。 +3. **手术者签名字段**: + - `types.ts` 中 `User` 增加 `signature?: string`;`FieldType` 增加 `'signature'`;`DEFAULT_FORM_FIELDS` 追加 `surgeonSignature`(分类"图片",系统锁定)。 + - `TemplateManage` 插入字段分类增加"图片",`surgeonSignature` 自动出现在该分类下。 + - `ReportEditor` 的"表单 → 编辑器"同步 `useEffect` 中,对 `fieldKey === 'surgeonSignature'` 做特殊分支:有签名则填充 `<img class="report-signature-img" src="..." />`,无签名则填充文本"【请上传电子签】"。 +4. **签名排版优化**: + - 在 `index.css` 和 `print.ts` 中定义 `.report-signature-img`: + ```css + .report-signature-img { + height: 2.4em; + width: auto; + vertical-align: middle; + display: inline-block; + margin: -0.3em 0; + } + ``` + - 打印媒体查询中同步使用 `!important` 确保打印输出也保持同样尺寸。 + +**D. 后续如何避免问题** +- 当需要在 React 之外直接操作 DOM 样式实现即时反馈时,优先使用原生事件 + inline style(避免触发组件重渲染导致光标丢失)。 +- 任何新增的持久化字段,应在类型定义(TypeScript interface)、默认值(DEFAULT_xxx)、以及所有相关读写逻辑中同步补齐,防止类型不一致。 +- 在 `contentEditable` 中插入图片时,务必通过 CSS 对 `height`/`width`/`vertical-align` 做严格约束,避免原图尺寸破坏文本流。 +- 涉及打印的样式必须在 iframe 打印模板和 `@media print` 中双端同步,防止打印效果与屏幕预览不一致。 + +--- + +## 记录 19:撤销栈修复、字段删除交互优化与签名字段闭环 + +**A. 具体问题** +1. `TemplateManage` 中通过红色 × 或键盘删除智能字段后,浏览器撤销栈(Undo)失效,点击"撤销"按钮无法恢复。 +2. 插入"手术日期"、"手术者签名"等字段后,字段框有时会跳到下一行。 +3. Backspace 键无法删除字段;Delete 键会误删字段前面的大段文本(如"手术步骤、术中出现的情况及处理:")。 +4. 签名图片没有最大尺寸限制;"手术者签名"字段不在 ReportEditor 表单中显示,无法受控管理签字状态。 +5. 点击"完成报告"时缺少对签名状态的确认提示。 + +**B. 产生问题原因** +1. 删除字段时使用了 `target.remove()` 直接操作 DOM,绕过了浏览器的原生撤销栈(`undo stack`)。 +2. 插入的 `smart-field-wrapper` 是 `inline-block` 元素,但其后缺少行内锚点文本节点,浏览器在特定光标位置插入时容易将其挤到新行。 +3. `keydown` 拦截逻辑中 `target.remove()` 同样会误删父级块节点(WebKit 在边界处对 `contenteditable="false"` inline 元素的处理缺陷)。 +4. `surgeonSignature` 字段原先 `visibleInForm: false`,且签名图片样式仅用 `height: 2.4em` 约束,没有 `max-width/max-height` 的硬限制。 +5. 完成报告逻辑中缺少针对签名字段的业务校验。 + +**C. 解决问题方案** +1. **撤销栈修复**:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 `Range.selectNode(target)` + `document.execCommand('delete')`。这样浏览器会将删除操作记录到撤销栈中,`execCommand('undo')` 可以正确恢复。 +2. **防换行**:在 `insertSmartField` 和 `defaultContent.ts` 的 `smartField()` 生成的 HTML 末尾增加 `​`(零宽空格),作为稳定的行内锚点,防止字段被浏览器排到新行。 +3. **精准键盘删除**:配合 `Range.selectNode` + `execCommand('delete')`,不再直接 `remove()` DOM 节点,彻底避免误删父级 `<p>` 的问题。 +4. **签名尺寸与字段管理**: + - `types.ts` 中将 `surgeonSignature` 改为 `visibleInForm: true, isSystemLocked: false`,使其出现在字段管理和右侧表单中。 + - 新增 `isSigned` 字段(单选:已签字 / 未签字,默认"未签字")。 + - 签名图片样式改为 `max-width: 120px; max-height: 40px; object-fit: contain;`,并在打印样式和 `print.ts` 中同步。 +5. **签名同步逻辑重构**:`ReportEditor` 中 `surgeonSignature` 的渲染由 `isSigned` 控制: + - `已签字` 且 `currentUser.signature` 存在 → 显示签名图片。 + - `已签字` 但无签名图 → 显示 "【请上传电子签】"。 + - `未签字` → 显示 "【未签字】"。 +6. **完成报告签名校验**:`saveReport('completed')` 中,若模板包含 `surgeonSignature`: + - 未选择"已签字" → `confirm` 弱阻断提示。 + - 已选择"已签字"但无签名图 → `confirm` 弱阻断提示。 + - 用户点击"取消"则中断保存,点击"确定"仍可继续保存。 + +**D. 后续如何避免问题** +- 在 `contentEditable` 中删除元素时,**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`,以确保撤销/重做等原生编辑行为正常工作。 +- 插入 `inline-block` 或 `inline-flex` 控件时,可在其后追加 `​` 零宽空格,为浏览器提供稳定的行内文本锚点,减少排版异常。 +- 任何需要从"不可见"改为"可见/可配置"的字段,应在 `DEFAULT_FORM_FIELDS`、`Report 类型`、`reportData 初始值` 三处同步更新,防止表单渲染遗漏。 +- 对于图片类嵌入内容,应使用 `max-width`/`max-height` + `object-fit: contain` 做硬约束,避免不同来源图片破坏页面布局。 + +--- + +## 记录 20:TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复 + +**A. 具体问题** +1. `TemplateManage` 中删除智能字段(通过红 × 或 Backspace/Delete)后,点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。 +2. 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。 + +**B. 产生问题原因** +1. 即使将删除逻辑改为 `execCommand('delete')`,浏览器原生的 undo stack 在 `contentEditable` 中结合 React 状态更新时仍然非常脆弱,容易被清空。 +2. 点击侧边栏按钮会导致编辑器 `blur`,浏览器内部的光标位置(Selection/Range)丢失;再次 `focus()` 后光标被重置,导致 `insertHTML` 插入位置错误。 + +**C. 解决问题方案** +1. **自定义 Undo/Redo 栈**: + - 在 `TemplateManage.tsx` 中引入 `undoStack` 和 `redoStack` 两个 `useRef<string[]>([])`。 + - 实现 `pushHistory()`,在执行任何结构性变更(删除字段、插入字段、插入表格/图片、格式化命令)前将当前 `editorRef.current.innerHTML` 推入 undo 栈并清空 redo 栈。 + - 实现 `handleUndo()` / `handleRedo()`,直接替换工具栏按钮的 `execCmd('undo')` / `execCmd('redo')` 调用。从栈中取出历史 HTML 字符串并赋值给 `editorRef.current.innerHTML`,再调用 `saveTemplateContent()` 同步到 React state 和 `localStorage`。 +2. **阻止焦点流失**: + - 在所有工具栏按钮和字段库插入按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,阻止 mousedown 默认行为导致编辑器失去焦点。 +3. **光标位置记忆与恢复**: + - 利用已有的 `savedRangeRef`,实现 `saveSelection()` 和 `restoreSelection()`。 + - 在编辑器 `<div>` 上绑定 `onBlur={saveSelection}`、`onMouseUp={saveSelection}`、`onKeyUp={saveSelection}`,持续记录光标位置。 + - 在 `insertSmartField` 和 `insertImage` 中,执行 `insertHTML` 前先调用 `restoreSelection()` 恢复光标,确保字段插入到正确的位置。 + +**D. 后续如何避免问题** +- 对于 `contentEditable` 编辑器中的结构性变更(插入/删除特殊节点),如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。 +- 侧边栏/工具栏按钮与编辑器共存时,**必须**通过 `onMouseDown={e => e.preventDefault()}` 或等价手段阻止焦点流失,这是保证光标位置不丢失的最简单有效方案。 +- 插入操作前恢复 `savedRangeRef` 可以作为焦点流失后的兜底保险,两者结合使用效果最佳。 + +--- + +## 记录 21:TemplateManage 快捷键 Undo/Redo 与字段插入排版修复 + +**A. 具体问题** +1. TemplateManage 中删除 smart-field-wrapper 后按键盘 Ctrl+Z 无法撤销,但点击工具栏撤销按钮可以恢复。 +2. 当目标段落以 `<br>` 结尾时,从字段库插入 smart-field-wrapper 会被拆到下一行(`<span>` 跑到了 `<p>` 外部)。 + +**B. 问题产生原因** +1. keydown 事件监听器只拦截了 Backspace/Delete,未拦截 Ctrl+Z/Ctrl+Y,导致浏览器原生 undo 与自定义 undoStack/redoStack 完全脱节。 +2. insertSmartField 使用 document.execCommand('insertHTML'),WebKit/Blink 在块级元素末尾存在 `<br>` 时,会自动将插入的 inline `<span>` 修正到块级元素外部,造成排版错位。 + +**C. 解决问题方法** +1. **快捷键拦截**:在 keydown 监听的最开头增加 Ctrl+Z / Cmd+Z / Ctrl+Shift+Z / Ctrl+Y 的拦截,调用 e.preventDefault() 后路由到 handleUndo() 或 handleRedo()。 +2. **精确 Range 插入**:将 insertSmartField 的插入方式从 execCommand('insertHTML') 替换为手动 Range.insertNode(): + - restoreSelection() 恢复光标; + - Range.deleteContents() 清空当前选区; + - 将 HTML 字符串转为 DocumentFragment; + - Range.insertNode(fragment) 精确插入到 Range 位置; + - setStartAfter(lastNode) 把光标移动到插入内容末尾。 + +**D. 经验与教训总结** +- 在 contentEditable 中实现自定义撤销栈时,必须**同时拦截界面按钮和键盘快捷键**的 undo/redo,否则两套历史机制会互相冲突。 +- document.execCommand('insertHTML') 对块级元素边界(尤其是 `<br>` 结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。 +- 任何对 contentEditable 的 DOM 修改后,都应同步保存内容(saveTemplateContent),确保 localStorage 中的模板数据与编辑器状态一致。 + +--- + +## 记录 22:TemplateManage 字段体系升级与双向交互联动 + +**A. 具体问题** +1. 新增字段时单选/多选分类仍显示"文本"选项,联动逻辑错误。 +2. 默认模板中存在大量静态灰色占位文本(术前诊断、术后诊断等),无法与右侧表单双向绑定。 +3. 字段管理列表平铺展示,无分组折叠,系统字段选项不可修改。 +4. 图片占位符只能通过本地上传填充,无法使用签名图或系统素材。 +5. 编辑器中的智能字段与右侧侧边栏完全无联动。 + +**B. 问题产生原因** +1. `newFieldForm.category` onChange 时未正确过滤 type select 的 options。 +2. `DEFAULT_FORM_FIELDS` 缺少术前/术后诊断等临床字段,导致 `defaultContent.ts` 只能写死占位文本。 +3. 字段管理 UI 未按 category 分组,也未提供编辑系统字段选项的入口。 +4. `ReportEditor.tsx` 中图片占位符点击后直接调用 `input.click()`,缺少多渠道选择机制。 +5. `TemplateManage.tsx` 的 `handleEditorClick` 仅处理了删除逻辑,未处理点击高亮/导航。 + +**C. 解决问题方法** +1. **类型联动修复**:category onChange 时强制设置对应 type(单选→single_select、多选→multi_select、图片→image);type select 使用条件渲染,只显示当前 category 支持的选项。 +2. **扩展默认字段**:在 `types.ts` 追加 `preoperativeDiagnosis`、`postoperativeDiagnosis`、`postOpCondition`、`specimenDescription`、`pathologyCheck`、`frozenPathology`、`hospitalLogo` 等系统字段,全部 `isSystemLocked: true`。 +3. **替换模板占位文本**:在 `defaultContent.ts` 中将所有灰色占位文本替换为 `smartField(...)`,Logo 替换为带 `data-bind="hospitalLogo"` 的 `image-placeholder`。 +4. **字段管理折叠与编辑**:新增 `expandedCategories` 状态实现折叠面板;新增 `editingFieldKey` 等状态实现点击编辑(系统字段 label 只读、选项可编辑)。 +5. **素材库与图片字段**:`FieldType` 扩展 `'image'`;初始化时自动将 Logo 转 Base64 存入 `imageAssets`;`insertSmartField` 对图片类型插入 `image-placeholder`。 +6. **图片来源选择弹窗**:`ReportEditor.tsx` 点击图片占位符弹出 Modal,支持本地上传、我的签名、系统素材三选一。 +7. **编辑器-侧边栏双向联动**:点击 `smart-field-wrapper` 时读取 `data-bind`,高亮并滚动定位到右侧对应字段,自动展开分组。 + +**D. 经验与教训总结** +- category→type 的联动应在 state 变更层强制收敛,而不是仅依赖 JSX 条件渲染。 +- 升级静态占位文本为字段时,必须同步修改 `DEFAULT_FORM_FIELDS`、`defaultContent.ts` 和 `formFieldsConfig`。 +- 图片字段与普通文本字段的 DOM 结构差异大,插入逻辑需要按 type 分支。 +- 编辑器与侧边栏联动建议使用 `scrollIntoView` + 临时 CSS 类,避免复杂的状态同步。 +- 新增 localStorage key 时应提供合理的默认值或降级处理。 + +--- + +## 记录 23:图片占位符体系重构与双端统一 + +**A. 具体问题** +1. `template-manage` 的"插入字段"中仍存在"图片"分类(手术者签名、医院Logo),用户认为不再需要。 +2. 插入图片占位符时无法自定义默认宽高,且使用 `<div>` 导致强制换行。 +3. 占位符框太小时"插入/点击放置图片"文字显示不全。 +4. 默认模板中签名和 Logo 的结构不统一(一个是 `smartField`,一个是 `div.image-placeholder`)。 +5. `template-manage` 点击图片占位符直接调起本地文件选择器,与 `report-editor` 的三选一弹窗行为不一致。 + +**B. 问题产生原因** +1. `DEFAULT_FORM_FIELDS` 仍包含 `surgeonSignature` 和 `hospitalLogo`。 +2. 两端编辑器的 `insertImage()` 使用块级 `<div>` 插入,未提供尺寸 prompt。 +3. 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。 +4. `TemplateManage` 的 placeholder 点击事件直接调用 `triggerPlaceholderUpload()`,缺少与 `ReportEditor` 一致的弹窗组件。 + +**C. 解决问题方法** +1. **清理图片字段**:从 `DEFAULT_FORM_FIELDS` 和 `types.ts` 中移除 `surgeonSignature` 和 `hospitalLogo`;在 `TemplateManage.tsx` 的插入字段/字段管理/新增字段表单中彻底移除"图片"分类。 +2. **统一默认模板**:在 `defaultContent.ts` 中将 Logo 和签名均替换为 `<span class="image-placeholder" style="display:inline-flex;...">`。 +3. **改造 insertImage()**:在 `TemplateManage.tsx` 和 `ReportEditor.tsx` 中,插入前通过 `prompt` 获取最大宽度/高度(px),生成带 `max-width/max-height` 的 `<span>` 行内占位符;提示文字中附加"正文一行文字高度约为 20 像素左右"。 +4. **文本自适应**:根据 prompt 输入的宽度决定提示文字:宽度 < 80px 时显示"插入图片",否则显示"插入/点击放置图片"。 +5. **统一弹窗行为**:将 `ReportEditor` 的 `imagePickerOpen` / `imagePickerTarget` / `fillPlaceholderSrc` 逻辑完整移植到 `TemplateManage`;删除旧的 `triggerPlaceholderUpload` 直接上传逻辑;两端点击图片占位符均弹出"本地上传 / 我的签名 / 系统素材"三选一弹窗。 +6. **优化填充样式**:`fillPlaceholderSrc` 中给 `<img>` 增加 `max-width:100%; max-height:100%; object-fit:contain;`,避免撑破设置了固定尺寸的占位符。 + +**D. 经验与教训总结** +- 当从字段体系中彻底移除某一分类时,需要同时清理:`DEFAULT_FORM_FIELDS`、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑(如编辑字段时显示 options 输入框的条件)。 +- 在 `contentEditable` 中实现"同行插入"必须使用行内元素(`<span>`)并显式设置 `display:inline-flex` + `vertical-align:middle`;块级 `<div>` 即使通过 CSS 改 display 也可能因浏览器 execCommand 修正导致换行。 +- 跨页面/跨编辑器的一致交互(如图片选择弹窗)应抽取为可复用逻辑或至少保持代码结构一致,避免用户在不同页面产生认知割裂。 +- `prompt` 虽不是最优雅的用户交互,但在工具栏快捷操作中是一种零依赖、快速落地的方案;若后续需要更复杂交互,可再替换为 Modal 组件。 + +--- + +## 记录 24:时间/日期字段格式配置与撰写时间动态字段 + +**A. 具体问题** +用户提出 2 个需求: +1. TemplateManage 字段管理中,时间/日期字段增加配置:date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「手动选择」作为默认值策略。 +2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。 + +**B. 产生问题原因** +1. `FormField` 数据结构缺少格式和默认值配置字段。 +2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。 +3. 模板底部「年 月 日」是纯静态 HTML 文本,没有数据绑定能力。 + +**C. 解决问题方案** +1. **扩展数据结构**:`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值(`surgeryDate` → `YYYY-MM-DD`+`specific`,`startTime/endTime` → `24h`+`specific`);新增系统字段 `reportDate`(`YYYY年MM月DD日`+`current`)。 +2. **TemplateManage UI 增强**: + - 新增字段表单:category 为「时间」时显示「默认值」select(手动选择/当前时间)和「显示格式」select(date 提供两种日期格式,time 提供 24h/12h)。 + - 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。 +3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。 +4. **ReportEditor 表单渲染重构**: + - `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围(24h: 00-23,12h: 01-12),12h 时额外增加 AM/PM select。存储仍保持 24h(`startHour/startMinute`),转换函数 `to24h`/`from24h` 处理 12h↔24h。 + - 通用 time 字段(非 startTime/endTime):新增 hour+minute select 渲染,值统一存储为 `HH:MM` 字符串。 +5. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`,将原始值转换为配置格式后写入编辑器。 +6. **编辑器反向编辑解析**:`handleEditorInput` 中,当用户直接在编辑器内修改 date/time smart field 时,通过正则解析格式化文本(如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`),转回原始值后存入 `reportData`。 +7. **默认模板更新**:`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。 + +**D. 后续如何避免问题** +- 当为字段增加新的配置属性时,务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值,保证向后兼容。 +- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。 +- 12h/24h 转换要覆盖所有边界情况:12AM→00、12PM→12、1PM→13,建议用独立纯函数(`to24h`/`from24h`)集中处理,避免在 JSX 中内联复杂计算。 +- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。 + +--- + +## 记录 25:时间字段增强——自定义格式、固定时间默认值、系统锁定标签 + +**A. 具体问题** +用户提出 4 个改进需求: +1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符; +2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段; +3. 「手动选择」文案歧义,应改为「固定时间」; +4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。 + +**B. 产生问题原因** +1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间:${smartField('reportDate')}`,导致编辑器中显示重复文字。 +2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`,字段库允许修改 label。 +3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。 +4. 日期/时间格式仅通过固定 `<select>` 提供预设选项(如 `YYYY-MM-DD`、`24h`),无法覆盖用户自定义需求(如 `YYYY/MM/DD`、`hh:mm A` 等)。 +5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。 + +**C. 解决问题方案** +1. **删除前缀**:`defaultContent.ts` 中将底部 HTML 从 `撰写时间:${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。 +2. **系统锁定**:`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。 +3. **文案修改**:`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。 +4. **自定义格式输入**: + - `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。 + - `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式: + - `formatInputMode: 'select' | 'custom'`,默认 `select`。 + - 选择「自定义」时显示 `<input>`,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。 + - 预设候选包含常用格式:`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。 + - 通用化显示函数: + ```ts + const formatDateDisplay = (isoDate: string, fmt?: string): string => { + if (!isoDate || !fmt) return isoDate || ''; + const [y, m, d] = isoDate.split('-'); + return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || ''); + }; + const formatTimeDisplay = (timeStr: string, fmt?: string): string => { + if (!timeStr || !fmt) return timeStr || ''; + const [h24str, mstr] = timeStr.split(':'); + const h24 = parseInt(h24str) || 0; + const isPM = h24 >= 12; + let h12 = h24 % 12; if (h12 === 0) h12 = 12; + return fmt.replace(/HH/g, String(h24).padStart(2, '0')) + .replace(/mm/g, mstr || '00') + .replace(/hh/g, String(h12).padStart(2, '0')) + .replace(/A/g, isPM ? '下午' : '上午'); + }; + ``` +5. **通用化反向解析**:新增 `parseDateFromFormat` / `parseTimeFromFormat`,从格式化文本中通过数字正则提取原始值,确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。 +6. **固定时间默认值自动填充**:`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支,若字段配置了 `fixedTimeValue` 且当前值为空,则自动填入固定值。 + +**D. 后续如何避免问题** +- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数,否则编辑器双向同步会断裂。 +- 使用占位符替换(如 `fmt.replace(/YYYY/g, y)`)实现通用格式化时,要确保所有可能的 token 都覆盖到,且替换顺序不会相互干扰。 +- 当某个字段被标记为 `isSystemLocked: true` 后,需在 UI 层面同时禁用 label 输入框,否则用户会困惑「为何修改无效」。 +- 时间/日期字段的默认值策略文案应直接体现业务含义(如「固定时间」「当前时间」),避免使用技术词汇(如「手动选择」)。 +- 对于 `startTime`/`endTime` 这类拆分存储(`startHour`+`startMinute`)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。 + +--- + +## 记录 26:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换 + +**A. 具体问题** +用户发现 3 个时间字段配置与报告编辑器的联动断层: +1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式 `YYYY年MM月DD日`;新建时间字段时默认格式为不可解析的 `'24h'`。 +2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。 +3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`(12小时制),报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。 + +**B. 产生问题原因** +1. **默认格式错误**:`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token, `'24h'` 无法被正确解析。 +2. **固定时间未注入**:`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中,`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过(因为已有值了)。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。 +3. **12h 判断写死**:`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时,判断失败,表单始终渲染为 24 小时制。 + +**C. 解决问题方案** +1. **修正默认格式**: + - `TemplateManage.tsx` 中新建字段的默认格式改为: + ```ts + setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm'); + ``` + - 重置表单时的默认值同步修正。 +2. **注入固定时间默认值**: + - `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。 + - 切换模板的 `useEffect` 中,在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑: + ```ts + formFields.forEach(field => { + if (field.category === '时间') { + if (field.timeDefault === 'specific' && field.fixedTimeValue) { + // 按 field.type 和 field.key 注入固定值 + } else if (field.timeDefault === 'current') { + // 注入当前系统时间 + } + } + }); + if (!nextReportData.surgeryDate) { + nextReportData.surgeryDate = new Date().toISOString().split('T')[0]; + } + ``` +3. **通用化 12h 判断**: + - `ReportEditor.tsx` 中: + ```ts + const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false; + ``` + - 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`,只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。 + +**D. 后续如何避免问题** +- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'`、`'12h'`)作为存储值,除非格式化函数也能识别这些别名。 +- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口(初始 state、切换模板、重置表单等)中显式遍历字段配置并注入,不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。 +- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**(`includes`)而非**精确匹配**,以兼容用户自定义格式。如果判断逻辑较为复杂,建议抽离为独立工具函数(如 `is12HourFormat(fmt: string): boolean`)。 +- 当某个字段在初始化时被赋予了「看似合理的默认值」(如 `surgeryDate: new Date()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。 + + +--- + +## 记录 27:DEFAULT_FORM_FIELDS 遗留 '24h' 默认值导致报告显示异常 + 格式选项未分类 + 编辑面板点击失效 + +**A. 具体问题** +1. `template-manage` 字段管理中,时间字段的格式 datalist 只显示 `YYYY-MM-DD` 和 `24h`,缺少 `YYYY年MM月DD日` 和 `HH:mm`/`hh:mm A`。 +2. `report-editor` 中手术终止时间 smart field 显示为 "24h" 字样,而非正常时间值。 +3. `template-manage` 字段管理中,点击底部字段进入编辑模式后,部分输入框/下拉框点击无响应,需手动滚动后才能获取焦点。 + +**B. 产生问题原因** +1. **`DEFAULT_FORM_FIELDS` 遗留旧值**:`types.ts` 中 `startTime` 和 `endTime` 的 `timeFormat` 仍被硬编码为 `'24h'`(历史遗留简写别名)。当新用户登录或重置系统时,该值被加载到 `formFieldsConfig` 中。`ReportEditor.tsx` 的 `formatTimeDisplay` 函数用 `'24h'` 作为格式模板进行 token 替换,但 `'24h'` 中不含 `HH`/`hh`/`mm`/`A` 等任何可替换 token,函数直接原样返回 `'24h'`,导致编辑器中显示 "24h"。 +2. **`customTimeFormats` 未按类型过滤**:`TemplateManage.tsx` 的 datalist 直接渲染了 `customTimeFormats` 数组中的所有格式(日期和时间混在一起)。当用户编辑 time 字段时,会看到 `YYYY-MM-DD` 等日期格式;编辑 date 字段时,会看到 `HH:mm` 等时间格式,选项混乱。 +3. **布局突变导致点击穿透失效**:字段管理列表位于 `overflow-y-auto` 滚动容器内。点击字段卡片后,内部编辑表单展开,高度瞬间增加。若卡片原本位于可视区域底部边缘,新出现的输入框可能刚好处于容器裁剪区域之外,浏览器 hit-testing 无法将点击事件正确路由到输入框上。 + +**C. 解决问题方案** +1. **修正默认值**:`types.ts` 中 `startTime`/`endTime` 的 `timeFormat` 从 `'24h'` 改为 `'HH:mm'`。 +2. **兼容兜底**:`ReportEditor.tsx` 的 `formatTimeDisplay` 开头增加 `if (fmt === '24h') fmt = 'HH:mm';`,防止已有用户的 `formFieldsConfig` 中仍残留 `'24h'` 导致显示异常。 +3. **清理旧缓存**:`TemplateManage.tsx` 初始化 `customTimeFormats` 时,对 `savedFormats` 增加 `.filter(f => f !== '24h' && f !== '12h')`,自动清理历史遗留的无效旧格式。 +4. **按类型过滤 datalist**:编辑字段和新增字段的 format `<datalist>` 渲染时,增加 `.filter`: + ```ts + .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; + }) + ``` +5. **自动滚动对齐**:字段卡片 `onClick` 中,在设置完编辑状态后增加 `setTimeout(() => { e.currentTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 50);`,确保编辑面板展开后卡片位于可视区域内。 + +**D. 后续如何避免问题** +- 当将格式简写别名(如 `'24h'`)迁移为标准 token 格式(如 `'HH:mm'`)时,必须**全局搜索**所有硬编码默认值(`DEFAULT_FORM_FIELDS`、测试数据、mock 数据等),确保源头不再产生脏数据。 +- `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}`,确保浏览器打印对话框识别正确的默认文件名 +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 内部 `` 标签。 +- 双向联动时,`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` 的默认值变更不影响已有用户数据,但重置操作会覆盖用户自定义的索引选择,需在重置提示中明确告知。 + + +--- + +## 记录 44:admin 用户默认部门改为 "admin" + +**A. 具体问题** +user-manage 中超级管理员(admin)的部门字段显示为空,用户希望默认设置为 "admin"。 + +**B. 产生问题原因** +`Login.tsx` 初始化默认用户时,admin 用户未显式设置 `department` 字段(第 36 行),导致部门为 undefined;`handleLogin` fallback 逻辑中(第 108 行),`super` 角色的部门被硬编码为空字符串 `''`。 + +**C. 解决问题方案** +1. `initData()` 的 `defaultUsers` 中 admin 用户显式添加 `department: 'admin'`。 +2. `handleLogin` fallback 中 `d.r === 'super' ? '' : '外科'` 改为 `d.r === 'super' ? 'admin' : '外科'`。 + +**D. 后续如何避免问题** +- 初始化默认数据时,应为所有用户显式设置完整字段,避免依赖 TypeScript 类型中的可选属性回退到 undefined。 +- 对于具有角色区分逻辑的字段赋值(如 super 用户的部门),应使用明确的业务默认值而非空字符串。 diff --git a/过往经验/经验记录-2.md b/过往经验/经验记录-2.md new file mode 100644 index 0000000..7ae1fce --- /dev/null +++ b/过往经验/经验记录-2.md @@ -0,0 +1,583 @@ +# 经验记录 + +--- + +## 记录 1:report-editor 新建报告时显示空白模板 + +**A. 具体问题** +超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。 + +**B. 产生问题原因** +1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。 +2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。 +3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。 +4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。 + +**C. 解决问题方案** +1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。 +2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。 +3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。 + +**D. 后续如何避免问题** +- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。 +- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。 +- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。 + +--- + +## 记录 2:关键帧一键插入占位符功能实现 + +**A. 具体问题** +用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。 + +**B. 产生问题原因** +原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。 + +**C. 解决问题方案** +1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。 +2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。 +3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。 + +**D. 后续如何避免问题** +- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。 +- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。 +- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。 + +--- + +## 记录 3:关键帧 "插入" 按钮位置与样式优化 + +**A. 具体问题** +用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。 + +**B. 产生问题原因** +初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。 + +**C. 解决问题方案** +1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。 +2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。 +3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。 + +**D. 后续如何避免问题** +- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。 +- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。 +- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。 + +--- + +## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部) + +**A. 具体问题** +用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。 + +**B. 产生问题原因** +按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。 + +**C. 解决问题方案** +1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。 +2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。 +3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。 +4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。 + +**D. 后续如何避免问题** +- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。 +- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。 +- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。 + + +--- + +## 记录 5:路由切换后视频分析图片丢失 + +**A. 具体问题** +在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。 + +**B. 产生问题原因** +1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。 +2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。 +3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。 +4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。 + +**C. 解决问题方案** +在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。 + +**D. 后续如何避免问题** +- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。 +- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。 +- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。 + + +--- + +## 记录 6:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新 + +**A. 具体问题** +1. 在 `/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`,**报告内容变空、基本信息清空、视频分析数据全部丢失**。 +2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。 + +**B. 产生问题原因** +1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft,覆盖了用户已有的数据。 +2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。 + +**C. 解决问题方案** +1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。 +2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。 +3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。 + +**D. 后续如何避免问题** +- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。 +- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。 +- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。 + + +--- + +## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview) + +**A. 具体问题** +用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。 + +**B. 产生问题原因** +1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI; +2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署; +3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。 + +**C. 解决问题方案** +1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程; +2. 执行 `npm run build` 重新构建生产包; +3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器; +4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。 + +**D. 后续如何避免问题** +- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本; +- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱; +- 如需固定端口,可在 `package.json` 的 `preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。 + + +--- + +## 记录 8:路由切换后所有内容仍然丢失——彻底重构自动保存机制 + +**A. 具体问题** +在 `/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。 + +**B. 产生问题原因** +1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。 +2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []`、`capturedFrames: []`、默认 `reportData`)。 +3. 组件卸载(cleanup)时调用保存,用这个空值**覆盖了 localStorage 中已有的正确 draft**。 +4. 重新挂载后,系统读取了被清空的 draft,导致所有数据全部丢失。 +5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。 + +**C. 解决问题方案** +1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current` 和 `stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId`、`reportId`,确保闭包永远绑定当前渲染周期的最新 state。 +2. **重构自动保存 effect**:将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`,effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载,cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。 +3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。 + +**D. 后续如何避免问题** +- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。 +- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。 +- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。 +- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。 + + +--- + +## 记录 9:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效 + +**A. 具体问题** +在 `/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`: +- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失; +- 视频分析面板中的自动关键帧和手动截图全部丢失。 + +**B. 产生问题原因** +1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state(如 `capturedFrames`、`videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。 +2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。 +3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。 + +**C. 解决问题方案** +1. **重构 `saveDraftToStorage` 从 Ref 读取**: + - `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`。 + - `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。 + - `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。 +2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。 + +**D. 后续如何避免问题** +- 对于需要在异步操作或组件卸载时读取的"最新状态",**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。 +- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。 +- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。 +- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景(React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。 + + +--- + +## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入 + +**A. 具体问题** +开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。 + +**B. 产生问题原因** +`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`: +```tsx +if ((settings.autoInsertDelay || 0) > 0) { + await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000)); +} +``` + +`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。 + +**C. 解决问题方案** +1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。 +2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。 +3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。 + +**D. 后续如何避免问题** +- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。 +- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。 +- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。 + + +--- + +## 记录 12:5 项交互与默认值优化(占位符尺寸、签名状态、素材预加载) + +**A. 具体问题** +用户提出 5 个 UI/UX 改进需求: +1. 插入图片占位符时两次 `prompt` 弹窗合并为一次,用英文逗号分隔宽高; +2. 占位符未指定尺寸时默认显示为 `200×200px`,且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`; +3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`; +4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列,根据 `user.signature` 显示「已上传」/「未上传」; +5. 修复系统重置后 `ReportEditor` 的素材库为空的问题,将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。 + +**B. 产生问题原因** +1. `insertImage()` 在两个编辑器(`TemplateManage`、`ReportEditor`)中均使用两次独立的 `prompt()`,操作冗余且中断感强。 +2. 旧占位符样式使用 `max-width`/`max-height`,当内容区域大于占位符时,边框和背景不会收缩到指定尺寸,视觉尺寸不可控;且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化,不统一。 +3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段,导致新系统首次进入 `/system-settings` 时相关开关为空。 +4. `UserManage.tsx` 表格缺少签名可视化列,管理员无法一眼辨别哪些医生已上传电子签名。 +5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`,素材库为空,图片选择器无法使用系统默认 logo。 + +**C. 解决问题方案** +1. **合并 prompt**: + ```ts + const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', ''); + const parts = input.split(',').map(s => s.trim()); + ``` + 按逗号分割,第一部分为宽度,第二部分为高度。留空或单侧留空时,另一侧自动回退到 `200`。 +2. **固定尺寸样式**: + - 移除 `max-width`/`max-height`,改用 `width:${width}px;` / `height:${height}px;`。 + - 默认值逻辑:`!widthStr && !heightStr` → `200×200`;`widthStr && !heightStr` → 宽自定义、高 `200`;`!widthStr && heightStr` → 宽 `200`、高自定义。 +3. **默认设置补全**:在 `Login.tsx` 的 `defaultSettings` 中显式加入: + ```ts + autoInsertFrames: true, + autoInsertDelay: 1, + autoInsertFrameIndices: [0, 1, 2, 3, 4, 5] + ``` +4. **签名状态列**:在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中,于「部门」之后、「状态」之前插入: + ```tsx + <span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${ + user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500' + }`}> + {user.signature ? '已上传' : '未上传'} + </span> + ``` +5. **素材预加载前置**:将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中,并增加 `savedAssets.length === 0` 的判空保护,避免覆盖用户后续上传的素材。 + +**D. 后续如何避免问题** +- 对于成对的数值输入(如宽高、行列),优先考虑单输入框 + 分隔符,减少弹窗次数;同时做好格式解析和容错(空值、单侧空值、非数字)。 +- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控,避免 `inline-flex` 内容撑大容器。 +- 任何需要在多个页面共享的初始化数据(如素材库、默认配置),应放在全局初始化入口(如登录页的 `initData`),而不是分散在各个页面的 `useEffect` 中。 +- 表格字段变更时,注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致,避免列错位。 + +--- + +## 记录 13:6 项交互优化(placeholder 虚线框、删除按钮、签名尺寸、多选重构) + +**A. 具体问题** +用户提出 6 个 UI/UX 改进需求: +1. 图片插入占位符后虚线框残留——内联 `border:1px dashed #cbd5e1` 优先级高于 `.has-image` CSS class; +2. `insertImage` 生成的 placeholder 中 `overflow:hidden` 裁切了绝对定位的删除按钮(`×`); +3. 占位符尺寸输入从逗号分隔改为星号(`*`)分隔,格式错误时提示重新输入; +4. 默认模板中「手术者签名」占位符固定为 `200×40px`; +5. 删除「手术者签名确认」字段及相关的弱阻断确认弹窗; +6. 多选组件从 tag 形态重构为纯文本拼接形态,支持多种标点符号拆分并自动保存新选项。 + +**B. 产生问题原因** +1. `fillPlaceholderSrc` 仅添加了 `has-image` class,但内联 `style="border:..."` 的优先级永远高于外部 CSS,导致虚线框无法消除。 +2. `insertImage` 的 `styleStr` 中硬编码了 `overflow:hidden;`,而删除按钮使用 `position:absolute; top:-8px; right:-8px` 之类的定位,必然被父级裁切。 +3. 英文逗号分隔容易与用户输入的千位分隔符或中文逗号混淆。 +4. 默认模板中签名占位符使用 `min-width:80px;min-height:24px`,尺寸过小且不一致。 +5. `isSigned` 字段与签名图片是两个独立的状态,造成医生需要多点一次确认,流程冗余。 +6. 原多选使用 tag 胶囊形式,每个 tag 带背景色和删除按钮,占用空间大,且无法直接复制粘贴整段文本。 + +**C. 解决问题方案** +1. **清除内联样式**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `fillPlaceholderSrc` 中增加: + ```ts + placeholder.style.border = 'none'; + placeholder.style.background = 'transparent'; + ``` + 同时统一 `defaultContent.ts` 中所有 8 个 placeholder 为 `<span style="display:inline-flex;...">` 格式,表格中的 6 个也统一使用 `width:100%;height:150px;`。 +2. **移除 overflow:hidden**:从两个 `insertImage` 的 `styleStr` 中删除 `overflow:hidden;`,保留在 `placeholder-text` 子元素上(文字截断仍可用)。 +3. **星号分隔 + 校验循环**: + ```ts + while (true) { + const input = prompt('...用 * 分隔...', ''); + 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]); height = parseInt(parts[1]); break; + } + alert('格式错误...'); + } + ``` +4. **签名占位符尺寸**:`defaultContent.ts` 中改为 `width:200px;height:40px;`。 +5. **移除 `isSigned`**: + - `types.ts` 的 `DEFAULT_FORM_FIELDS` 中删除; + - `ReportEditor.tsx` 的初始 `reportData` 中删除; + - `saveReport` 的完成确认逻辑中删除 `isSigned` 判断; + - smart field 同步逻辑中删除 `isSigned` 判断,只要有 `signatureData` 就直接显示签名图。 +6. **多选重构为文本拼接**: + - `displayText = currentValues.join(', ')`; + - input 使用 `value={displayText}` 受控组件; + - `onChange` 实时解析并更新 `reportData`:`parseMultiInput(text)` 用 `/[,,;;、]/` 正则拆分、去重; + - `onBlur` / `Enter` 时调用 `handleMultiCommit`,将拆分出的新选项保存到 `multiSelectOptions` 和 `formFieldsConfig`; + - 下拉选择时追加 `, opt` 到现有文本。 + +**D. 后续如何避免问题** +- 当使用内联样式设置边框/背景时,如需在特定状态下移除,**必须在内联层面重置**(`style.border = 'none'`),不能仅依赖 CSS class 覆盖。 +- `overflow:hidden` 与绝对定位子元素互斥,若需要裁切文字但保留溢出按钮,应将 `overflow:hidden` 限制在文字子元素上,而非父容器。 +- 用户输入的格式校验应使用 `while` 循环 + `alert` 重试,避免静默容错导致不可预期的行为。 +- 删除字段时务必全局搜索(`grep -r 'isSigned'`),确保初始化状态、表单验证、模板绑定等所有引用点都被清理。 +- 将「标签胶囊」改为「纯文本拼接」时,注意保持 `reportData` 的数据结构仍为数组,UI 层只做 `join/split` 转换。 + +--- + +## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志 + +**A. 具体问题** +报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。 + +**B. 产生问题原因** +1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9: + ```tsx + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const dataUrl = canvas.toDataURL('image/jpeg', 0.9); + ``` + 对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。 +2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志: + ```typescript + } catch { + // ignore quota exceeded + } + ``` + 当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。 + +**C. 解决问题方案** +1. **压缩关键帧分辨率与质量**: + - 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放: + ```tsx + const MAX_WIDTH = 800; + const scale = Math.min(1, MAX_WIDTH / video.videoWidth); + canvas.width = video.videoWidth * scale; + canvas.height = video.videoHeight * scale; + ``` + - 将 JPEG 导出质量从 `0.9` 降到 `0.6`。 + - 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。 + +2. **增加存储错误可见性**: + - 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`: + ```typescript + } catch (e) { + console.error('Storage save failed (possibly quota exceeded):', e); + } + ``` + +**D. 后续如何避免问题** +- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。 +- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。 +- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。 +- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。 + + +--- + +## 记录 14:5 项交互修复(虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示) + +**A. 具体问题** +用户提出 5 个修复需求: +1. 删除 `image-placeholder` 中的图片后,虚线框消失——`fillPlaceholderSrc` 中设置了 `border='none'`,但删除图片的代码没有恢复; +2. `ReportEditor.tsx` 的 `insertImage` prompt 文案仍显示旧版 "用英文逗号分隔",未同步修改; +3. 新生成的 `image-placeholder` 右上角红色 `×` 显示不完全——`ReportEditor.tsx` 的 `insertImage` 中 `overflow:hidden` 未移除; +4. 多选框无法输入 `,`、`;`、`,`、`、` 等分隔符——`onChange` 实时调用 `parseMultiInput` + `filter(Boolean)`,末尾的分隔符被瞬间吃掉; +5. 多选框 label 缺少 "(可多选)" 提示。 + +**B. 产生问题原因** +1. 上批次修改时,`ReportEditor.tsx` 的 `insertImage` 替换未成功匹配(旧字符串与文件实际内容有微小差异),导致该函数保留了旧代码。删除图片逻辑同样缺少 border/background 恢复。 +2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除,`ReportEditor.tsx` 中仍保留。 +3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`,每次输入都会触发 `split(/[,,;;、]/)` 和 `filter(Boolean)`。当用户输入一个逗号时,split 产生空字符串,filter 将其过滤,输入框值立即回退到无逗号状态。 +4. label 渲染时直接使用 `{field.label}`,未追加多选提示。 + +**C. 解决问题方案** +1. **恢复虚线框**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的删除图片分支中,增加: + ```ts + placeholder.style.border = '1px dashed #cbd5e1'; + placeholder.style.background = '#f8fafc'; + ``` +2. **修正 prompt 文案**:将 `ReportEditor.tsx` 的 `insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑(`*` 分隔 + while 循环校验)。 +3. **移除 overflow:hidden**:从 `ReportEditor.tsx` 的 `styleStr` 中删除 `overflow:hidden;`。 +4. **多选输入解耦**:引入本地状态 `multiInputText: Record<string, string>`, + - `onChange` 仅更新 `multiInputText`,不触发拆分; + - `onBlur` 和 `Enter` 时才调用 `handleMultiCommit` 执行 `split` + `filter(Boolean)` + 保存新选项; + - 输入框 `value` 优先读取 `multiInputText[field.key]`,无本地缓存时回退到 `displayText`。 +5. **label 追加提示**:`{field.label}(可多选)`。 + +**D. 后续如何避免问题** +- 同类型函数在多个文件中存在时,务必逐个文件 grep 确认修改结果,不能假设一次替换就能覆盖所有实例。 +- 任何 "实时解析输入" 的逻辑都必须警惕 `filter(Boolean)` 对空字符串的过滤效应——如果允许用户输入分隔符,应使用独立状态缓存原始输入,仅在确认时(blur/enter)执行解析。 +- `StrReplaceFile` 的批量替换若返回 "Applied N edit(s) with M total replacement(s)" 且 M < N,应立即检查未匹配的文件,避免遗漏。 + + + +--- + +## 记录 15:时间/日期字段格式配置与撰写时间动态字段 + +**A. 具体问题** +用户提出 2 个需求: +1. TemplateManage 字段管理中,时间/日期字段增加配置:date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「手动选择」作为默认值策略。 +2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。 + +**B. 产生问题原因** +1. `FormField` 数据结构缺少格式和默认值配置字段。 +2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。 +3. 模板底部「年 月 日」是纯静态 HTML 文本,没有数据绑定能力。 + +**C. 解决问题方案** +1. **扩展数据结构**:`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值(`surgeryDate` → `YYYY-MM-DD`+`specific`,`startTime/endTime` → `24h`+`specific`);新增系统字段 `reportDate`(`YYYY年MM月DD日`+`current`)。 +2. **TemplateManage UI 增强**: + - 新增字段表单:category 为「时间」时显示「默认值」select(手动选择/当前时间)和「显示格式」select(date 提供两种日期格式,time 提供 24h/12h)。 + - 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。 +3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。 +4. **ReportEditor 表单渲染重构**: + - `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围(24h: 00-23,12h: 01-12),12h 时额外增加 AM/PM select。存储仍保持 24h(`startHour/startMinute`),转换函数 `to24h`/`from24h` 处理 12h↔24h。 + - 通用 time 字段(非 startTime/endTime):新增 hour+minute select 渲染,值统一存储为 `HH:MM` 字符串。 +5. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`,将原始值转换为配置格式后写入编辑器。 +6. **编辑器反向编辑解析**:`handleEditorInput` 中,当用户直接在编辑器内修改 date/time smart field 时,通过正则解析格式化文本(如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`),转回原始值后存入 `reportData`。 +7. **默认模板更新**:`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。 + +**D. 后续如何避免问题** +- 当为字段增加新的配置属性时,务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值,保证向后兼容。 +- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。 +- 12h/24h 转换要覆盖所有边界情况:12AM→00、12PM→12、1PM→13,建议用独立纯函数(`to24h`/`from24h`)集中处理,避免在 JSX 中内联复杂计算。 +- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。 + + +--- + +## 记录 16:时间字段增强——自定义格式、固定时间默认值、系统锁定标签 + +**A. 具体问题** +用户提出 4 个改进需求: +1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符; +2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段; +3. 「手动选择」文案歧义,应改为「固定时间」; +4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。 + +**B. 产生问题原因** +1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间:${smartField('reportDate')}`,导致编辑器中显示重复文字。 +2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`,字段库允许修改 label。 +3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。 +4. 日期/时间格式仅通过固定 `<select>` 提供预设选项(如 `YYYY-MM-DD`、`24h`),无法覆盖用户自定义需求(如 `YYYY/MM/DD`、`hh:mm A` 等)。 +5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。 + +**C. 解决问题方案** +1. **删除前缀**:`defaultContent.ts` 中将底部 HTML 从 `撰写时间:${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。 +2. **系统锁定**:`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。 +3. **文案修改**:`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。 +4. **自定义格式输入**: + - `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。 + - `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式: + - `formatInputMode: 'select' | 'custom'`,默认 `select`。 + - 选择「自定义」时显示 `<input>`,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。 + - 预设候选包含常用格式:`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。 + - 通用化显示函数: + ```ts + const formatDateDisplay = (isoDate: string, fmt?: string): string => { + if (!isoDate || !fmt) return isoDate || ''; + const [y, m, d] = isoDate.split('-'); + return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || ''); + }; + const formatTimeDisplay = (timeStr: string, fmt?: string): string => { + if (!timeStr || !fmt) return timeStr || ''; + const [h24str, mstr] = timeStr.split(':'); + const h24 = parseInt(h24str) || 0; + const isPM = h24 >= 12; + let h12 = h24 % 12; if (h12 === 0) h12 = 12; + return fmt.replace(/HH/g, String(h24).padStart(2, '0')) + .replace(/mm/g, mstr || '00') + .replace(/hh/g, String(h12).padStart(2, '0')) + .replace(/A/g, isPM ? '下午' : '上午'); + }; + ``` +5. **通用化反向解析**:新增 `parseDateFromFormat` / `parseTimeFromFormat`,从格式化文本中通过数字正则提取原始值,确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。 +6. **固定时间默认值自动填充**:`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支,若字段配置了 `fixedTimeValue` 且当前值为空,则自动填入固定值。 + +**D. 后续如何避免问题** +- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数,否则编辑器双向同步会断裂。 +- 使用占位符替换(如 `fmt.replace(/YYYY/g, y)`)实现通用格式化时,要确保所有可能的 token 都覆盖到,且替换顺序不会相互干扰。 +- 当某个字段被标记为 `isSystemLocked: true` 后,需在 UI 层面同时禁用 label 输入框,否则用户会困惑「为何修改无效」。 +- 时间/日期字段的默认值策略文案应直接体现业务含义(如「固定时间」「当前时间」),避免使用技术词汇(如「手动选择」)。 +- 对于 `startTime`/`endTime` 这类拆分存储(`startHour`+`startMinute`)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。 + +--- + +## 记录 17:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换 + +**A. 具体问题** +用户发现 3 个时间字段配置与报告编辑器的联动断层: +1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式 `YYYY年MM月DD日`;新建时间字段时默认格式为不可解析的 `'24h'`。 +2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。 +3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`(12小时制),报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。 + +**B. 产生问题原因** +1. **默认格式错误**:`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token, `'24h'` 无法被正确解析。 +2. **固定时间未注入**:`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中,`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过(因为已有值了)。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。 +3. **12h 判断写死**:`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时,判断失败,表单始终渲染为 24 小时制。 + +**C. 解决问题方案** +1. **修正默认格式**: + - `TemplateManage.tsx` 中新建字段的默认格式改为: + ```ts + setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm'); + ``` + - 重置表单时的默认值同步修正。 +2. **注入固定时间默认值**: + - `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。 + - 切换模板的 `useEffect` 中,在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑: + ```ts + formFields.forEach(field => { + if (field.category === '时间') { + if (field.timeDefault === 'specific' && field.fixedTimeValue) { + // 按 field.type 和 field.key 注入固定值 + } else if (field.timeDefault === 'current') { + // 注入当前系统时间 + } + } + }); + if (!nextReportData.surgeryDate) { + nextReportData.surgeryDate = new Date().toISOString().split('T')[0]; + } + ``` +3. **通用化 12h 判断**: + - `ReportEditor.tsx` 中: + ```ts + const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false; + ``` + - 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`,只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。 + +**D. 后续如何避免问题** +- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'`、`'12h'`)作为存储值,除非格式化函数也能识别这些别名。 +- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口(初始 state、切换模板、重置表单等)中显式遍历字段配置并注入,不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。 +- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**(`includes`)而非**精确匹配**,以兼容用户自定义格式。如果判断逻辑较为复杂,建议抽离为独立工具函数(如 `is12HourFormat(fmt: string): boolean`)。 +- 当某个字段在初始化时被赋予了「看似合理的默认值」(如 `surgeryDate: new Date()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。