37 Commits
v1.3.0 ... main

Author SHA1 Message Date
4110579e19 上传文件至「/」 2026-04-20 10:51:54 +08:00
f0eda59c63 2026-04-20-02-53-00 - 初始化代码编纂工作流:创建工程分析目录、文档模板、经验记录,并将工作流规范写入AGENTS.md 2026-04-20 02:54:50 +08:00
35d8dd4ce2 release: v2.0.0 - AI手术图文报告系统(最小可部署包)
- 移除工程分析文档、参考信息、过往经验等非部署内容
- 保留 src/ public/ Dockerfile docker-compose.yaml nginx.conf 等核心文件
2026-04-20 02:41:15 +08:00
3827d09ad3 security: 讯飞配置输入框改用ref非受控设置,防止DOM value属性暴露 2026-04-20 02:28:08 +08:00
49886e5080 ui: AI撰写tab增加机器人头像图标 2026-04-20 02:24:34 +08:00
5f68f4b820 security: 讯飞配置输入框增加密码隐藏+防复制+防自动填充 2026-04-20 02:21:09 +08:00
c544483705 fix: AI修改确认弹窗左侧字体与右侧统一 2026-04-20 02:16:38 +08:00
9aec836e93 fix: 讯飞语音识别文字覆盖+麦克风未释放
- 使用setChatInput(prev => prev + seg)函数式更新,彻底修复文字覆盖问题
- 提取stopMicrophone函数,在手动停止/VAD自动挂断/报错/关闭时统一释放物理麦克风
2026-04-20 02:10:38 +08:00
75e4e56cb3 fix: 讯飞语音识别停止后内容未写入输入框
- 停止录音时不再立即关闭WebSocket,仅发送结束帧+关闭麦克风
- 移除dwa: wpgs动态修正,避免返回数据结构复杂导致拼接混乱
- ws.onmessage检测到服务端status:2时才彻底断开连接并写入最终文字
2026-04-20 01:54:50 +08:00
b07bcfaad2 fix: 讯飞鉴权签名与官方demo对齐+结束帧+文字修改
- ReportEditor: getXfAuthUrl signatureOrigin去掉引号,Base64改用CryptoJS.enc.Base64.stringify与demo完全一致
- ReportEditor: 第一帧增加dwa: wpgs动态修正参数
- ReportEditor: 停止录音时先发送status:2结束帧再关闭WebSocket
- SystemSettings: 标题改为讯飞语音听写Websocket接口配置
2026-04-20 01:43:58 +08:00
4f27edcc92 fix: 讯飞配置字段名统一+WebSocket错误码拦截
- SystemSettings: state初始化和表单绑定中xfIatConfig统一改为xfSpeechConfig,与编辑器读取字段一致
- ReportEditor: ws.onmessage增加jsonData.code错误码拦截,展示具体讯飞报错信息
2026-04-20 01:33:14 +08:00
d235ced187 fix: 讯飞鉴权HTTP兼容+AI段落紧凑+对话模式JSON降级
- ReportEditor: 讯飞鉴权改用crypto-js HMAC-SHA256,兼容HTTP非安全环境
- defaultContent.ts+ReportEditor: AI区域<p>标签去掉margin-bottom,段落紧密排布
- ReportEditor: 纯对话模式下JSON解析失败时降级为直接文本回复
- ReportEditor: 对话模式systemPrompt强化JSON格式约束
- deps: 新增crypto-js用于讯飞鉴权
2026-04-20 01:18:57 +08:00
ea789cee26 fix: 讯飞配置默认值+AI图片同步+TemplateManage空行清理
- ReportEditor: 讯飞语音读取xfSpeechConfig增加DEFAULT_XF_SPEECH后备,旧用户无需手动保存
- ReportEditor: 切换到AI面板时强制同步编辑器图片到视觉参考上下文
- TemplateManage: saveCurrentTemplate/saveTemplateContent保存前清理空段落和标签间空白
- types.ts: 新增XfSpeechConfig接口和DEFAULT_XF_SPEECH常量
2026-04-20 01:01:01 +08:00
9f2b5dce21 fix: AI修改确认弹窗段落分段修复
- defaultContent.ts: 手术步骤模板<p>改为紧凑HTML,统一style添加margin: 0 0 8px 0
- ReportEditor.tsx: stripHtml在</p>与<p>之间插入\\n\\n,diff modal可正确分段
- ReportEditor.tsx: AI返回的cleanHtml<p>样式与模板一致,避免标签差异干扰diff
2026-04-20 00:41:53 +08:00
2dbdbe02b2 feat: AI面板图片联动+聊天图片展示+讯飞语音识别
- AI图片选择区改为仅展示编辑器中已插入的占位图
- 用户发送的图片在聊天气泡中展示并包含在导出日志中
- 接入讯飞Spark IAT流式听写WebSocket替换原生语音识别
2026-04-20 00:36:55 +08:00
963a7541c9 fix: Diff modal原始版本大段空白修复(20260420_0009)
- currentHtml提取后添加 replace(/>(\s+)</g, '><') 压缩标签间空白
- cleanHtml处理后同样压缩, 确保diff两侧格式对齐
- 消除模板源码排版换行被diff算法误识别为删除内容的问题
2026-04-20 00:10:31 +08:00
e549419a4c feat: 模板切换重置AI对话+Diff间距修复+API密钥DOM安全+模型切换(20260419_2344)
- 切换模板时同步清空 chatMessages/chatInput/aiUploadedImages/aiSelectedFrames
- 修复stripHtml双换行导致diff modal原始版本段落间距过大
- API密钥input改为ref非受控组件, DOM中不再出现value=sk-xxx属性
- 默认模型名改为 moonshot-v1-32k-vision-preview
2026-04-19 23:50:51 +08:00
18d81cb4a6 security: API密钥源码级字符码混淆(20260419_2316+)
- 将 DEFAULT_AI_PROVIDERS.kimi.apiKey 从明文改为空字符串
- storage.ts新增 getDefaultApiKey(): 字符码数组→String.fromCharCode 运行时还原
- SystemSettings.tsx/Login.tsx/ReportEditor.tsx 在 apiKey为空时自动填充默认值
- 彻底消除源码中 sk-xxx 明文字符串的直接暴露
2026-04-19 23:30:13 +08:00
0039b18a26 feat: 模板AI区域化+默认配置优化+API密钥安全(20260419_2316)
- 默认模板: 手术步骤段落包裹进 .ai-region AI可编辑区域
- API密钥: DEFAULT_AI_PROVIDERS.kimi.apiKey 预设默认值,
  输入框增加onCopy/onCut防复制, storage.ts增加XOR+Base64透明加密
- 默认模型: kimi modelName改为 moonshot-v1-auto
- 抽帧配置: 12个位置改为指定百分比[7.9,9.3,46.2,49.1,63.9,64.8,
  68.8,73.7,80.2,85.0,96.3,98.6], 默认模式从uniform改为keep
2026-04-19 23:24:36 +08:00
3bec69986e feat: Kimi k2.5参数适配+AI日志导出完善(20260419_2249)
- Kimi k2.5 强制传参拦截: 当 provider=kimi 且 model 包含 k2.5 时,
  从请求体中 delete temperature/top_p/presence_penalty/frequency_penalty,
  彻底解决 HTTP 400 报错
- 完善导出AI日志: 新增 lastExchangeLog 状态, 记录每次调用的
  完整请求体(requestPayload)、原始响应(responsePayload)、
  错误详情(errorDetail含status/statusText/responseText)、模型配置
- 更新导出按钮 JSON 结构, 包含 lastExchange 字段
2026-04-19 22:54:00 +08:00
2e634ff832 feat: AI写作模块4项优化(20260419_2226)
- 修复 diff 颜色残留: confirmAiInjection 使用 cleanHtml 而非 newHtml
- 更新默认快捷指令: 4条外科专用 -> 2条通用短语
- 新增发送按钮: 输入框旁显式发送按钮
- 导出AI日志: 快捷指令区域新增调试日志导出(JSON)
2026-04-19 22:29:43 +08:00
1ec25065ad feat(ai): diff弹窗文档对比高亮 + 二次修改未弹窗修复
- 引入diff库,实现字符级差异比对
- diffModal左右两侧增加diff高亮:左侧删除内容标红,右侧新增内容标绿
- systemPrompt增加绝对强制条款:无论指令多小都必须返回updatedHtml
- 前端校验兜底:修改模式下未返回updatedHtml时在聊天面板给出提示
- confirmAiInjection注入前清理diff高亮span,避免污染编辑器
2026-04-19 22:08:05 +08:00
7275906f3c fix(editor): AI注入后Ctrl+Z失效 + 字体格式统一
- confirmAiInjection改用Range.selectNodeContents + execCommand('insertHTML')保留浏览器撤销栈
- handleAIGenerate中对cleanHtml增加<p>标签内联样式注入:padding 0px、font-family SimSun、font-size 12pt、line-height 1.5
- 确保AI替换后的文字字体与原有文字完全一致
2026-04-19 20:33:43 +08:00
b24ba08658 fix(ui): 打印隐藏AI区域蓝框 + diff弹窗字体统一
- print.ts的iframe样式中增加.ai-region隐藏规则:去除边框/背景/内边距,隐藏右上角标签
- diffModal右侧AI提议版本容器增加style属性:fontFamily SimSun、fontSize 12pt、lineHeight 1.5
- 确保打印输出和diff对比的视觉一致性
2026-04-19 18:25:38 +08:00
6abd7d1e3a fix(editor): contentEditable回车导致段落溢出.ai-content
- handleAIGenerate中获取currentHtml前增加溢出段落合并逻辑
- 遍历.ai-content之后的兄弟<p>节点,移回.ai-content内
- 合并后同步更新contentRef和saveDraftToStorage
- 确保diff弹窗左侧能显示AI可编辑区域内的全部段落
2026-04-19 18:10:40 +08:00
a3cafcb672 fix(ai): AI越界生成——Prompt增加内容边界约束
- systemPrompt去掉'基于全局信息补充完善'诱导性措辞,改为明确【内容边界】警告
- systemPrompt明确告知AI:全局参考仅供理解上下文,updatedHtml只能包含目标区域本身内容
- promptText增加⚠️防越界指令:明确列出禁止混入的模块类型(术后情况、标本描述、病理结果、签名等)
2026-04-19 04:26:16 +08:00
c7e7033e7d feat(ai): diff弹窗内容完整性优化 + HTML空行清洗
- systemPrompt和promptText中明确要求AI生成完整多段落内容,不要只改写现有段落
- systemPrompt增加HTML格式约束:<p>标签包裹、禁止<br>和换行符、紧凑HTML
- setDiffModal和execCmd之前增加正则清洗:移除<br>、移除</p>与<p>间空白、trim首尾
2026-04-19 04:15:36 +08:00
9f73d8595c feat(ai): 修改模式自动锁定目标区域 + SystemPrompt模式语义强化
- handleAIGenerate开头增加自动修正目标区域逻辑:修改模式开启且未选区域时,自动选择文档中第一个AI区域
- systemPrompt明确标注'当前处于【修改模式】/【对话模式】',并细化字段要求
- diffModal的targetId改为使用actualTargetId,确保确认注入时使用实际修正后的区域ID
2026-04-19 04:02:05 +08:00
c1d2438d2b fix(editor): AI只聊天不修改——解绑SystemPrompt目标区域依赖 + 增加光标插入降级
- systemPrompt条件从'aiModifyEnabled && targetRegionEl'改为'aiModifyEnabled',确保开启修改模式后大模型始终返回updatedHtml
- 接收updatedHtml逻辑增加if/else分支:targetRegionEl存在时走diff弹窗,不存在时调用execCmd('insertHTML')降级插入光标位置
- 参考参考-ReportEditor.tsx中injectAIText的降级机制
2026-04-19 03:47:14 +08:00
854a00c2fa fix(editor): Checkbox点击失效 + AI全局上下文注入
- 将'允许修改正文'复选框从id/htmlFor绑定改为label直接包裹input,增加e.stopPropagation防止事件冒泡被拦截
- handleAIGenerate中新增editorRef.current.innerText作为全局上下文注入prompt
- currentHtml增加过滤&#8203;零宽字符
- 优化systemPrompt,明确告知大模型全局参考内容+目标区域源码的双信息源结构
2026-04-19 03:35:52 +08:00
a065f6af27 docs: 记录28-chatInput草稿恢复与AI请求content条件格式经验 2026-04-19 03:24:23 +08:00
da2ecdc224 fix(editor): chatInput草稿恢复 + AI请求content条件格式
- 草稿恢复分支增加chatInput恢复,避免路由切换后聊天输入框内容丢失
- handleAIGenerate中messageContent改为条件判断:无图片时发送string,有图片时发送vision数组,修复Kimi文本模型400 Bad Request
2026-04-19 03:23:49 +08:00
9173aa7733 2026-04-19-03-03-55 修复AI撰写体验:API endpoint斜杠净化、模型列表下拉栏、聊天记录持久化存储 2026-04-19 03:09:46 +08:00
d5cbbf9137 2026-04-19-02-48-25 重构AI接口配置:多服务商底座架构、OpenAI兼容协议、动态模型切换、旧数据自动迁移 2026-04-19 02:53:26 +08:00
221daf61a5 2026-04-19-02-26-05 集成AI撰写功能:Kimi-2.5多模态API、AI可编辑区域、Diff确认弹窗、语音与图片输入、快捷指令 2026-04-19 02:36:20 +08:00
96b295f919 2026-04-19-02-00-33 建立代码编纂工作流:工程分析框架、经验记录迁移、工作流规范制定 2026-04-19 02:04:40 +08:00
1dc3d60248 docs: add AGENTS.md and experience logs for v1.3 2026-04-19 01:49:30 +08:00
22 changed files with 4610 additions and 76 deletions

305
AGENTS.md Normal file
View File

@@ -0,0 +1,305 @@
# AGENTS.md — 手术图文病历报告系统AI手术图文报告系统 V2.0
> 本文档面向 AI 编程助手。如果你正在阅读本文件,说明你对本项目一无所知。以下内容全部基于项目实际代码与配置,请勿假设任何未在本文中显式说明的内容。
---
## 1. 项目概述
本项目是一个**纯前端**的医疗图文报告管理应用,面向医院手术记录场景。核心能力包括:
- 通过富文本编辑器撰写手术图文报告
- 上传手术视频并自动/手动截取关键帧插入报告
- 模板管理(创建、维护标准模板,新建报告时自动加载默认模板)
- 基于角色的权限控制(超级管理员 / 管理员 / 普通用户)
- AI 辅助撰写(支持 Kimi、DeepSeek、OpenAI 及自定义兼容接口)
- 报告批量操作(删除、导出 PDF / JSON
- 讯飞语音输入WebSocket 实时转写)
**重要前提**:本项目没有任何后端服务。所有数据(用户、报告、模板、视频帧、系统配置)全部持久化在浏览器 `localStorage` 中。认证与授权逻辑完全在客户端执行。
默认测试账号:
- `admin` / `123456`
- `manager` / `123456`
- `doctor` / `123456`
---
## 2. 技术栈
| 层级 | 技术 |
|------|------|
| 框架 | React 19 + TypeScript ~5.8.2 |
| 构建工具 | Vite 6.2.0`@vitejs/plugin-react` |
| 路由 | React Router DOM v7`BrowserRouter` |
| 样式 | Tailwind CSS v4.1.14(通过 `@tailwindcss/vite` 插件引入,非 PostCSS |
| 图标 | `lucide-react` |
| 动画 | `motion`Framer Motion 继任者) |
| AI SDK | `@google/genai`(依赖列表中,实际主要使用 OpenAI 兼容 API |
| 其他关键依赖 | `diff`(字符级差异比对)、`crypto-js`(讯飞 HMAC-SHA256 鉴权) |
`package.json` 中虽然声明了 `express``@types/express`,但源码中未见服务端代码,推测为历史遗留或未使用的依赖。
---
## 3. 项目结构
```
src/
├── App.tsx # 根组件,定义所有路由
├── main.tsx # 应用入口React 19 + StrictMode
├── types.ts # 全局类型定义与默认值常量
├── index.css # Tailwind v4 引入 + 自定义编辑器/打印/A4 样式
├── components/
│ └── Sidebar.tsx # 全局侧边导航栏(带角色过滤、自动收起)
├── pages/ # 所有页面均为大而整的单文件组件
│ ├── Login.tsx # 登录 + 初始数据种子写入
│ ├── Dashboard.tsx # 统计看板、SVG 趋势图、快捷入口
│ ├── ReportEditor.tsx # 核心编辑器(~2900 行富文本、视频、AI、Diff、表单
│ ├── ReportManage.tsx # 报告列表、检索、批量操作、历史版本恢复
│ ├── ReportView.tsx # 只读报告展示 + 打印触发
│ ├── TemplateManage.tsx # 模板编辑器(~1700 行)+ 字段库侧边栏
│ ├── UserManage.tsx # RBAC 用户 CRUD + 电子签名
│ └── SystemSettings.tsx # 抽帧配置、AI 服务商、讯飞语音参数
└── utils/
├── storage.ts # localStorage/sessionStorage 封装(含 XOR 加密)
├── print.ts # iframe 静默渲染 + window.print() 实现 A4 打印
└── defaultContent.ts # 默认手术报告 HTML 模板(含 data-bind 占位)
```
**架构特点**
- 无外部状态管理库,全部使用 React 原生 `useState` / `useRef` / `useEffect`
- 无组件拆分策略,页面级组件体积巨大(`ReportEditor.tsx``TemplateManage.tsx` 均超 1500 行)
- 无懒加载,所有页面在 `App.tsx` 中直接静态导入
- 共享布局仅有 `Sidebar.tsx` 一处
---
## 4. 构建与运行命令
```bash
# 安装依赖
npm install
# 本地开发(端口 3000监听 0.0.0.0
npm run dev
# 类型检查(无单元测试)
npm run lint # 等价于 tsc --noEmit
# 生产构建
npm run build
# 预览生产构建(端口 4173
npm run preview
# 清理构建产物
npm run clean # rm -rf dist
```
**环境变量**(复制 `.env.example``.env.local`
- `GEMINI_API_KEY`Gemini AI API 密钥(构建时通过 Vite `define` 注入为 `process.env.GEMINI_API_KEY`
- `APP_URL`:部署后的访问地址,用于自引用链接
**Windows 特别注意**:重新部署前若使用 `npm run preview`,需手动终止旧的 `vite preview` 进程,避免端口冲突。
---
## 5. 开发规范与代码风格
以下规范全部来自 `过往经验/` 中的踩坑记录,**必须遵守**
### 5.1 contentEditable 编辑器规范
- **插入 HTML 时,模板字符串必须为紧凑单行**,禁止多行缩进/换行,否则浏览器会解析出额外文本节点破坏排版。
- **删除特殊节点**优先使用 `Range.selectNode(target) + document.execCommand('delete')`,而非直接 `target.remove()`,以保留浏览器原生撤销栈。
- **所有工具栏/侧边栏按钮**必须加 `onMouseDown={(e) => e.preventDefault()}`,防止点击时编辑器失焦导致光标位置丢失。
- **自定义 Undo/Redo**:结构性变更(插入/删除智能字段、表格等)应基于 `innerHTML` 快照实现自定义历史栈(`undoStack` / `redoStack`),并在操作前执行 `pushHistory()`
- **键盘事件拦截**:对 `contenteditable="false"` 的内联控件,若处于块级边界,必须拦截 Backspace/Delete以及 Ctrl+Z/Ctrl+Y防止浏览器误删父级 `<p>`
- **防换行技巧**:在 `inline-block` / `inline-flex` 控件后追加 `&#8203;`(零宽空格)作为行内锚点。
### 5.2 自动保存与草稿机制(高频踩坑区)
- **禁止单独依赖 `useRef` 作为自动保存的唯一数据源**。React 18 `StrictMode` 的模拟卸载会导致 ref 仍保持初始空值,从而用空数据覆盖有效的 `localStorage` 草稿。
-`ReportEditor.tsx` 中新增任何 `useState` 时,必须三问:
1. 是否需要持久化到 draft
2. 是否已加入 `stateRef` 初始化?
3. 是否已在 `saveDraftToStorage`、所有恢复分支、以及 state→ref 同步 effect 中补齐?
- `contentRef.current = editorRef.current.innerHTML` 必须紧跟在任何直接操作 DOM 修改编辑器内容的代码之后。
### 5.3 图片与视频处理
- **LocalStorage 容量预警**:视频抽帧必须使用 Canvas 等比压缩(最大宽度 800pxJPEG 质量 0.6),单张控制在 30KB~80KB避免 5MB 上限静默失败。
- **存储层异常捕获绝不应静默吞掉**,至少输出 `console.error`
- **占位符体系**:所有 `.image-placeholder` 必须携带 `data-mode="frame|manual"` 属性。修改占位符的创建/填充/删除逻辑时,必须同步检索所有入口:`fillPlaceholderSrc``fillPlaceholder``autoCaptureFrames`、拖拽填充、`TemplateManage.tsx`,以及 `defaultContent.ts` 中的预置模板。
- 填充图片后需将占位符外壳的固定 `width/height` 改为 `auto`,同时保留 `max-width/max-height` 作为硬限制。
### 5.4 智能字段与动态表单
- **DOM 三层结构**`<span class="smart-field-wrapper" contenteditable="false">``<span class="field-label">``<span class="field-value" contenteditable="true" data-bind="xxx">`
- **双向绑定**:富文本→表单通过 `handleEditorInput``e.target.hasAttribute('data-bind')` 实时更新;表单→富文本通过 `useEffect` 监听 `reportData`,仅在 `el.innerText !== newValue` 时才重写 DOM防止光标跳动。
- **时间/日期字段**显示格式与存储格式必须分离且同时实现正向格式化与反向解析12h/24h 判断使用 `includes('hh') || includes('A')` 等包含性判断,兼容自定义格式。
### 5.5 AI 功能开发规范
- **模型兼容性**:调用 OpenAI 兼容 API 时,必须根据「是否有图片附件」动态决定 `content` 类型(`string` vs `array`),纯文本模型发送数组会 400。
- **System Prompt 设计**
- 条件应只依赖用户意图开关(如 `aiModifyEnabled`),不应依赖具体 UI 状态。
- 向大模型发送局部修改请求时,必须提供全局上下文,但同时设置**严格的内容边界Fencing**,明确声明"全局参考仅供理解,严禁输出"其他模块内容。
- 对关键输出字段(如 `updatedHtml`)使用「绝对强制」「绝对不允许」等最强措辞,并在前端增加缺失校验兜底。
- **降级机制**:目标区域注入必须配备降级方案(如光标处 `execCommand('insertHTML')`);修改模式开启后应自动兜底锁定第一个可用 AI 区域。
- **DOM 结构修复**:在读取 `.ai-content` 数据前,必须将因用户回车而溢出为兄弟节点的 `<p>` 重新移回容器内。
- **样式与撤销**AI 内容注入必须使用 `Range.selectNodeContents + execCommand('insertHTML')` 以保留撤销栈;返回的 HTML 需在后处理阶段统一注入内联字体样式(`font-family: SimSun; font-size: 12pt;`);注入前需清洗(去掉 `<br>`、段落间空白)。
- **Diff 对比**:使用 `diff` 库做字符级差异比对,并在注入前去掉 diff 高亮标签。
### 5.6 数据迁移与类型安全
- 任何涉及 `localStorage` 数据结构变更的重构,必须在初始化入口提供**自动迁移逻辑**,使用 `(obj as any).oldField` 安全访问旧字段,避免类型污染和配置丢失。
- 从持久化存储读取的数组类型数据,渲染前务必做 `Array.isArray` 校验,防止历史脏数据导致整页崩溃。
- 当将格式简写迁移为标准 token 时,必须全局搜索所有硬编码默认值,并在初始化时建立无效值清理机制。
### 5.7 UI/UX 通用规范
- **禁用原生 `prompt`/`confirm`/`alert`**,统一使用项目风格的自定义 Modal 组件。
- **URL 拼接**:必须对基础路径做尾部斜杠移除 `.replace(/\/+$/, '')`
- **打印边距**:使用 `@page { margin: ... }` 为每一页物理纸张独立分配边距,切勿依赖 `body { padding: ... }`
- **滚动容器内展开**:点击展开/折叠的交互组件,应考虑增加 `scrollIntoView` 兜底,防止布局突变导致点击失效。
- **表格列变更**:保持 `<thead>``<tbody>` 列顺序严格一致。
- **大文件修改策略**:对于超过 1500 行的单文件组件采用「Grep 定位 → 精确读取 → 最小化替换」的三段式策略,避免盲目滚动。
---
## 6. 测试说明
**本项目没有任何测试框架或测试用例**
- `npm run lint``tsc --noEmit`)是唯一的自动化质量门禁。
- 确保 `tsconfig.json` 已正确 `exclude` 非源码目录(如 `参考信息``dist``node_modules`),否则参考文件会导致类型检查失败。
- 由于无后端、无测试,所有功能验证依赖手动端到端测试。
---
## 7. 部署流程
### 7.1 Docker 部署(推荐)
```bash
docker-compose up -d --build
```
- 构建阶段:`node:20-alpine` 执行 `npm ci` + `npm run build`
- 运行阶段:`nginx:alpine` 托管静态产物,暴露容器端口 `80`
- 宿主机端口映射:`4002``80`
- 服务名:`tuwen_system`,重启策略:`unless-stopped`
### 7.2 无 Docker 静态部署
```bash
npm run build
npm run preview
```
构建产物输出到 `dist/` 目录,可直接作为静态站点托管到任意 Web 服务器。
### 7.3 Nginx 配置要点(`nginx.conf`
- **SPA 路由**`try_files $uri $uri/ /index.html`,确保客户端路由正常工作。
- **Gzip 压缩**对文本、CSS、JS、XML、JSON 启用。
- **静态资源缓存**JS、CSS、图片、字体等设置 `immutable` 1 年缓存。
---
## 8. 安全注意事项
**本项目为纯前端应用,不存在服务端安全层。以下风险必须在部署前被充分理解:**
1. **用户密码以明文形式保存在浏览器 `localStorage` 中**,无后端哈希处理。
2. **API 密钥与凭证通过源码混淆存放**字符码数组、XOR 加密),但仍在客户端可还原。
3. **所有认证与授权逻辑在客户端执行**,可通过开发者工具绕过。
4. **若用于生产环境,必须部署在内网或受信任环境中**,严禁直接暴露于公网。
5. `storage.ts` 中对 `systemSettings` 使用 XOR 加密,密钥硬编码于源码,仅提供最低限度的防窥视保护。
---
## 9. 关键架构决策
| 决策 | 说明 |
|------|------|
| 纯前端 / 无后端 | 所有数据存 `localStorage`,零服务端依赖,开箱即用 |
| 原生 `contentEditable` | 不使用第三方富文本库,通过 `document.execCommand` 和自定义 DOM 操作实现 |
| 单文件大组件 | 页面逻辑、状态、副作用全部内聚在同一文件中,无细粒度组件拆分 |
| localStorage 即数据库 | 用户、报告、模板、配置、视频帧全部序列化后存入浏览器本地存储 |
| iframe 打印 | 通过隐藏 iframe 独立渲染 A4 尺寸 HTML隔离打印样式与交互样式 |
| OpenAI 兼容 API | AI 功能不绑定特定厂商,通过统一接口支持多服务商切换 |
---
*最后更新:基于当前代码库实际内容生成。若项目结构或依赖发生变更,请同步更新本文件。*
---
## 10. 代码编纂工作流(强制执行)
> 以下工作流由项目所有者定义,**所有涉及项目代码修改的需求必须严格执行**,不允许跳过任何步骤。
### 工作流触发条件
- 用户提出任何与项目修改相关的需求功能新增、Bug 修复、重构、优化等)
- 用户明确说"按照工作流执行"或类似指令
### 工作流 7 步骤
#### Step 0记录开始时间
在对话开头记录时间戳:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
#### Step 1阅读工程分析
- 阅读 `.\工程分析\` 文件夹下所有已有文档(需求分析、实现方案、测试方案、经验记录)。
- 若文件夹为空,基于当前代码库做一次整体工程分析并写入 `.\工程分析\` 留存。
#### Step 2整理需求
- 将用户原始需求结构化拆解。
- 写入 `.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 模板见 `.\工程分析\需求分析-模板.md`
#### Step 3制定实现方案
- 基于需求分析和代码理解,制定详细实现方案。
- 写入 `.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 模板见 `.\工程分析\实现方案-模板.md`
- **⚠️ 写完后必须停止,等待用户二次人工审核确认,严禁擅自继续。**
#### Step 4制定测试方案
- 基于实现方案,制定可执行的测试验证方案。
- 写入 `.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 模板见 `.\工程分析\测试方案-模板.md`
- **⚠️ 写完后必须停止,等待用户二次人工审核确认,严禁擅自继续。**
#### Step 5执行前检查 + 经验沉淀
1. **执行前必读** `.\工程分析\经验记录.md`,避免重复踩坑。
2. 严格按照已审核的实现方案执行代码修改。
3. 按已审核的测试方案执行验证。
4. 若执行过程中遇到任何问题,在 `.\工程分析\经验记录.md` 中以四段式追加:
- **A. 具体问题**
- **B. 产生问题原因**
- **C. 解决问题方案**
- **D. 后续如何避免问题**
#### Step 6Git 备份
```bash
git add .
git commit -m "{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - 本次修改简要描述"
git push origin main
```
- 完成后向用户报告:"已完成对文档的备份 commit。"
#### Step 7重新部署
```bash
npm install # 如需
npm run lint
npm run build
npm run preview # 如需预览
```
- 确保构建成功无错误,向用户报告部署完成。
### 不可跳过的硬性要求
| 要求 | 说明 |
|------|------|
| 时间戳贯穿 | 同一需求的所有文档使用同一时间戳 |
| 用户审核卡点 | 实现方案、测试方案文档写完后必须人工确认 |
| 经验沉淀 | 遇到问题必须写入经验记录.md四段式格式 |
| Git 备份 | 每次修改完成后必须 commit + push |
| 构建验证 | 必须执行 `npm run build` 并确保成功 |
| 规范兼容 | 实现方案必须自检 AGENTS.md 第 5 章规范清单 |

Binary file not shown.

24
package-lock.json generated
View File

@@ -10,7 +10,10 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
@@ -1491,6 +1494,12 @@
"@types/node": "*"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1903,6 +1912,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -1957,6 +1972,15 @@
"node": ">=8"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",

View File

@@ -13,7 +13,10 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",

View File

@@ -204,6 +204,15 @@
.print-content .smart-field-wrapper .delete-btn {
display: none !important;
}
.print-content .ai-region {
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
.print-content .ai-region > [contenteditable="false"] {
display: none !important;
}
.report-signature-img {
max-width: 120px !important;
max-height: 40px !important;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_AI_PROVIDERS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { storage } from '../utils/storage';
import { storage, getDefaultApiKey } from '../utils/storage';
import { User as UserIcon, Lock } from 'lucide-react';
export default function Login() {
@@ -63,21 +63,17 @@ export default function Login() {
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (!settingsRaw.frameCount) {
const round1 = (n: number) => Math.round(n * 10) / 10;
const positions: number[] = [];
for (let i = 1; i <= 12; i++) {
positions.push(round1((100 / 13) * i));
}
const defaultSettings = {
frameCount: 12,
framePositions: positions,
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: savedTemplates[0]?.id || '',
frameMode: 'uniform',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfIatConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
};
storage.set('systemSettings', defaultSettings);
}

View File

@@ -5,12 +5,15 @@ import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft, Download
Video, Play, Pause, Plus, X, ChevronLeft, Download,
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_XF_SPEECH } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
import { storage, getDefaultApiKey } from '../utils/storage';
import { diffChars } from 'diff';
import CryptoJS from 'crypto-js';
export default function ReportEditor() {
const navigate = useNavigate();
@@ -53,9 +56,78 @@ export default function ReportEditor() {
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
// AI 撰写相关核心状态
const [chatInput, setChatInput] = useState<string>('');
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string, images?: string[]}[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
const [isListening, setIsListening] = useState(false);
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
const [editorImages, setEditorImages] = useState<{id: string, src: string}[]>([]);
const [aiSelectedEditorImages, setAiSelectedEditorImages] = useState<string[]>([]);
const xfWsRef = useRef<WebSocket | null>(null);
const xfAudioContextRef = useRef<AudioContext | null>(null);
const xfMediaStreamRef = useRef<MediaStream | null>(null);
const [quickPrompts, setQuickPrompts] = useState<string[]>([
'请完善报告内容', '请对内容做如下修改:'
]);
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
const [lastExchangeLog, setLastExchangeLog] = useState<{
startTime: string;
modelConfig: { provider: string; endpoint: string; modelName: string };
requestPayload: any;
responsePayload: any | null;
errorDetail: { status: number; statusText: string; responseText: string; message: string } | null;
} | null>(null);
useEffect(() => {
stateRef.current.chatMessages = chatMessages;
}, [chatMessages]);
// 监听编辑器中已插入的图片,同步到 AI 面板
useEffect(() => {
if (!editorRef.current) return;
const updateEditorImages = () => {
if (!editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
if (same) return prev;
// 清除已不存在的选中项
setAiSelectedEditorImages(prevSelected => prevSelected.filter(id => imgs.some(img => img.id === id)));
return imgs;
});
};
updateEditorImages();
const observer = new MutationObserver(updateEditorImages);
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
}, []);
useEffect(() => {
stateRef.current.chatInput = chatInput;
}, [chatInput]);
// 切换到 AI 面板时强制同步编辑器中的图片
useEffect(() => {
if (activeTab !== 'ai' || !editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
return same ? prev : imgs;
});
}, [activeTab]);
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
@@ -101,7 +173,7 @@ export default function ReportEditor() {
const videoInputRef = useRef<HTMLInputElement>(null);
const contentLoadedRef = useRef(false);
const contentRef = useRef('');
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages, chatInput });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
@@ -126,7 +198,9 @@ export default function ReportEditor() {
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
loadedTemplateId: stateRef.current.loadedTemplateId,
chatMessages: stateRef.current.chatMessages,
chatInput: stateRef.current.chatInput
});
}
}, [reportId]);
@@ -170,12 +244,16 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
@@ -230,12 +308,14 @@ export default function ReportEditor() {
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
}
if (draft.activeTab) setActiveTab(draft.activeTab);
if (draft.chatMessages) setChatMessages(draft.chatMessages);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || []
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
@@ -604,6 +684,20 @@ export default function ReportEditor() {
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []) as File[];
const newVideos = files.map(file => ({
@@ -681,7 +775,7 @@ export default function ReportEditor() {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
const positions = settings.framePositions || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
const dur = video.duration || 1;
const canvas = canvasRef.current;
@@ -767,6 +861,361 @@ export default function ReportEditor() {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const checkAiRegions = () => {
if (!editorRef.current) return [];
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter(r => r.id);
};
const stripHtml = (html: string): string => {
const tmp = document.createElement('div');
tmp.innerHTML = html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p/gi, '</p>\n\n<p');
return (tmp.innerText || tmp.textContent || '').trim();
};
const computeDiffHtml = (oldText: string, newText: string, side: 'left' | 'right'): string => {
const diffs = diffChars(oldText, newText);
let html = '';
for (const part of diffs) {
let value = part.value.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (side === 'left' && part.removed) {
html += `<span class="diff-removed" style="background-color:#fee2e2;color:#dc2626;text-decoration:line-through;">${value}</span>`;
} else if (side === 'right' && part.added) {
html += `<span class="diff-added" style="background-color:#dcfce7;color:#16a34a;font-weight:500;">${value}</span>`;
} else if (!part.added && !part.removed) {
html += value;
}
}
return html;
};
async function getXfAuthUrl(apiKey: string, apiSecret: string): Promise<string> {
const host = 'iat-api.xfyun.cn';
const date = new Date().toUTCString();
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
const signature = CryptoJS.enc.Base64.stringify(CryptoJS.HmacSHA256(signatureOrigin, apiSecret));
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const authorization = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin));
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${authorization}&date=${date}&host=${host}`;
}
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
const output = new DataView(new ArrayBuffer(input.length * 2));
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return output.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
const toggleListening = async () => {
// 专门提取一个彻底关闭物理麦克风的函数
const stopMicrophone = () => {
if (xfAudioContextRef.current) {
try { xfAudioContextRef.current.close(); } catch {}
xfAudioContextRef.current = null;
}
if (xfMediaStreamRef.current) {
xfMediaStreamRef.current.getTracks().forEach(t => t.stop());
xfMediaStreamRef.current = null;
}
};
if (isListening) {
setIsListening(false);
stopMicrophone();
if (xfWsRef.current && xfWsRef.current.readyState === WebSocket.OPEN) {
try {
const endFrame = { data: { status: 2, format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' } };
xfWsRef.current.send(JSON.stringify(endFrame));
} catch {}
}
return;
}
const xfConfig = storage.get<SystemSettings>('systemSettings', {} as SystemSettings).xfSpeechConfig || DEFAULT_XF_SPEECH;
if (!xfConfig?.appId || !xfConfig?.apiKey || !xfConfig?.apiSecret) {
alert('请先在系统设置中配置讯飞语音 APPID/APIKey/APISecret');
return;
}
try {
const authUrl = await getXfAuthUrl(xfConfig.apiKey, xfConfig.apiSecret);
const ws = new WebSocket(authUrl);
xfWsRef.current = ws;
let frameStatus = 0;
ws.onopen = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
xfMediaStreamRef.current = stream;
const audioContext = new AudioContext({ sampleRate: 16000 });
xfAudioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (ws.readyState !== WebSocket.OPEN || !xfAudioContextRef.current) return;
const inputData = e.inputBuffer.getChannelData(0);
const pcmBuffer = floatTo16BitPCM(inputData);
const base64Audio = arrayBufferToBase64(pcmBuffer);
const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } };
if (frameStatus === 0) {
frame.common = { app_id: xfConfig.appId };
frame.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin' };
}
ws.send(JSON.stringify(frame));
frameStatus = 1;
};
source.connect(processor);
processor.connect(audioContext.destination);
setIsListening(true);
} catch (e: any) {
alert('麦克风启动失败: ' + e.message);
setIsListening(false);
ws.close();
}
};
ws.onmessage = (event) => {
try {
const jsonData = JSON.parse(event.data);
if (jsonData.code !== 0 && jsonData.code !== undefined) {
alert(`讯飞语音报错: ${jsonData.message || '未知错误'} (错误码: ${jsonData.code})`);
setIsListening(false);
stopMicrophone();
ws.close();
return;
}
if (jsonData.data?.result?.ws) {
let seg = '';
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
if (seg) {
setChatInput(prev => prev + seg);
}
}
if (jsonData.data?.status === 2) {
ws.close();
xfWsRef.current = null;
setIsListening(false);
stopMicrophone();
}
} catch {}
};
ws.onerror = () => { alert('讯飞语音连接失败'); setIsListening(false); stopMicrophone(); };
ws.onclose = () => { setIsListening(false); stopMicrophone(); };
} catch (e: any) {
alert('讯飞语音初始化失败: ' + e.message);
}
};
const handleAIGenerate = async (text: string) => {
if (!text.trim()) return;
const userMsgId = Date.now().toString();
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text, images: allImages.length > 0 ? allImages : undefined }]);
setChatInput('');
setIsGenerating(true);
try {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
const apiKey = provider?.apiKey || getDefaultApiKey();
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
if (!apiKey) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key请前往系统设置填写。' }]);
setIsGenerating(false);
return;
}
let actualTargetId = aiTargetRegion;
if (aiModifyEnabled && actualTargetId === 'none') {
const availableRegions = checkAiRegions();
if (availableRegions.length > 0) {
actualTargetId = availableRegions[0].id;
setAiTargetRegion(actualTargetId);
}
}
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
// 合并溢出的段落:浏览器 contentEditable 可能在回车时把 <p> 生成到 .ai-content 之外
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
// 同步更新 contentRef 和草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').replace(/>(\s+)</g, '><').trim() : '';
const globalContextText = editorRef.current?.innerText || '';
let messageContent: any;
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
if (aiModifyEnabled && targetRegionEl) {
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
}
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 仅针对【目标区域】的主题生成对应的多段落 HTML 内容\n2. ⚠️ 绝对禁止将【全局参考内容】中的其他模块(如:基本信息、术后情况、标本描述、病理结果、医生签名、日期等)生成并混入你的输出中!你只能输出该区域应有的内容\n3. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n4. 输出紧凑 HTML标签间不要有空格或换行`;
if (allImages.length > 0) {
messageContent = [];
allImages.forEach(url => {
messageContent.push({ type: 'image_url', image_url: { url } });
});
messageContent.push({ type: 'text', text: promptText });
} else {
messageContent = promptText;
}
const systemPrompt = aiModifyEnabled
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】直接重写并输出目标区域的 HTML。\n\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json'
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】并根据【医生指令】进行专业解答。\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释';
const payload: any = {
model: modelName,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: messageContent }
],
temperature: 0.3
};
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
if (isKimiK25) {
delete payload.temperature;
delete payload.top_p;
delete payload.presence_penalty;
delete payload.frequency_penalty;
}
const logEntry = {
startTime: new Date().toISOString(),
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: apiEndpoint, modelName },
requestPayload: JSON.parse(JSON.stringify(payload)),
responsePayload: null as any | null,
errorDetail: null as { status: number; statusText: string; responseText: string; message: string } | null
};
const response = await fetch(`${apiEndpoint}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
logEntry.errorDetail = {
status: response.status,
statusText: response.statusText,
responseText: errorText,
message: `API 请求失败: ${response.status}`
};
setLastExchangeLog(logEntry);
throw new Error(`API 请求失败: ${response.status}${errorText ? ' - ' + errorText : ''}`);
}
const data = await response.json();
logEntry.responsePayload = data;
setLastExchangeLog(logEntry);
const responseText = data.choices[0].message.content.trim();
const cleanedText = responseText.replace(/```json\n?|```/g, '');
let responseJson: any = {};
try {
responseJson = JSON.parse(cleanedText);
} catch {
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
responseJson = JSON.parse(jsonMatch[0]);
} catch {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
} else {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
}
if (responseJson.reply) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
}
if (aiModifyEnabled && !responseJson.updatedHtml) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]);
}
if (responseJson.updatedHtml && aiModifyEnabled) {
let cleanHtml = responseJson.updatedHtml;
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
cleanHtml = cleanHtml.trim();
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">');
if (targetRegionEl) {
setDiffModal({
isOpen: true,
originalHtml: currentHtml,
newHtml: cleanHtml,
targetId: actualTargetId
});
} else {
execCmd('insertHTML', cleanHtml);
}
}
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
} catch (error: any) {
console.error(error);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
} finally {
setIsGenerating(false);
}
};
const confirmAiInjection = (newHtml: string, regionId: string) => {
if (!editorRef.current) return;
const cleanHtml = newHtml.replace(/<span class="diff-(added|removed)"[^>]*>(.*?)<\/span>/gi, '$2');
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
if (targetContent) {
targetContent.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(targetContent);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('insertHTML', false, cleanHtml);
targetContent.style.transition = 'background-color 0.3s ease';
targetContent.style.backgroundColor = '#bfdbfe';
setTimeout(() => {
targetContent.style.backgroundColor = '#eff6ff';
setTimeout(() => {
targetContent.style.backgroundColor = 'transparent';
}, 800);
}, 400);
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
setDiffModal(null);
};
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
e.dataTransfer.setData('frameId', frame.id.toString());
};
@@ -941,6 +1390,10 @@ export default function ReportEditor() {
setVideos([]);
setCapturedFrames([]);
setCurrentVideoIndex(-1);
setChatMessages([]);
setChatInput('');
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
@@ -948,6 +1401,8 @@ export default function ReportEditor() {
reportData: nextReportData,
videos: [],
capturedFrames: [],
chatMessages: [],
chatInput: '',
activeTab: stateRef.current.activeTab
};
updatePageHeight();
@@ -976,7 +1431,9 @@ export default function ReportEditor() {
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
@@ -1482,6 +1939,7 @@ export default function ReportEditor() {
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
</div>
</div>
@@ -1509,15 +1967,16 @@ export default function ReportEditor() {
{/* Right Sidebar */}
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['info', 'video'] as const).map(tab => (
{(['info', 'video', 'ai'] as const).map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider flex items-center justify-center gap-1.5 ${
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'info' ? '基本信息' : '视频分析'}
{tab === 'ai' && <Bot size={16} className={activeTab === 'ai' ? 'text-accent' : 'text-text-muted'} />}
{tab === 'info' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
</button>
))}
</div>
@@ -2009,6 +2468,191 @@ export default function ReportEditor() {
)}
</div>
)}
{activeTab === 'ai' && (
<div className="flex flex-col h-full bg-[#f8fafc] overflow-hidden">
{/* 聊天气泡记录区 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
{chatMessages.length === 0 ? (
<div className="text-center flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<Bot size={48} className="text-slate-300 opacity-50" />
<p className="text-xs"> SurClaw </p>
</div>
) : (
chatMessages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`rounded-2xl px-4 py-2.5 max-w-[85%] text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none shadow-md' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
<div>{msg.content}</div>
{msg.images && msg.images.length > 0 && (
<div className="flex gap-1.5 mt-2 flex-wrap">
{msg.images.map((src, idx) => (
<img key={idx} src={src} className="w-10 h-10 object-cover rounded border border-white/30" />
))}
</div>
)}
</div>
</div>
))
)}
{isGenerating && (
<div className="flex justify-start">
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-3 shadow-sm flex gap-1.5 items-center">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
</div>
</div>
)}
</div>
{/* 控制台与输入区 */}
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
{/* 区域锚定与沙盒控制 */}
<div className="flex items-center justify-between bg-slate-50 p-2 rounded-lg border border-slate-200">
<div className="flex items-center gap-2 flex-1">
<select
value={aiTargetRegion}
onChange={(e) => setAiTargetRegion(e.target.value)}
disabled={!aiModifyEnabled}
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
>
{checkAiRegions().length > 0 ? (
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
) : (
<option value="none"> AI </option>
)}
</select>
</div>
<label className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300 cursor-pointer">
<input
type="checkbox"
checked={aiModifyEnabled}
onChange={(e) => {
e.stopPropagation();
setAiModifyEnabled(e.target.checked);
}}
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
/>
<span className="text-[11px] text-slate-600 font-bold">
</span>
</label>
</div>
{/* 视觉参考上下文 - 编辑器中已插入的图片 */}
{editorImages.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
{editorImages.map(img => {
const isSelected = aiSelectedEditorImages.includes(img.id);
return (
<div key={img.id} onClick={() => setAiSelectedEditorImages(prev => isSelected ? prev.filter(id => id !== img.id) : [...prev, img.id])}
className={`relative shrink-0 w-12 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600' : 'border-transparent opacity-50'}`}>
<img src={img.src} className="w-full h-full object-cover" />
{isSelected && <div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full p-0.5"><Check size={8} className="text-white" /></div>}
</div>
);
})}
</div>
)}
{/* 自定义快捷指令胶囊 */}
<div className="flex flex-wrap gap-1.5 max-h-16 overflow-y-auto">
{quickPrompts.map((p, i) => (
<div key={i} className="group relative">
<button onClick={() => setChatInput(p)} className="px-3 py-1 bg-[#f1f5f9] hover:bg-blue-50 hover:text-blue-600 text-slate-600 text-[11px] rounded-full transition-colors whitespace-nowrap">
{p}
</button>
{isEditingPrompts && (
<button onClick={() => setQuickPrompts(prev => prev.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 scale-75 shadow-sm">
<X size={10} />
</button>
)}
</div>
))}
<button onClick={() => {
if (isEditingPrompts) {
const newP = prompt('新增快捷指令:');
if (newP) setQuickPrompts([...quickPrompts, newP]);
} else {
setIsEditingPrompts(true);
}
}} className="px-2 py-1 bg-slate-100 text-slate-400 text-[11px] rounded-full hover:bg-slate-200">
{isEditingPrompts ? '+ 添加' : '⚙️'}
</button>
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full"></button>}
<button onClick={() => {
const data = {
exportAt: new Date().toISOString(),
url: window.location.href,
messages: chatMessages,
lastExchange: lastExchangeLog,
metadata: {
user: currentUser?.username || 'anonymous',
activeProvider: (() => { const s = storage.get<SystemSettings>('systemSettings', {} as SystemSettings); return s.activeAiProvider || 'kimi'; })(),
targetRegion: aiTargetRegion,
modifyEnabled: aiModifyEnabled,
chatInput,
uploadedImagesCount: aiUploadedImages.length,
selectedFramesCount: aiSelectedEditorImages.length
}
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}} className="px-2 py-1 bg-slate-100 text-slate-500 text-[11px] rounded-full hover:bg-slate-200 ml-auto" title="导出 AI 日志(调试用)">
AI
</button>
</div>
{/* 沉浸式输入框 */}
<div className="relative border border-slate-300 rounded-xl bg-white shadow-inner focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent transition-all">
{aiUploadedImages.length > 0 && (
<div className="flex gap-2 p-2 border-b border-slate-100 bg-slate-50 rounded-t-xl overflow-x-auto">
{aiUploadedImages.map(img => (
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden shadow-sm shrink-0">
<img src={img.dataUrl} className="w-full h-full object-cover" />
<button onClick={() => setAiUploadedImages(prev => prev.filter(i => i.id !== img.id))} className="absolute top-0 right-0 bg-red-500/80 text-white rounded-bl-md">
<X size={10} />
</button>
</div>
))}
</div>
)}
<textarea
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isGenerating) handleAIGenerate(chatInput); } }}
placeholder={isListening ? '正在将语音转为文字...' : '输入需求(按 Enter 发送)...'}
className="w-full min-h-[80px] p-3 pr-12 text-sm bg-transparent outline-none resize-none custom-scrollbar"
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
<label className="p-1.5 text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 rounded-lg cursor-pointer transition-colors" title="上传外部图像">
<input type="file" accept="image/*" multiple className="hidden" onChange={(e) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file: File) => {
const reader = new FileReader();
reader.onload = (ev) => { if (ev.target?.result) setAiUploadedImages(prev => [...prev, { id: Date.now(), dataUrl: ev.target!.result as string }]); };
reader.readAsDataURL(file);
});
}} />
<ImagePlus size={16} />
</label>
<button onClick={toggleListening} className={`p-1.5 rounded-lg transition-colors ${isListening ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50'}`}>
{isListening ? <Mic size={16} /> : <MicOff size={16} />}
</button>
<button onClick={() => { if (!isGenerating && chatInput.trim()) handleAIGenerate(chatInput); }} disabled={isGenerating || !chatInput.trim()} className="p-1.5 rounded-lg transition-colors text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed" title="发送">
<Send size={16} />
</button>
</div>
</div>
</div>
</div>
)}
</div>
</aside>
</div>
@@ -2219,6 +2863,61 @@ export default function ReportEditor() {
</div>
</div>
)}
{/* AI 修改二次确认 Diff 弹窗 */}
{diffModal && diffModal.isOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-[800px] shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div>
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Sparkles size={18} className="text-blue-600" />
AI
</h3>
<p className="text-xs text-slate-500 mt-1"> AI </p>
</div>
<button onClick={() => setDiffModal(null)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
{(() => {
const oldText = stripHtml(diffModal.originalHtml);
const newText = stripHtml(diffModal.newHtml);
const leftDiffHtml = computeDiffHtml(oldText, newText, 'left');
const rightDiffHtml = computeDiffHtml(oldText, newText, 'right');
return (
<div className="flex-1 overflow-hidden flex gap-4 p-6 bg-slate-100">
<div className="flex-1 flex flex-col bg-white border border-red-200 rounded-xl overflow-hidden shadow-sm">
<div className="bg-red-50 px-3 py-2 text-xs font-bold text-red-600 border-b border-red-100 uppercase tracking-wider"></div>
<div className="p-4 flex-1 overflow-y-auto opacity-70 cursor-not-allowed custom-scrollbar"
dangerouslySetInnerHTML={{ __html: leftDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></div>
</div>
<div className="flex-1 flex flex-col bg-white border border-green-400 rounded-xl overflow-hidden shadow-md relative">
<div className="bg-green-50 px-3 py-2 text-xs font-bold text-green-700 border-b border-green-200 uppercase tracking-wider flex justify-between">
<span>AI ()</span>
<span className="text-[10px] bg-green-200 px-1.5 py-0.5 rounded text-green-800"></span>
</div>
<div
className="p-4 flex-1 overflow-y-auto outline-none custom-scrollbar"
contentEditable
suppressContentEditableWarning
onBlur={(e) => setDiffModal(prev => prev ? { ...prev, newHtml: e.target.innerHTML } : null)}
dangerouslySetInnerHTML={{ __html: rightDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></div>
</div>
</div>
);
})()}
<div className="px-6 py-4 border-t border-slate-100 flex justify-end gap-3 bg-white">
<button onClick={() => setDiffModal(null)} className="px-6 py-2 rounded-lg text-slate-600 font-medium hover:bg-slate-100"></button>
<button onClick={() => confirmAiInjection(diffModal.newHtml, diffModal.targetId)} className="px-6 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 shadow-sm flex items-center gap-2">
<Check size={16} />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,25 +1,55 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
import { User, SystemSettings as ISystemSettings, Template } from '../types';
import { storage } from '../utils/storage';
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
import { storage, getDefaultApiKey } from '../utils/storage';
export default function SystemSettings() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: '',
frameMode: 'uniform'
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS },
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
});
const [templates, setTemplates] = useState<Template[]>([]);
const [isSaved, setIsSaved] = useState(false);
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
const [modeModalOpen, setModeModalOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const xfAppIdRef = useRef<HTMLInputElement>(null);
const xfApiKeyRef = useRef<HTMLInputElement>(null);
const xfApiSecretRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (apiKeyInputRef.current) {
const targetValue = settings.aiProviders[settings.activeAiProvider]?.apiKey || '';
if (apiKeyInputRef.current.value !== targetValue) {
apiKeyInputRef.current.value = targetValue;
}
}
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
useEffect(() => {
if (xfAppIdRef.current) {
const target = settings.xfSpeechConfig?.appId || '';
if (xfAppIdRef.current.value !== target) xfAppIdRef.current.value = target;
}
if (xfApiKeyRef.current) {
const target = settings.xfSpeechConfig?.apiKey || '';
if (xfApiKeyRef.current.value !== target) xfApiKeyRef.current.value = target;
}
if (xfApiSecretRef.current) {
const target = settings.xfSpeechConfig?.apiSecret || '';
if (xfApiSecretRef.current.value !== target) xfApiSecretRef.current.value = target;
}
}, [settings.xfSpeechConfig]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -30,17 +60,37 @@ export default function SystemSettings() {
setCurrentUser(user);
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
// Migrate old flat fields to new structured format
if (!savedSettings.aiProviders) {
const providers = { ...DEFAULT_AI_PROVIDERS };
if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
providers.kimi = {
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
apiKey: (savedSettings as any).kimiApiKey || '',
modelName: 'moonshot-v1-32k-vision-preview'
};
}
savedSettings.aiProviders = providers;
savedSettings.activeAiProvider = 'kimi';
storage.set('systemSettings', savedSettings);
}
// 若 API 密钥为空,填充默认密钥(源码级字符码混淆)
if (savedSettings.aiProviders?.kimi?.apiKey === '') {
savedSettings.aiProviders.kimi.apiKey = getDefaultApiKey();
storage.set('systemSettings', savedSettings);
}
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedSettings.frameCount) {
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
savedSettings.defaultTemplate = savedTemplates[0].id;
}
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
if (!savedSettings.frameMode) savedSettings.frameMode = 'keep';
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' };
setSettings(savedSettings);
} else if (savedTemplates.length > 0) {
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'keep', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
}
setTemplates(savedTemplates);
}, [navigate]);
@@ -79,25 +129,49 @@ export default function SystemSettings() {
};
const testApi = async () => {
if (!settings.apiEndpoint) {
alert('请先输入 API 接口地址');
const provider = settings.aiProviders[settings.activeAiProvider];
if (!provider?.apiKey) {
alert('请先输入 API 密钥');
return;
}
alert(`正在测试连接到: ${settings.apiEndpoint}\n(模拟测试: 连接成功)`);
try {
const res = await fetch(`${provider.endpoint.replace(/\/+$/, '')}/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
const models = data.data?.map((m: any) => m.id).filter((id: string) => id) || [];
setAvailableModels(models);
if (models.length > 0 && !provider.modelName) {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: models[0] };
setSettings({ ...settings, aiProviders: next });
}
alert(`连接成功!可用模型数: ${models.length}`);
} else {
alert(`连接失败: ${res.status} ${res.statusText}`);
setAvailableModels([]);
}
} catch (e: any) {
alert(`连接失败: ${e.message}`);
setAvailableModels([]);
}
};
const resetToDefault = () => {
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: templates[0]?.id || '',
frameMode: 'uniform',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
};
setSettings(defaultSettings);
storage.set('systemSettings', defaultSettings);
@@ -288,26 +362,143 @@ export default function SystemSettings() {
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> API (Endpoint)</label>
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">AI </label>
<select
value={settings.activeAiProvider}
onChange={(e) => setSettings({ ...settings, activeAiProvider: e.target.value })}
className="input-minimal bg-white"
>
<option value="kimi">Kimi (Moonshot)</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
<option value="custom"></option>
</select>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Base URL)</label>
<input
type="url"
value={settings.apiEndpoint}
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
placeholder="https://api.example.com/v1/generate"
value={settings.aiProviders[settings.activeAiProvider]?.endpoint || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
placeholder="https://api.example.com/v1"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API (Secret Key)</label>
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API </label>
<input
ref={apiKeyInputRef}
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
placeholder="sk-xxxxxxxxxxxxxxxx"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Model Name)</label>
{availableModels.length > 0 ? (
<select
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
className="input-minimal bg-white"
>
{availableModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<input
type="text"
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
placeholder="kimi-k2-5"
className="input-minimal"
/>
)}
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
</div>
</div>
</div>
)}
{currentUser.role === 'super' && (
<div className="card-minimal">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
Websocket接口配置
</h3>
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APPID</label>
<input
ref={xfAppIdRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APIKey</label>
<input
ref={xfApiKeyRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********************************"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APISecret</label>
<input
ref={xfApiSecretRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********************************"
className="input-minimal"
/>
</div>
</div>
</div>
)}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download, Upload } from 'lucide-react';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Bot, Check, Download, Upload } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
@@ -419,9 +419,16 @@ export default function TemplateManage() {
const saveTemplateContent = () => {
if (!currentTemplateId || !editorRef.current) return;
let cleanContent = editorRef.current.innerHTML;
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
t.id === currentTemplateId ? { ...t, content: cleanContent, updatedAt: new Date().toISOString() } : t
);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
@@ -587,12 +594,33 @@ export default function TemplateManage() {
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
// Insert ai-region HTML
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
saveTemplateContent();
};
const saveCurrentTemplate = () => {
if (!currentTemplateId || !editorRef.current) return;
let cleanContent = editorRef.current.innerHTML;
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t => {
if (t.id === currentTemplateId) {
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
return { ...t, content: cleanContent, updatedAt: new Date().toISOString() };
}
return t;
});
@@ -978,6 +1006,7 @@ export default function TemplateManage() {
<div className="flex gap-1">
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
</div>
</div>

View File

@@ -70,18 +70,44 @@ export interface Template {
fields?: FormField[];
}
export interface AiProviderConfig {
endpoint: string;
apiKey: string;
modelName: string;
}
export interface XfSpeechConfig {
appId: string;
apiKey: string;
apiSecret: string;
}
export const DEFAULT_XF_SPEECH: XfSpeechConfig = {
appId: 'e0fe23e3',
apiKey: '7fd08be316718c2280e85af4fe126306',
apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0'
};
export interface SystemSettings {
frameCount: number;
framePositions: number[];
apiEndpoint: string;
apiKey: string;
defaultTemplate?: string;
frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean;
autoInsertFrameIndices?: number[];
autoInsertDelay?: number;
activeAiProvider: string;
aiProviders: Record<string, AiProviderConfig>;
xfSpeechConfig?: XfSpeechConfig;
}
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' }
};
export interface BindableField {
key: string;
label: string;

View File

@@ -55,25 +55,10 @@ export const defaultReportContent = `
<strong>手术步骤、术中出现的情况及处理:</strong>
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。
</p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
<div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。</p></div>
</div>
<!-- 手术图片说明表格 -->
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">

View File

@@ -41,6 +41,8 @@ export const printDocument = (htmlContent: string, docTitle: string = '图文报
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
.ai-region { border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
.ai-region > [contenteditable="false"] { display: none !important; }
@media print {
.smart-field-wrapper .field-value { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px 0px 2px !important; line-height: 1 !important; }
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }

View File

@@ -1,8 +1,42 @@
// API Key 以字符码形式存储,避免源码中直接出现明文字符串
const API_KEY_CODES = [115, 107, 45, 50, 73, 65, 70, 110, 56, 79, 82, 111, 83, 100, 85, 99, 67, 120, 89, 88, 54, 68, 109, 88, 74, 87, 98, 72, 55, 66, 120, 102, 116, 83, 83, 65, 56, 107, 78, 56, 56, 109, 68, 49, 75, 85, 68, 84, 109, 107, 118];
export function getDefaultApiKey(): string {
return String.fromCharCode(...API_KEY_CODES);
}
const CRYPTO_KEY = 'MedicalReportSys2024';
function xorEncrypt(text: string, key: string): string {
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return btoa(result);
}
function xorDecrypt(encrypted: string, key: string): string {
const text = atob(encrypted);
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return result;
}
export const storage = {
get<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
if (!raw) return fallback;
if (key === 'systemSettings') {
try {
return JSON.parse(raw) as T;
} catch {
return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T;
}
}
return JSON.parse(raw) as T;
} catch {
return fallback;
}
@@ -10,7 +44,11 @@ export const storage = {
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
let data = JSON.stringify(value);
if (key === 'systemSettings') {
data = xorEncrypt(data, CRYPTO_KEY);
}
localStorage.setItem(key, data);
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}

View File

@@ -22,5 +22,6 @@
},
"allowImportingTsExtensions": true,
"noEmit": true
}
},
"exclude": ["参考信息", "dist", "node_modules"]
}

View File

@@ -0,0 +1,136 @@
# 代码编纂工作流总览
> 本文档定义了所有项目修改需求的标准执行流程。
> 每次接到修改需求后,严格按照以下 7 个步骤执行。
---
## 执行前准备
- 记录当前时间戳:`{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 时间戳将作为本次需求的唯一标识,贯穿所有文档命名
---
## Step 0记录开始时间
在对话开头明确记录:
```
[工作流开始] {Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}
```
---
## Step 1阅读工程分析
- 阅读 `.\工程分析\` 文件夹下所有已有文档
- 了解项目当前状态、历史修改、已知踩坑点
- 若文件夹为空(首次执行),则基于当前代码库做一次整体工程分析并留存
---
## Step 2整理需求
- 将用户提出的修改需求整理成结构化文档
- 写入 `.\工程分析\需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 文档结构见 [需求分析-模板.md](./需求分析-模板.md)
---
## Step 3制定实现方案
- 基于需求分析和代码理解,制定详细的实现方案
- 写入 `.\工程分析\实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 文档结构见 [实现方案-模板.md](./实现方案-模板.md)
- **⚠️ 必须经用户二次人工审核确认后方可继续**
---
## Step 4制定测试方案
- 基于实现方案,制定可执行的测试验证方案
- 写入 `.\工程分析\测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 文档结构见 [测试方案-模板.md](./测试方案-模板.md)
- **⚠️ 必须经用户二次人工审核确认后方可继续**
---
## Step 5执行前检查 + 经验沉淀
### 5.1 执行前必读
- 阅读 `.\工程分析\经验记录.md`
- 避免重复踩坑
### 5.2 执行修改
- 严格按照已审核的实现方案执行代码修改
- 按测试方案执行验证
### 5.3 执行后记录
- 若执行过程中遇到任何问题,在 `.\工程分析\经验记录.md` 中以以下四段式追加:
```markdown
## [{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}] 问题简述
### A. 具体问题
(描述遇到的现象或错误)
### B. 产生问题原因
(根因分析)
### C. 解决问题方案
(具体的解决步骤或代码改动)
### D. 后续如何避免问题
(预防措施、规范建议、检查清单项)
```
---
## Step 6Git 备份
使用 gitea 进行备份提交:
```bash
# 添加所有变更(含新增的需求/方案文档)
git add .
# 提交,消息格式:"{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec} - 本次修改简要描述"
git commit -m "2026-04-20-02-46-43 - 示例:修复报告编辑器自动保存失效问题"
# 推送至远程主分支
git push origin main
```
- 完成后向用户报告:"已完成对文档的备份 commit"
---
## Step 7重新部署
执行项目构建并部署:
```bash
# 安装依赖(如 node_modules 缺失)
npm install
# 类型检查
npm run lint
# 生产构建
npm run build
# 如需预览
npm run preview
```
- 确保构建成功无错误
- 向用户报告部署完成
---
## 附件
- [需求分析-模板.md](./需求分析-模板.md)
- [实现方案-模板.md](./实现方案-模板.md)
- [测试方案-模板.md](./测试方案-模板.md)
- [经验记录.md](./经验记录.md)

View File

@@ -0,0 +1,74 @@
# 实现方案
> 时间戳:{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}
> 对应需求分析:`需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
---
## 1. 方案概述
(用 2-3 句话概括整体实现思路)
---
## 2. 详细改动清单
### 2.1 文件xxx.tsx
| 行号范围 | 改动类型 | 说明 |
|----------|----------|------|
| | 新增/修改/删除 | |
**具体代码逻辑**
```typescript
// 关键代码片段或伪代码
```
### 2.2 文件yyy.ts
(同上格式)
---
## 3. 关键设计决策
| 决策点 | 选择方案 | 备选方案 | 不选备选的原因 |
|--------|----------|----------|----------------|
| | | | |
---
## 4. 与现有规范的兼容性检查
- [ ] contentEditable 规范5.1
- [ ] 自动保存与草稿机制5.2
- [ ] 图片与视频处理5.3
- [ ] 智能字段与动态表单5.4
- [ ] AI 功能开发规范5.5
- [ ] 数据迁移与类型安全5.6
- [ ] UI/UX 通用规范5.7
---
## 5. 回滚策略
若方案执行失败或验证不通过,如何快速回滚:
```bash
# 回滚命令(示例)
git reset --hard HEAD~1
```
---
## 6. 预估工作量
| 阶段 | 预估时间 |
|------|----------|
| 代码修改 | |
| 测试验证 | |
| 文档更新 | |
---
**状态**:□ 待审核 | □ 已确认 | □ 已驳回(原因:)

View File

@@ -0,0 +1,76 @@
# 测试方案
> 时间戳:{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}
> 对应需求分析:`需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
> 对应实现方案:`实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
---
## 1. 测试环境
- 浏览器:
- Node 版本:
- 是否使用默认测试数据:□ 是 / □ 否
---
## 2. 功能测试用例
| 用例ID | 测试场景 | 操作步骤 | 预期结果 | 实际结果 | 是否通过 |
|--------|----------|----------|----------|----------|----------|
| TC-01 | | 1. <br>2. | | | □ |
| TC-02 | | 1. <br>2. | | | □ |
---
## 3. 边界测试
| 场景 | 测试内容 | 预期表现 |
|------|----------|----------|
| 空数据 | | |
| 大数据量 | | |
| 异常输入 | | |
---
## 4. 回归测试范围
(列出可能受影响的已有功能,需验证未被破坏)
- [ ] 功能 A
- [ ] 功能 B
---
## 5. 类型检查
```bash
npm run lint
```
- 预期结果:`tsc --noEmit` 无错误
---
## 6. 构建验证
```bash
npm run build
```
- 预期结果:构建成功,无报错,产物输出至 `dist/`
---
## 7. 测试结论
| 项目 | 结果 |
|------|------|
| 功能测试 | □ 通过 / □ 部分通过 / □ 未通过 |
| 类型检查 | □ 通过 / □ 未通过 |
| 构建验证 | □ 通过 / □ 未通过 |
| 整体结论 | □ 可交付 / □ 需修复后复测 |
---
**状态**:□ 待审核 | □ 已确认 | □ 已驳回(原因:)

View File

@@ -0,0 +1,32 @@
# 经验记录
> 本文档用于沉淀项目修改过程中遇到的关键问题及解决方案。
> 每次执行工作流 Step 5 后,按四段式追加新记录。
> 执行修改前必须阅读本文档,避免重复踩坑。
---
## 记录格式
```markdown
## [{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}] 问题简述
### A. 具体问题
(描述遇到的现象或错误)
### B. 产生问题原因
(根因分析)
### C. 解决问题方案
(具体的解决步骤或代码改动)
### D. 后续如何避免问题
(预防措施、规范建议、检查清单项)
```
---
## 历史记录
(以下为实际踩坑记录,按时间倒序排列)

View File

@@ -0,0 +1,49 @@
# 需求分析
> 时间戳:{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}
---
## 1. 原始需求描述
(用户原始表述,尽量保持原话)
---
## 2. 需求拆解
| 序号 | 子需求 | 涉及页面/模块 | 优先级 |
|------|--------|---------------|--------|
| 1 | | | |
| 2 | | | |
| 3 | | | |
---
## 3. 影响范围评估
### 3.1 需要修改的文件
- `src/...`
- `src/...`
### 3.2 潜在风险点
-
-
### 3.3 是否涉及数据迁移
- [ ]
- [ ] 是,说明:
---
## 4. 验收标准
- [ ] 标准1
- [ ] 标准2
- [ ] 标准3
---
## 5. 关联历史需求
(如有相似或相关历史修改,在此引用对应的需求分析文档)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,583 @@
# 经验记录
---
## 记录 1report-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 在延迟期间已被用户修改。
---
## 记录 125 项交互与默认值优化(占位符尺寸、签名状态、素材预加载)
**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>` 的列顺序严格一致,避免列错位。
---
## 记录 136 项交互优化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 容量瓶颈。
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
---
## 记录 145 项交互修复虚线框恢复、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手动选择/当前时间和「显示格式」selectdate 提供两种日期格式time 提供 24h/12h
- 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。
3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。
4. **ReportEditor 表单渲染重构**
- `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围24h: 00-2312h: 01-1212h 时额外增加 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()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。

View File

@@ -0,0 +1,916 @@
# 经验记录
> 本文档为项目统一知识库,记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读,防止重复踩坑。
> 记录格式A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题
---
## 记录 1report-editor 新建报告时显示空白模板
**A. 具体问题**
超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
**B. 产生问题原因**
1. `ReportEditor.tsx` 在组件卸载时会自动将当前编辑器内容保存为草稿。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML并将 `contentLoadedRef.current` 设为 `true`
3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过。
**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)')` 查找第一个空置占位符。
3. 在关键帧卡片底部新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity`,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`
**D. 后续如何避免问题**
- 当同一交互效果需要支持多种触发方式时,应将核心逻辑抽离为独立函数,避免重复代码。
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用。
---
## 记录 3路由切换后视频分析图片丢失
**A. 具体问题**
`/report-editor` 中上传视频、自动摘取关键帧后,切换到 `/report-manage` 再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失。
**B. 产生问题原因**
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`
2. 初始化 `useEffect` 从 draft 恢复数据时,仅通过 `setState` 更新了 React state**没有同步更新 `stateRef.current`**
3. 离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。
**C. 解决问题方案**
`ReportEditor.tsx` 的所有数据恢复入口中,恢复 `reportData``videos``capturedFrames` 后立即同步赋值给 `stateRef.current`
**D. 后续如何避免问题**
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**。
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径,确保 ref 与 state 的一致性。
---
## 记录 4路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
**A. 具体问题**
1.`/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回,**报告内容变空、基本信息清空、视频分析数据全部丢失**。
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**。
**B. 产生问题原因**
1. **数据丢失**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块内部。当 `editorRef` 尚未挂载或 `draft.content` 为空时,`stateRef.current` 就得不到同步。
2. **UI 延迟**`autoCaptureFrames` 是 async 函数,内部循环中连续调用 `setCapturedFrames`。React 18 的自动批处理机制在异步函数中会合并状态更新DOM 重渲染被推迟到整个循环结束后。
**C. 解决问题方案**
1.`stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**。
2.`autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新。
**D. 后续如何避免问题**
- ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断。
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染。
---
## 记录 5路由切换后所有内容仍然丢失——彻底重构自动保存机制
**A. 具体问题**
`/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
**B. 产生问题原因**
1. 自动保存机制过度依赖 `stateRef``contentRef` 作为"数据快照"。
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值。
3. 组件卸载cleanup时调用保存用这个空值**覆盖了 localStorage 中已有的正确 draft**。
**C. 解决问题方案**
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current``stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含所有相关 state确保闭包永远绑定当前渲染周期的最新 state。
2.`beforeunload``visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`effect 的 dependency 改为 `[saveDraftToStorage]`
**D. 后续如何避免问题**
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
---
## 记录 6编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
**A. 具体问题**
`/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回:报告内容全部丢失;视频分析面板中的自动关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **闭包陷阱**`saveDraftToStorage` 直接从 React state 读取,但代码中存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值。
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null``content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中。
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`
**C. 解决问题方案**
1. **重构 `saveDraftToStorage` 从 Ref 读取**`content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在);`reportData``videos``capturedFrames` 全部从 `stateRef.current` 读取。
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick``document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`
**D. 后续如何避免问题**
- 对于需要在异步操作或组件卸载时读取的"最新状态"**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**。
---
## 记录 7自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
**A. 具体问题**
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟,整体过程非常缓慢。
**B. 产生问题原因**
`autoCaptureFrames``for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))``await` 会暂停整个 `for` 循环的执行。
**C. 解决问题方案**
1.`await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行。
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`
**D. 后续如何避免问题**
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout``Promise.all` 实现并行/异步解耦。
-`setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用。
---
## 记录 8关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
**A. 具体问题**
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9,对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB十几张关键帧即可超过 5MB。
2. **静默失败**`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志,导致用户和开发者都感知不到错误。
**C. 解决问题方案**
1. **压缩关键帧分辨率与质量**Canvas 等比缩放至最大 800px 宽JPEG 导出质量从 `0.9` 降到 `0.6`。单张图片体积可从 500KB 降至 30KB~80KB。
2. **增加存储错误可见性**:将静默 `catch` 改为输出 `console.error`
**D. 后续如何避免问题**
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
---
## 记录 9contentEditable 中实现标签锁定与输入方格的双向绑定
**A. 具体问题**
需要在富文本编辑器中插入"标签锁定、内容可调"的智能占位控件,使"姓名:"等固定文本不会被用户误删,同时方格内的输入能与右侧表单双向联动。
**B. 产生问题原因**
原生 `contentEditable` 区域内所有文本节点对用户都是可编辑的,无法直接保护某一段固定标签不被单独删除或篡改。
**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">`
双向绑定逻辑:富文本 → 表单通过 `handleEditorInput``e.target.hasAttribute('data-bind')` 判断;表单 → 富文本通过 `useEffect` 监听 `reportData` 变化,仅当 `el.innerText !== newValue` 时才重写 DOM。
**D. 后续如何避免问题**
- 对于需要在富文本中保护的固定文本,优先采用 `contenteditable="false"` 的包装器。
-`State -> DOM` 的同步中务必加入差异判断,避免不必要的 DOM 重写导致输入焦点异常。
---
## 记录 10智能字段插入间距修复与 Backspace 防误删
**A. 具体问题**
1. 插入智能字段后,字段后方会出现一个可见的空格(由 `&nbsp;` 和多行模板字符串中的换行/缩进空白引起)。
2. 光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 BackspaceWebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。
**B. 产生问题原因**
1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 `&nbsp;`
2. `contenteditable="false"` 的 inline 元素处于行边界时WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除。
**C. 解决问题方案**
1. **压缩 HTML 字符串**:将 `insertSmartField``defaultContent.ts``smartField` 输出改为单行 HTML移除所有无意义的换行和缩进并去掉尾部的 `&nbsp;`
2. **防止内部折行**:给 `.smart-field-wrapper` 增加 `white-space: nowrap;`
3. **拦截 Backspace/Delete**:在编辑器上增加 `keydown` 事件监听capture 阶段)。当光标位于文本节点起始位置且前一个兄弟节点是 `.smart-field-wrapper` 时按 Backspace主动 `preventDefault()` 并手动移除该字段节点。
**D. 后续如何避免问题**
-`contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**。
- 对于 `contenteditable="false"` 的内联控件,若放置在块级边界,务必增加键盘事件拦截。
---
## 记录 11撤销栈修复、字段删除交互优化与签名字段闭环
**A. 具体问题**
1. 删除智能字段后浏览器撤销栈Undo失效点击"撤销"按钮无法恢复。
2. 插入字段后,字段框有时会跳到下一行。
3. Backspace 键无法删除字段Delete 键会误删字段前面的大段文本。
**B. 产生问题原因**
1. 删除字段时使用了 `target.remove()` 直接操作 DOM绕过了浏览器的原生撤销栈。
2. 插入的 `smart-field-wrapper``inline-block` 元素,但其后缺少行内锚点文本节点,浏览器容易将其挤到新行。
3. `keydown` 拦截逻辑中 `target.remove()` 同样会误删父级块节点。
**C. 解决问题方案**
1. **撤销栈修复**:将点击红 × 删除和键盘 Backspace/Delete 删除全部改为 `Range.selectNode(target)` + `document.execCommand('delete')`
2. **防换行**:在 `insertSmartField``defaultContent.ts``smartField()` 生成的 HTML 末尾增加 `&#8203;`(零宽空格),作为稳定的行内锚点。
3. **精准键盘删除**:配合 `Range.selectNode` + `execCommand('delete')`,不再直接 `remove()` DOM 节点。
**D. 后续如何避免问题**
-`contentEditable` 中删除元素时,**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`,以确保撤销/重做等原生编辑行为正常工作。
- 插入 `inline-block``inline-flex` 控件时,可在其后追加 `&#8203;` 零宽空格,为浏览器提供稳定的行内文本锚点。
---
## 记录 12TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复
**A. 具体问题**
1. 删除智能字段后,点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。
2. 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。
**B. 产生问题原因**
1. 即使将删除逻辑改为 `execCommand('delete')`,浏览器原生的 undo stack 在 `contentEditable` 中结合 React 状态更新时仍然非常脆弱,容易被清空。
2. 点击侧边栏按钮会导致编辑器 `blur`浏览器内部的光标位置Selection/Range丢失再次 `focus()` 后光标被重置,导致 `insertHTML` 插入位置错误。
**C. 解决问题方案**
1. **自定义 Undo/Redo 栈**:引入 `undoStack``redoStack` 两个 `useRef<string[]>([])`。实现 `pushHistory()`,在执行任何结构性变更前将当前 `editorRef.current.innerHTML` 推入 undo 栈。
2. **阻止焦点流失**:在所有工具栏按钮和字段库插入按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,阻止 mousedown 默认行为导致编辑器失去焦点。
3. **光标位置记忆与恢复**:利用 `savedRangeRef`,实现 `saveSelection()``restoreSelection()`。在编辑器 `<div>` 上绑定 `onBlur={saveSelection}``onMouseUp={saveSelection}``onKeyUp={saveSelection}`
**D. 后续如何避免问题**
- 对于 `contentEditable` 编辑器中的结构性变更,如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。
- 侧边栏/工具栏按钮与编辑器共存时,**必须**通过 `onMouseDown={e => e.preventDefault()}` 阻止焦点流失。
---
## 记录 13时间/日期字段格式配置与撰写时间动态字段
**A. 具体问题**
1. 时间/日期字段缺少配置date 可选显示格式time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「固定时间」作为默认值策略。
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
**B. 产生问题原因**
1. `FormField` 数据结构缺少格式和默认值配置字段。
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制smart field 同步时直接显示原始值,不做任何格式转换。
**C. 解决问题方案**
1. **扩展数据结构**`FormField` 增加 `timeFormat?: string``timeDefault?: 'current' | 'specific'`
2. **ReportEditor 表单渲染重构**`startTime/endTime` 根据 `timeFormat` 选择 hour select 的选项范围12h 时额外增加 AM/PM select。
3. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`
4. **编辑器反向编辑解析**`handleEditorInput` 中,通过正则解析格式化文本,转回原始值后存入 `reportData`
**D. 后续如何避免问题**
- 显示格式与存储格式分离时,**必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储)**,否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。
---
## 记录 14时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换
**A. 具体问题**
1. 新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式;新建时间字段时默认格式为不可解析的 `'24h'`
2. 时间字段设为「固定时间」后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
3. `startTime` 格式改为 `hh:mm A`12小时制报告编辑器中的表单仍显示为 24 小时制下拉框。
**B. 产生问题原因**
1. **默认格式错误**`TemplateManage.tsx``newFieldForm.type``onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数使用的是 `HH``hh``mm``A` 等 token。
2. **固定时间未注入**`ReportEditor.tsx` 初始 `reportData``surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过。
3. **12h 判断写死**`const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。
**C. 解决问题方案**
1. 默认格式改为:`t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm'`
2. `surgeryDate` 初始值从 `new Date()` 改为空字符串 `''`;切换模板时显式遍历 `formFields` 注入固定值/当前值。
3. 12h 判断改为包含性判断:`field.timeFormat.includes('hh') || field.timeFormat.includes('A')`
**D. 后续如何避免问题**
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'``'12h'`)作为存储值。
- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口中显式遍历字段配置并注入。
- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**`includes`)而非**精确匹配**。
---
## 记录 15打印分页边距失效
**A. 具体问题**
`report-editor` / `report-view` 打印多页报告时,第二页及后续页面的上下边距几乎为 0内容紧贴纸张边缘。
**B. 产生问题原因**
`@page { margin: 0 }` 将物理纸张边距设为 0`body { padding: 10mm }` 只在整个 HTML 文档的顶部和底部各生效一次。当内容跨页时,浏览器在分页切断处不会保留 `body` 的 padding。
**C. 解决问题方案**
`print.ts` 中:
- `@page { margin: 15mm 10mm; }` 让打印引擎为每一页物理纸张独立分配边距
- `body { padding: 0; }` 清除 body padding
- `.content { width: 100%; }` 让内容自然撑满可用区域
**D. 后续如何避免问题**
- 打印样式的边距控制**必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`**,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。
---
## 记录 16表格内 execCommand 插入破坏结构
**A. 具体问题**
`template-manage` 编辑器表格中点击"插入图片占位符"后HTML 结构被破坏——外层 `<span class="image-placeholder">` 丢失,仅剩内部子元素散落为 `<td>` 的直接子元素。
**B. 产生问题原因**
`document.execCommand('insertHTML')``<td>` 内处理复杂的 `inline-flex` 嵌套 `<span>`WebKit/Blink 会将其自动"拍平"或重新排列。外层 `contenteditable="false"` 的 inline 容器被浏览器移除。
**C. 解决问题方案**
`insertImage` 中通过 `window.getSelection().anchorNode` 向上遍历检测是否在 `<td>` / `<th>` 内:
- 若在表格内:不弹出 prompt使用 `<div>` 块级容器 + `width:100%;height:100%;`
- 若不在表格内:保持现有 `<span>` 行内容器
**D. 后续如何避免问题**
- `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `<td>` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `<div>`)作为外层容器。
---
## 记录 17图片占位符体系重构与双端统一
**A. 具体问题**
1. `template-manage` 的"插入字段"中仍存在"图片"分类手术者签名、医院Logo
2. 插入图片占位符时无法自定义默认宽高,且使用 `<div>` 导致强制换行。
3. 占位符框太小时"插入/点击放置图片"文字显示不全。
**B. 产生问题原因**
1. `DEFAULT_FORM_FIELDS` 仍包含 `surgeonSignature``hospitalLogo`
2. 两端编辑器的 `insertImage()` 使用块级 `<div>` 插入,未提供尺寸 prompt。
3. 占位符提示文本固定为长文本,未根据容器宽度做缩写适配。
**C. 解决问题方案**
1.`DEFAULT_FORM_FIELDS``types.ts` 中移除 `surgeonSignature``hospitalLogo`;在 `TemplateManage.tsx` 中彻底移除"图片"分类。
2. 改造 `insertImage()`:插入前通过 `prompt` 获取最大宽度/高度px生成带 `max-width/max-height``<span>` 行内占位符。
3. 根据 prompt 输入的宽度决定提示文字:宽度 < 80px 时显示"插入图片",否则显示"插入/点击放置图片"。
**D. 后续如何避免问题**
- 当从字段体系中彻底移除某一分类时,需要同时清理:`DEFAULT_FORM_FIELDS`、UI 渲染数组、新增表单 options、以及可能残留的分类判断逻辑。
-`contentEditable` 中实现"同行插入"必须使用行内元素(`<span>`)并显式设置 `display:inline-flex` + `vertical-align:middle`
---
## 记录 18默认模板中 image-placeholder 缺少 data-mode 导致来源隔离失效
**A. 具体问题**
默认模板 `defaultContent.ts` 中的 8 个 `.image-placeholder` 使用的是旧版 HTML 结构,缺少 `data-mode="frame|manual"` 属性。新建报告加载默认模板后,签名和 Logo 区域可被关键帧拖拽误填充。
**B. 产生问题原因**
此前对「插入图片占位符」进行弹窗改造时,仅在运行时插入逻辑中新增了 `data-mode` 属性,但未同步回刷默认模板 `defaultContent.ts`
**C. 解决问题方案**
`defaultContent.ts` 中对 8 个占位符做最小化修补:
1. 医院 Logo65×65和手术者签名200×40添加 `data-mode="manual"`
2. 表格内 6 个术中影像占位符100%×150添加 `data-mode="frame"`
3. 所有占位符的 `width/height/margin/display` 等布局属性绝对保持不变。
**D. 后续如何避免问题**
- 当为 `image-placeholder` 引入新的核心属性(如 `data-mode``data-allow-source`)时,**必须同步检索 `defaultContent.ts` 和任何预置模板文件**,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
---
## 记录 195 项交互修复虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示)
**A. 具体问题**
1. 删除 `image-placeholder` 中的图片后,虚线框消失。
2. `ReportEditor.tsx``insertImage` prompt 文案仍显示旧版 "用英文逗号分隔",未同步修改。
3. 新生成的 `image-placeholder` 右上角红色 `×` 显示不完全——`overflow:hidden` 未移除。
4. 多选框无法输入 `,``;````、` 等分隔符——`onChange` 实时调用 `parseMultiInput` + `filter(Boolean)`,末尾的分隔符被瞬间吃掉。
5. 多选框 label 缺少 "(可多选)" 提示。
**B. 产生问题原因**
1. `fillPlaceholderSrc` 设置了 `border='none'`,但删除图片的代码没有恢复。
2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除,`ReportEditor.tsx` 中仍保留。
3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`,每次输入都会触发 `split(/[,;;、]/)``filter(Boolean)`。当用户输入一个逗号时split 产生空字符串filter 将其过滤,输入框值立即回退。
**C. 解决问题方案**
1. 在删除图片分支中增加:`placeholder.style.border = '1px dashed #cbd5e1'; placeholder.style.background = '#f8fafc';`
2.`ReportEditor.tsx``insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑(`*` 分隔 + while 循环校验)。
3.`ReportEditor.tsx``styleStr` 中删除 `overflow:hidden;`
4. **多选输入解耦**:引入本地状态 `multiInputText: Record<string, string>``onChange` 仅更新 `multiInputText`,不触发拆分;`onBlur``Enter` 时才调用 `handleMultiCommit` 执行拆分。
5. label 追加 `(可多选)` 提示。
**D. 后续如何避免问题**
- 同类型函数在多个文件中存在时,务必逐个文件 grep 确认修改结果,不能假设一次替换就能覆盖所有实例。
- 任何 "实时解析输入" 的逻辑都必须警惕 `filter(Boolean)` 对空字符串的过滤效应——如果允许用户输入分隔符应使用独立状态缓存原始输入仅在确认时blur/enter执行解析。
---
## 记录 20图片占位符填充后高度自适应
**A. 具体问题**
图片占位符填充后仍保留固定高度(如 200px导致图片下方出现大片空白。
**B. 产生问题原因**
此前仅将 `height` 改为 `auto`,未同步处理 `width`,也未利用 `max-width`/`max-height` 作为硬限制来实现等比例缩放。
**C. 解决问题方案**
1. **插入时**:为 inline-block 占位符追加 `max-width:${w}px;max-height:${h}px;`
2. **填充时**:统一执行以下步骤:
- 读取 `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};`
**D. 后续如何避免问题**
- `image-placeholder` 的尺寸逻辑涉及「创建时预设」和「填充后自适应」两个阶段,修改时必须同时考虑:创建时是否写入了 `max-width`/`max-height`;填充时是否同步清除了固定宽高并保留了硬限制。
---
## 记录 21重新部署应用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. 使用 `Start-Process` 以独立 Windows 进程启动 `npm run preview -- --host`(避免后台任务超时杀死服务)。
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200。
**D. 后续如何避免问题**
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview -- --host` 作为标准部署脚本。
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱。
- **切勿使用 Shell 后台任务(`run_in_background=true`)长时间运行 `npm run preview`**,因为任务超时机制(默认 60s会强制终止 preview 进程,导致服务中断。
---
## 记录 22参考信息文件夹导致 tsc 编译失败
**A. 具体问题**
执行 `npm run lint``tsc --noEmit`)时,报错 `参考信息/参考-ReportEditor.tsx` 中找不到模块 `'../components/Sidebar'` 等。该文件只是用户提供的参考代码,不应参与编译。
**B. 产生问题原因**
`tsconfig.json` 中没有配置 `exclude` 字段TypeScript 默认会递归编译项目根目录下所有 `.ts`/`.tsx` 文件,包括非源码的参考文件。
**C. 解决问题方案**
`tsconfig.json` 中增加 `exclude` 字段:
```json
"exclude": ["参考信息", "dist", "node_modules"]
```
**D. 后续如何避免问题**
- 任何非源码的参考文件、文档、备份代码必须放在被 `tsconfig.exclude``.gitignore` 排除的目录中。
- 修改 `tsconfig.json` 后应立即运行 `npm run lint` 验证排除是否生效。
---
## 记录 23大文件2200+行)增量修改的定位策略
**A. 具体问题**
`ReportEditor.tsx` 有 2224 行需要在多处插入代码imports、state、函数、工具栏 JSX、tab 切换 JSX、AI 面板 JSX、Diff 弹窗 JSX。直接全文搜索效率低且容易定位错误。
**B. 产生问题原因**
单文件组件承载了过多功能编辑器、视频分析、表单、AI 面板),导致任何新增功能都需要在文件的多个离散位置插入代码。
**C. 解决问题方案**
采用 **"Grep 定位 + 精确读取 + StrReplaceFile"** 的三段式策略:
1. 先用 `Grep` 找到目标代码的精确行号
2.`ReadFile` 读取该行号前后 10-20 行,获取精确文本
3.`StrReplaceFile` 进行最小化字符串替换,确保只改目标区域
**D. 后续如何避免问题**
- 对于超过 1500 行的单文件组件,新增功能时应优先使用 `Grep` 定位关键锚点(如 `const [activeTab``<div className="flex items-center gap-1 p-3` 等),避免盲目滚动阅读。
- 若需在同一文件的 5 个以上位置插入代码,建议先用 Agent 生成修改草案,再人工审核关键锚点。
- 考虑在未来重构中将超大组件按功能拆分为子组件(如 `ReportEditorToolbar``ReportEditorAiPanel`),降低后续修改成本。
---
## 记录 24数据结构重构时的旧数据迁移策略
**A. 具体问题**
重构 `SystemSettings` 时,将 `apiEndpoint`/`apiKey`/`kimiApiKey`/`kimiApiEndpoint` 四个散装字段替换为 `activeAiProvider` + `aiProviders` 字典结构。如果直接删除旧字段,已配置 API Key 的老用户会丢失配置。
**B. 产生问题原因**
TypeScript 接口变更后,从 `localStorage` 读出的旧数据对象缺少新字段,直接赋值给新类型的 state 会导致类型错误(缺少 `activeAiProvider``aiProviders`)或运行时逻辑断裂。
**C. 解决问题方案**
1.`SystemSettings.tsx` 的初始化 `useEffect` 中增加数据迁移逻辑:
```ts
if (!savedSettings.aiProviders) {
const providers = { ...DEFAULT_AI_PROVIDERS };
if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
providers.kimi = {
endpoint: (savedSettings as any).kimiApiApiEndpoint || providers.kimi.endpoint,
apiKey: (savedSettings as any).kimiApiKey || '',
modelName: 'kimi-k2-5'
};
}
savedSettings.aiProviders = providers;
savedSettings.activeAiProvider = 'kimi';
storage.set('systemSettings', savedSettings);
}
```
2. 使用 `(savedSettings as any)` 临时绕过旧字段的类型检查,避免在 `types.ts` 中保留废弃字段。
3. 迁移后旧字段仍保留在 `localStorage` 中(不主动删除),但代码不再读取。
**D. 后续如何避免问题**
- 任何涉及 `localStorage` 数据结构变更的重构,都必须在初始化入口提供**自动迁移逻辑**,否则用户数据会静默丢失。
- 迁移逻辑应使用 `try/catch` 包裹,防止脏数据导致页面白屏。
- 旧字段可通过 `(obj as any).oldField` 安全访问,无需在类型定义中长期保留废弃字段。
---
## 记录 25多服务商配置字典的 UI 绑定模式
**A. 具体问题**
SystemSettings 需要支持 4 个服务商Kimi/DeepSeek/OpenAI/自定义),每个服务商有 3 个配置项endpoint/apiKey/modelName。若用 12 个独立 state 或输入框,代码会极其臃肿。
**B. 产生问题原因**
早期设计采用平铺字段(`kimiApiKey`、`deepseekApiKey`...),导致每新增一个服务商就要改 types + UI + 调用逻辑三处。
**C. 解决问题方案**
采用 **"字典 + 动态下标"** 模式:
1. `types.ts` 中统一定义 `Record<string, AiProviderConfig>`
2. UI 中只有一个 `activeAiProvider` select下方 3 个输入框统一绑定到 `aiProviders[activeAiProvider].xxx`
3. `onChange` 时创建浅拷贝更新:
```ts
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
setSettings({ ...settings, aiProviders: next });
```
**D. 后续如何避免问题**
- 当一组配置具有"同构多实例"特征时(多个服务商、多个环境、多个账号),优先使用 `Record<string, Config>` 而非平铺字段。
- 动态表单的 `onChange` 必须注意不可变更新:先浅拷贝外层字典,再浅拷贝当前项,最后修改目标字段。直接 `settings.aiProviders[k].endpoint = x` 会触发 React 引用比较优化导致不刷新。
---
## 记录 26API Endpoint 尾部斜杠导致 404
**A. 具体问题**
SystemSettings 中测试连接成功(`/models` 返回 200但 ReportEditor 中调用 `/chat/completions` 报 404。用户输入的 Base URL 末尾带有 `/`,导致拼接后路径为 `https://api.xxx.com/v1//chat/completions`。
**B. 产生问题原因**
用户从文档复制 Base URL 时,末尾可能带斜杠;代码中直接做字符串拼接 `${apiEndpoint}/chat/completions`,未做净化处理。
**C. 解决问题方案**
在 `handleAIGenerate` 和 `testApi` 中统一对 endpoint 做尾部斜杠移除:
```ts
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
```
**D. 后续如何避免问题**
- 任何从用户输入拼接 URL 的场景,都必须先对基础路径做 `.replace(/\/+$/, '')` 或 `new URL(path, base)` 标准化处理。
- 测试连通性(`/models`)和实际业务调用(`/chat/completions`)应使用同一套 endpoint 净化逻辑,避免"测试通过、调用失败"的认知落差。
---
## 记录 27State 未纳入 Ref 导致自动保存遗漏
**A. 具体问题**
AI 撰写面板的 `chatMessages` 在路由切换后全部丢失。因为 `saveDraftToStorage` 从 `stateRef.current` 读取数据快照,而 `chatMessages` 从未被同步到 `stateRef`。
**B. 产生问题原因**
ReportEditor 采用 `useRef` 作为自动保存的数据快照机制(避免 React state 闭包陷阱)。新增 `chatMessages` state 时,只关注了 UI 渲染,遗漏了与 `stateRef` 的同步。
**C. 解决问题方案**
1. `stateRef` 初始化时包含 `chatMessages`
2. `saveDraftToStorage` 保存对象中增加 `chatMessages: stateRef.current.chatMessages`
3. 增加 `useEffect` 监听 `chatMessages` 变化,实时同步到 `stateRef.current.chatMessages`
4. 所有草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)均增加 `chatMessages` 的恢复和 ref 同步
**D. 后续如何避免问题**
- 在 `ReportEditor.tsx` 中新增任何 `useState` 时,必须同时问自己三个问题:
1. 这个 state 是否需要持久化到 draft
2. 若需要,是否已加入 `stateRef` 初始化?
3. 若需要,是否已在 `saveDraftToStorage`、所有恢复分支、以及 state→ref 同步 effect 中补齐?
- 建议维护一份 "Draft 持久化字段清单" 注释在 `stateRef` 定义附近,作为新增 state 时的检查单。
---
## 记录 28chatInput 草稿恢复遗漏 + AI 请求 content 格式条件判断
**A. 具体问题**
1. AI 撰写面板的聊天输入框内容在路由切换后丢失——虽然 `chatMessages` 已修复,但 `chatInput`(用户正在输入但未发送的文本)未纳入 draft 恢复。
2. Kimi 等纯文本模型在没有任何图片时,若将 `content` 以 vision 数组格式发送,会返回 `400 Bad Request`。
**B. 产生问题原因**
1. **遗漏模式**:此前修复 `chatMessages` 持久化时,只关注了已发送消息的恢复,忽略了输入框中未提交的 `chatInput` state。该 state 同样参与了 `stateRef` 同步和 `saveDraftToStorage` 保存,但所有 3 处草稿恢复分支均未恢复它。
2. **模型格式差异**OpenAI 兼容 API 中vision 模型支持 `content: [{type:'image_url'...}, {type:'text'...}]`,但纯文本模型(如 Kimi 默认的 `kimi-k2-5`)要求 `content` 必须是 `string`。即使数组中只有 text 元素,也会触发 400。
**C. 解决问题方案**
1. **chatInput 恢复**
- 在 `stateRef` 初始化和 `saveDraftToStorage` 中确认 `chatInput` 已存在(此前修改已完成)
- 在 3 处草稿恢复分支中增加:
```ts
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
// 以及 stateRef.current 中增加 chatInput: draft.chatInput || ''
```
2. **content 条件格式**
```ts
let messageContent: any;
if (allImages.length > 0) {
messageContent = [];
allImages.forEach(url => messageContent.push({ type: 'image_url', image_url: { url } }));
messageContent.push({ type: 'text', text: promptText });
} else {
messageContent = promptText;
}
```
**D. 后续如何避免问题**
- 新增任何 `useState` 时,除了问自己「是否已加入 stateRef / saveDraftToStorage / state→ref effect」还必须**逐个审查所有 draft 恢复分支**,确认恢复逻辑完整。
- 调用多模型兼容的 OpenAI 格式 API 时,必须根据「是否有图片附件」动态决定 `content` 的类型(`string` vs `array`),不能无条件发送数组。
---
## 记录 29Checkbox 在复杂 React 组件树中点击失效 + AI 上下文缺失
**A. 具体问题**
1. AI 面板底部的「允许修改正文」复选框无法点击切换。
2. AI 无法回答编辑器中已有的报告内容(如「气腹压力是多少」),表现得像「瞎子」。
**B. 产生问题原因**
1. **Checkbox 失效**:使用了独立的 `<input id="x">` + `<label htmlFor="x">` 组合。在复杂的 contentEditable 编辑器 + React 重渲染环境中,`id`/`htmlFor` 的绑定可能因事件冒泡、DOM 结构覆盖或 React 的 reconciliation 导致点击事件无法正确路由到 input。
2. **AI 上下文缺失**`handleAIGenerate` 只向大模型发送了「目标 AI 区域的 HTML 源码」。当该区域为空或信息在其他区域时,大模型收到的上下文只有用户指令,自然无法回答。
**C. 解决问题方案**
1. **Checkbox 修复**:将 `div > input + label` 改为 `label > input + span`,让 label 直接包裹 input天然扩大点击区域并避免 `id`/`htmlFor` 绑定冲突;`onChange` 中增加 `e.stopPropagation()` 防止事件冒泡被外层拦截。
2. **AI 上下文增强**
- 新增 `globalContextText = editorRef.current?.innerText || ''`,将编辑器完整纯文本作为全局背景知识注入 prompt
- `currentHtml` 增加 `.replace(/&#8203;/g, '').trim()` 过滤零宽字符
- 重构 prompt 结构:先放「全局参考内容」,再放「目标区域源码」,最后放「医生指令」
- 同步优化 systemPrompt明确告知大模型有两个信息源
**D. 后续如何避免问题**
- 在复杂 React 组件(尤其是与 contentEditable 共存)中使用 Checkbox 时,**优先使用 `<label>` 直接包裹 `<input>`** 的写法,避免依赖 `id`/`htmlFor`。
- 向大模型发送局部修改请求时,**必须同时提供全局上下文**,否则 AI 无法基于文档其他部分的信息进行推理和修改。
---
## 记录 30AI「只聊天不干活」——System Prompt 过度依赖目标区域 + 缺少降级插入
**A. 具体问题**
用户在 report-editor 中输入「请随机填充文本内容」AI 聊天面板有输出,但编辑器中的 AI 可编辑区域没有任何更新。
**B. 产生问题原因**
1. **System Prompt 条件过于严苛**`systemPrompt` 的构建条件是 `aiModifyEnabled && targetRegionEl`。由于 `aiTargetRegion` 默认值为 `'none'`,如果用户未在下拉框中明确选中区域,`targetRegionEl` 为 `null`systemPrompt 降级为纯聊天模式,大模型根本不会返回 `updatedHtml` 字段。
2. **接收逻辑缺少降级**`responseJson.updatedHtml` 的接收条件是 `aiModifyEnabled && targetRegionEl`,同样因为 `targetRegionEl` 为 `null` 而被跳过。即使大模型返回了 HTML也会被丢弃。
3. **缺少光标插入降级**:参考代码 `injectAIText` 中,当找不到目标区域时,会调用 `execCmd('insertHTML', htmlContent)` 将内容直接插入当前光标位置。当前代码完全没有这种降级机制。
**C. 解决问题方案**
1. **解绑 System Prompt**:将条件从 `aiModifyEnabled && targetRegionEl` 改为 `aiModifyEnabled`,让大模型在「允许修改正文」开启时始终返回 `updatedHtml`。
2. **增加降级插入逻辑**
```ts
if (responseJson.updatedHtml && aiModifyEnabled) {
if (targetRegionEl) {
setDiffModal({...}); // 原有流程:目标区域存在时走 diff 弹窗
} else {
execCmd('insertHTML', responseJson.updatedHtml); // 降级:插入光标位置
}
}
```
3. 复用当前代码已存在的 `execCmd` 辅助函数,自动处理 focus、contentRef 更新和草稿保存。
**D. 后续如何避免问题**
- 设计「修改/生成」类 AI 功能时,**systemPrompt 的条件应尽量只依赖用户意图开关**(如 `aiModifyEnabled`),而非依赖具体 UI 状态如某个下拉框是否选中。UI 状态应只影响「如何注入结果」,不应影响「是否要求模型生成结果」。
- 任何「目标区域注入」逻辑都必须配备**降级方案**(如光标处插入),防止因用户未选中区域而导致功能完全失效。
---
## 记录 31AI 修改模式自动锁定目标区域 + System Prompt 模式语义强化
**A. 具体问题**
用户希望实现两个明确场景:
1. **修改模式**:勾选「允许修改正文」→ AI 修改目标区域 → 弹出 diff 对比弹窗
2. **对话模式**:取消勾选「允许修改正文」→ AI 只聊天不修改
实际使用时,用户勾选了修改模式但未在下拉框中选择具体区域(`aiTargetRegion` 仍为 `'none'`),导致 AI 虽然返回了 `updatedHtml`,但 prompt 中缺少目标区域源码diff 弹窗中的「原稿」一侧为空。
**B. 产生问题原因**
1. **目标区域未自动锁定**`aiTargetRegion` 默认 `'none'`,修改模式开启后,如果用户未手动选择区域,`targetRegionEl` 为 `null`prompt 中不会注入目标区域源码。
2. **System Prompt 模式语义不够强烈**大模型对「修改模式」vs「对话模式」的区分不够清晰可能即使在对话模式下也返回 HTML。
**C. 解决问题方案**
1. **自动修正目标区域**:在 `handleAIGenerate` 开头增加:
```ts
let actualTargetId = aiTargetRegion;
if (aiModifyEnabled && actualTargetId === 'none') {
const availableRegions = checkAiRegions();
if (availableRegions.length > 0) {
actualTargetId = availableRegions[0].id;
setAiTargetRegion(actualTargetId);
}
}
```
后续 querySelector 和 diffModal 的 `targetId` 均使用 `actualTargetId`。
2. **强化 System Prompt 模式语义**
- 修改模式明确标注「当前处于【修改模式】」,并要求必须包含 `reply` + `updatedHtml`
- 对话模式明确标注「当前处于【对话模式】」,并要求仅包含 `reply`,不要返回 HTML
**D. 后续如何避免问题**
- 当功能存在「全局开关 + 局部选择器」两层控制时,**全局开关开启后应自动兜底局部选择器**,避免因用户遗漏局部配置而导致功能降级。
- System Prompt 中应显式标注当前模式名称(如「修改模式」「对话模式」),大模型对显式标签的遵循度远高于隐式条件推断。
---
## 记录 32AI diff 弹窗内容不完整 + 右侧多余空行
**A. 具体问题**
1. AI 修改确认弹窗左侧原始版本只显示一段内容,用户希望 AI 能一次性生成完整的多段落内容。
2. diff 弹窗右侧 AI 提议版本的段落间出现额外空行,与左侧结构不一致。
**B. 产生问题原因**
1. **内容不完整**:大模型被给予的目标区域源码(`currentHtml`)可能只有一段,且 systemPrompt 没有明确要求「生成完整、结构化的多段落内容」,导致 AI 只做局部改写。
2. **多余空行**:大模型返回的 HTML 中常包含 `<br>` 标签或 `\n` 换行符。`</p>\n<p>` 中的换行符会被浏览器解析为文本节点,产生额外空白。
**C. 解决问题方案**
1. **输入端控制System Prompt + Prompt**
- systemPrompt 增加明确要求:`updatedHtml 必须生成完整、结构化的多段落内容,不要只改写现有段落`
- systemPrompt 增加 HTML 格式约束:`段落必须使用 <p> 标签包裹,段落之间绝对不要使用 <br> 标签,也不要使用任何换行符`
- promptText 末尾追加「格式要求」段落,再次强调完整多段落、`<p>` 标签、禁止 `<br>`、紧凑 HTML
2. **输出端兜底(正则清洗)**
```ts
let cleanHtml = responseJson.updatedHtml;
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
cleanHtml = cleanHtml.trim();
```
在 `setDiffModal` 和 `execCmd` 之前统一清洗,确保右侧渲染结构与左侧一致。
**D. 后续如何避免问题**
- 当大模型返回的 HTML 需要在前端渲染时,**必须同时在输入端prompt和输出端后处理进行格式约束**,单靠一端无法完全控制不同 LLM 的输出随机性。
- 对于「生成完整性」类需求,必须在 prompt 中明确使用「必须生成完整...」「不要只改写...」等强制性措辞,否则大模型倾向于做最小化修改。
---
## 记录 33AI 越界生成——Prompt 中"补充完善"导致大模型过度联想
**A. 具体问题**
AI 修改确认弹窗右侧出现了不属于目标区域的内容:术后情况、切除标本描述、是否送病理检查、冰冻病理结果、手术者签名等。这些模块本应在报告的其他位置,却被 AI 混入了"手术步骤"区域的 updatedHtml 中。
**B. 产生问题原因**
1. **全局上下文暴露过多**`globalContextText` 包含了整个编辑器的纯文本AI 看到了报告中所有模块的内容。
2. **Prompt 措辞诱导过度联想**systemPrompt 中写着 `要基于全局信息补充完善`,大模型非常"听话"地把它在全局上下文中看到的所有内容都"补充"进了输出。
3. **缺少内容边界约束**Prompt 中没有明确告知 AI"只能输出目标区域本身的内容,严禁混入其他模块"。
**C. 解决问题方案**
1. **System Prompt 去掉诱导性措辞**
- 将 `请根据全局内容和用户的【医生指令】` 改为 `请根据用户的【医生指令】`
- 将 `updatedHtml 必须生成完整...要基于全局信息补充完善` 改为明确的【内容边界】警告:
> "全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容。严禁输出签名、落款、术后总结等属于报告其他部分的结构!"
2. **User Prompt 增加防越界指令**
- 增加第 2 点:用 ⚠️ 警告符号明确列出禁止混入的模块类型(基本信息、术后情况、标本描述、病理结果、医生签名、日期等)
**D. 后续如何避免问题**
- 在向大模型发送局部修改请求时,**必须设置严格的内容边界Fencing**。全局上下文可以提供给 AI 作为背景理解,但必须在 Prompt 中明确声明"仅供理解,严禁输出"。
- 避免使用"补充完善""基于全局信息扩展"等容易被大模型过度解读的措辞。大模型会尽其所能地"满足"用户的指令,即使这意味着越界生成。
---
## 记录 34contentEditable 回车导致段落溢出 .ai-content
**A. 具体问题**
AI 修改确认弹窗的「原始版本」左侧只显示了 AI 可编辑区域中的一段内容,但编辑器中该区域实际上有 2-5 段。从 DOM 源码可以看到:
```html
<div class="ai-content"><p>第2段</p></div>
<p>第3段</p>
<p>第4段</p>
<p>第5段</p>
```
第 3-5 段变成了 `.ai-content` 的兄弟节点,不在 `.ai-content` 内部。
**B. 产生问题原因**
浏览器原生 `contentEditable` 机制在用户按回车换行时,会截断当前的块级容器(`.ai-content` div在同级生成新的 `<p>` 标签。这导致后续段落脱离了 `.ai-content` 父容器,变成了 `.ai-region` 的直接子节点。
**C. 解决问题方案**
在 `handleAIGenerate` 获取 `currentHtml` 之前,增加溢出段落合并逻辑:
```ts
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
```
遍历 `.ai-content` 之后的所有兄弟节点,把 `<p>` 标签移回 `.ai-content` 内,然后同步更新 contentRef 和草稿。
**D. 后续如何避免问题**
- `contentEditable` 中的嵌套容器(如 `.ai-content`)在用户输入时极易被浏览器原生编辑行为破坏结构。任何依赖特定 DOM 层级关系的功能,都必须在读取数据前做**结构完整性检查和修复**。
- 对于 AI 区域这类核心功能,应考虑在编辑器层面增加 `keydown`/`paste` 事件拦截,或改用更可控的编辑方案(如 ProseMirror/Slate来替代原生 `contentEditable`。
---
## 记录 35打印时隐藏 AI 区域蓝框 + diff 弹窗字体统一
**A. 具体问题**
1. 点击下载/打印时AI 可编辑区域的蓝色虚线框和右上角标签仍然显示在输出中。
2. AI 修改确认弹窗中右侧「AI 提议版本」的字体与左侧「原始版本」不一致(左侧宋体 12pt右侧默认无衬线体
**B. 产生问题原因**
1. **打印样式缺失**`src/utils/print.ts` 使用 iframe 生成打印文档,其 `<style>` 中没有针对 `.ai-region` 的隐藏样式。虽然 `src/index.css` 中有 `.print-content .ai-region` 规则,但 `print.ts` 中实际使用的是 `.content` 类CSS 选择器不匹配。
2. **字体继承缺失**AI 返回的 HTML 是纯净的 `<p>` 标签没有内联样式。diff 弹窗右侧容器没有设置默认字体,导致浏览器使用默认无衬线字体。
**C. 解决问题方案**
1. **打印样式**:在 `print.ts` 的 iframe `<style>` 中增加:
```css
.ai-region { border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
.ai-region > [contenteditable="false"] { display: none !important; }
```
2. **diff 弹窗字体**:在右侧容器的 `style` 属性中指定:
```tsx
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
```
**D. 后续如何避免问题**
- 任何通过 iframe 或独立文档实现的打印/导出功能,都必须在 iframe 的 `<style>` 中独立维护打印样式,不能依赖外部 CSS 文件(因为外部样式不会自动注入 iframe
- 对于 diff 对比类 UI左右两侧容器应显式设置相同的默认字体样式避免依赖内容自带的内联样式造成视觉不一致。
---
## 记录 36AI 注入后 Ctrl+Z 失效 + 字体格式丢失
**A. 具体问题**
1. 点击「确认并写入报告」后Ctrl+Z 无法撤销 AI 的修改。
2. AI 替换后的文字丢失了原有内联样式(宋体 12pt显示为浏览器默认字体。
**B. 产生问题原因**
1. **撤销失效**`confirmAiInjection` 使用 `targetContent.innerHTML = newHtml;` 直接修改 DOM 属性。这种方式完全绕过了浏览器 `contentEditable` 的原生撤销/重做历史栈。
2. **字体丢失**:大模型返回的是纯净的 `<p>` 标签(如 `<p>内容</p>`),没有内联样式。替换后浏览器使用默认字体渲染,与原有 `<p style="font-family: SimSun; font-size: 12pt;">` 不一致。
**C. 解决问题方案**
1. **保留撤销栈**:将 `innerHTML = newHtml` 替换为:
```ts
targetContent.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(targetContent);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('insertHTML', false, newHtml);
```
`Range.selectNodeContents` 选中区域内所有旧内容,`execCommand('insertHTML')` 让浏览器原生撤销栈记录这次替换。
2. **注入内联样式**:在 `handleAIGenerate` 的 `cleanHtml` 清洗后增加:
```ts
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
```
给所有 `<p>` 标签注入标准内联样式,确保替换后字体与原有文字一致。
**D. 后续如何避免问题**
- 在 `contentEditable` 环境中修改内容时,**优先使用 `Range.selectNodeContents` + `execCommand('insertHTML')` 而非直接 `innerHTML` 赋值**,前者能让浏览器原生撤销/重做栈正常工作。
- 当大模型返回的 HTML 缺少必要的内联样式时,应在**前端后处理阶段**统一注入样式,而不是依赖大模型生成完整的样式代码(大模型对样式生成的稳定性较差)。
---
## 记录 37AI 二次修改未弹窗 + diff 弹窗增加文档对比高亮
**A. 具体问题**
1. 第一次 AI 修改正常弹出 diff 弹窗,第二次输入微调指令(如"把 5x3x2 变成 5x3x10")后没有弹窗。
2. diff 弹窗左侧和右侧只是简单渲染两段 HTML无法直观看到 AI 具体修改了哪些字词。
**B. 产生问题原因**
1. **未弹窗**:大模型在微小修改指令时可能"偷懒",只返回 `reply` 而不返回 `updatedHtml`。当前逻辑 `if (responseJson.updatedHtml && aiModifyEnabled)` 会跳过弹窗,用户没有任何反馈。
2. **无对比**:没有使用差异比对算法来标记变更,用户只能通过肉眼对比左右两侧发现差异。
**C. 解决问题方案**
1. **强化 systemPrompt**:增加第 8 条:「⚠️ 绝对强制:无论用户的修改指令多么微小,你都必须返回 updatedHtml。绝对不允许只返回 reply 而不返回 updatedHtml
2. **前端校验兜底**:在 `updatedHtml` 处理分支前增加:
```ts
if (aiModifyEnabled && !responseJson.updatedHtml) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]);
}
```
3. **引入 diff 库**`npm install diff`,使用 `diffChars` 进行字符级差异比对。
4. **左右两侧 diff 高亮**
- 左侧(原始版本):删除的内容标红(`background-color:#fee2e2; color:#dc2626; text-decoration:line-through;`
- 右侧AI 版本):新增的内容标绿(`background-color:#dcfce7; color:#16a34a;`
5. **注入前清理**`confirmAiInjection` 中去掉 diff 高亮 span
```ts
const cleanHtml = newHtml.replace(/<span class="diff-(added|removed)"[^>]*>(.*?)<\/span>/gi, '$2');
```
**D. 后续如何避免问题**
- 大模型对「必须返回某字段」的遵循度与 prompt 中该字段的强调程度正相关。对于关键输出字段,应在 systemPrompt 中使用「绝对强制」「绝对不允许」等最强措辞,并在前端增加缺失校验兜底。
- 在 diff 对比场景中,**纯文本层面的差异比对**比 HTML 层面的比对更可靠。应先将 HTML strip 为纯文本,再做 diff最后把结果渲染为 HTML。