release: v2.0.0 - AI手术图文报告系统(最小可部署包)
- 移除工程分析文档、参考信息、过往经验等非部署内容 - 保留 src/ public/ Dockerfile docker-compose.yaml nginx.conf 等核心文件
This commit is contained in:
289
AGENTS.md
289
AGENTS.md
@@ -1,289 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
> 本文档面向 AI 编程助手。阅读者应假定对项目一无所知。所有信息均基于项目实际代码和开发历史,不做假设性推断。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
**手术图文病历报告系统**(版本 1.3)是一款纯前端、单页应用(SPA),面向医院手术室场景,用于:
|
|
||||||
|
|
||||||
- 通过富文本编辑器撰写结构化手术图文报告
|
|
||||||
- 上传手术视频并自动/手动抽取关键帧,拖拽插入报告
|
|
||||||
- 管理报告模板、用户权限、系统设置
|
|
||||||
- 导出 PDF / JSON 格式的报告和模板
|
|
||||||
|
|
||||||
**核心特征**:
|
|
||||||
- **无后端服务器**:所有数据(用户、报告、模板、设置、素材)持久化在浏览器 `localStorage` 中
|
|
||||||
- **离线可用**:部署后即为静态文件,无需网络 API
|
|
||||||
- **A4 打印优先**:编辑器按 A4 尺寸(210mm × 297mm)排版,支持浏览器打印转 PDF
|
|
||||||
- **角色权限控制**:三级角色 `super`(超级管理员)/`admin`(科室管理员)/`user`(医生)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
| 层级 | 技术 |
|
|
||||||
|------|------|
|
|
||||||
| 框架 | React 19 + TypeScript 5.8 |
|
|
||||||
| 构建工具 | Vite 6 |
|
|
||||||
| 样式 | Tailwind CSS v4(使用 `@theme` 和 `@import "tailwindcss"` 新语法) |
|
|
||||||
| 路由 | React Router DOM v7 |
|
|
||||||
| 图标 | lucide-react |
|
|
||||||
| 动画 | motion |
|
|
||||||
| AI SDK | `@google/genai`(依赖已安装,但当前源码中**未实际调用**任何 LLM API) |
|
|
||||||
| 运行时 | 纯浏览器客户端;`express` 仅在依赖列表中,未被源码使用 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── public/ # 静态资源(favicon、logo_square.png)
|
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ └── Sidebar.tsx # 左侧导航栏(角色过滤、自动折叠)
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ ├── Login.tsx # 登录页 + 全局初始化(默认用户/模板/字段/素材)
|
|
||||||
│ │ ├── Dashboard.tsx # 工作台(统计卡片、SVG 趋势图)
|
|
||||||
│ │ ├── ReportEditor.tsx# 核心:报告编辑器(2,200+ 行,最大文件)
|
|
||||||
│ │ ├── ReportManage.tsx# 报告列表(搜索、筛选、批量操作、历史回溯)
|
|
||||||
│ │ ├── ReportView.tsx # 报告只读查看 + 打印
|
|
||||||
│ │ ├── TemplateManage.tsx # 模板编辑器(1,600+ 行,自定义 Undo/Redo)
|
|
||||||
│ │ ├── UserManage.tsx # 用户管理(RBAC、签名上传、模板权限)
|
|
||||||
│ │ └── SystemSettings.tsx # 系统设置(抽帧配置、AI API、默认模板)
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── storage.ts # localStorage / sessionStorage 封装
|
|
||||||
│ │ ├── print.ts # iframe 打印工具(A4 样式、@page 边距)
|
|
||||||
│ │ └── defaultContent.ts # 默认模板 HTML(腹腔镜胆囊切除术报告)
|
|
||||||
│ ├── App.tsx # BrowserRouter + 路由表
|
|
||||||
│ ├── main.tsx # React 根挂载(StrictMode)
|
|
||||||
│ ├── types.ts # 核心 TypeScript 类型(User/Report/Template/FormField 等)
|
|
||||||
│ └── index.css # Tailwind 入口 + @theme 变量 + 打印媒体查询
|
|
||||||
├── Dockerfile # 多阶段构建:node builder → nginx alpine
|
|
||||||
├── docker-compose.yaml # 映射宿主机 4002 → 容器 80
|
|
||||||
├── nginx.conf # SPA 回退、Gzip、静态缓存
|
|
||||||
├── vite.config.ts # Vite + Tailwind 插件、GEMINI_API_KEY 注入
|
|
||||||
├── tsconfig.json # ES2022、react-jsx、路径别名 `@/*`
|
|
||||||
├── package.json
|
|
||||||
├── .env.example # GEMINI_API_KEY、APP_URL
|
|
||||||
├── index.html # 入口 HTML(标题 "My Google AI Studio App")
|
|
||||||
└── 过往经验/ # 开发经验记录(经验记录-1.md / 经验记录-2.md)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 构建与部署命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发
|
|
||||||
npm run dev # vite --port=3000 --host=0.0.0.0
|
|
||||||
|
|
||||||
# 生产构建
|
|
||||||
npm run build # vite build → dist/
|
|
||||||
npm run preview # vite preview(默认端口 4173)
|
|
||||||
|
|
||||||
# 清理
|
|
||||||
npm run clean # rm -rf dist
|
|
||||||
|
|
||||||
# 类型检查(唯一 lint 手段,无 ESLint)
|
|
||||||
npm run lint # tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker 部署**:
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
# 宿主机访问 http://localhost:4002
|
|
||||||
```
|
|
||||||
|
|
||||||
**无 Docker 环境部署**(Windows 等):
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm run preview
|
|
||||||
# 或任何静态文件服务器托管 dist/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 数据持久化与状态管理
|
|
||||||
|
|
||||||
- **无全局状态库**(无 Redux、Zustand、Context API)
|
|
||||||
- 每个页面独立通过 `useState` + `useEffect` 管理状态
|
|
||||||
- **`localStorage` 即数据库**。关键键名:
|
|
||||||
- `users` — 用户列表
|
|
||||||
- `currentUser` — 当前登录用户
|
|
||||||
- `reports` — 报告列表
|
|
||||||
- `templates` — 模板列表
|
|
||||||
- `systemSettings` — 系统设置
|
|
||||||
- `formFieldsConfig` — 动态字段配置
|
|
||||||
- `imageAssets` — 系统素材库(Base64 图片)
|
|
||||||
- `reportEditorDraft_${username}` — 每用户报告草稿
|
|
||||||
- `customTimeFormats` — 用户自定义时间格式缓存
|
|
||||||
|
|
||||||
⚠️ **localStorage 容量约 5MB**。关键帧图片采用 Canvas 压缩(最大宽度 800px、JPEG 质量 0.6)以避免超限。`storage.ts` 中的异常已改为 `console.error` 输出,不再静默吞掉。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心模块说明
|
|
||||||
|
|
||||||
### 1. 富文本编辑器(ReportEditor.tsx / TemplateManage.tsx)
|
|
||||||
|
|
||||||
- **底层**:原生 `contentEditable` + `document.execCommand`
|
|
||||||
- **智能字段(Smart Field)**:三层嵌套结构
|
|
||||||
```html
|
|
||||||
<span class="smart-field-wrapper" contenteditable="false">
|
|
||||||
<span class="field-label">标签:</span>
|
|
||||||
<span class="field-value" contenteditable="true" data-bind="key"></span>
|
|
||||||
</span>​
|
|
||||||
```
|
|
||||||
- 外层 `contenteditable="false"` 保护标签不被逐字删除
|
|
||||||
- 输入层 `data-bind` 实现与右侧表单的双向绑定
|
|
||||||
- 末尾追加 `​`(零宽空格)防止排版换行异常
|
|
||||||
- **图片占位符**:`<span class="image-placeholder">`,支持 `data-mode="frame|manual"` 分类隔离
|
|
||||||
- **自定义 Undo/Redo**:TemplateManage 中已实现基于 HTML 字符串快照的自定义历史栈(`undoStack`/`redoStack`),取代不可靠的浏览器原生 undo
|
|
||||||
|
|
||||||
### 2. 视频分析(ReportEditor.tsx)
|
|
||||||
|
|
||||||
- 上传本地视频 → 生成 object URL
|
|
||||||
- 自动抽帧:按 `systemSettings.framePositions` 百分比位置逐帧截图
|
|
||||||
- 手动截图:点击按钮从当前播放时间捕获
|
|
||||||
- 图片压缩:Canvas 等比缩放至最大 800px 宽,JPEG 质量 0.6
|
|
||||||
- 拖拽/一键插入:填充到第一个空置的 `image-placeholder:not([data-mode="manual"])`
|
|
||||||
- 自动帧插入:非阻塞 `setTimeout` 队列式插入,避免阻塞抽帧循环
|
|
||||||
|
|
||||||
### 3. 打印系统(src/utils/print.ts)
|
|
||||||
|
|
||||||
- 创建隐藏 iframe,写入带 A4 打印样式的 HTML
|
|
||||||
- `@page { margin: 15mm 10mm; }` 为**每一页**纸张独立分配边距
|
|
||||||
- `body { padding: 0 }` — 不可用 body padding 代替 @page margin,否则第二页及后续页边距失效
|
|
||||||
- 打印前临时设置 `document.title` 并注入 iframe `<title>`,确保 PDF 默认文件名正确
|
|
||||||
- 打印后恢复原始标题
|
|
||||||
|
|
||||||
### 4. 角色权限(RBAC)
|
|
||||||
|
|
||||||
| 角色 | 权限 |
|
|
||||||
|------|------|
|
|
||||||
| `super` | 全部页面、全部数据 |
|
|
||||||
| `admin` | 仅管理本科室用户;可管理模板;不能看系统设置中的 AI 配置 |
|
|
||||||
| `user` | 仅创建/查看/编辑自己的报告;可见被分配模板 |
|
|
||||||
|
|
||||||
- `Sidebar.tsx` 根据 `currentUser.role` 过滤导航项
|
|
||||||
- `UserManage.tsx` 中 admin 只能管理 `department` 与自己相同的用户
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发规范与约定
|
|
||||||
|
|
||||||
### 代码风格
|
|
||||||
- TypeScript 严格模式未开启;`skipLibCheck: true`
|
|
||||||
- 路径别名 `@/` 映射到项目根目录(`src/` 同级)
|
|
||||||
- **无 ESLint、无 Prettier、无格式化配置**
|
|
||||||
- 所有字符串插值、UI 文案、注释均以**中文**为主
|
|
||||||
|
|
||||||
### 关键开发教训(必读)
|
|
||||||
|
|
||||||
以下经验来自 `过往经验/` 中的 40+ 条记录,是修改本项目时**最容易踩的坑**:
|
|
||||||
|
|
||||||
#### A. contentEditable 与 DOM 操作
|
|
||||||
1. **插入 HTML 必须为紧凑单行**:使用 `document.execCommand('insertHTML', ...)` 或 `Range.insertNode()` 时,多行模板字符串中的缩进/换行会被浏览器解析为额外文本节点,破坏排版和光标行为。
|
|
||||||
2. **删除字段用 `Range.selectNode + execCommand('delete')`**:直接 `target.remove()` 会绕过浏览器撤销栈,且可能在 WebKit 中误删父级 `<p>`。
|
|
||||||
3. **任何直接操作 DOM 修改编辑器内容后,必须紧跟**:
|
|
||||||
```ts
|
|
||||||
contentRef.current = editorRef.current.innerHTML;
|
|
||||||
```
|
|
||||||
4. **在表格 `<td>` 内插入复杂 inline 元素时**:优先使用块级 `<div>` 作为外层容器,`execCommand('insertHTML')` 对 `<td>` 内的 inline-flex 嵌套会自动"拍平"结构。
|
|
||||||
5. **对齐操作弃用 `execCommand('justifyLeft'...)`**:改用直接设置 `block.style.textAlign = align`,避免浏览器对混合排版(文字 + 智能字段/占位符)的肢解。
|
|
||||||
|
|
||||||
#### B. 自动保存与 Ref/State 同步
|
|
||||||
1. **永远不要将 `useRef` 作为自动保存的唯一数据源**:React 18 `StrictMode` 的"挂载 → 卸载 → 重挂载"会导致 ref 在首次卸载时仍保持初始空值,从而用空数据覆盖有效的 localStorage draft。
|
|
||||||
2. **自动保存函数应直接从最新的 React state 和 DOM 读取数据**,通过 `useCallback` + 完整 dependency 数组保证闭包新鲜;或从 `stateRef` / `contentRef` 读取稳定快照,但必须在**所有**数据恢复路径中同步 ref。
|
|
||||||
3. **`setState` 是异步的**:`setCapturedFrames(next); saveDraftToStorage();` 的写法会导致闭包读到旧值。若需即时保存,应在 `setState` 回调中触发保存,或从 ref 读取。
|
|
||||||
4. **组件卸载时 DOM 可能已失效**:`editorRef.current?.innerHTML` 在卸载阶段可能为空,应优先使用 `contentRef.current`(内存引用)。
|
|
||||||
5. **异步循环中不要 `await` 阻塞主流程**:自动帧插入使用 `setTimeout` 推入事件队列,而非 `await new Promise(...)`。
|
|
||||||
|
|
||||||
#### C. 图片占位符体系
|
|
||||||
1. 占位符涉及**三处必须同步修改**:`defaultContent.ts`(静态模板)、`ReportEditor.tsx`(运行时插入/填充/删除恢复)、`TemplateManage.tsx`(模板管理)。
|
|
||||||
2. 占位符创建时需写入 `max-width` / `max-height`;填充后改为 `width:auto; height:auto`,让图片 shrink-wrap;删除恢复时需回读 `maxWidth/maxHeight` 重置尺寸。
|
|
||||||
3. 提示文字使用 `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center;`,要求父容器带 `position:relative`。
|
|
||||||
4. 占位符分类隔离:通过 `data-mode="frame|manual"` 区分;自动插入和拖拽填充时必须用 `:not([data-mode="manual"])` 过滤。
|
|
||||||
|
|
||||||
#### D. 时间/日期字段格式系统
|
|
||||||
1. 存储格式与显示格式分离:`YYYY-MM-DD` / `HH:mm` 存储;`YYYY年MM月DD日` / `hh:mm A` 显示。
|
|
||||||
2. 必须同时实现**正向格式化**(存储 → 显示)和**反向解析**(显示 → 存储),否则编辑器内直接编辑 smart field 会导致数据混乱。
|
|
||||||
3. 12h/24h 判断使用包含性判断:`field.timeFormat.includes('hh') || field.timeFormat.includes('A')`,避免精确匹配无法覆盖自定义格式。
|
|
||||||
4. 默认值策略:「固定时间」/`specific` 与「当前时间」/`current`。自动填充必须加「仅当值为空时触发」保护,防止编辑已有报告时覆盖用户数据。
|
|
||||||
5. 时间格式 token 体系:`YYYY`、`MM`、`DD`、`HH`、`hh`、`mm`、`A`。避免使用简写别名如 `'24h'`、`'12h'` 作为存储值。
|
|
||||||
|
|
||||||
#### E. 事件与交互
|
|
||||||
1. **工具栏/字段库按钮必须加 `onMouseDown={(e) => e.preventDefault()}`**:防止点击时编辑器失焦导致 `Selection/Range` 丢失。
|
|
||||||
2. **插入操作前恢复 `savedRangeRef`**:作为焦点流失后的兜底保险。
|
|
||||||
3. **双向联动高亮**:通过 `activeFieldKey` 状态 + `useEffect` 直接操作 DOM style(`backgroundColor`、`outline`),避免触发组件重渲染导致光标丢失。点击非字段区域时清空高亮。
|
|
||||||
4. 打印样式必须通过 `@media print` 强制抹除所有交互高亮内联样式(`outline: none !important; box-shadow: none !important;`)。
|
|
||||||
|
|
||||||
#### F. 数据初始化与默认值
|
|
||||||
1. `Login.tsx` 的 `initData()` 是全局唯一初始化入口:默认用户、默认模板、默认字段配置、默认设置、素材预加载(logo)均应在此处完成。
|
|
||||||
2. 新增 `localStorage` key 时需提供合理的默认值或降级处理。
|
|
||||||
3. `resetToDefault` / 恢复出厂设置函数必须**包含所有** `SystemSettings` 字段,不能遗漏新增配置项。
|
|
||||||
4. 修改 `DEFAULT_FORM_FIELDS` 默认值后,已有用户的 `localStorage` 中旧配置不会自动更新;若变更影响核心功能,应考虑启动时做配置迁移或版本校验。
|
|
||||||
5. 批量操作后必须同步清理 `selectedIds` 和当前选中状态,避免选中已删除项。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
**当前状态:零自动化测试。**
|
|
||||||
|
|
||||||
- 无单元测试、无集成测试、无 E2E 测试
|
|
||||||
- 无 Jest、Vitest、Playwright、Cypress 配置
|
|
||||||
- 唯一类型检查:`npm run lint`(`tsc --noEmit`)
|
|
||||||
|
|
||||||
**建议补充方向**(如用户要求):
|
|
||||||
- `storage.ts` 的 JSON 序列化/反序列化
|
|
||||||
- `types.ts` 中日期/时间格式化与解析函数的正反向一致性
|
|
||||||
- 报告编辑器的草稿保存/恢复逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全注意事项
|
|
||||||
|
|
||||||
1. **密码明文存储**:用户密码以明文形式保存在 `localStorage` 的 `users` 数组中。这是纯前端架构的固有限制,**不适合生产环境处理真实敏感数据**。
|
|
||||||
2. **无 HTTPS 强制**:Docker 部署默认 HTTP 80 端口。
|
|
||||||
3. **无 API 鉴权**:无后端,因此无 Token、Session、CSRF 防护概念。
|
|
||||||
4. **XSS 风险**:报告和模板内容直接以 HTML 字符串存储并在 `innerHTML` 中渲染。当前通过 `contentEditable` 限制输入来源,但若导入外部 JSON 模板/报告,需警惕恶意脚本。
|
|
||||||
5. **Gemini API Key**:通过 Vite `define` 注入客户端,构建后 key 会暴露在静态 JS 中(当前源码未实际调用)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署环境变量
|
|
||||||
|
|
||||||
复制 `.env.example` 为 `.env`:
|
|
||||||
|
|
||||||
```
|
|
||||||
GEMINI_API_KEY="YOUR_KEY" # Google Gemini API Key(当前未在业务代码中使用)
|
|
||||||
APP_URL="YOUR_APP_URL" # 应用托管 URL
|
|
||||||
```
|
|
||||||
|
|
||||||
Vite 构建时仅将 `GEMINI_API_KEY` 注入 `process.env.GEMINI_API_KEY`,其余变量不自动暴露给客户端。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 默认账号(首次登录或清空数据后)
|
|
||||||
|
|
||||||
| 账号 | 密码 | 角色 |
|
|
||||||
|------|------|------|
|
|
||||||
| admin | 123456 | super |
|
|
||||||
| manager | 123456 | admin |
|
|
||||||
| doctor / 0001 | 123456 | user |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修改前必读检查清单
|
|
||||||
|
|
||||||
在修改任何涉及以下内容的功能前,请先搜索并同步检查所有相关文件:
|
|
||||||
|
|
||||||
- [ ] **智能字段结构** → `types.ts`、`defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`、`index.css`、`print.ts`
|
|
||||||
- [ ] **图片占位符(创建/填充/删除恢复)** → `defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`
|
|
||||||
- [ ] **打印样式** → `print.ts`、`index.css`(`@media print`)
|
|
||||||
- [ ] **时间/日期格式** → `types.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`
|
|
||||||
- [ ] **数据初始化/默认值** → `Login.tsx`、`SystemSettings.tsx`
|
|
||||||
- [ ] **自动保存/草稿** → `ReportEditor.tsx` 中的 `saveDraftToStorage`、`stateRef`、`contentRef`
|
|
||||||
@@ -1,822 +0,0 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
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, Bot, Sparkles, Send, Loader2,
|
|
||||||
Mic, MicOff, ImagePlus
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
|
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
|
||||||
import { printDocument } from '../utils/print';
|
|
||||||
import { storage } from '../utils/storage';
|
|
||||||
|
|
||||||
export default function ReportEditor() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const reportId = searchParams.get('id');
|
|
||||||
const restoreFlag = searchParams.get('restore');
|
|
||||||
|
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
||||||
const [reportData, setReportData] = useState<Partial<Report>>({
|
|
||||||
title: '腹腔镜胆囊切除术报告',
|
|
||||||
patientName: '',
|
|
||||||
hospitalId: '',
|
|
||||||
patientGender: '',
|
|
||||||
patientAge: '',
|
|
||||||
department: '',
|
|
||||||
bedNumber: '',
|
|
||||||
surgeryDate: '',
|
|
||||||
startHour: '',
|
|
||||||
startMinute: '',
|
|
||||||
endHour: '',
|
|
||||||
endMinute: '',
|
|
||||||
surgeon: [],
|
|
||||||
assistant: [],
|
|
||||||
anesthesiologist: [],
|
|
||||||
anesthesiaType: '',
|
|
||||||
reportNote: '',
|
|
||||||
status: 'draft'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
|
||||||
const [videos, setVideos] = useState<{id: string, name: string, url: string, duration: number}[]>([]);
|
|
||||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(-1);
|
|
||||||
const [capturedFrames, setCapturedFrames] = useState<CapturedFrame[]>([]);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
|
||||||
const [loadedTemplateId, setLoadedTemplateId] = useState('');
|
|
||||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
|
||||||
const prevVideoCountRef = useRef(0);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'video' | '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}[]>([]);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [aiSelectedFrames, setAiSelectedFrames] = useState<number[]>([]);
|
|
||||||
const [aiTargetRegion, setAiTargetRegion] = useState<string>('surgical-steps');
|
|
||||||
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
|
|
||||||
const [isListening, setIsListening] = useState(false);
|
|
||||||
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
|
|
||||||
const speechRecognitionRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
|
||||||
allFields.forEach(el => {
|
|
||||||
(el as HTMLElement).style.backgroundColor = '';
|
|
||||||
(el as HTMLElement).style.outline = '';
|
|
||||||
(el as HTMLElement).style.outlineOffset = '';
|
|
||||||
});
|
|
||||||
if (activeFieldKey) {
|
|
||||||
const targetEl = editorRef.current.querySelector(`.field-value[data-bind="\${activeFieldKey}"]`) as HTMLElement;
|
|
||||||
if (targetEl) {
|
|
||||||
targetEl.style.backgroundColor = '#f1f5f9';
|
|
||||||
targetEl.style.outline = '1px solid #94a3b8';
|
|
||||||
targetEl.style.outlineOffset = '1px';
|
|
||||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeFieldKey]);
|
|
||||||
|
|
||||||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
|
||||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const contentLoadedRef = useRef(false);
|
|
||||||
const contentRef = useRef('');
|
|
||||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId });
|
|
||||||
|
|
||||||
const draftKey = currentUser ? `reportEditorDraft_\${currentUser.username}` : '';
|
|
||||||
|
|
||||||
const updatePageHeight = () => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
const contentHeight = editorRef.current.scrollHeight;
|
|
||||||
const pageHeightMm = 297;
|
|
||||||
const mmToPx = 3.7795275591;
|
|
||||||
const pages = Math.max(2, Math.ceil(contentHeight / (pageHeightMm * mmToPx)));
|
|
||||||
editorRef.current.style.minHeight = `\${pages * pageHeightMm}mm`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveDraftToStorage = React.useCallback(() => {
|
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
|
||||||
const key = user ? `reportEditorDraft_\${user.username}` : '';
|
|
||||||
if (key) {
|
|
||||||
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
|
|
||||||
storage.set(key, {
|
|
||||||
content: currentContent,
|
|
||||||
draftReportId: reportId || null,
|
|
||||||
reportData: stateRef.current.reportData,
|
|
||||||
videos: stateRef.current.videos,
|
|
||||||
capturedFrames: stateRef.current.capturedFrames,
|
|
||||||
activeTab: stateRef.current.activeTab,
|
|
||||||
loadedTemplateId: stateRef.current.loadedTemplateId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [reportId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
|
||||||
if (!user) { navigate('/'); return; }
|
|
||||||
setCurrentUser(user);
|
|
||||||
|
|
||||||
const savedFields = storage.get<FormField[]>('formFieldsConfig', []);
|
|
||||||
if (savedFields.length > 0) {
|
|
||||||
setFormFields(savedFields);
|
|
||||||
} else {
|
|
||||||
setFormFields(DEFAULT_FORM_FIELDS);
|
|
||||||
storage.set('formFieldsConfig', DEFAULT_FORM_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTemplates = storage.get<Template[]>('templates', []);
|
|
||||||
setTemplates(allTemplates);
|
|
||||||
|
|
||||||
if (reportId) {
|
|
||||||
const reports = storage.get<Report[]>('reports', []);
|
|
||||||
const found = reports.find(r => r.id === reportId);
|
|
||||||
if (found) {
|
|
||||||
setReportData(found);
|
|
||||||
if (found.capturedFrames) setCapturedFrames(found.capturedFrames);
|
|
||||||
if (found.videos) setVideos(found.videos);
|
|
||||||
if (editorRef.current) {
|
|
||||||
editorRef.current.innerHTML = found.content;
|
|
||||||
contentRef.current = found.content;
|
|
||||||
contentLoadedRef.current = true;
|
|
||||||
setTimeout(() => updatePageHeight(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!contentLoadedRef.current && editorRef.current) {
|
|
||||||
editorRef.current.innerHTML = defaultReportContent;
|
|
||||||
contentRef.current = defaultReportContent;
|
|
||||||
contentLoadedRef.current = true;
|
|
||||||
setTimeout(() => updatePageHeight(), 0);
|
|
||||||
}
|
|
||||||
}, [reportId, navigate]);
|
|
||||||
|
|
||||||
const execCmd = (command: string, value: string | undefined = undefined) => {
|
|
||||||
editorRef.current?.focus();
|
|
||||||
document.execCommand(command, false, value);
|
|
||||||
editorRef.current?.focus();
|
|
||||||
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 => ({
|
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
name: file.name,
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
duration: 0
|
|
||||||
}));
|
|
||||||
setVideos([...videos, ...newVideos]);
|
|
||||||
if (currentVideoIndex === -1 && newVideos.length > 0) setCurrentVideoIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (!videoRef.current) return;
|
|
||||||
if (isPlaying) videoRef.current.pause();
|
|
||||||
else videoRef.current.play();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `\${mins.toString().padStart(2, '0')}:\${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const captureFrame = () => {
|
|
||||||
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
|
|
||||||
const video = videoRef.current;
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
|
|
||||||
// Create an unconstrained canvas to get native resolution or properly scaled frame
|
|
||||||
const scale = Math.min(1, 800 / video.videoWidth);
|
|
||||||
canvas.width = video.videoWidth * scale;
|
|
||||||
canvas.height = video.videoHeight * scale;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const newFrame: CapturedFrame = {
|
|
||||||
id: Date.now(),
|
|
||||||
videoIndex: currentVideoIndex,
|
|
||||||
videoName: videos[currentVideoIndex].name,
|
|
||||||
time: video.currentTime,
|
|
||||||
timeFormatted: formatTime(video.currentTime),
|
|
||||||
dataUrl: canvas.toDataURL('image/jpeg', 0.8),
|
|
||||||
isManual: true
|
|
||||||
};
|
|
||||||
|
|
||||||
setCapturedFrames([...capturedFrames, newFrame]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveReport = (status: 'draft' | 'completed') => {
|
|
||||||
const content = editorRef.current?.innerHTML || '';
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const finalReport: Report = {
|
|
||||||
...(reportData as Report),
|
|
||||||
id: reportId || 'RPT_' + Date.now(),
|
|
||||||
content,
|
|
||||||
author: currentUser?.username || '',
|
|
||||||
authorName: currentUser?.name || '',
|
|
||||||
createdAt: reportData.createdAt || now.split('T')[0],
|
|
||||||
status,
|
|
||||||
capturedFrames,
|
|
||||||
videos,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
|
|
||||||
const reports = storage.get<Report[]>('reports', []);
|
|
||||||
let updatedReports;
|
|
||||||
if (reportId) {
|
|
||||||
updatedReports = reports.map(r => r.id === reportId ? finalReport : r);
|
|
||||||
} else {
|
|
||||||
updatedReports = [...reports, finalReport];
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set('reports', updatedReports);
|
|
||||||
setIsSaved(true);
|
|
||||||
setTimeout(() => setIsSaved(false), 3000);
|
|
||||||
if (status === 'completed') navigate('/report-manage');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiLocalImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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() + Math.random(), dataUrl: ev.target!.result as string }]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
// reset input
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleListening = () => {
|
|
||||||
if (isListening) {
|
|
||||||
setIsListening(false);
|
|
||||||
if (speechRecognitionRef.current) {
|
|
||||||
speechRecognitionRef.current.stop();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
|
||||||
if (!SpeechRecognition) {
|
|
||||||
alert("您的浏览器不支持语音识别功能,请使用 Chrome 等支持该特性的浏览器。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const recognition = new SpeechRecognition();
|
|
||||||
recognition.lang = 'zh-CN';
|
|
||||||
recognition.continuous = false;
|
|
||||||
recognition.interimResults = false;
|
|
||||||
|
|
||||||
recognition.onstart = () => setIsListening(true);
|
|
||||||
recognition.onresult = (event: any) => {
|
|
||||||
const transcript = event.results[0][0].transcript;
|
|
||||||
setChatInput(prev => prev + (prev ? ' ' : '') + transcript);
|
|
||||||
};
|
|
||||||
recognition.onerror = (event: any) => {
|
|
||||||
console.error("Speech recognition error", event.error);
|
|
||||||
setIsListening(false);
|
|
||||||
};
|
|
||||||
recognition.onend = () => {
|
|
||||||
setIsListening(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
speechRecognitionRef.current = recognition;
|
|
||||||
recognition.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAIGenerate = async (text: string) => {
|
|
||||||
if (!text.trim()) return;
|
|
||||||
|
|
||||||
const userMsgId = Date.now().toString();
|
|
||||||
const newUserMsg = { id: userMsgId, role: 'user' as const, content: text };
|
|
||||||
setChatMessages(prev => [...prev, newUserMsg]);
|
|
||||||
setChatInput('');
|
|
||||||
setIsGenerating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { GoogleGenAI, Type } = await import('@google/genai');
|
|
||||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
|
||||||
const sysSettings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
|
||||||
const modelName = sysSettings.geminiModel || 'gemini-3-flash-preview';
|
|
||||||
|
|
||||||
const historyContents = chatMessages.map(msg => ({
|
|
||||||
role: msg.role === 'user' ? 'user' : 'model',
|
|
||||||
parts: [{ text: msg.content }]
|
|
||||||
}));
|
|
||||||
|
|
||||||
const currentParts: any[] = [];
|
|
||||||
const selectedFrameDataUrls = aiSelectedFrames
|
|
||||||
.map(id => capturedFrames.find(f => f.id === id)?.dataUrl)
|
|
||||||
.filter(Boolean) as string[];
|
|
||||||
|
|
||||||
const allImages = [...selectedFrameDataUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
|
||||||
|
|
||||||
allImages.forEach(url => {
|
|
||||||
const match = url.match(/^data:(image\/[a-z]+);base64,(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
currentParts.push({ inlineData: { mimeType: match[1], data: match[2] } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 清空本地上传的图片以备下次
|
|
||||||
setAiUploadedImages([]);
|
|
||||||
|
|
||||||
const targetRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"]`);
|
|
||||||
const currentHtml = targetRegion ? targetRegion.innerHTML : '';
|
|
||||||
|
|
||||||
if (aiModifyEnabled) {
|
|
||||||
currentParts.push({
|
|
||||||
text: `【当前待修改内容的 HTML 源码】:\n${currentHtml}\n\n【医生的期望/修改要求】: ${text}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
currentParts.push({
|
|
||||||
text: `【医生的指令/要求】: ${text}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
historyContents.push({ role: 'user', parts: currentParts });
|
|
||||||
|
|
||||||
const systemInstruction = aiModifyEnabled
|
|
||||||
? "你是一名专业的外科医生助理。根据用户提供的(图像)和(修改要求),修改(当前待修改内容的 HTML 源码)。\n" +
|
|
||||||
"你需要返回 JSON 数据,其中包含两部分:\n" +
|
|
||||||
"1. 'reply': 向医生报告您做了哪些修改(友好的文本对话回复,如‘好的,我已为您更新了相关描述。’)。\n" +
|
|
||||||
"2. 'updatedHtml': 修改后的完整 HTML 代码片段(保留原有的 HTML 格式,如 `<p>` 标签和行内样式)。\n" +
|
|
||||||
"你的输出必须严格符合 JSON 结构,不要包含 markdown 代码块的包裹。"
|
|
||||||
: "你是一名专业的外科医生助理。根据用户提供的(图像)和(指令),回答问题、提取信息或生成段落总结。\n" +
|
|
||||||
"你只需要返回 JSON 数据中的 'reply' 字段即可(友好的文本对话回复)。不要返回 updatedHtml。\n" +
|
|
||||||
"你的输出必须严格符合 JSON 结构,不要包含 markdown 代码块的包裹。";
|
|
||||||
|
|
||||||
const responseSchema = aiModifyEnabled
|
|
||||||
? {
|
|
||||||
type: Type.OBJECT,
|
|
||||||
properties: {
|
|
||||||
reply: { type: Type.STRING },
|
|
||||||
updatedHtml: { type: Type.STRING }
|
|
||||||
},
|
|
||||||
required: ["reply", "updatedHtml"]
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: Type.OBJECT,
|
|
||||||
properties: {
|
|
||||||
reply: { type: Type.STRING }
|
|
||||||
},
|
|
||||||
required: ["reply"]
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: modelName,
|
|
||||||
contents: historyContents as any,
|
|
||||||
config: {
|
|
||||||
systemInstruction,
|
|
||||||
responseMimeType: "application/json",
|
|
||||||
responseSchema,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseJson = JSON.parse(response.text || '{}');
|
|
||||||
if (responseJson.reply) {
|
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
|
|
||||||
}
|
|
||||||
if (responseJson.updatedHtml) {
|
|
||||||
injectAIText(responseJson.updatedHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `[系统错误]: ${error.message}` }]);
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const injectAIText = (htmlContent: string) => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
const targetRegion = editorRef.current.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"]`);
|
|
||||||
if (targetRegion) {
|
|
||||||
const regionTitle = availableAiRegions.find(r => r.id === aiTargetRegion)?.title || '';
|
|
||||||
const badgeLabel = regionTitle ? `${regionTitle}-AI可编辑区域` : 'AI可编辑区域';
|
|
||||||
targetRegion.innerHTML = `
|
|
||||||
<div contenteditable="false" style="position: absolute; top: -10px; right: 12px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; cursor: default; white-space: nowrap; z-index: 10; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${badgeLabel}</div>
|
|
||||||
${htmlContent}
|
|
||||||
`;
|
|
||||||
contentRef.current = editorRef.current.innerHTML;
|
|
||||||
saveDraftToStorage();
|
|
||||||
const targetElement = targetRegion as HTMLElement;
|
|
||||||
targetElement.style.transition = 'background-color 0.5s';
|
|
||||||
targetElement.style.backgroundColor = '#dbeafe';
|
|
||||||
setTimeout(() => {
|
|
||||||
targetElement.style.backgroundColor = '#eff6ff';
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
execCmd('insertHTML', htmlContent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkAiRegions = () => {
|
|
||||||
if (!editorRef.current) return [];
|
|
||||||
const regions = Array.from(editorRef.current.querySelectorAll('.ai-region'));
|
|
||||||
return regions.map((el: any) => {
|
|
||||||
const id = el.getAttribute('data-ai-id') || '';
|
|
||||||
const title = el.getAttribute('data-ai-title') || id;
|
|
||||||
return { id, title };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableAiRegions = checkAiRegions();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex bg-slate-50 h-screen overflow-hidden">
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 shrink-0 z-10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/report-manage')}
|
|
||||||
className="p-2 rounded-lg hover:bg-slate-100 text-slate-500 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={20} />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-lg font-bold text-slate-800">
|
|
||||||
{reportId ? `编辑报告: \${reportId}` : '新建手术报告'}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isSaved && (
|
|
||||||
<span className="text-xs text-green-600 font-bold flex items-center gap-1">
|
|
||||||
<Check size={14} /> 已保存
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => saveReport('draft')}
|
|
||||||
className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm font-medium hover:bg-slate-200"
|
|
||||||
>
|
|
||||||
保存草稿
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => saveReport('completed')}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white flex items-center gap-2 rounded text-sm font-medium hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
<Check size={16} />
|
|
||||||
完成报告
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editorRef.current && printDocument(editorRef.current.innerHTML)}
|
|
||||||
className="p-2 rounded bg-slate-100 text-slate-600 hover:bg-slate-200"
|
|
||||||
>
|
|
||||||
<Printer size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
{/* Main Editor Section */}
|
|
||||||
<div className="flex-1 flex flex-col bg-slate-200 border-r border-slate-200 overflow-hidden relative">
|
|
||||||
<div className="flex items-center gap-1 p-2 bg-white border-b border-slate-200 shrink-0">
|
|
||||||
{/* 简化版工具栏 */}
|
|
||||||
<button onClick={() => execCmd('undo')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Undo size={16}/></button>
|
|
||||||
<button onClick={() => execCmd('redo')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Redo size={16}/></button>
|
|
||||||
<div className="w-px h-6 bg-slate-300 mx-1"></div>
|
|
||||||
<button onClick={() => execCmd('bold')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Bold size={16}/></button>
|
|
||||||
<button onClick={() => execCmd('italic')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Italic size={16}/></button>
|
|
||||||
<button onClick={() => execCmd('underline')} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><Underline size={16}/></button>
|
|
||||||
<div className="w-px h-6 bg-slate-300 mx-1"></div>
|
|
||||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyLeft'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignLeft size={16}/></button>
|
|
||||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyCenter'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignCenter size={16}/></button>
|
|
||||||
<button onClick={(e) => { e.preventDefault(); execCmd('justifyRight'); }} className="p-1.5 hover:bg-slate-100 rounded text-slate-600"><AlignRight size={16}/></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto w-full flex justify-center py-8">
|
|
||||||
<div
|
|
||||||
ref={editorRef}
|
|
||||||
contentEditable
|
|
||||||
className="bg-white shadow-md p-10 w-[800px] min-h-[1100px] outline-none"
|
|
||||||
style={{ fontFamily: 'SimSun', lineHeight: '1.5', transition: 'width 0.2s', paddingBottom: '100px' }}
|
|
||||||
onInput={(e) => { contentRef.current = editorRef.current?.innerHTML || ''; }}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
|
||||||
<aside className="w-[360px] bg-white flex flex-col shrink-0">
|
|
||||||
<div className="flex border-b border-slate-200 shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('info')}
|
|
||||||
className={`flex-1 py-3 text-sm font-medium border-b-2 \${activeTab === 'info' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
|
||||||
>信息录入</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('video')}
|
|
||||||
className={`flex-1 py-3 text-sm font-medium border-b-2 \${activeTab === 'video' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
|
||||||
>视频分析</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('ai')}
|
|
||||||
className={`flex-1 py-3 text-sm font-medium border-b-2 flex items-center justify-center gap-1 \${activeTab === 'ai' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'}`}
|
|
||||||
>
|
|
||||||
<Bot size={16} /> AI撰写
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
|
||||||
{activeTab === 'info' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100 text-sm text-blue-800">
|
|
||||||
您可以在这里编辑右侧表单。这些值会自动同步到中间的报告占位符中。
|
|
||||||
</div>
|
|
||||||
{/* Basic bindings for the most common fields */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-slate-600">患者姓名</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).patientName || ''} onChange={(e) => {
|
|
||||||
const val = e.target.value; setReportData({...reportData, patientName: val});
|
|
||||||
// Sync to span
|
|
||||||
const s = editorRef.current?.querySelector('[data-bind="patientName"]') as HTMLElement;
|
|
||||||
if(s) s.innerText = val;
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-slate-600">住院号</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).hospitalId || ''} onChange={(e) => {
|
|
||||||
const val = e.target.value; setReportData({...reportData, hospitalId: val});
|
|
||||||
const s = editorRef.current?.querySelector('[data-bind="hospitalId"]') as HTMLElement;
|
|
||||||
if(s) s.innerText = val;
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-slate-600">手术名称</label>
|
|
||||||
<input type="text" className="w-full px-3 py-2 border rounded" value={(reportData as any).title || ''} onChange={(e) => {
|
|
||||||
const val = e.target.value; setReportData({...reportData, title: val});
|
|
||||||
const s = editorRef.current?.querySelector('[data-bind="title"]') as HTMLElement;
|
|
||||||
if(s) s.innerText = val;
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'video' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<input
|
|
||||||
ref={videoInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleVideoUpload}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => videoInputRef.current?.click()}
|
|
||||||
className="w-full py-3 flex items-center justify-center gap-2 border-2 border-dashed border-slate-300 rounded-lg hover:border-blue-500 hover:text-blue-600 text-slate-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Video size={18} />
|
|
||||||
<span className="text-sm font-medium">上传手术视频</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{videos.length > 0 && currentVideoIndex !== -1 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden aspect-video">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={videos[currentVideoIndex].url}
|
|
||||||
className="w-full h-full"
|
|
||||||
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
|
|
||||||
onLoadedMetadata={() => setDuration(videoRef.current?.duration || 0)}
|
|
||||||
/>
|
|
||||||
{!isPlaying && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 cursor-pointer" onClick={togglePlay}>
|
|
||||||
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center text-white">
|
|
||||||
<Play size={24} fill="currentColor" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] px-2 py-1 rounded">
|
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={togglePlay} className="px-3 py-2 bg-slate-100 rounded text-slate-700 hover:bg-slate-200">
|
|
||||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
|
||||||
</button>
|
|
||||||
<button onClick={captureFrame} className="flex-1 bg-blue-600 text-white py-2 rounded text-sm font-medium hover:bg-blue-700">
|
|
||||||
截取当前画面并保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 mt-4">
|
|
||||||
{capturedFrames.map((frame) => (
|
|
||||||
<div key={frame.id} className="relative group border border-slate-200 rounded p-1 bg-white">
|
|
||||||
<img src={frame.dataUrl} className="w-full aspect-video object-cover rounded" />
|
|
||||||
<div className="text-[10px] text-slate-500 mt-1 flex justify-between">
|
|
||||||
<span>{frame.timeFormatted}</span>
|
|
||||||
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => {
|
|
||||||
// Simple insert helper
|
|
||||||
execCmd('insertHTML', `<img src="\${frame.dataUrl}" style="max-width:200px" />`);
|
|
||||||
}}>插入插入文档</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCapturedFrames(prev => prev.filter(f => f.id !== frame.id))}
|
|
||||||
className="absolute top-0 right-0 bg-red-500 text-white p-1 rounded-bl group-hover:opacity-100 opacity-0 transition-opacity"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'ai' && (
|
|
||||||
<div className="flex flex-col h-full bg-slate-50 overflow-hidden">
|
|
||||||
{/* Chat History */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{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" />
|
|
||||||
<p className="text-xs">我是您的 AI 智能撰写助手,请在下方选择参考框架和图片后随时与我对话。</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' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{isGenerating && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-2.5 shadow-sm flex gap-1 items-center">
|
|
||||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce" />
|
|
||||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce delay-75" />
|
|
||||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-bounce delay-150" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings and Input Box */}
|
|
||||||
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<label className="text-[11px] font-bold text-slate-500 shrink-0">指定修改区域</label>
|
|
||||||
<select
|
|
||||||
value={aiTargetRegion}
|
|
||||||
onChange={(e) => setAiTargetRegion(e.target.value)}
|
|
||||||
disabled={!aiModifyEnabled}
|
|
||||||
className="flex-1 w-0 px-2 py-1.5 border border-slate-300 rounded text-xs bg-slate-50 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{availableAiRegions.length > 0 ? (
|
|
||||||
availableAiRegions.map(r => (
|
|
||||||
<option key={r.id} value={r.id}>{r.title}</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value="none">无区域 (将插入光标处)</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="aiModifyEnabled"
|
|
||||||
checked={aiModifyEnabled}
|
|
||||||
onChange={(e) => setAiModifyEnabled(e.target.checked)}
|
|
||||||
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label htmlFor="aiModifyEnabled" className="text-[11px] text-slate-600 cursor-pointer select-none font-bold">
|
|
||||||
修改内容
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{capturedFrames.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-[11px] font-bold text-slate-500 shrink-0">参考关键帧</label>
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
|
||||||
{capturedFrames.map(frame => {
|
|
||||||
const isSelected = aiSelectedFrames.includes(frame.id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={frame.id}
|
|
||||||
onClick={() => {
|
|
||||||
setAiSelectedFrames(prev =>
|
|
||||||
isSelected ? prev.filter(id => id !== frame.id) : [...prev, frame.id]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={`relative shrink-0 w-16 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600 scale-105' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
|
||||||
>
|
|
||||||
<img src={frame.dataUrl} className="w-full h-full object-cover" />
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full w-3 h-3 flex items-center justify-center">
|
|
||||||
<Check size={8} className="text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Prompts */}
|
|
||||||
<div className="flex flex-wrap gap-1.5 pb-1">
|
|
||||||
{['请详细描述手术步骤', '提取术中关键病灶信息', '生成简短的术后总结'].map(p => (
|
|
||||||
<button key={p} onClick={() => setChatInput(p)} className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-[10px] rounded-full transition-colors whitespace-nowrap">
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{/* 上传图片的预览区 */}
|
|
||||||
{aiUploadedImages.length > 0 && (
|
|
||||||
<div className="absolute top-2 left-2 flex gap-2 z-10 bg-white/80 p-1 rounded">
|
|
||||||
{aiUploadedImages.map(img => (
|
|
||||||
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden border border-slate-200 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 hover:bg-red-500 text-white p-0.5 rounded-bl-md transition-colors"
|
|
||||||
>
|
|
||||||
<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 (chatInput.trim() && !isGenerating) {
|
|
||||||
handleAIGenerate(chatInput);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ paddingTop: aiUploadedImages.length > 0 ? '56px' : '8px' }}
|
|
||||||
placeholder={isListening ? "正在聆听中..." : "输入修改意见... (按 Enter 发送)"}
|
|
||||||
className="w-full h-24 px-3 pr-[50px] border border-slate-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none transition-all custom-scrollbar"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-2 right-2 flex flex-col gap-1 items-end">
|
|
||||||
{/* 额外动作栏: 语音、上传图片 */}
|
|
||||||
<div className="flex bg-white rounded-full border border-slate-200 shadow-sm overflow-hidden mb-1">
|
|
||||||
<button
|
|
||||||
onClick={toggleListening}
|
|
||||||
className={`p-1.5 transition-colors ${isListening ? 'text-red-500 bg-red-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'}`}
|
|
||||||
title={isListening ? "停止语音输入" : "语音输入"}
|
|
||||||
>
|
|
||||||
{isListening ? <Mic size={14} className="animate-pulse" /> : <MicOff size={14} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-50 cursor-pointer transition-colors" title="上传本地图片">
|
|
||||||
<input type="file" accept="image/*" multiple className="hidden" onChange={handleAiLocalImageUpload} />
|
|
||||||
<ImagePlus size={14} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleAIGenerate(chatInput)}
|
|
||||||
disabled={isGenerating || !chatInput.trim()}
|
|
||||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{isGenerating ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置 1:Checkbox 包裹结构(约 line 2258-2268)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox" id="aiModifyEnabled"
|
|
||||||
checked={aiModifyEnabled}
|
|
||||||
onChange={(e) => setAiModifyEnabled(e.target.checked)}
|
|
||||||
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label htmlFor="aiModifyEnabled" className="text-[11px] text-slate-600 cursor-pointer font-bold">
|
|
||||||
允许修改正文
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 外层 `div` 改为 `label`,直接包裹 `input`
|
|
||||||
2. 移除 `id`/`htmlFor`,避免绑定冲突
|
|
||||||
3. `onChange` 增加 `e.stopPropagation()` 防止事件冒泡被拦截
|
|
||||||
4. `label` 文本改为 `span`
|
|
||||||
|
|
||||||
## 修改位置 2:handleAIGenerate Prompt 构建(约 line 877-897)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
|
|
||||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML : '';
|
|
||||||
// ...
|
|
||||||
let promptText = `【医生指令】: ${text}`;
|
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
|
||||||
promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
|
|
||||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').trim() : '';
|
|
||||||
const globalContextText = editorRef.current?.innerText || '';
|
|
||||||
// ...
|
|
||||||
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
|
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
|
||||||
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
|
|
||||||
}
|
|
||||||
promptText += `【医生指令】: ${text}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. `currentHtml` 增加 `.replace(/​/g, '').trim()` 过滤零宽字符
|
|
||||||
2. 新增 `globalContextText` 读取整个编辑器的纯文本
|
|
||||||
3. `promptText` 重构:先放全局上下文,再放目标区域(如果存在),最后放医生指令
|
|
||||||
4. 目标区域为空时显示 `(当前区域为空)` 提示
|
|
||||||
|
|
||||||
## 修改位置 3:System Prompt 优化(约 line 895-897)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled && targetRegionEl
|
|
||||||
? '你是一名专业的外科医生助理。你需要根据用户的指令及可能提供的截图,修改给定的 HTML 源码。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
|
||||||
: '你是一名专业的外科医生助理。请根据用户的指令和截图进行分析解答。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled && targetRegionEl
|
|
||||||
? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
|
||||||
: '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 修改模式 systemPrompt 明确告知大模型有两个信息源:全局参考内容 + 目标区域源码
|
|
||||||
2. 非修改模式 systemPrompt 明确提到「全局手术报告参考内容」
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:Checkbox 可正常切换
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 打开右侧「AI 撰写」面板
|
|
||||||
2. 观察底部「允许修改正文」复选框,当前应为勾选状态(默认 `aiModifyEnabled = true`)
|
|
||||||
3. 点击复选框,观察勾选状态是否消失
|
|
||||||
4. 再次点击,观察勾选状态是否恢复
|
|
||||||
5. 点击复选框左侧的文字「允许修改正文」,观察勾选状态是否切换
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 点击复选框本身:状态正常切换
|
|
||||||
- 点击文字标签:状态正常切换
|
|
||||||
- 切换时上方「区域锚定」select 的 `disabled` 状态同步变化(禁用/启用)
|
|
||||||
|
|
||||||
## 测试用例 2:AI 能看到全局报告内容
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 在编辑器中输入一些文本,例如「气腹压力为 12mmHg」
|
|
||||||
2. 插入一个 AI 可编辑区域(如「手术步骤」)
|
|
||||||
3. 在 AI 面板中输入:「你能看到当前气腹压力吗?」
|
|
||||||
4. 不勾选「允许修改正文」,直接发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 回复中应提到「12mmHg」或「气腹压力」,表明它读取了全局上下文
|
|
||||||
|
|
||||||
## 测试用例 3:AI 能基于全局上下文修改目标区域
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中有完整报告内容(含患者信息、手术步骤等)
|
|
||||||
2. 在 AI 可编辑区域(如「手术步骤」)中已有部分内容
|
|
||||||
3. 勾选「允许修改正文」
|
|
||||||
4. 输入指令:「根据全局报告内容,将手术步骤中提到的止血方法更新为电凝止血」
|
|
||||||
5. 发送并查看 diff 确认弹窗
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 返回的 `updatedHtml` 应能引用全局报告中的其他信息
|
|
||||||
- Diff 弹窗能正确展示原文 vs 修改后的内容
|
|
||||||
- 确认注入后目标区域内容更新
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 03:33
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户反馈 AI 辅助撰写功能存在两个 Bug:
|
|
||||||
1. 「允许修改正文」复选框无法被点击切换
|
|
||||||
2. AI 大模型无法看到编辑器中的报告内容,无法执行修改正文的指令
|
|
||||||
|
|
||||||
## 问题一:Checkbox 无法切换
|
|
||||||
|
|
||||||
**现象**:AI 面板底部的「允许修改正文」复选框点击无反应,无法关闭或开启。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- 当前实现使用独立的 `<input id="aiModifyEnabled">` + `<label htmlFor="aiModifyEnabled">` 组合
|
|
||||||
- 在复杂的 React 组件树中,`id`/`htmlFor` 绑定可能因事件冒泡、DOM 结构覆盖或 React 重渲染导致失效
|
|
||||||
- 外层 `.ai-region` 等元素可能对点击事件有拦截
|
|
||||||
|
|
||||||
**约束条件**:
|
|
||||||
- 最小化改动,只改包裹结构,不改样式语义
|
|
||||||
- 必须保留 `cursor-pointer` 和原有视觉样式
|
|
||||||
|
|
||||||
## 问题二:AI 无法读取编辑器内容
|
|
||||||
|
|
||||||
**现象**:用户在 AI 区域外写入了「气腹压力为 12mmHg」等信息,但问 AI「你能看到当前气腹压力吗?」时,AI 回答无法看到。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- `handleAIGenerate` 目前只将「目标 AI 区域的 HTML 源码」发送给大模型
|
|
||||||
- 目标区域可能为空(默认只有 `​`),导致大模型收到的上下文只有用户指令
|
|
||||||
- AI 看不到编辑器中其他区域(如基本信息、其他手术步骤)的内容
|
|
||||||
|
|
||||||
**约束条件**:
|
|
||||||
- 必须保留现有 `currentHtml` 作为修改目标(用于 diff 注入)
|
|
||||||
- 全局上下文使用纯文本而非 HTML,减少 token 消耗和格式干扰
|
|
||||||
- 需要过滤 `​` 零宽字符
|
|
||||||
- systemPrompt 需同步更新,明确告知大模型有两个信息源:全局参考内容 + 目标区域源码
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置:`handleAIGenerate` 函数(约 line 897-937)
|
|
||||||
|
|
||||||
### 修改 1:System Prompt 条件解绑
|
|
||||||
|
|
||||||
**原代码**(line 897-899):
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled && targetRegionEl
|
|
||||||
? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
|
||||||
: '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写或生成目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
|
||||||
: '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:条件从 `aiModifyEnabled && targetRegionEl` 改为 `aiModifyEnabled`,解绑对目标区域存在的强依赖。
|
|
||||||
|
|
||||||
### 修改 2:接收逻辑增加降级插入
|
|
||||||
|
|
||||||
**原代码**(line 930-937):
|
|
||||||
```tsx
|
|
||||||
if (responseJson.updatedHtml && aiModifyEnabled && targetRegionEl) {
|
|
||||||
setDiffModal({
|
|
||||||
isOpen: true,
|
|
||||||
originalHtml: currentHtml,
|
|
||||||
newHtml: responseJson.updatedHtml,
|
|
||||||
targetId: aiTargetRegion
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
if (responseJson.updatedHtml && aiModifyEnabled) {
|
|
||||||
if (targetRegionEl) {
|
|
||||||
setDiffModal({
|
|
||||||
isOpen: true,
|
|
||||||
originalHtml: currentHtml,
|
|
||||||
newHtml: responseJson.updatedHtml,
|
|
||||||
targetId: aiTargetRegion
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
execCmd('insertHTML', responseJson.updatedHtml);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 接收条件从 `responseJson.updatedHtml && aiModifyEnabled && targetRegionEl` 放宽为 `responseJson.updatedHtml && aiModifyEnabled`
|
|
||||||
2. 增加 `if/else` 分支:
|
|
||||||
- `targetRegionEl` 存在 → 走 diff 弹窗(原有流程)
|
|
||||||
- `targetRegionEl` 不存在 → 调用 `execCmd('insertHTML', ...)` 直接插入光标位置(参考代码降级逻辑)
|
|
||||||
3. `execCmd` 为当前代码已存在的辅助函数,会自动处理 focus、execCommand、contentRef 更新和草稿保存
|
|
||||||
|
|
||||||
## 不修改的内容
|
|
||||||
- `aiTargetRegion` 默认值 `'none'` 保持不变
|
|
||||||
- `promptText` 构建逻辑保持不变(全局上下文 + 目标区域源码 + 医生指令)
|
|
||||||
- Diff 弹窗和 `confirmAiInjection` 保持不变
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:开启修改模式 + 选中目标区域 → Diff 弹窗
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入一个 AI 可编辑区域(如「手术步骤」)
|
|
||||||
2. AI 面板底部勾选「允许修改正文」
|
|
||||||
3. 下拉框选中「手术步骤」区域
|
|
||||||
4. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- 弹出 diff 确认弹窗,展示原文 vs AI 修改后的 HTML
|
|
||||||
- 点击确认后,目标区域内容更新
|
|
||||||
|
|
||||||
## 测试用例 2:开启修改模式 + 未选中目标区域 → 光标插入
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中不插入任何 AI 可编辑区域(或保持下拉框为「无可用 AI 区域」/不选)
|
|
||||||
2. AI 面板底部勾选「允许修改正文」
|
|
||||||
3. 在编辑器正文处点击放置光标
|
|
||||||
4. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- **不弹出 diff 弹窗**
|
|
||||||
- AI 生成的内容直接插入到编辑器当前光标位置
|
|
||||||
- 编辑器内容自动保存到草稿
|
|
||||||
|
|
||||||
## 测试用例 3:关闭修改模式 → 纯聊天
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. AI 面板底部取消勾选「允许修改正文」
|
|
||||||
2. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- 编辑器内容不发生任何变化
|
|
||||||
- 不弹出 diff 弹窗
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 03:44
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户在 report-editor 中输入「请随机填充文本内容」,AI 在聊天气泡中正常回复,但编辑器中的「手术步骤-AI可编辑部分」没有修改。
|
|
||||||
|
|
||||||
## 现象
|
|
||||||
1. AI 聊天面板有输出(reply)
|
|
||||||
2. 编辑器中的 AI 可编辑区域内容未更新
|
|
||||||
3. 没有触发 diff 确认弹窗
|
|
||||||
|
|
||||||
## 根因分析
|
|
||||||
|
|
||||||
对比当前代码与 `参考-ReportEditor.tsx`,问题有两个层面:
|
|
||||||
|
|
||||||
### 层面 1:System Prompt 条件过于严苛
|
|
||||||
当前代码:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled && targetRegionEl ? '...修改HTML...' : '...纯聊天...'
|
|
||||||
```
|
|
||||||
`targetRegionEl` 的获取依赖 `aiTargetRegion`(默认 `'none'`)。如果用户未在下拉框中选中具体区域,`targetRegionEl` 为 `null`,systemPrompt 降级为纯聊天模式,大模型根本不会返回 `updatedHtml` 字段。
|
|
||||||
|
|
||||||
参考代码:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled ? '...修改HTML...' : '...纯聊天...'
|
|
||||||
```
|
|
||||||
只依赖 `aiModifyEnabled`,只要用户开启「允许修改正文」,大模型就会返回 `updatedHtml`。
|
|
||||||
|
|
||||||
### 层面 2:接收逻辑缺少降级插入
|
|
||||||
当前代码:
|
|
||||||
```tsx
|
|
||||||
if (responseJson.updatedHtml && aiModifyEnabled && targetRegionEl) {
|
|
||||||
setDiffModal({...})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
同样因为 `targetRegionEl` 为 `null`,即使大模型返回了 `updatedHtml`,也会被丢弃。
|
|
||||||
|
|
||||||
参考代码 `injectAIText`:
|
|
||||||
```tsx
|
|
||||||
if (targetRegion) {
|
|
||||||
targetRegion.innerHTML = ... // 注入目标区域
|
|
||||||
} else {
|
|
||||||
execCmd('insertHTML', htmlContent); // 降级:插入光标位置
|
|
||||||
}
|
|
||||||
```
|
|
||||||
找不到目标区域时,直接将内容插入编辑器当前光标位置。
|
|
||||||
|
|
||||||
## 约束条件
|
|
||||||
- 当前代码已有 `execCmd` 辅助函数,可直接复用
|
|
||||||
- 需要保留 diff 弹窗流程(目标区域存在时)
|
|
||||||
- `aiTargetRegion` 默认值 `'none'` 可保留,但需通过降级逻辑补偿
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置:`handleAIGenerate` 函数(约 line 860-950)
|
|
||||||
|
|
||||||
### 修改 1:自动修正目标区域
|
|
||||||
|
|
||||||
在获取 `targetRegionEl` 之前,增加自动修正逻辑:如果修改模式开启且未选定区域,自动选择文档中的第一个 AI 区域。
|
|
||||||
|
|
||||||
**原代码**(line 877):
|
|
||||||
```tsx
|
|
||||||
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${aiTargetRegion}"] .ai-content`) as HTMLElement | null;
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
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;
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 引入 `actualTargetId` 变量,初始值为 `aiTargetRegion`
|
|
||||||
2. 若 `aiModifyEnabled` 为 true 且 `actualTargetId === 'none'`,调用 `checkAiRegions()` 获取可用区域列表
|
|
||||||
3. 若有可用区域,将 `actualTargetId` 设为第一个区域的 id,并同步更新 React state(`setAiTargetRegion`)
|
|
||||||
4. 后续所有依赖目标区域 ID 的地方(querySelector、diffModal targetId)均使用 `actualTargetId`
|
|
||||||
|
|
||||||
### 修改 2:优化 systemPrompt 文案
|
|
||||||
|
|
||||||
**原代码**(line 897-899):
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写或生成目标区域的 HTML。\n重要指令:你必须严格返回合法的 JSON 对象,绝对不要包含任何 Markdown 标记(如 ```json)。\nJSON 格式如下:\n{ "reply": "简短的回复话术", "updatedHtml": "修改后的完整内部 HTML 代码" }'
|
|
||||||
: '你是一名专业的外科医生助理。请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:严格返回合法的 JSON 对象。\nJSON 格式如下:\n{ "reply": "你的分析和回答" }';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复话术)和 "updatedHtml"(修改后的完整内部 HTML 代码)两个字段\n3. 绝对不要包含任何 Markdown 标记(如 ```json)'
|
|
||||||
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 修改模式 systemPrompt 明确标注「当前处于【修改模式】」
|
|
||||||
2. 明确要求必须包含 `reply` 和 `updatedHtml` 两个字段
|
|
||||||
3. 对话模式 systemPrompt 明确标注「当前处于【对话模式】」
|
|
||||||
4. 明确要求仅包含 `reply` 字段,不要返回 HTML
|
|
||||||
|
|
||||||
### 修改 3:diffModal targetId 使用 actualTargetId
|
|
||||||
|
|
||||||
**原代码**(line 936):
|
|
||||||
```tsx
|
|
||||||
targetId: aiTargetRegion
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
targetId: actualTargetId
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:确保 diff 弹窗确认注入时,使用实际修正后的区域 ID,而非可能仍为 `'none'` 的 `aiTargetRegion`。
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:修改模式 + 已选中区域 → Diff 弹窗
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入一个 AI 可编辑区域(如「手术步骤」)
|
|
||||||
2. AI 面板底部勾选「允许修改正文」
|
|
||||||
3. 下拉框手动选中「手术步骤」区域
|
|
||||||
4. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- 弹出 diff 确认弹窗,左侧展示原稿,右侧展示 AI 生成内容(可直接编辑)
|
|
||||||
- 点击「确认并写入报告」后,目标区域内容更新
|
|
||||||
- 点击「放弃修改」后,编辑器内容不变
|
|
||||||
|
|
||||||
## 测试用例 2:修改模式 + 未选中区域 → 自动选中第一个 + Diff 弹窗
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入一个 AI 可编辑区域(如「手术步骤」)
|
|
||||||
2. AI 面板底部勾选「允许修改正文」
|
|
||||||
3. **不手动选择下拉框中的区域**(保持默认或未选状态)
|
|
||||||
4. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 发送后下拉框自动跳转为「手术步骤」(第一个可用区域)
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- 弹出 diff 确认弹窗
|
|
||||||
- 确认后目标区域内容更新
|
|
||||||
|
|
||||||
## 测试用例 3:对话模式 → 仅聊天不修改
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. AI 面板底部取消勾选「允许修改正文」
|
|
||||||
2. 输入「请随机填充文本内容」并发送
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 聊天面板有回复
|
|
||||||
- 编辑器内容不发生任何变化
|
|
||||||
- 不弹出 diff 弹窗
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 03:59
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户希望 AI 辅助撰写功能实现两个明确的场景:
|
|
||||||
- **场景 A(修改模式)**:勾选「允许修改正文」→ AI 自动修改当前选定的 AI 可编辑区域 → 弹出 diff 对比弹窗(左侧原稿、右侧可二次编辑的 AI 稿)→ 用户可确认或放弃
|
|
||||||
- **场景 B(纯对话模式)**:取消勾选「允许修改正文」→ AI 只回复聊天内容,不修改编辑器
|
|
||||||
|
|
||||||
## 当前状态评估
|
|
||||||
|
|
||||||
从上一次修改后,当前代码已基本实现两个场景的核心逻辑:
|
|
||||||
1. `systemPrompt` 条件为 `aiModifyEnabled`(已解绑 targetRegionEl)
|
|
||||||
2. 接收逻辑为 `if (responseJson.updatedHtml && aiModifyEnabled)`,然后分支判断 targetRegionEl
|
|
||||||
3. `diffModal` 已实现左右分栏:左侧原稿只读,右侧 AI 稿可编辑(contentEditable)
|
|
||||||
|
|
||||||
## 剩余问题
|
|
||||||
|
|
||||||
### 问题 1:修改模式开启但未选区域时,AI 找不到修改目标
|
|
||||||
`aiTargetRegion` 默认值为 `'none'`。用户勾选「允许修改正文」后,如果未在下拉框中手动选择具体区域,`targetRegionEl` 为 `null`,prompt 中不会包含目标区域源码,导致 AI 虽然返回了 `updatedHtml`,但可能不是用户期望的修改位置。
|
|
||||||
|
|
||||||
### 问题 2:systemPrompt 文案可进一步优化
|
|
||||||
当前 systemPrompt 对修改模式和纯对话模式的区分可以更加明确,让大模型更清楚知道是否应该生成 HTML。
|
|
||||||
|
|
||||||
## 约束条件
|
|
||||||
- 自动修正目标区域时,若下拉框原本为 `'none'`,应同步更新 UI 状态(`setAiTargetRegion`)
|
|
||||||
- `diffModal` 的 `targetId` 应使用实际修正后的区域 ID
|
|
||||||
- 保持现有 diff 弹窗交互不变(左原稿右可编辑稿)
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置 1:System Prompt 强化(约 line 905-907)
|
|
||||||
|
|
||||||
在 systemPrompt 中增加对「生成完整性」和「HTML 紧凑性」的明确要求。
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复话术)和 "updatedHtml"(修改后的完整内部 HTML 代码)两个字段\n3. 绝对不要包含任何 Markdown 标记(如 ```json)'
|
|
||||||
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\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 标记';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 修改模式 systemPrompt 增加第 3 点:明确要求生成完整、结构化的多段落内容,基于全局信息补充完善
|
|
||||||
2. 修改模式 systemPrompt 增加第 4 点:段落必须用 `<p>` 包裹,禁止 `<br>` 和 `\n`
|
|
||||||
3. 修改模式 systemPrompt 增加第 5 点:HTML 必须紧凑,标签间不要有空格或换行
|
|
||||||
|
|
||||||
## 修改位置 2:Prompt 文本强化(约 line 891-895)
|
|
||||||
|
|
||||||
在 `promptText` 中增加对「生成完整性」的强调。
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
|
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
|
||||||
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
|
|
||||||
}
|
|
||||||
promptText += `【医生指令】: ${text}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
|
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
|
||||||
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
|
|
||||||
}
|
|
||||||
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 生成完整、结构化的多段落 HTML 内容,不要只改写现有段落\n2. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n3. 输出紧凑 HTML,标签间不要有空格或换行`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 在医生指令后追加「格式要求」段落
|
|
||||||
2. 明确要求生成完整多段落内容,不要只改写现有段落
|
|
||||||
3. 强调 `<p>` 标签、禁止 `<br>`、禁止换行符、紧凑 HTML
|
|
||||||
|
|
||||||
## 修改位置 3:HTML 清洗后处理(约 line 938-945)
|
|
||||||
|
|
||||||
在 `setDiffModal` 之前对 `updatedHtml` 进行正则清洗。
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
if (responseJson.updatedHtml && aiModifyEnabled) {
|
|
||||||
if (targetRegionEl) {
|
|
||||||
setDiffModal({
|
|
||||||
isOpen: true,
|
|
||||||
originalHtml: currentHtml,
|
|
||||||
newHtml: responseJson.updatedHtml,
|
|
||||||
targetId: actualTargetId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
execCmd('insertHTML', responseJson.updatedHtml);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
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();
|
|
||||||
if (targetRegionEl) {
|
|
||||||
setDiffModal({
|
|
||||||
isOpen: true,
|
|
||||||
originalHtml: currentHtml,
|
|
||||||
newHtml: cleanHtml,
|
|
||||||
targetId: actualTargetId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
execCmd('insertHTML', cleanHtml);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 新增 `cleanHtml` 变量,初始值为 `responseJson.updatedHtml`
|
|
||||||
2. 移除 `<br>` 标签(不区分大小写)
|
|
||||||
3. 移除 `</p>` 和 `<p>` 之间的空白字符(空格、换行、回车)
|
|
||||||
4. 移除首尾空白
|
|
||||||
5. `setDiffModal` 和 `execCmd` 均使用 `cleanHtml`
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:AI 生成完整多段落内容
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入一个 AI 可编辑区域
|
|
||||||
2. 在区域中只写一句话(如「建立气腹」)
|
|
||||||
3. 在编辑器其他位置写入完整的手术报告信息(患者信息、其他步骤等)
|
|
||||||
4. 勾选「允许修改正文」→ 发送「请完善手术步骤描述」
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- diff 弹窗左侧显示原有的一句话
|
|
||||||
- diff 弹窗右侧显示 AI 生成的完整多段落内容(包含多个 `<p>` 标签)
|
|
||||||
- 内容应基于全局报告信息补充完善,不只是改写原有的一句话
|
|
||||||
|
|
||||||
## 测试用例 2:右侧无多余空行
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入 AI 可编辑区域,写入多段内容(如 3 个 `<p>` 段落)
|
|
||||||
2. 勾选「允许修改正文」→ 发送「请润色这段内容」
|
|
||||||
3. 观察 diff 弹窗左右两侧的段落间距
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 左右两侧段落间距一致
|
|
||||||
- 右侧 AI 版本不应出现额外的空行或 `<br>`
|
|
||||||
- 段落间仅由 `<p>` 标签的自然 margin 分隔
|
|
||||||
|
|
||||||
## 测试用例 3:HTML 清洗兜底
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 触发 AI 修改,在浏览器 DevTools 中查看 `responseJson.updatedHtml` 原始值
|
|
||||||
2. 确认原始值中可能包含 `<br>` 或 `\n`
|
|
||||||
3. 观察 diff 弹窗右侧最终渲染结果
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 即使原始返回值包含 `<br>` 或 `\n`,diff 弹窗右侧也不应显示多余空行
|
|
||||||
- 清洗后的 HTML 结构紧凑
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 04:13
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户在使用 AI 修改确认弹窗时遇到两个问题:
|
|
||||||
1. diff 弹窗左侧原始版本只显示一段内容,希望 AI 能一次性把全部内容都修改好
|
|
||||||
2. diff 弹窗右侧 AI 提议版本的段落间有多余空行,希望结构和左侧保持一致
|
|
||||||
|
|
||||||
## 问题 1:左侧只显示一段
|
|
||||||
|
|
||||||
**现象**:diff 弹窗左侧「原始版本」只展示了一段文本。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- `currentHtml` 取自 `.ai-content` 的 `innerHTML`,如果 `.ai-content` 内确实只有一段内容,左侧自然只显示一段
|
|
||||||
- 这不是代码 Bug,而是当前 AI 区域的内容组织问题
|
|
||||||
- 但用户期望 AI 能基于全局上下文生成更丰富、更完整的内容,而不是仅改写当前已有的段落
|
|
||||||
|
|
||||||
**解决方向**:
|
|
||||||
- 在 systemPrompt 和 prompt 中明确要求 AI 生成完整的、结构化的多段落内容
|
|
||||||
- 强调 AI 应基于全局参考内容补充和完善目标区域,而不是仅做局部改写
|
|
||||||
|
|
||||||
## 问题 2:右侧多余空行
|
|
||||||
|
|
||||||
**现象**:diff 弹窗右侧「AI 提议版本」的段落间出现额外空行,与左侧结构不一致。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
1. 大模型返回的 HTML 中可能包含 `<br>` 标签或 `\n` 换行符
|
|
||||||
2. `</p>\n<p>` 中的换行符会被浏览器解析为文本节点,产生额外空白
|
|
||||||
3. 不同 LLM 的输出格式随机性导致 HTML 结构不统一
|
|
||||||
|
|
||||||
**解决方向**:
|
|
||||||
1. **输入端控制**:在 systemPrompt 中明确要求紧凑 HTML(禁止 `<br>`、禁止换行符)
|
|
||||||
2. **输出端兜底**:在 `setDiffModal` 之前对 `updatedHtml` 进行正则清洗,移除多余空行和 `<br>`
|
|
||||||
|
|
||||||
## 约束条件
|
|
||||||
- 保持现有 diff 弹窗的左右分栏结构不变
|
|
||||||
- 清洗逻辑不应破坏合法的 HTML 结构(如 `<p>` 标签内的内容)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置 1:Prompt 文本增加内容边界约束(约 line 895)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 生成完整、结构化的多段落 HTML 内容,不要只改写现有段落\n2. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n3. 输出紧凑 HTML,标签间不要有空格或换行`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 仅针对【目标区域】的主题生成对应的多段落 HTML 内容\n2. ⚠️ 绝对禁止将【全局参考内容】中的其他模块(如:基本信息、术后情况、标本描述、病理结果、医生签名、日期等)生成并混入你的输出中!你只能输出该区域应有的内容\n3. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n4. 输出紧凑 HTML,标签间不要有空格或换行`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 第 1 点改为"仅针对【目标区域】的主题生成"
|
|
||||||
2. 新增第 2 点:用 ⚠️ 警告符号明确列出禁止混入的模块类型
|
|
||||||
3. 原第 2-3 点顺延为第 3-4 点
|
|
||||||
|
|
||||||
## 修改位置 2:System Prompt 去掉"补充完善"、强化边界(约 line 905-907)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const systemPrompt = aiModifyEnabled
|
|
||||||
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 HTML 源码】。\n请根据全局内容和用户的【医生指令】,直接重写并输出目标区域的 HTML。\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 标记';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
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 标记';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 去掉 `请根据全局内容和用户的【医生指令】` 中的"全局内容",改为 `请根据用户的【医生指令】`
|
|
||||||
2. 第 3 点从 `updatedHtml 必须生成完整...要基于全局信息补充完善` 改为明确的【内容边界】警告
|
|
||||||
3. 明确告知 AI:全局参考仅供理解上下文,updatedHtml 只能包含目标区域本身的内容
|
|
||||||
4. 明确列出严禁输出的内容类型:签名、落款、术后总结等
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:AI 不越界生成其他模块
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中有完整报告内容(包含手术步骤、术后情况、标本描述、病理结果、手术者签名等)
|
|
||||||
2. 插入 AI 可编辑区域「手术步骤」
|
|
||||||
3. 勾选「允许修改正文」→ 选中「手术步骤」区域
|
|
||||||
4. 发送「请完善手术步骤描述」
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- diff 弹窗右侧 AI 提议版本**只包含手术步骤相关内容**
|
|
||||||
- 不应出现:术后情况、标本描述、病理结果、手术者签名、日期等属于其他模块的内容
|
|
||||||
- 左侧原始版本显示该区域原有的内容
|
|
||||||
|
|
||||||
## 测试用例 2:AI 基于全局上下文理解但不越界
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 全局报告中有患者信息(如胆囊结石伴慢性胆囊炎)
|
|
||||||
2. AI 区域为「手术步骤」,原有内容较简略
|
|
||||||
3. 发送「请根据患者病情完善手术步骤」
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 生成的手术步骤应体现对患者病情(胆囊结石伴慢性胆囊炎)的理解
|
|
||||||
- 但不应把「术后情况」「病理结果」等其他模块的内容混进来
|
|
||||||
|
|
||||||
## 测试用例 3:对话模式不修改
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 取消勾选「允许修改正文」
|
|
||||||
2. 发送任意指令
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 只回复聊天内容,不修改编辑器,不弹 diff 弹窗
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 04:24
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户在使用 AI 修改确认弹窗时发现两个问题:
|
|
||||||
1. diff 弹窗左侧原始版本的内容似乎不完整(或被截断)
|
|
||||||
2. diff 弹窗右侧 AI 提议版本混入了不该出现的内容(术后情况、标本描述、病理结果、手术者签名等)
|
|
||||||
|
|
||||||
## 问题分析
|
|
||||||
|
|
||||||
### 问题 1:AI 越界生成其他模块内容
|
|
||||||
|
|
||||||
**现象**:右侧 AI 提议版本中出现了"手术后情况"、"切除标本描述"、"是否送病理检查"、"冰冻病理结果"、"手术者签名"等本不属于"手术步骤"区域的内容。
|
|
||||||
|
|
||||||
**根因**:
|
|
||||||
1. `globalContextText` 包含了整个编辑器的纯文本,AI 看到了全局所有内容
|
|
||||||
2. systemPrompt 第 3 点写着:`updatedHtml 必须生成完整、结构化的多段落内容,不要只改写现有段落,要基于全局信息补充完善`
|
|
||||||
3. AI 为了达成"补充完善"的指令,把全局上下文中看到的其他模块内容都塞进了 updatedHtml
|
|
||||||
|
|
||||||
### 问题 2:左侧内容显示
|
|
||||||
|
|
||||||
**现象**:左侧原始版本显示了多段内容(1-3段),但可能被截断。
|
|
||||||
|
|
||||||
**根因**:
|
|
||||||
- 左侧 `currentHtml` 取自 `.ai-content` 的 innerHTML,其内容取决于模板中 `.ai-content` 实际包含了多少段落
|
|
||||||
- 这不是代码层面的 Bug,而是模板结构中 `.ai-content` 的包裹范围问题
|
|
||||||
|
|
||||||
## 解决方向
|
|
||||||
|
|
||||||
1. **Prompt 边界加固**:去掉"基于全局信息补充完善"这种容易被 AI 过度解读的措辞,改为明确的内容边界警告
|
|
||||||
2. **System Prompt 职责界定**:明确告知 AI"全局参考仅供理解上下文,严禁输出其他模块"
|
|
||||||
3. **Prompt 增加防越界指令**:在 promptText 中明确列出禁止混入的模块类型
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改位置:`handleAIGenerate` 函数内,获取 `currentHtml` 之前(约 line 885)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
|
|
||||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').trim() : '';
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
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(/​/g, '').trim() : '';
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 新增 `aiRegion` 查询,获取 `.ai-region` 容器
|
|
||||||
2. 遍历 `targetRegionEl.nextElementSibling`,把所有 `<p>` 标签移回 `targetRegionEl`
|
|
||||||
3. 移动完成后同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`
|
|
||||||
4. 然后才获取 `currentHtml`
|
|
||||||
|
|
||||||
**为什么这样可行**:
|
|
||||||
- `confirmAiInjection` 只替换 `.ai-content` 的 innerHTML
|
|
||||||
- 修复后 `.ai-content` 已包含所有段落,注入时自然替换全部
|
|
||||||
- 溢出的段落不会再留在 `.ai-region` 内造成重复
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:溢出段落自动合并
|
|
||||||
|
|
||||||
**前置条件**:
|
|
||||||
手动构造一个 DOM 结构被「破坏」的 AI 区域(模拟用户回车导致段落溢出):
|
|
||||||
1. 插入 AI 可编辑区域「手术步骤」
|
|
||||||
2. 在区域内输入第 2 段内容
|
|
||||||
3. 在区域末尾按回车,输入第 3、4、5 段(观察 DOM,确认 `<p>` 标签变成了 `.ai-content` 的兄弟节点)
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 勾选「允许修改正文」→ 选中「手术步骤」区域
|
|
||||||
2. 发送「请完善手术步骤描述」
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- diff 弹窗左侧「原始版本」应显示全部 2-5 段内容,而不只是第 2 段
|
|
||||||
- diff 弹窗右侧「AI 提议版本」也显示完整的多段落内容
|
|
||||||
- 确认注入后,编辑器中 AI 区域的全部内容被替换,没有重复段落
|
|
||||||
|
|
||||||
## 测试用例 2:正常结构不受影响
|
|
||||||
|
|
||||||
**前置条件**:
|
|
||||||
AI 可编辑区域内的所有段落都在 `.ai-content` 内部(正常结构)。
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 发送修改指令
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- diff 弹窗左侧正常显示所有段落
|
|
||||||
- 合并逻辑不会误删或误移任何内容
|
|
||||||
|
|
||||||
## 测试用例 3:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 18:05
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户发现 AI 修改确认弹窗的「原始版本」左侧只显示了一段内容,而编辑器中的 AI 可编辑区域实际上有 2-5 段内容。
|
|
||||||
|
|
||||||
## 现象
|
|
||||||
从用户提供的 DOM 源码可以清楚看到:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="ai-content">
|
|
||||||
<p><span>2.腹腔镜探查:...</span></p>
|
|
||||||
</div>
|
|
||||||
<p>3.切除胆囊:...</p>
|
|
||||||
<p>4.检查腹腔内无活动性出血及漏胆后:...</p>
|
|
||||||
<p>5.手术顺利,麻醉满意:...</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `.ai-content` 内只有第 2 段
|
|
||||||
- 第 3、4、5 段变成了 `.ai-content` 的**兄弟节点**(在 `.ai-region` 内但在 `.ai-content` 外)
|
|
||||||
- 左侧 diff 弹窗只显示 `.ai-content.innerHTML`,所以只有第 2 段
|
|
||||||
|
|
||||||
## 根因分析
|
|
||||||
|
|
||||||
**浏览器 contentEditable 机制的锅**:
|
|
||||||
当用户在 `.ai-content`(一个 contentEditable 的 div)内按回车换行,或粘贴包含多个段落的外部文本时,浏览器的默认行为会截断当前的 `div`,在同级生成新的 `<p>` 标签。这导致后续的段落脱离了 `.ai-content` 父容器,变成了 `.ai-region` 的直接子节点。
|
|
||||||
|
|
||||||
## 解决方向
|
|
||||||
|
|
||||||
**代码层面修复**:在 `handleAIGenerate` 获取 `currentHtml` 之前,自动把 `.ai-region` 内 `.ai-content` 之外的 `<p>` 节点移回 `.ai-content`。这样:
|
|
||||||
1. `currentHtml` 就能包含所有段落
|
|
||||||
2. 左侧 diff 弹窗显示全部内容
|
|
||||||
3. `confirmAiInjection` 注入时替换 `.ai-content`,此时 `.ai-content` 已包含所有段落
|
|
||||||
|
|
||||||
## 约束条件
|
|
||||||
- 只移动 `.ai-content` 之后的 `<p>` 节点,不移动 `.ai-region-label` 或其他元素
|
|
||||||
- 移动后要同步更新 `contentRef.current` 和 `saveDraftToStorage()`
|
|
||||||
- 不改变现有 diff 弹窗和注入逻辑
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件 1:`src/utils/print.ts`
|
|
||||||
|
|
||||||
在 iframe 的 `<style>` 标签中,增加 `.ai-region` 的打印隐藏样式。
|
|
||||||
|
|
||||||
**原代码**(line 23-48 的 style 块):
|
|
||||||
```css
|
|
||||||
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
|
|
||||||
@media print {
|
|
||||||
.smart-field-wrapper .field-value { 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; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```css
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 新增 `.ai-region` 样式:去除边框、背景、内边距、外边距
|
|
||||||
2. 新增 `.ai-region > [contenteditable="false"]` 样式:隐藏右上角标签
|
|
||||||
|
|
||||||
## 修改文件 2:`src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
在 diffModal 右侧「AI 提议版本」容器上增加 `style` 属性。
|
|
||||||
|
|
||||||
**原代码**(line 2630-2636):
|
|
||||||
```tsx
|
|
||||||
<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: diffModal.newHtml }}
|
|
||||||
></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
<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: diffModal.newHtml }}
|
|
||||||
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
|
|
||||||
></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:增加 `style` 属性,指定宋体 12pt、行高 1.5,与编辑器正文样式一致。
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:打印时 AI 区域蓝框隐藏
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入 AI 可编辑区域,写入一些内容
|
|
||||||
2. 点击「打印」按钮(或导出/下载)
|
|
||||||
3. 观察打印预览或生成的 PDF
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 可编辑区域的蓝色虚线边框不显示
|
|
||||||
- 右上角「XX-AI可编辑区域」标签不显示
|
|
||||||
- 灰色背景不显示
|
|
||||||
- 文字正常显示,排版整洁
|
|
||||||
|
|
||||||
## 测试用例 2:diff 弹窗字体统一
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入 AI 可编辑区域,写入带宋体 12pt 样式的内容
|
|
||||||
2. 勾选「允许修改正文」→ 发送修改指令
|
|
||||||
3. 观察 diff 弹窗左右两侧
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 左侧「原始版本」显示宋体 12pt
|
|
||||||
- 右侧「AI 提议版本」也应显示宋体 12pt,与左侧视觉一致
|
|
||||||
- 行高一致(1.5)
|
|
||||||
|
|
||||||
## 测试用例 3:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 18:22
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户希望优化两个视觉问题:
|
|
||||||
1. 点击下载或打印时,不显示 AI 可编辑区域的蓝色虚线框和标签
|
|
||||||
2. AI 修改确认弹窗中,右侧「AI 提议版本」的字体和大小与左侧「原始版本」统一
|
|
||||||
|
|
||||||
## 问题 1:打印/下载时 AI 区域蓝框未隐藏
|
|
||||||
|
|
||||||
**现象**:打印或下载报告时,AI 可编辑区域的蓝色虚线边框和右上角标签仍然显示。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- `src/utils/print.ts` 使用 iframe 生成打印文档,其 `<style>` 中没有针对 `.ai-region` 的隐藏样式
|
|
||||||
- `src/index.css` 中有 `.print-content .ai-region` 的样式,但 `print.ts` 中实际使用的是 `.content` 类,CSS 选择器不匹配
|
|
||||||
- `@media print` 在 `index.css` 中只影响浏览器打印预览,不影响 `print.ts` 的 iframe
|
|
||||||
|
|
||||||
## 问题 2:diff 弹窗右侧字体不一致
|
|
||||||
|
|
||||||
**现象**:左侧「原始版本」显示宋体 12pt,右侧「AI 提议版本」显示默认字体(无衬线体),视觉不一致。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- 左侧原始版本的文本带有内联样式(如 `<span style="font-family: SimSun; font-size: 12pt;">`)
|
|
||||||
- 右侧 AI 返回的是纯净的 `<p>` 标签,没有内联样式
|
|
||||||
- diff 弹窗右侧容器没有设置默认字体样式,导致浏览器使用默认字体
|
|
||||||
|
|
||||||
## 解决方向
|
|
||||||
1. 在 `print.ts` 的 iframe `<style>` 中增加 `.ai-region` 的打印隐藏样式
|
|
||||||
2. 在 diff 弹窗右侧容器的 `style` 属性中指定与正文一致的字体和字号
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 修改 1:`confirmAiInjection` 保留撤销栈(约 line 981-998)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
|
||||||
if (targetContent) {
|
|
||||||
targetContent.innerHTML = newHtml;
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
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, newHtml);
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:
|
|
||||||
1. 去掉 `targetContent.innerHTML = newHtml;`
|
|
||||||
2. 增加 `targetContent.focus()`
|
|
||||||
3. 使用 `Range.selectNodeContents(targetContent)` 选中区域内所有旧内容
|
|
||||||
4. 使用 `document.execCommand('insertHTML', false, newHtml)` 执行替换
|
|
||||||
5. 浏览器撤销栈会记录这次替换,Ctrl+Z 可正常撤销
|
|
||||||
|
|
||||||
## 修改 2:`handleAIGenerate` 中 `<p>` 标签注入样式(约 line 955-970)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
```tsx
|
|
||||||
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();
|
|
||||||
if (targetRegionEl) {
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```tsx
|
|
||||||
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(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
|
|
||||||
if (targetRegionEl) {
|
|
||||||
```
|
|
||||||
|
|
||||||
**变更点**:在 `cleanHtml.trim()` 后增加一行正则替换,将所有 `<p>` 标签替换为带标准内联样式的 `<p>` 标签。
|
|
||||||
|
|
||||||
**样式值说明**:
|
|
||||||
- `padding: 0px`:与原始段落一致
|
|
||||||
- `font-family: SimSun`:宋体
|
|
||||||
- `font-size: 12pt`:12pt 字号
|
|
||||||
- `line-height: 1.5`:1.5 倍行高
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:Ctrl+Z 可撤销 AI 修改
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中插入 AI 可编辑区域,写入一些内容
|
|
||||||
2. 勾选「允许修改正文」→ 发送修改指令
|
|
||||||
3. 在 diff 弹窗中点击「确认并写入报告」
|
|
||||||
4. 按 Ctrl+Z(或点击工具栏撤销按钮)
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- AI 修改的内容被撤销,恢复到修改前的状态
|
|
||||||
- 可连续按 Ctrl+Z 继续撤销更早的操作
|
|
||||||
- 撤销后内容完整,无 DOM 结构损坏
|
|
||||||
|
|
||||||
## 测试用例 2:替换后字体格式一致
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中 AI 可编辑区域内原有内容带宋体 12pt 样式
|
|
||||||
2. 发送 AI 修改指令
|
|
||||||
3. 观察 diff 弹窗左右两侧
|
|
||||||
4. 确认注入后观察编辑器中该区域内容
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- diff 弹窗右侧「AI 提议版本」的字体为宋体 12pt,与左侧一致
|
|
||||||
- 确认注入后,编辑器中 AI 区域的文字字体与周边/原有文字一致
|
|
||||||
- 无视觉割裂感
|
|
||||||
|
|
||||||
## 测试用例 3:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 20:30
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户在 AI 修改确认后遇到两个问题:
|
|
||||||
1. 点击「确认并写入报告」后,Ctrl+Z 撤销按钮无法撤销 AI 的修改
|
|
||||||
2. AI 替换后的文字字体格式与原有文字不一致(原有宋体 12pt,替换后变为浏览器默认字体)
|
|
||||||
|
|
||||||
## 问题 1:Ctrl+Z 撤销失效
|
|
||||||
|
|
||||||
**现象**:AI 修改确认注入后,按 Ctrl+Z 无法撤销。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
`confirmAiInjection` 使用 `targetContent.innerHTML = newHtml;` 直接修改 DOM 属性。这种方式绕过了浏览器 `contentEditable` 的原生撤销/重做历史栈,导致浏览器无法追踪这次更改。
|
|
||||||
|
|
||||||
## 问题 2:字体格式不一致
|
|
||||||
|
|
||||||
**现象**:
|
|
||||||
- 替换前:`<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">内容</p>`
|
|
||||||
- 替换后:`<p>内容</p>`(无内联样式,显示为浏览器默认字体)
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
大模型严格按照指令返回纯净的 `<p>` 标签,没有内联样式。注入后浏览器使用默认字体渲染,与原有宋体 12pt 不一致。
|
|
||||||
|
|
||||||
## 解决方向
|
|
||||||
1. `confirmAiInjection` 改用 `Range.selectNodeContents` + `document.execCommand('insertHTML')`,让浏览器原生撤销栈记录这次替换
|
|
||||||
2. `handleAIGenerate` 中对 `cleanHtml` 进行正则替换,给 `<p>` 标签注入标准内联样式
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# 实现方案
|
|
||||||
|
|
||||||
## 修改文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
- `package.json` / `package-lock.json`(已安装 `diff` 库)
|
|
||||||
|
|
||||||
## 依赖安装
|
|
||||||
```bash
|
|
||||||
npm install diff --save
|
|
||||||
```
|
|
||||||
已完成。
|
|
||||||
|
|
||||||
## 修改 1:导入 diff 库 + 增加辅助函数
|
|
||||||
|
|
||||||
在 `ReportEditor.tsx` 顶部 imports 区域增加:
|
|
||||||
```tsx
|
|
||||||
import { diffChars } from 'diff';
|
|
||||||
```
|
|
||||||
|
|
||||||
在组件内部增加辅助函数(建议放在 `checkAiRegions` 之后):
|
|
||||||
```tsx
|
|
||||||
const stripHtml = (html: string): string => {
|
|
||||||
const tmp = document.createElement('div');
|
|
||||||
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
|
||||||
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, '<').replace(/>/g, '>').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;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 修改 2:强化 systemPrompt(约 line 922)
|
|
||||||
|
|
||||||
在修改模式的 systemPrompt 中增加第 7 条:
|
|
||||||
```
|
|
||||||
7. ⚠️ 绝对强制:无论用户的修改指令多么微小,你都必须返回 updatedHtml。绝对不允许只返回 reply 而不返回 updatedHtml!
|
|
||||||
```
|
|
||||||
|
|
||||||
## 修改 3:前端校验兜底(约 line 955)
|
|
||||||
|
|
||||||
在 `if (responseJson.updatedHtml && aiModifyEnabled)` 分支之前,增加兜底提示:
|
|
||||||
```tsx
|
|
||||||
if (aiModifyEnabled && !responseJson.updatedHtml) {
|
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 修改 4:diffModal 渲染逻辑(约 line 2612-2653)
|
|
||||||
|
|
||||||
**原代码**:
|
|
||||||
左侧直接渲染 `diffModal.originalHtml`,右侧直接渲染 `diffModal.newHtml`。
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
在 `diffModal` 渲染区域内部,计算 diff 高亮 HTML:
|
|
||||||
```tsx
|
|
||||||
{diffModal && diffModal.isOpen && (
|
|
||||||
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
|
||||||
{/* ... 弹窗头部 ... */}
|
|
||||||
{(() => {
|
|
||||||
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 }}></div>
|
|
||||||
</div>
|
|
||||||
{/* 右侧:AI 版本 + 新增高亮 */}
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:使用 IIFE(立即执行函数)在 JSX 中计算 diff,避免在渲染外额外处理。
|
|
||||||
|
|
||||||
## 修改 5:confirmAiInjection 清理 diff 高亮(约 line 981)
|
|
||||||
|
|
||||||
在注入前去掉 diff 高亮 span:
|
|
||||||
```tsx
|
|
||||||
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
// 去掉 diff 高亮 span,避免污染编辑器
|
|
||||||
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);
|
|
||||||
// ... 后续动画和保存逻辑不变
|
|
||||||
```
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# 测试方案
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
- 浏览器访问 `http://localhost:4173/`
|
|
||||||
- 进入「图文报告生成」→ 新建报告
|
|
||||||
|
|
||||||
## 测试用例 1:微小指令也能弹窗
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 第一次发送修改指令,确认 diff 弹窗正常弹出
|
|
||||||
2. 点击「确认并写入报告」
|
|
||||||
3. 第二次发送非常简短的微调指令,如"把 5x3x2 改成 5x3x10"
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 第二次也应弹出 diff 弹窗
|
|
||||||
- 如果未弹窗,聊天面板应显示【系统提示】AI 未能生成修改内容
|
|
||||||
|
|
||||||
## 测试用例 2:diff 高亮显示
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 编辑器中 AI 可编辑区域写入一段内容
|
|
||||||
2. 发送修改指令,让 AI 修改其中几个字词
|
|
||||||
3. 观察 diff 弹窗
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 左侧「原始版本」中,被 AI 删除的字词显示红色背景 + 删除线
|
|
||||||
- 右侧「AI 提议版本」中,AI 新增的字词显示绿色背景
|
|
||||||
- 未变更的内容正常显示,无高亮
|
|
||||||
|
|
||||||
## 测试用例 3:确认注入后无高亮残留
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 在 diff 弹窗中点击「确认并写入报告」
|
|
||||||
2. 观察编辑器中 AI 区域的内容
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 编辑器中不应有红色/绿色的 diff 高亮 span
|
|
||||||
- 文字格式正常(宋体 12pt)
|
|
||||||
|
|
||||||
## 测试用例 4:编译与部署
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 执行 `npm run build`
|
|
||||||
2. 确认无 TypeScript 编译错误
|
|
||||||
3. 预览服务正常启动并返回 200
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `vite build` 成功完成
|
|
||||||
- 预览页面可正常访问
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 需求分析
|
|
||||||
|
|
||||||
## 时间戳
|
|
||||||
2026-04-19 21:59
|
|
||||||
|
|
||||||
## 需求来源
|
|
||||||
用户反馈两个体验问题:
|
|
||||||
1. 第二次输入需求时,没有弹出 AI 修改确认框
|
|
||||||
2. AI 修改确认框的左侧和右侧希望增加文档对比(Diff)功能,高亮显示新增/删除的内容
|
|
||||||
|
|
||||||
## 问题 1:第二次输入未弹框
|
|
||||||
|
|
||||||
**现象**:第一次 AI 修改正常弹出 diff 弹窗,第二次输入微调指令(如"把 5x3x2 变成 5x3x10")后没有弹窗。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
1. 大模型在微小修改指令时可能"偷懒",只返回 `reply` 而不返回 `updatedHtml`
|
|
||||||
2. 当前逻辑 `if (responseJson.updatedHtml && aiModifyEnabled)` 会跳过弹窗
|
|
||||||
3. 用户不知道发生了什么,没有反馈
|
|
||||||
|
|
||||||
## 问题 2:缺少文档对比(Diff)
|
|
||||||
|
|
||||||
**现象**:diff 弹窗左侧和右侧只是简单渲染两段 HTML,无法直观看到 AI 具体修改了哪些字词。
|
|
||||||
|
|
||||||
**根因分析**:
|
|
||||||
- 当前实现使用 `dangerouslySetInnerHTML` 直接渲染原始 HTML 和 AI HTML
|
|
||||||
- 没有使用差异比对算法来标记变更
|
|
||||||
|
|
||||||
## 解决方向
|
|
||||||
1. **强化 systemPrompt**:明确要求"无论指令多小都必须返回 updatedHtml"
|
|
||||||
2. **前端校验兜底**:如果修改模式下未返回 updatedHtml,在聊天面板给出明确提示
|
|
||||||
3. **引入 diff 库**:使用 `diff` 库进行文本差异比对
|
|
||||||
4. **左右两侧 diff 高亮**:
|
|
||||||
- 左侧(原始版本):删除的内容标红(红色背景 + 删除线)
|
|
||||||
- 右侧(AI 版本):新增的内容标绿(绿色背景)
|
|
||||||
5. **注入前清理**:`confirmAiInjection` 注入前去掉 diff 高亮 span,避免污染编辑器
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 功能变更执行文档(20260419_2226)
|
|
||||||
|
|
||||||
## 变更摘要
|
|
||||||
本次实施 4 项优化:
|
|
||||||
|
|
||||||
| # | 变更项 | 目标效果 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 修复 diff 颜色残留 | `confirmAiInjection` 使用 `cleanHtml` 而非 `newHtml` |
|
|
||||||
| 2 | 更新默认快捷指令 | 4 条外科专用短语 → 2 条通用短语 |
|
|
||||||
| 3 | 新增发送按钮 | 在麦克风按钮旁添加显式发送按钮 |
|
|
||||||
| 4 | 导出 AI 日志 | 快捷指令区域新增「导出 AI 日志」按钮,下载 JSON |
|
|
||||||
|
|
||||||
## 变更文件
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
|
|
||||||
## 技术要点
|
|
||||||
1. **Diff 颜色残留根因**:`confirmAiInjection` 中 `document.execCommand('insertHTML')` 传入的是未清理的 `newHtml`,而 `cleanHtml`(已去除 diff span)未被使用。
|
|
||||||
2. **Send 按钮复用**:`lucide-react` 的 `Send` 图标已在导入列表中,无需新增依赖。
|
|
||||||
3. **导出 AI 日志**:点击后组装 `{ exportAt, url, messages, metadata }` 为 JSON Blob 并触发浏览器下载。
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 功能变更实现方案文档(20260419_2249)
|
|
||||||
|
|
||||||
## 实现方案 A:Kimi k2.5 参数拦截
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
`ReportEditor.tsx` 中 `handleAIGenerate` 函数的请求体构建部分(当前行号约 948-962)。
|
|
||||||
|
|
||||||
### 具体实现
|
|
||||||
1. 在构建 `body` 前,先创建一个 `payload` 对象:
|
|
||||||
```ts
|
|
||||||
const payload: any = {
|
|
||||||
model: modelName,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: messageContent }
|
|
||||||
],
|
|
||||||
temperature: 0.3
|
|
||||||
};
|
|
||||||
```
|
|
||||||
2. 增加条件判断:
|
|
||||||
```ts
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. `fetch` 的 `body: JSON.stringify(payload)`
|
|
||||||
|
|
||||||
### 设计理由
|
|
||||||
- 使用 `delete` 而非覆盖为固定值,是因为 Kimi 官方文档明确禁止这些参数出现;即使设为 `1.0` 仍可能触发校验失败
|
|
||||||
- 条件判断放在 `settings` 读取后立即执行,确保使用用户实际配置的 provider 和 modelName
|
|
||||||
- 正则 `/k2\.5/i` 兼容可能的模型 ID 变体(如 `kimi-k2.5`、`kimi-k2.5-preview` 等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 B:完善 AI 日志导出
|
|
||||||
|
|
||||||
### 变更点 1:新增 `lastExchangeLog` 状态
|
|
||||||
在 `ReportEditor.tsx` 的 state 定义区(`chatMessages` 附近)新增:
|
|
||||||
```ts
|
|
||||||
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);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 变更点 2:在 `handleAIGenerate` 中记录日志
|
|
||||||
将函数重构为在关键节点捕获数据:
|
|
||||||
|
|
||||||
**A. 请求前记录:**
|
|
||||||
```ts
|
|
||||||
const logEntry = {
|
|
||||||
startTime: new Date().toISOString(),
|
|
||||||
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: apiEndpoint, modelName },
|
|
||||||
requestPayload: { ...payload },
|
|
||||||
responsePayload: null,
|
|
||||||
errorDetail: null
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**B. 错误处理增强:**
|
|
||||||
当前错误处理只有:
|
|
||||||
```ts
|
|
||||||
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
|
|
||||||
```
|
|
||||||
改为:
|
|
||||||
```ts
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
logEntry.errorDetail = {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
responseText: errorText,
|
|
||||||
message: `API 请求失败: ${response.status}`
|
|
||||||
};
|
|
||||||
setLastExchangeLog(logEntry);
|
|
||||||
throw new Error(`API 请求失败: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**C. 成功响应记录:**
|
|
||||||
在解析 `responseJson` 后:
|
|
||||||
```ts
|
|
||||||
logEntry.responsePayload = responseJson;
|
|
||||||
setLastExchangeLog(logEntry);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 变更点 3:更新导出按钮
|
|
||||||
在「导出 AI 日志」按钮的 `onClick` 中,将 `lastExchangeLog` 加入导出数据:
|
|
||||||
```ts
|
|
||||||
const data = {
|
|
||||||
exportAt: new Date().toISOString(),
|
|
||||||
url: window.location.href,
|
|
||||||
messages: chatMessages,
|
|
||||||
lastExchange: lastExchangeLog,
|
|
||||||
metadata: { ... }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 设计理由
|
|
||||||
- `lastExchangeLog` 只记录最后一次调用,避免无限增长导致内存/状态膨胀
|
|
||||||
- 错误时捕获 `response.text()` 获取 Kimi 官方返回的详细错误 JSON(通常包含 `error.code` 和 `error.message`)
|
|
||||||
- `requestPayload` 深拷贝防止后续 `delete` 操作污染日志记录
|
|
||||||
- 使用 `useState` 而非 `useRef`,因为导出按钮需要读取最新值并触发重渲染显示状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖与兼容性
|
|
||||||
- 无新增 npm 依赖
|
|
||||||
- TypeScript 类型在组件内部定义,不影响 `src/types.ts`
|
|
||||||
- 向后兼容:旧数据无 `lastExchangeLog`,导出时字段为 `null`
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 功能变更测试文档(20260419_2249)
|
|
||||||
|
|
||||||
## 测试项 1:Kimi k2.5 参数拦截
|
|
||||||
|
|
||||||
### 测试场景 A:Kimi + k2.5 模型
|
|
||||||
1. 进入系统设置 → AI 接口集成
|
|
||||||
2. 供应商选择「Kimi (Moonshot)」,模型名填写 `kimi-k2.5`
|
|
||||||
3. 填写有效 API Key,点击「测试连接」确认配置有效
|
|
||||||
4. 进入报告编辑器,打开 AI 面板,发送任意消息
|
|
||||||
5. **预期结果**:
|
|
||||||
- 网络请求成功(HTTP 200),无 400 报错
|
|
||||||
- 浏览器 DevTools → Network → 请求体中**不包含** `temperature`、`top_p`、`presence_penalty`、`frequency_penalty`
|
|
||||||
|
|
||||||
### 测试场景 B:Kimi + 非 k2.5 模型
|
|
||||||
1. 系统设置中模型名改为 `moonshot-v1-32k`
|
|
||||||
2. 发送 AI 消息
|
|
||||||
3. **预期结果**:
|
|
||||||
- 请求体中**包含** `temperature: 0.3`
|
|
||||||
- 调用正常
|
|
||||||
|
|
||||||
### 测试场景 C:DeepSeek / OpenAI / Custom
|
|
||||||
1. 切换供应商为 DeepSeek,模型 `deepseek-chat`
|
|
||||||
2. 发送 AI 消息
|
|
||||||
3. **预期结果**:
|
|
||||||
- 请求体中**包含** `temperature: 0.3`
|
|
||||||
- 调用正常
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 2:AI 日志导出完善
|
|
||||||
|
|
||||||
### 测试场景 A:成功调用后的导出
|
|
||||||
1. 发送一条 AI 消息并等待成功返回
|
|
||||||
2. 点击「导出 AI 日志」
|
|
||||||
3. **预期结果**:
|
|
||||||
- 下载的 JSON 中 `lastExchange` 字段非空
|
|
||||||
- `lastExchange.requestPayload` 包含完整的 `model`、`messages`(system + user)
|
|
||||||
- `lastExchange.requestPayload.messages[0].content` 包含系统提示词全文
|
|
||||||
- `lastExchange.responsePayload` 包含 AI 返回的原始 JSON(含 `reply` 和 `updatedHtml`)
|
|
||||||
- `lastExchange.modelConfig` 包含 provider、endpoint、modelName
|
|
||||||
- `lastExchange.errorDetail` 为 `null`
|
|
||||||
|
|
||||||
### 测试场景 B:失败调用后的导出
|
|
||||||
1. 故意填写错误的 API Key 或断开网络
|
|
||||||
2. 发送 AI 消息,等待报错
|
|
||||||
3. 点击「导出 AI 日志」
|
|
||||||
4. **预期结果**:
|
|
||||||
- `lastExchange.errorDetail` 非空
|
|
||||||
- 包含 `status`(如 401/400/403)、`statusText`、`responseText`(服务端返回的原始错误 JSON)、`message`
|
|
||||||
- `lastExchange.responsePayload` 为 `null`
|
|
||||||
|
|
||||||
### 测试场景 C:未进行任何 AI 调用时的导出
|
|
||||||
1. 刷新页面后直接点击「导出 AI 日志」
|
|
||||||
2. **预期结果**:
|
|
||||||
- `lastExchange` 为 `null`
|
|
||||||
- 其他字段(messages、metadata)正常导出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 回归测试
|
|
||||||
- `tsc --noEmit` 零错误
|
|
||||||
- `npm run build` 构建成功
|
|
||||||
- 预览服务器正常启动并可访问
|
|
||||||
- 现有 AI 对话功能不受影响的供应商(deepseek/openai)调用正常
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 功能变更需求文档(20260419_2249)
|
|
||||||
|
|
||||||
## 需求 1:Kimi k2.5 模型强制传参规则适配
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
Kimi 最新版 `kimi-k2.5` 模型对 API 请求体有极其苛刻的要求,不允许出现非标准的温度和概率参数。当前系统在向所有模型发送请求时均硬编码了 `temperature: 0.3`,导致调用 `kimi-k2.5` 时返回 HTTP 400 错误。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
在封装向大模型发起 `fetch` 请求的地方,增加条件判断:
|
|
||||||
- **触发条件**:当前激活供应商为 `kimi` 且模型名包含 `k2.5`(大小写不敏感)
|
|
||||||
- **数据处理**:强制从请求体中 `delete` 移除 `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` 等可选参数,让 Kimi 官方服务器使用其默认安全值
|
|
||||||
- **兼容性**:其他供应商(deepseek/openai/custom)及 Kimi 非 k2.5 模型不受影响,继续保留 `temperature: 0.3`
|
|
||||||
|
|
||||||
### 参考文档
|
|
||||||
https://platform.kimi.com/docs/guide/kimi-k2-5-quickstart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 2:完善「导出 AI 日志」功能
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
当前「导出 AI 日志」按钮仅导出 `chatMessages`(UI 对话历史)和少量元数据,缺少:
|
|
||||||
- 实际发往 AI 的完整请求体(System Prompt + Messages + Parameters)
|
|
||||||
- AI 返回的原始 JSON 响应
|
|
||||||
- API 调用失败时的具体错误信息(HTTP 状态码 + 响应体)
|
|
||||||
- 当前生效的模型完整配置
|
|
||||||
|
|
||||||
这些信息对于排查大模型幻觉、优化提示词、定位网络/接口故障至关重要。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
1. 在 `ReportEditor.tsx` 中建立 `lastExchangeLog` 状态,每次 `handleAIGenerate` 调用时记录:
|
|
||||||
- `startTime`:请求发起时间
|
|
||||||
- `requestPayload`:完整请求体(model、messages、实际发送的参数)
|
|
||||||
- `responsePayload`:AI 原始响应 JSON
|
|
||||||
- `errorDetail`:失败时的完整错误信息(含 HTTP 状态码、错误响应体文本)
|
|
||||||
- `modelConfig`:当前 provider、endpoint、modelName
|
|
||||||
2. 更新「导出 AI 日志」按钮,将 `lastExchangeLog` 一并写入导出的 JSON
|
|
||||||
3. 保持向后兼容:无 AI 调用记录时,`lastExchangeLog` 为 `null`,导出时不影响其他字段
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 影响范围
|
|
||||||
- `src/pages/ReportEditor.tsx`(主要修改文件)
|
|
||||||
- 无新增依赖
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# 功能变更实现方案文档(20260419_2316)
|
|
||||||
|
|
||||||
## 实现方案 A:模板手术步骤 AI 区域化
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
`src/utils/defaultContent.ts` line 54-76。
|
|
||||||
|
|
||||||
### 具体实现
|
|
||||||
将原有的:
|
|
||||||
```html
|
|
||||||
<p style="..."><strong>手术步骤、术中出现的情况及处理:</strong></p>
|
|
||||||
<p>1.患者仰卧位...</p>
|
|
||||||
<p>2.腹腔镜探查...</p>
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
替换为:
|
|
||||||
```html
|
|
||||||
<p style="..."><strong>手术步骤、术中出现的情况及处理:</strong></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>1.患者仰卧位...</p>
|
|
||||||
<p>2.腹腔镜探查...</p>
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 B:API 密钥安全增强
|
|
||||||
|
|
||||||
### 变更点 1:默认值预设
|
|
||||||
`src/types.ts` line 92:
|
|
||||||
```ts
|
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: 'sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv', modelName: 'moonshot-v1-auto' }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 变更点 2:前端防复制
|
|
||||||
`src/pages/SystemSettings.tsx` API Key input 添加事件拦截:
|
|
||||||
```tsx
|
|
||||||
onCopy={(e) => e.preventDefault()}
|
|
||||||
onCut={(e) => e.preventDefault()}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 变更点 3:轻度加密存储
|
|
||||||
`src/utils/storage.ts` 增加透明加密层:
|
|
||||||
- 使用 XOR + Base64 对 `systemSettings` key 的数据进行加解密
|
|
||||||
- 加密密钥固定为 `'MedicalReportSys2024'`
|
|
||||||
- 完全透明:所有调用方无需改动,`get`/`set` 自动处理
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `get` 和 `set` 中:
|
|
||||||
```ts
|
|
||||||
if (key === 'systemSettings') {
|
|
||||||
data = xorEncrypt(JSON.stringify(value), CRYPTO_KEY);
|
|
||||||
// 存储时加一个前缀标记以便区分
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
为保持向后兼容(旧数据是明文 JSON),解密时先尝试 `JSON.parse`,如果失败再尝试 XOR 解密:
|
|
||||||
```ts
|
|
||||||
get<T>(key: string, fallback: T): T {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (!raw) return fallback;
|
|
||||||
if (key === 'systemSettings') {
|
|
||||||
// 先尝试直接 JSON.parse(兼容旧明文数据)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 C:默认模型名切换
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
- `src/types.ts` line 92:`modelName: 'moonshot-v1-auto'`
|
|
||||||
- `src/pages/SystemSettings.tsx` migration fallback line 41:`modelName: 'moonshot-v1-auto'`
|
|
||||||
- `src/pages/ReportEditor.tsx` fallback line 901:`modelName = provider?.modelName || 'moonshot-v1-auto'`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 D:抽帧百分比 + 模式默认值
|
|
||||||
|
|
||||||
### 硬编码数组
|
|
||||||
```ts
|
|
||||||
const DEFAULT_FRAME_POSITIONS = [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
| 文件 | 位置 | 变更 |
|
|
||||||
|------|------|------|
|
|
||||||
| `src/pages/Login.tsx` | `initData()` framePositions | 从均匀计算改为硬编码数组 |
|
|
||||||
| `src/pages/Login.tsx` | `initData()` frameMode | `'uniform'` → `'keep'` |
|
|
||||||
| `src/pages/SystemSettings.tsx` | `useState` framePositions | `[5,10,...]` → 硬编码数组 |
|
|
||||||
| `src/pages/SystemSettings.tsx` | `useState` frameMode | `'uniform'` → `'keep'` |
|
|
||||||
| `src/pages/SystemSettings.tsx` | loaded settings fallback | `'uniform'` → `'keep'` |
|
|
||||||
| `src/pages/SystemSettings.tsx` | `resetToDefault()` | framePositions + frameMode |
|
|
||||||
| `src/pages/ReportEditor.tsx` | fallback framePositions | `[5,10,...]` → 硬编码数组 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖与兼容性
|
|
||||||
- 无新增 npm 依赖
|
|
||||||
- storage 加密保持向后兼容:旧明文数据可正常读取,新写入的数据自动加密
|
|
||||||
- 所有变更均为默认值修改,不影响已有用户配置(除非手动重置)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 功能变更测试文档(20260419_2316)
|
|
||||||
|
|
||||||
## 测试项 1:模板手术步骤 AI 区域化
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 清除浏览器缓存 / 以新用户身份登录
|
|
||||||
2. 进入报告编辑器,新建报告
|
|
||||||
3. **预期结果**:
|
|
||||||
- "手术步骤、术中出现的情况及处理"标题下方出现一个蓝色虚线边框的 AI 区域
|
|
||||||
- 区域右上角显示蓝色标签"手术步骤、术中出现的情况及处理-AI可编辑区域"
|
|
||||||
- 区域内部包含原有的 5 个手术步骤段落
|
|
||||||
- 打开 AI 面板,选择目标区域为"手术步骤",发送消息
|
|
||||||
- AI 可以正常对该区域内容进行修改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 2:API 密钥安全增强
|
|
||||||
|
|
||||||
### 测试场景 A:默认值预设
|
|
||||||
1. 新用户首次登录,进入系统设置 → AI 接口集成
|
|
||||||
2. **预期结果**:
|
|
||||||
- API 密钥输入框中已预填值,显示为密码圆点(不可见明文)
|
|
||||||
- 供应商为 Kimi,模型名称为 `moonshot-v1-auto`
|
|
||||||
|
|
||||||
### 测试场景 B:防复制
|
|
||||||
1. 在 API 密钥输入框中尝试 `Ctrl+C` 复制或右键复制
|
|
||||||
2. **预期结果**:复制操作被阻止,剪贴板内容不变
|
|
||||||
|
|
||||||
### 测试场景 C:加密存储
|
|
||||||
1. 打开浏览器 DevTools → Application → LocalStorage
|
|
||||||
2. 找到 `systemSettings` 键
|
|
||||||
3. **预期结果**:值为 Base64 编码的乱码字符串,无法直接阅读出 apiKey 明文
|
|
||||||
4. 系统在读写 `systemSettings` 时正常工作,无异常
|
|
||||||
|
|
||||||
### 测试场景 D:向后兼容
|
|
||||||
1. 在已有明文 `systemSettings` 的旧数据环境下刷新页面
|
|
||||||
2. **预期结果**:系统正常读取旧数据,无报错
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 3:默认模型名
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 新用户首次登录,进入系统设置
|
|
||||||
2. **预期结果**:模型名称 (Model Name) 默认为 `moonshot-v1-auto`
|
|
||||||
3. 点击"测试连接",下拉列表中可选择该模型
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 4:抽帧百分比 + 模式
|
|
||||||
|
|
||||||
### 测试场景 A:默认值
|
|
||||||
1. 新用户首次登录,进入系统设置 → 视频分析设置
|
|
||||||
2. **预期结果**:
|
|
||||||
- 抽帧模式显示为"保持当前抽帧"(而非"整体均匀抽取")
|
|
||||||
- 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%
|
|
||||||
|
|
||||||
### 测试场景 B:重置后
|
|
||||||
1. 修改抽帧设置后,点击"恢复默认"
|
|
||||||
2. **预期结果**:恢复为上述 12 个百分比和 keep 模式
|
|
||||||
|
|
||||||
### 测试场景 C:实际抽帧
|
|
||||||
1. 上传视频,点击"自动关键帧摘取"
|
|
||||||
2. **预期结果**:按照 12 个指定百分比位置截取关键帧
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 回归测试
|
|
||||||
- `tsc --noEmit` 零错误
|
|
||||||
- `npm run build` 构建成功
|
|
||||||
- 预览服务器正常启动并可访问
|
|
||||||
- 现有报告编辑、AI 对话、视频分析功能不受影响
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# 功能变更需求文档(20260419_2316)
|
|
||||||
|
|
||||||
## 需求 1:模板手术步骤放入 AI 可编辑区域
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
当前默认模板中"手术步骤、术中出现的情况及处理"下的 5 个 `<p>` 段落是静态纯文本,用户希望将其完整包裹进 AI 专属可编辑区域(`.ai-region`),使 AI 可以直接对该部分内容进行生成和修改。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
修改 `defaultContent.ts`,将 line 54-76 的手术步骤段落用 `.ai-region` 容器包裹,结构与其他 AI 区域保持一致:
|
|
||||||
- `data-ai-id="手术步骤"`
|
|
||||||
- `data-ai-title="手术步骤、术中出现的情况及处理"`
|
|
||||||
- 顶部标签显示"手术步骤、术中出现的情况及处理-AI可编辑区域"
|
|
||||||
- 内部 `.ai-content` 包含原有 5 个 `<p>` 段落
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 2:API 密钥默认预设、密文显示与轻度加密
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
当前系统首次使用时 API 密钥为空,用户每次都需要手动填写;且密钥在 localStorage 中以明文存储,存在安全风险。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
1. **默认值**:`DEFAULT_AI_PROVIDERS.kimi.apiKey` 预设为 `sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv`
|
|
||||||
2. **前端脱敏**:SystemSettings 中 API 密钥输入框已是 `type="password"`,需补充 `onCopy`/`onCut` 事件拦截阻止复制
|
|
||||||
3. **轻度加密**:`storage.ts` 中对 `systemSettings` key 的读写增加透明 XOR+Base64 加密层,localStorage 中不以明文存储,所有调用方无感知
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 3:默认模型名称切换为 moonshot-v1-auto
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
将 `DEFAULT_AI_PROVIDERS.kimi.modelName` 从 `'kimi-k2-5'` 改为 `'moonshot-v1-auto'`;同步修改所有 fallback 默认值和 migration 代码中的硬编码模型名。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 4:预设 12 个特定的视频抽帧百分比 + 默认 keep 模式
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
1. 将默认的 12 个抽帧位置百分比从均匀计算 `[7.7, 15.4, ...]` 或 `[5,10,15,...]` 改为指定硬编码数组:
|
|
||||||
`[7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6]`
|
|
||||||
2. 将默认抽帧模式从 `'uniform'` 改为 `'keep'`
|
|
||||||
3. 修改所有初始化入口:`Login.tsx` 的 `initData()`、`SystemSettings.tsx` 的 `useState` 初始值和 `resetToDefault()`、`ReportEditor.tsx` 的 fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 影响范围
|
|
||||||
- `src/utils/defaultContent.ts`
|
|
||||||
- `src/types.ts`
|
|
||||||
- `src/utils/storage.ts`
|
|
||||||
- `src/pages/Login.tsx`
|
|
||||||
- `src/pages/SystemSettings.tsx`
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# 功能变更实现方案文档(20260419_2344)
|
|
||||||
|
|
||||||
## 实现方案 A:切换模板时重置 AI 对话
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
`ReportEditor.tsx` line 1240-1251(模板应用 useEffect 中)。
|
|
||||||
|
|
||||||
### 具体实现
|
|
||||||
在 `setCapturedFrames([])` 之后、`updatePageHeight()` 之前,添加:
|
|
||||||
```tsx
|
|
||||||
setChatMessages([]);
|
|
||||||
setChatInput('');
|
|
||||||
setAiUploadedImages([]);
|
|
||||||
setAiSelectedFrames([]);
|
|
||||||
stateRef.current = {
|
|
||||||
...stateRef.current,
|
|
||||||
chatMessages: [],
|
|
||||||
chatInput: '',
|
|
||||||
videos: [],
|
|
||||||
capturedFrames: []
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
同时更新 `stateRef.current`,确保草稿保存时不会残留旧 AI 数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 B:修复 diff modal 段落间距
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
`ReportEditor.tsx` line 835-838 的 `stripHtml` 函数。
|
|
||||||
|
|
||||||
### 具体实现
|
|
||||||
从:
|
|
||||||
```ts
|
|
||||||
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
|
||||||
```
|
|
||||||
|
|
||||||
改为:
|
|
||||||
```ts
|
|
||||||
tmp.innerHTML = html.replace(/<br\s*\/?>/gi, '\n');
|
|
||||||
```
|
|
||||||
|
|
||||||
`innerText` 本身会在块级 `<p>` 元素之间自动插入换行,无需手动添加。去掉手动插入后,段落间距恢复正常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 C:API 密钥 DOM 暴露修复
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
`src/pages/SystemSettings.tsx` line 362-377 的 API Key input。
|
|
||||||
|
|
||||||
### 具体实现
|
|
||||||
1. 导入 `useRef`
|
|
||||||
2. 创建 `apiKeyInputRef = useRef<HTMLInputElement>(null)`
|
|
||||||
3. 使用 `useEffect` 在 apiKey 变化时通过 ref 设置 DOM value:
|
|
||||||
```tsx
|
|
||||||
const apiKeyInputRef = 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]);
|
|
||||||
```
|
|
||||||
4. input 标签移除 `value` prop:
|
|
||||||
```tsx
|
|
||||||
<input
|
|
||||||
ref={apiKeyInputRef}
|
|
||||||
type="password"
|
|
||||||
onChange={...}
|
|
||||||
onCopy={(e) => e.preventDefault()}
|
|
||||||
onCut={(e) => e.preventDefault()}
|
|
||||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
|
||||||
className="input-minimal"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
DOM 中不再出现 `value="sk-..."` HTML 属性。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 实现方案 D:默认模型名切换
|
|
||||||
|
|
||||||
### 变更点
|
|
||||||
- `src/types.ts` line 92:`modelName: 'moonshot-v1-32k-vision-preview'`
|
|
||||||
- `src/pages/SystemSettings.tsx` migration fallback line 41:同步修改
|
|
||||||
- `src/pages/ReportEditor.tsx` fallback line 901:同步修改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 依赖与兼容性
|
|
||||||
- 无新增 npm 依赖
|
|
||||||
- API Key input 改为非受控后,不影响现有 onChange 保存逻辑
|
|
||||||
- stripHtml 修改仅影响 diff modal 渲染,不影响编辑器本身
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 功能变更测试文档(20260419_2344)
|
|
||||||
|
|
||||||
## 测试项 1:切换模板时重置 AI 对话
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 打开报告编辑器,在 AI 面板发送几条消息,上传几张图片,选择几个视频帧
|
|
||||||
2. 点击顶部模板下拉框,选择另一个模板并确认
|
|
||||||
3. **预期结果**:
|
|
||||||
- 右侧 AI 面板聊天历史完全清空
|
|
||||||
- 输入框为空
|
|
||||||
- 已上传图片和已选视频帧清零
|
|
||||||
- 新模板内容正确加载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 2:Diff Modal 段落间距
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 在 AI 可编辑区域(如"手术步骤")发送修改请求
|
|
||||||
2. AI 返回修改内容后,查看 diff 弹窗
|
|
||||||
3. **预期结果**:
|
|
||||||
- 左侧"原始版本"段落间距与右侧"AI 提议版本"一致
|
|
||||||
- 段落之间没有多余的大段空白
|
|
||||||
- 删除/添加的高亮标记正常显示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 3:API 密钥 DOM 安全
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 进入系统设置 → AI 接口集成
|
|
||||||
2. 打开浏览器 DevTools → Elements 面板
|
|
||||||
3. 找到 API 密钥的 `<input>` 元素
|
|
||||||
4. **预期结果**:
|
|
||||||
- `<input>` 标签中不存在 `value="sk-..."` 属性
|
|
||||||
- 页面上显示密码圆点(正常视觉)
|
|
||||||
- 输入新密钥、切换 provider 后功能正常
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试项 4:默认模型名
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 清除 localStorage 或以新用户登录
|
|
||||||
2. 进入系统设置 → AI 接口集成
|
|
||||||
3. **预期结果**:模型名称默认为 `moonshot-v1-32k-vision-preview`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 回归测试
|
|
||||||
- `tsc --noEmit` 零错误
|
|
||||||
- `npm run build` 构建成功
|
|
||||||
- 预览服务器正常启动并可访问
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 功能变更需求文档(20260419_2344)
|
|
||||||
|
|
||||||
## 需求 1:切换模板时重置 AI 对话内容
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
当前在顶部下拉框切换模板时,代码只替换了编辑器内容和 reportData,但右侧 AI 聊天面板中的历史对话(chatMessages)和输入框内容(chatInput)仍然残留,与新模板不匹配。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
在 `ReportEditor.tsx` 模板切换的 `useEffect` 中,同步清空 AI 相关状态:
|
|
||||||
- `setChatMessages([])`
|
|
||||||
- `setChatInput('')`
|
|
||||||
- `setAiUploadedImages([])`
|
|
||||||
- `setAiSelectedFrames([])`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 2:修复"AI 修改确认"弹窗原始版本段落间距过大
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
Diff 弹窗左侧"原始版本"中,段落之间的间距明显过大(截图显示有大段空白),而右侧"AI 提议版本"间距正常。
|
|
||||||
|
|
||||||
### 根因分析
|
|
||||||
`stripHtml` 函数中手动在每个 `</p>` 后插入 `\n`:
|
|
||||||
```ts
|
|
||||||
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
|
|
||||||
```
|
|
||||||
而浏览器 `innerText` 本身就会在块级 `<p>` 元素之间自动插入换行。两者叠加导致双换行 → `computeDiffHtml` 将 `\n` 转为 `<br>` → 左侧显示双 `<br>` 间距。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
移除 `stripHtml` 中 `</p>\n` 的手动插入,仅保留 `<br>` 转 `\n` 的逻辑,让 `innerText` 自然处理段落间距。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 3:修复 API 密钥在 DOM 源码中暴露
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
即使 `type="password"`,浏览器的 Elements 面板中仍可直接看到 `<input value="sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv">` 的完整明文。
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
将 API Key 输入框从 React 受控组件(`value={...}`)改为 ref 控制的非受控组件:
|
|
||||||
- JSX 中不写 `value` / `defaultValue` prop
|
|
||||||
- 通过 `useEffect` + `useRef` 在 JavaScript 层面设置 DOM 的 `value` property(非 HTML attribute)
|
|
||||||
- 当 provider 切换或 apiKey 变化时,ref 同步更新 input 值
|
|
||||||
- 添加安全检查:仅当 ref 当前值与目标值不同时才设置,避免覆盖用户正在输入的字符
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需求 4:默认模型名改为 moonshot-v1-32k-vision-preview
|
|
||||||
|
|
||||||
### 需求描述
|
|
||||||
将 Kimi 默认模型从 `moonshot-v1-auto` 改为 `moonshot-v1-32k-vision-preview`,支持视觉输入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 影响范围
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
- `src/pages/SystemSettings.tsx`
|
|
||||||
- `src/types.ts`
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 功能变更实现方案文档(20260420_0009)
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 变更点 1:currentHtml 提取后压缩空白
|
|
||||||
Line 933:
|
|
||||||
```ts
|
|
||||||
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/​/g, '').trim() : '';
|
|
||||||
```
|
|
||||||
→ 添加 `.replace(/>(\s+)</g, '><')`
|
|
||||||
|
|
||||||
### 变更点 2:cleanHtml 处理后压缩空白
|
|
||||||
Line 1016-1020:
|
|
||||||
在 `cleanHtml.trim()` 之后追加 `.replace(/>(\s+)</g, '><')`
|
|
||||||
|
|
||||||
### 设计理由
|
|
||||||
- `>(\s+)<` 正则匹配任意 HTML 标签之间的空白字符(换行、空格、制表符),替换为 `><` 使 HTML 紧凑
|
|
||||||
- 仅影响 diff 比对前的数据清洗,不影响编辑器实际渲染
|
|
||||||
- 两侧(原始版本和 AI 版本)都做同样处理,确保 diff 只比对有意义的内容差异
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 功能变更测试文档(20260420_0009)
|
|
||||||
|
|
||||||
## 测试项:Diff Modal 段落空白修复
|
|
||||||
|
|
||||||
### 测试场景
|
|
||||||
1. 在 AI 可编辑区域(如"手术步骤")发送修改请求
|
|
||||||
2. AI 返回修改内容后,查看 diff 弹窗
|
|
||||||
3. **预期结果**:
|
|
||||||
- 左侧"原始版本"中段落之间没有大段空白
|
|
||||||
- 红色删除线仅标记有实际意义的文字差异
|
|
||||||
- 右侧"AI 提议版本"排版正常
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 功能变更需求文档(20260420_0009)
|
|
||||||
|
|
||||||
## 需求:修复 AI 修改确认弹窗原始版本的大段空白
|
|
||||||
|
|
||||||
### 问题背景
|
|
||||||
`defaultContent.ts` 模板源码中 `<p>` 标签之间存在大量换行和缩进空白(代码排版需要)。当 `currentHtml` 从 `.ai-content` 的 `innerHTML` 读取时,这些空白被完整保留。AI 返回的 `updatedHtml` 是紧凑的(无多余空白)。`diffChars` 比对时将原始 HTML 中的空白字符标记为 `removed`,`\n` 被转为 `<br>` 渲染,导致左侧"原始版本"出现大段空白。
|
|
||||||
|
|
||||||
### 根因
|
|
||||||
模板源码:
|
|
||||||
```html
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style="...">
|
|
||||||
```
|
|
||||||
读取后的 `currentHtml` 包含 `</p>\n\n <p>`,而 AI 返回的是 `</p><p>`,差异被标记为删除并渲染为多个 `<br>`。
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
在 `handleAIGenerate` 中:
|
|
||||||
1. `currentHtml` 提取后,添加 `.replace(/>(\s+)</g, '><')` 压缩标签间空白
|
|
||||||
2. `cleanHtml` 处理后,同样添加 `.replace(/>(\s+)</g, '><')` 保持两侧格式对齐
|
|
||||||
|
|
||||||
### 影响范围
|
|
||||||
- `src/pages/ReportEditor.tsx`
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 实现方案 — 2026-04-19-02-26-05
|
|
||||||
|
|
||||||
## 1. 方案概述
|
|
||||||
基于现有 `contentEditable` 架构,引入 `ai-region` DOM 区块作为 AI 的"手术锚点"。通过标准 Fetch API 接入 Kimi 多模态接口,采用 JSON Schema 约束输出。所有 AI 状态集中在 ReportEditor 管理,TemplateManage 仅负责区域插入。
|
|
||||||
|
|
||||||
## 2. 详细步骤
|
|
||||||
|
|
||||||
### 步骤 1:`src/types.ts` — 扩展 SystemSettings
|
|
||||||
**目标文件**:`src/types.ts`
|
|
||||||
**修改内容**:
|
|
||||||
- `SystemSettings` 接口新增 `kimiApiKey?: string` 和 `kimiApiEndpoint?: string`
|
|
||||||
- 默认值兜底在 `SystemSettings.tsx` 中处理
|
|
||||||
|
|
||||||
### 步骤 2:`src/pages/SystemSettings.tsx` — 完善 AI 接口配置
|
|
||||||
**目标文件**:`src/pages/SystemSettings.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- AI 接口集成卡片中,将现有的 `apiEndpoint` / `apiKey` 的 placeholder 和 label 改为明确指向 Kimi
|
|
||||||
- `testApi` 函数改为真实调用 Kimi `/v1/models` 或简单 chat completion 进行连通性测试
|
|
||||||
- `resetToDefault` 中保留新字段的默认空值
|
|
||||||
|
|
||||||
### 步骤 3:`src/pages/TemplateManage.tsx` — 工具栏新增 AI 区域按钮
|
|
||||||
**目标文件**:`src/pages/TemplateManage.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- import 中增加 `Bot` from `lucide-react`
|
|
||||||
- 新增 `insertAiRegion` 函数:prompt 输入名称 → 重名检查(`querySelector([data-ai-id="..."])`)→ `execCommand('insertHTML')` 插入标准 `ai-region` DOM
|
|
||||||
- 工具栏 JSX 中在 `ImageIcon` 右侧新增蓝色 `Bot` 按钮
|
|
||||||
- 插入后调用 `saveTemplateContent()`
|
|
||||||
|
|
||||||
### 步骤 4:`src/pages/ReportEditor.tsx` — 工具栏 + activeTab 扩展
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- import 中增加 `Bot`, `Mic`, `MicOff`, `ImagePlus`, `Sparkles`, `Send` 等图标
|
|
||||||
- `activeTab` 类型扩展为 `'info' | 'video' | 'ai'`
|
|
||||||
- 工具栏 JSX 中在 `ImageIcon` 右侧新增蓝色 `Bot` 按钮及 `insertAiRegion` 函数
|
|
||||||
- 右侧 Tab 按钮区新增 "AI撰写" 切换按钮
|
|
||||||
|
|
||||||
### 步骤 5:`src/pages/ReportEditor.tsx` — AI 核心状态与逻辑
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- 新增 State:
|
|
||||||
- `chatInput`, `chatMessages`, `isGenerating`
|
|
||||||
- `aiSelectedFrames`, `aiTargetRegion`, `aiModifyEnabled`
|
|
||||||
- `isListening`, `aiUploadedImages`, `speechRecognitionRef`
|
|
||||||
- `quickPrompts`, `isEditingPrompts`
|
|
||||||
- `diffModal`
|
|
||||||
- 新增函数:
|
|
||||||
- `checkAiRegions()`: 扫描编辑器内所有 `.ai-region` 返回 id/title 列表
|
|
||||||
- `toggleListening()`: Web Speech API 语音识别
|
|
||||||
- `handleAIGenerate(text)`: 组装多模态 message → Fetch 调用 Kimi → 解析 JSON → 更新聊天 → 触发 Diff
|
|
||||||
- `confirmAiInjection(newHtml, regionId)`: 注入 HTML → 视觉高亮动画 → saveDraft
|
|
||||||
- 注意:所有 DOM 修改后必须同步 `contentRef.current = editorRef.current.innerHTML` 并 `saveDraftToStorage()`
|
|
||||||
|
|
||||||
### 步骤 6:`src/pages/ReportEditor.tsx` — AI 面板 UI
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- 在 `activeTab === 'ai'` 分支中渲染完整面板:
|
|
||||||
- 顶部:聊天气泡区(user 右蓝 / model 左白)
|
|
||||||
- 中部:区域锚定下拉 + "允许修改正文" checkbox
|
|
||||||
- 中下部:关键帧缩略图多选 + 本地图片预览
|
|
||||||
- 底部:快捷指令胶囊 + 多行输入框(带 Mic / ImagePlus / Send 按钮)
|
|
||||||
- 在最外层新增 `diffModal` 弹窗 DOM(左右对比、可编辑右侧、确认/放弃按钮)
|
|
||||||
|
|
||||||
### 步骤 7:`src/index.css` — AI 区域打印样式
|
|
||||||
**目标文件**:`src/index.css`
|
|
||||||
**修改内容**:
|
|
||||||
- `@media print` 中隐藏 `.ai-region` 的蓝色边框和标签,或将其设为透明/灰色,避免打印时显示编辑态样式
|
|
||||||
|
|
||||||
## 3. 依赖关系
|
|
||||||
- 步骤 1(types)必须在步骤 2/3/4/5/6 之前
|
|
||||||
- 步骤 3(TemplateManage)和步骤 4-6(ReportEditor)可并行
|
|
||||||
- 步骤 2(SystemSettings)可独立并行
|
|
||||||
- 步骤 7(CSS)在最后执行
|
|
||||||
|
|
||||||
## 4. 风险预案
|
|
||||||
- 若 Kimi API 调用失败,catch 中向聊天列表追加 `[系统错误]` 消息,不阻断页面
|
|
||||||
- 若 `JSON.parse` 失败,尝试用正则提取 JSON 子串后再解析,仍失败则展示原始文本
|
|
||||||
- 若用户浏览器不支持 Web Speech API,点击麦克风时 alert 提示并优雅降级
|
|
||||||
- 若 `ai-region` 被用户手动删除,下拉选择器实时扫描会同步更新为空
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 实现方案 — 2026-04-19-02-48-25
|
|
||||||
|
|
||||||
## 1. 方案概述
|
|
||||||
基于 OpenAI 兼容协议进行标准化抽象。将 4 个散装字段收敛为 `activeAiProvider` + `aiProviders` 字典结构。UI 改为"选择器 + 动态表单",调用端完全配置驱动。通过初始化时的数据迁移保证旧用户无损升级。
|
|
||||||
|
|
||||||
## 2. 详细步骤
|
|
||||||
|
|
||||||
### 步骤 1:`src/types.ts` — 数据结构重构
|
|
||||||
**目标文件**:`src/types.ts`
|
|
||||||
**修改内容**:
|
|
||||||
1. 新增 `AiProviderConfig` 接口:
|
|
||||||
```ts
|
|
||||||
export interface AiProviderConfig {
|
|
||||||
endpoint: string;
|
|
||||||
apiKey: string;
|
|
||||||
modelName: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. 重构 `SystemSettings`:
|
|
||||||
- 删除:`apiEndpoint`, `apiKey`, `kimiApiKey`, `kimiApiEndpoint`
|
|
||||||
- 新增:`activeAiProvider: string`
|
|
||||||
- 新增:`aiProviders: Record<string, AiProviderConfig>`
|
|
||||||
3. 新增默认预设常量:
|
|
||||||
```ts
|
|
||||||
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'kimi-k2-5' },
|
|
||||||
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: '' }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:`src/pages/SystemSettings.tsx` — UI 重构 + 数据迁移
|
|
||||||
**目标文件**:`src/pages/SystemSettings.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
1. **初始化数据迁移**(在 `useEffect` 读取 settings 后):
|
|
||||||
```ts
|
|
||||||
const migrateOldAiSettings = (saved: any) => {
|
|
||||||
if (!saved.aiProviders) {
|
|
||||||
const providers = { ...DEFAULT_AI_PROVIDERS };
|
|
||||||
if (saved.kimiApiKey || saved.kimiApiEndpoint) {
|
|
||||||
providers.kimi = {
|
|
||||||
endpoint: saved.kimiApiEndpoint || providers.kimi.endpoint,
|
|
||||||
apiKey: saved.kimiApiKey || '',
|
|
||||||
modelName: 'kimi-k2-5'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
saved.aiProviders = providers;
|
|
||||||
saved.activeAiProvider = 'kimi';
|
|
||||||
// 清理旧字段(可选,保留 localStorage 中不删除,但代码不再读取)
|
|
||||||
storage.set('systemSettings', saved);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
2. **状态初始化**:
|
|
||||||
```ts
|
|
||||||
const [settings, setSettings] = useState<SystemSettings>({
|
|
||||||
frameCount: 12,
|
|
||||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
|
||||||
defaultTemplate: '',
|
|
||||||
frameMode: 'uniform',
|
|
||||||
activeAiProvider: 'kimi',
|
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
3. **UI 改造**:
|
|
||||||
- 用一个 `<select>` 选择 `activeAiProvider`(kimi / deepseek / openai / custom)
|
|
||||||
- 下方动态展示 3 个输入框:
|
|
||||||
- Base URL(type="url")
|
|
||||||
- API Key(type="password")
|
|
||||||
- Model Name(type="text")
|
|
||||||
- 输入框的 `onChange` 更新 `aiProviders[activeAiProvider]` 的对应字段
|
|
||||||
4. **`testApi` 重构**:
|
|
||||||
- 读取当前 `activeAiProvider` 对应的 `endpoint` 和 `apiKey`
|
|
||||||
- 发起 `GET {endpoint}/models` 测试连通性
|
|
||||||
- 若失败,fallback 到发一个极简的 chat completion(`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
||||||
5. **`resetToDefault`**:使用新的数据结构,包含 `activeAiProvider: 'kimi'` 和 `DEFAULT_AI_PROVIDERS`
|
|
||||||
|
|
||||||
### 步骤 3:`src/pages/ReportEditor.tsx` — 调用逻辑解耦
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
1. `handleAIGenerate` 中:
|
|
||||||
```ts
|
|
||||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
|
||||||
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
|
|
||||||
const apiKey = provider?.apiKey || '';
|
|
||||||
const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1';
|
|
||||||
const modelName = provider?.modelName || 'kimi-k2-5';
|
|
||||||
```
|
|
||||||
2. Fetch body 中:
|
|
||||||
```ts
|
|
||||||
model: modelName,
|
|
||||||
```
|
|
||||||
3. 错误提示文案从"尚未配置 Kimi API Key"改为"尚未配置 AI API Key"
|
|
||||||
|
|
||||||
### 步骤 4:`src/pages/Login.tsx` — 默认初始化更新(检查)
|
|
||||||
**目标文件**:`src/pages/Login.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
- 检查 `initData()` 或 `defaultSettings` 中是否硬编码了旧的 `apiKey`/`apiEndpoint`
|
|
||||||
- 若有,替换为新的 `activeAiProvider` + `aiProviders` 结构
|
|
||||||
- 由于 Login.tsx 的初始化是全局唯一入口,必须确保新用户首次使用时 `systemSettings` 包含正确的默认 AI 配置
|
|
||||||
|
|
||||||
### 步骤 5:CSS / 其他清理
|
|
||||||
- `index.css` 无需修改(本次不涉及新样式)
|
|
||||||
- `tsconfig.json` 无需修改
|
|
||||||
|
|
||||||
## 3. 依赖关系
|
|
||||||
- 步骤 1(types)必须先完成
|
|
||||||
- 步骤 2(SystemSettings)和步骤 3(ReportEditor)可并行
|
|
||||||
- 步骤 4(Login)在步骤 1 之后即可进行
|
|
||||||
- 步骤 5 在最后
|
|
||||||
|
|
||||||
## 4. 风险预案
|
|
||||||
- **数据迁移失败**:若 `localStorage` 中旧 settings 读取异常,使用 `try/catch` 包裹迁移逻辑,失败时回退到 `DEFAULT_AI_PROVIDERS`
|
|
||||||
- **类型不匹配**:旧代码中可能还有未清理的 `settings.apiKey` 引用,build/lint 阶段会暴露,逐一修复
|
|
||||||
- **Provider 配置为空**:在 ReportEditor 调用时增加 `provider?.apiKey` 的判空保护,避免 undefined 报错
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# 实现方案 — 2026-04-19-03-03-55
|
|
||||||
|
|
||||||
## 1. 方案概述
|
|
||||||
三处修补:① endpoint 尾部斜杠净化防止 404;② testApi 捕获模型列表并动态切换 Model Name 输入框为下拉栏;③ 将 `chatMessages` 纳入现有草稿持久化生命周期。
|
|
||||||
|
|
||||||
## 2. 详细步骤
|
|
||||||
|
|
||||||
### 步骤 1:`src/pages/ReportEditor.tsx` — endpoint 净化
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
在 `handleAIGenerate` 中,将:
|
|
||||||
```ts
|
|
||||||
const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1';
|
|
||||||
```
|
|
||||||
改为:
|
|
||||||
```ts
|
|
||||||
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:`src/pages/ReportEditor.tsx` — 聊天记录持久化
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
1. `stateRef` 增加 `chatMessages`:
|
|
||||||
```ts
|
|
||||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages });
|
|
||||||
```
|
|
||||||
2. `saveDraftToStorage` 增加 `chatMessages`:
|
|
||||||
```ts
|
|
||||||
storage.set(key, {
|
|
||||||
content: currentContent,
|
|
||||||
draftReportId: reportId || null,
|
|
||||||
reportData: stateRef.current.reportData,
|
|
||||||
videos: stateRef.current.videos,
|
|
||||||
capturedFrames: stateRef.current.capturedFrames,
|
|
||||||
activeTab: stateRef.current.activeTab,
|
|
||||||
loadedTemplateId: stateRef.current.loadedTemplateId,
|
|
||||||
chatMessages: stateRef.current.chatMessages
|
|
||||||
});
|
|
||||||
```
|
|
||||||
3. 在 `setChatMessages` 的调用处同步更新 `stateRef.current.chatMessages`:
|
|
||||||
- `handleAIGenerate` 中发送 user 消息时
|
|
||||||
- `handleAIGenerate` 中收到 model 消息时
|
|
||||||
- 也可在 `setChatInput('')` 之后统一用 `useEffect` 监听 `chatMessages` 变化来同步 ref
|
|
||||||
|
|
||||||
更简单的方案:增加一个 `useEffect` 监听 `chatMessages`:
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
stateRef.current.chatMessages = chatMessages;
|
|
||||||
}, [chatMessages]);
|
|
||||||
```
|
|
||||||
4. 初始化 `useEffect`(draft 恢复分支)中恢复 `chatMessages`:
|
|
||||||
```ts
|
|
||||||
if (draft.chatMessages) {
|
|
||||||
setChatMessages(draft.chatMessages);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 3:`src/pages/SystemSettings.tsx` — 模型名称下拉栏
|
|
||||||
**目标文件**:`src/pages/SystemSettings.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
1. 新增 state:
|
|
||||||
```ts
|
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
|
||||||
```
|
|
||||||
2. 修改 `testApi`:
|
|
||||||
```ts
|
|
||||||
const testApi = async () => {
|
|
||||||
const provider = settings.aiProviders[settings.activeAiProvider];
|
|
||||||
if (!provider?.apiKey) { alert('请先输入 API 密钥'); return; }
|
|
||||||
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([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
3. Model Name UI 改为条件渲染:
|
|
||||||
```tsx
|
|
||||||
{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" ... />
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 依赖关系
|
|
||||||
- 步骤 1 和步骤 2 可并行(都在 ReportEditor.tsx)
|
|
||||||
- 步骤 3 独立(SystemSettings.tsx)
|
|
||||||
|
|
||||||
## 4. 风险预案
|
|
||||||
- 若 `/models` 接口返回格式非标准 OpenAI 格式(无 `data` 数组),`models` 列表为空,自动回退到 input 输入框
|
|
||||||
- 若 draft 中没有 `chatMessages`(旧 draft),`setChatMessages` 不执行,保持空数组
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 实现方案 — 2026-04-19-03-19-57
|
|
||||||
|
|
||||||
## 1. 方案概述
|
|
||||||
在 `ReportEditor.tsx` 中完成两处修补:① 将 `chatInput` 纳入草稿持久化生命周期;② `handleAIGenerate` 中根据是否有图片动态选择 `content` 类型(字符串 vs 数组)。
|
|
||||||
|
|
||||||
## 2. 详细步骤
|
|
||||||
|
|
||||||
### 步骤 1:chatInput 持久化
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
1. `stateRef` 增加 `chatInput`:
|
|
||||||
```ts
|
|
||||||
const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages, chatInput });
|
|
||||||
```
|
|
||||||
2. `saveDraftToStorage` 增加 `chatInput`:
|
|
||||||
```ts
|
|
||||||
chatMessages: stateRef.current.chatMessages,
|
|
||||||
chatInput: stateRef.current.chatInput
|
|
||||||
```
|
|
||||||
3. 在已有的 `useEffect` 监听 `chatMessages` 下方,增加监听 `chatInput`:
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
stateRef.current.chatInput = chatInput;
|
|
||||||
}, [chatInput]);
|
|
||||||
```
|
|
||||||
4. 所有 4 处草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)增加:
|
|
||||||
```ts
|
|
||||||
if (draft.chatInput) setChatInput(draft.chatInput);
|
|
||||||
stateRef.current = { ...stateRef.current, chatInput: draft.chatInput || '' };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2:API content 格式自适应
|
|
||||||
**目标文件**:`src/pages/ReportEditor.tsx`
|
|
||||||
**修改内容**:
|
|
||||||
在 `handleAIGenerate` 中,将 `messageContent` 的组装逻辑改为:
|
|
||||||
```ts
|
|
||||||
const selectedFrameUrls = aiSelectedFrames.map(id => capturedFrames.find(f => f.id === id)?.dataUrl).filter(Boolean);
|
|
||||||
const allImages = [...selectedFrameUrls, ...aiUploadedImages.map(i => i.dataUrl)];
|
|
||||||
let promptText = `【医生指令】: ${text}`;
|
|
||||||
if (aiModifyEnabled && targetRegionEl) {
|
|
||||||
promptText = `【当前区域 HTML 源码】:\n${currentHtml}\n\n${promptText}`;
|
|
||||||
}
|
|
||||||
// 动态选择 content 类型
|
|
||||||
let finalContent: any = promptText;
|
|
||||||
if (allImages.length > 0) {
|
|
||||||
const visionContent: any[] = [];
|
|
||||||
allImages.forEach(url => {
|
|
||||||
visionContent.push({ type: 'image_url', image_url: { url } });
|
|
||||||
});
|
|
||||||
visionContent.push({ type: 'text', text: promptText });
|
|
||||||
finalContent = visionContent;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
然后在 fetch body 中:
|
|
||||||
```ts
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: finalContent }
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 依赖关系
|
|
||||||
两步都在 `ReportEditor.tsx` 中,可视为同一批修改。
|
|
||||||
|
|
||||||
## 4. 风险预案
|
|
||||||
- 若旧 draft 中无 `chatInput`,`setChatInput('')` 会清空输入框(符合预期)
|
|
||||||
- 纯文本模型的 `content` 必须为 `string` 类型,不能是 `number` 或其他类型。`promptText` 是模板字符串,类型安全
|
|
||||||
283
工程分析/工作流规范.md
283
工程分析/工作流规范.md
@@ -1,283 +0,0 @@
|
|||||||
# 代码编纂工作流规范
|
|
||||||
|
|
||||||
> 版本:V1.0
|
|
||||||
> 适用范围:本项目所有代码修改、功能迭代、Bug 修复、重构任务
|
|
||||||
> 生效日期:2026-04-19
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、工作流总览
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 0. 记录开始时间戳 │
|
|
||||||
│ 1. 阅读工程整体分析 + 经验记录 │
|
|
||||||
│ 2. 需求分析 → 写入 需求分析-{时间}.md → 【用户审核】 │
|
|
||||||
│ 3. 实现方案 → 写入 实现方案-{时间}.md → 【用户审核】 │
|
|
||||||
│ 4. 测试方案 → 写入 测试方案-{时间}.md → 【用户审核】 │
|
|
||||||
│ 5. 执行前再次阅读 经验记录.md(防止踩坑) │
|
|
||||||
│ 执行修改 │
|
|
||||||
│ 执行后向 经验记录.md 追加新踩坑记录(四段式) │
|
|
||||||
│ 6. Git 提交 → Gitea 推送 → 提醒用户 │
|
|
||||||
│ 7. npm 重新构建 + 部署 │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、步骤详解
|
|
||||||
|
|
||||||
### 步骤 0:记录开始时间戳
|
|
||||||
|
|
||||||
每次接收到修改需求时,首先获取当前时间,格式为:
|
|
||||||
```
|
|
||||||
{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}
|
|
||||||
```
|
|
||||||
|
|
||||||
**示例**:`2026-04-19-02-00-33`
|
|
||||||
|
|
||||||
该时间戳贯穿整个工作流,用于命名所有相关文档。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 1:阅读工程分析文档
|
|
||||||
|
|
||||||
**必做事项**:
|
|
||||||
- 阅读 `工程整体分析.md`,确认当前架构、技术栈、高风险区域
|
|
||||||
- 阅读 `经验记录.md`,回顾与本次需求相关的历史踩坑记录
|
|
||||||
- 若 `工程分析` 文件夹或上述文档不存在,立即创建
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 2:需求分析
|
|
||||||
|
|
||||||
**输出文件**:`需求分析-{时间戳}.md`
|
|
||||||
|
|
||||||
**必须包含的内容**:
|
|
||||||
1. **需求背景**:用户原始需求的转述
|
|
||||||
2. **需求拆解**:将大需求拆分为可执行的原子任务
|
|
||||||
3. **影响范围**:列出需要修改的文件清单(基于工程整体分析中的高风险区域判断)
|
|
||||||
4. **优先级排序**:P0(阻塞)/ P1(重要)/ P2(优化)
|
|
||||||
5. **验收标准**:明确"做到什么程度算完成"
|
|
||||||
|
|
||||||
**流转规则**:
|
|
||||||
- 文档写完后,**必须停止执行**,等待用户二次人工审核确认
|
|
||||||
- 用户确认后,方可进入步骤 3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 3:实现方案
|
|
||||||
|
|
||||||
**输出文件**:`实现方案-{时间戳}.md`
|
|
||||||
|
|
||||||
**必须包含的内容**:
|
|
||||||
1. **方案概述**:整体技术思路(1-3 句话)
|
|
||||||
2. **详细步骤**:按文件/模块逐条列出修改点,每条包含:
|
|
||||||
- 目标文件路径
|
|
||||||
- 具体修改内容(新增/删除/修改的代码逻辑)
|
|
||||||
- 与现有逻辑的兼容策略(如何处理旧数据、默认值、降级)
|
|
||||||
3. **依赖关系**:哪些修改有先后顺序,哪些可以并行
|
|
||||||
4. **风险预案**:如果方案失败,回滚策略是什么
|
|
||||||
|
|
||||||
**流转规则**:
|
|
||||||
- 文档写完后,**必须停止执行**,等待用户二次人工审核确认
|
|
||||||
- 用户确认后,方可进入步骤 4
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 4:测试方案
|
|
||||||
|
|
||||||
**输出文件**:`测试方案-{时间戳}.md`
|
|
||||||
|
|
||||||
**必须包含的内容**:
|
|
||||||
1. **测试范围**:哪些功能需要验证
|
|
||||||
2. **测试步骤**:手动的操作路径(因本项目无自动化测试)
|
|
||||||
3. **预期结果**:每一步的正确输出是什么
|
|
||||||
4. **边界场景**:异常输入、空值、大数据量、快速切换路由等
|
|
||||||
5. **回滚检查**:验证失败后如何恢复到修改前状态
|
|
||||||
|
|
||||||
**流转规则**:
|
|
||||||
- 文档写完后,**必须停止执行**,等待用户二次人工审核确认
|
|
||||||
- 用户确认后,方可进入步骤 5
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 5:执行修改 + 经验沉淀
|
|
||||||
|
|
||||||
**执行前**:
|
|
||||||
- 最后一次阅读 `经验记录.md`,确认本次修改不会触发已知坑点
|
|
||||||
- 若发现新的潜在风险,在实现方案文档中补充后再执行
|
|
||||||
|
|
||||||
**执行中**:
|
|
||||||
- 严格按照实现方案的步骤执行,不擅自扩大修改范围
|
|
||||||
- 若遇到方案外的意外问题,暂停执行,记录问题,与用户沟通后再继续
|
|
||||||
|
|
||||||
**执行后**:
|
|
||||||
- 按测试方案逐项验证
|
|
||||||
- 向 `经验记录.md` 追加新记录(仅当实际遇到问题时),格式如下:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 记录 N:{问题标题}
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
{问题描述}
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
{根因分析}
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
{具体修复步骤}
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
{给未来自己的警告}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 6:Git 备份
|
|
||||||
|
|
||||||
**提交规范**:
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "{时间戳} {修改简要描述}"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
**必须包含的信息**:
|
|
||||||
- 时间戳(与需求分析文档一致)
|
|
||||||
- 本次修改的简要描述(1-2 句话)
|
|
||||||
|
|
||||||
**完成后**:
|
|
||||||
- 明确提醒用户已完成 Gitea 备份
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 步骤 7:重新部署
|
|
||||||
|
|
||||||
**标准部署脚本**:
|
|
||||||
```powershell
|
|
||||||
# 1. 停止旧服务
|
|
||||||
Stop-Process -Id (Get-NetTCPConnection -LocalPort 4173 -ErrorAction SilentlyContinue).OwningProcess -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# 2. 重新构建
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3. 以独立进程启动预览服务(避免后台任务超时杀死)
|
|
||||||
Start-Process -FilePath "powershell.exe" -ArgumentList "-Command","cd '$PWD'; npm run preview -- --host" -WindowStyle Hidden
|
|
||||||
|
|
||||||
# 4. 验证
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
Invoke-WebRequest -Uri http://127.0.0.1:4173/ -UseBasicParsing -TimeoutSec 5
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:
|
|
||||||
- **严禁**使用 `Shell(run_in_background=true)` 运行 `npm run preview`,因为任务超时机制(默认 60s)会强制终止 preview 进程,导致服务中断(参见经验记录-21)。
|
|
||||||
- 必须使用 `Start-Process` 创建独立的 Windows 进程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、禁忌清单(严格执行)
|
|
||||||
|
|
||||||
| 编号 | 禁忌行为 | 后果 | 正确做法 |
|
|
||||||
|------|----------|------|----------|
|
|
||||||
| 1 | 跳过需求分析文档直接写代码 | 需求理解偏差,返工 | 必须先写文档,等用户确认 |
|
|
||||||
| 2 | 跳过实现方案文档直接改代码 | 架构混乱,影响范围失控 | 必须先写文档,等用户确认 |
|
|
||||||
| 3 | 跳过测试方案文档直接上线 | 遗漏边界场景,线上故障 | 必须先写文档,等用户确认 |
|
|
||||||
| 4 | 使用 `Shell(run_in_background=true)` 运行 `npm run preview` | 60s 后服务被强制杀死 | 使用 `Start-Process` 启动独立进程 |
|
|
||||||
| 5 | 修改后不更新 `经验记录.md` | 重复踩坑 | 每次遇到新问题必须追加记录 |
|
|
||||||
| 6 | `contentEditable` 插入多行缩进 HTML | 浏览器解析出额外文本节点,破坏排版 | 必须压缩为紧凑单行 HTML |
|
|
||||||
| 7 | 直接 `target.remove()` 删除 DOM 节点 | 撤销栈失效,WebKit 误删父级 `<p>` | 使用 `Range.selectNode + execCommand('delete')` |
|
|
||||||
| 8 | 将 `useRef` 作为自动保存唯一数据源 | StrictMode 下首次卸载覆盖有效 draft | 自动保存函数从最新 React state 读取 |
|
|
||||||
| 9 | 异步循环中用 `await setTimeout` 阻塞 | 抽帧/UI 更新被串行延迟 | 使用裸 `setTimeout` 推入事件队列 |
|
|
||||||
| 10 | 用 `body { padding }` 控制打印边距 | 第二页及后续页边距失效 | 使用 `@page { margin }` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、文档模板
|
|
||||||
|
|
||||||
### 需求分析模板
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# 需求分析 — {时间戳}
|
|
||||||
|
|
||||||
## 1. 需求背景
|
|
||||||
{用户原始需求}
|
|
||||||
|
|
||||||
## 2. 需求拆解
|
|
||||||
- [ ] 任务 1:{描述}
|
|
||||||
- [ ] 任务 2:{描述}
|
|
||||||
|
|
||||||
## 3. 影响范围
|
|
||||||
| 文件 | 修改类型 | 风险等级 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| {路径} | 新增/修改/删除 | 高/中/低 |
|
|
||||||
|
|
||||||
## 4. 优先级
|
|
||||||
- P0:{阻塞项}
|
|
||||||
- P1:{重要项}
|
|
||||||
- P2:{优化项}
|
|
||||||
|
|
||||||
## 5. 验收标准
|
|
||||||
- {标准 1}
|
|
||||||
- {标准 2}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 实现方案模板
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# 实现方案 — {时间戳}
|
|
||||||
|
|
||||||
## 1. 方案概述
|
|
||||||
{整体思路}
|
|
||||||
|
|
||||||
## 2. 详细步骤
|
|
||||||
|
|
||||||
### 步骤 1:{模块/文件}
|
|
||||||
**目标文件**:`{路径}`
|
|
||||||
**修改内容**:{详细描述}
|
|
||||||
**兼容策略**:{如何处理旧数据/默认值}
|
|
||||||
|
|
||||||
### 步骤 2:{模块/文件}
|
|
||||||
...
|
|
||||||
|
|
||||||
## 3. 依赖关系
|
|
||||||
- {步骤 A} 必须在 {步骤 B} 之前执行
|
|
||||||
- {步骤 C} 和 {步骤 D} 可并行
|
|
||||||
|
|
||||||
## 4. 风险预案
|
|
||||||
- 若 {某步骤} 失败,回滚方式为 {...}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试方案模板
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# 测试方案 — {时间戳}
|
|
||||||
|
|
||||||
## 1. 测试范围
|
|
||||||
{需要验证的功能点}
|
|
||||||
|
|
||||||
## 2. 测试步骤与预期结果
|
|
||||||
|
|
||||||
### 场景 1:{正常流程}
|
|
||||||
1. 操作:{步骤}
|
|
||||||
预期:{结果}
|
|
||||||
2. 操作:{步骤}
|
|
||||||
预期:{结果}
|
|
||||||
|
|
||||||
### 场景 2:{边界/异常}
|
|
||||||
...
|
|
||||||
|
|
||||||
## 3. 回滚检查
|
|
||||||
- 若测试失败,执行 `{命令}` 恢复到修改前状态
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、附录:项目快速参考
|
|
||||||
|
|
||||||
- **开发**:`npm run dev`(端口 3000)
|
|
||||||
- **构建**:`npm run build`
|
|
||||||
- **预览**:`npm run preview -- --host`(端口 4173)
|
|
||||||
- **类型检查**:`npm run lint`
|
|
||||||
- **Gitea 仓库**:`http://192.168.31.5:5002/admin/Mdeical_Sur_Report.git`
|
|
||||||
- **当前分支**:`main`
|
|
||||||
174
工程分析/工程整体分析.md
174
工程分析/工程整体分析.md
@@ -1,174 +0,0 @@
|
|||||||
# 手术图文病历报告系统 — 工程整体分析
|
|
||||||
|
|
||||||
> 版本:V1.3
|
|
||||||
> 最后更新:2026-04-19
|
|
||||||
> 分析维度:架构、数据流、核心模块、风险点、扩展方向
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、项目定位
|
|
||||||
|
|
||||||
**手术图文病历报告系统** 是一款面向医院手术室场景的纯前端单页应用(SPA),核心能力包括:
|
|
||||||
|
|
||||||
- 富文本编辑器撰写结构化手术图文报告
|
|
||||||
- 手术视频上传 + 自动/手动关键帧抽取,拖拽插入报告
|
|
||||||
- 报告模板管理、用户权限(RBAC)、系统设置
|
|
||||||
- 导出 PDF / JSON 格式报告与模板
|
|
||||||
|
|
||||||
**关键约束**:无后端服务器,所有数据持久化在浏览器 `localStorage`(约 5MB 容量上限)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、技术栈
|
|
||||||
|
|
||||||
| 层级 | 技术 |
|
|
||||||
|------|------|
|
|
||||||
| 框架 | React 19 + TypeScript 5.8 |
|
|
||||||
| 构建 | Vite 6 |
|
|
||||||
| 样式 | Tailwind CSS v4(`@theme` / `@import "tailwindcss"` 新语法) |
|
|
||||||
| 路由 | React Router DOM v7 |
|
|
||||||
| 图标 | lucide-react |
|
|
||||||
| 动画 | motion |
|
|
||||||
| AI SDK | `@google/genai`(已安装,业务代码中**未实际调用**) |
|
|
||||||
|
|
||||||
**无 ESLint、无 Prettier、无单元测试框架**,唯一类型检查为 `tsc --noEmit`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ └── Sidebar.tsx # 左侧导航(角色过滤、自动折叠)
|
|
||||||
├── pages/
|
|
||||||
│ ├── Login.tsx # 登录页 + 全局 initData(默认用户/模板/字段/素材)
|
|
||||||
│ ├── Dashboard.tsx # 工作台(统计卡片、SVG 趋势图)
|
|
||||||
│ ├── ReportEditor.tsx # 核心:报告编辑器(2,200+ 行,最大文件)
|
|
||||||
│ ├── ReportManage.tsx # 报告列表(搜索、筛选、批量操作、历史回溯)
|
|
||||||
│ ├── ReportView.tsx # 报告只读查看 + 打印
|
|
||||||
│ ├── TemplateManage.tsx # 模板编辑器(1,600+ 行,自定义 Undo/Redo)
|
|
||||||
│ ├── UserManage.tsx # 用户管理(RBAC、签名上传、模板权限)
|
|
||||||
│ └── SystemSettings.tsx # 系统设置(抽帧配置、AI API、默认模板)
|
|
||||||
├── utils/
|
|
||||||
│ ├── storage.ts # localStorage / sessionStorage 封装
|
|
||||||
│ ├── print.ts # iframe 打印工具(A4 样式、@page 边距)
|
|
||||||
│ └── defaultContent.ts # 默认模板 HTML(腹腔镜胆囊切除术报告)
|
|
||||||
├── App.tsx # BrowserRouter + 路由表
|
|
||||||
├── main.tsx # React 根挂载(StrictMode)
|
|
||||||
├── types.ts # 核心 TypeScript 类型
|
|
||||||
└── index.css # Tailwind 入口 + @theme 变量 + 打印媒体查询
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、数据持久化架构
|
|
||||||
|
|
||||||
**无全局状态库**(无 Redux/Zustand/Context)。每个页面独立通过 `useState` + `useEffect` 管理状态,`localStorage` 即数据库。
|
|
||||||
|
|
||||||
| localStorage Key | 说明 |
|
|
||||||
|------------------|------|
|
|
||||||
| `users` | 用户列表 |
|
|
||||||
| `currentUser` | 当前登录用户 |
|
|
||||||
| `reports` | 报告列表 |
|
|
||||||
| `templates` | 模板列表 |
|
|
||||||
| `systemSettings` | 系统设置 |
|
|
||||||
| `formFieldsConfig` | 动态字段配置 |
|
|
||||||
| `imageAssets` | 系统素材库(Base64 图片) |
|
|
||||||
| `reportEditorDraft_${username}` | 每用户报告草稿 |
|
|
||||||
| `customTimeFormats` | 用户自定义时间格式缓存 |
|
|
||||||
|
|
||||||
**容量风险**:关键帧采用 Canvas 压缩(最大宽度 800px、JPEG 质量 0.6)以控制体积。`storage.ts` 异常已改为 `console.error` 输出,不再静默吞掉。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、核心模块深度分析
|
|
||||||
|
|
||||||
### 5.1 富文本编辑器(ReportEditor / TemplateManage)
|
|
||||||
|
|
||||||
- **底层**:原生 `contentEditable` + `document.execCommand`
|
|
||||||
- **智能字段(Smart Field)三层嵌套**:
|
|
||||||
```html
|
|
||||||
<span class="smart-field-wrapper" contenteditable="false">
|
|
||||||
<span class="field-label">标签:</span>
|
|
||||||
<span class="field-value" contenteditable="true" data-bind="key"></span>
|
|
||||||
</span>​
|
|
||||||
```
|
|
||||||
- 外层 `contenteditable="false"` 保护标签不被逐字删除
|
|
||||||
- 输入层 `data-bind` 实现与右侧表单双向绑定
|
|
||||||
- 末尾追加 `​`(零宽空格)防止排版换行异常
|
|
||||||
- **图片占位符**:`<span class="image-placeholder">`,支持 `data-mode="frame|manual"` 分类隔离
|
|
||||||
- **自定义 Undo/Redo**(TemplateManage):基于 HTML 字符串快照的 `undoStack`/`redoStack`,完全接管撤销逻辑
|
|
||||||
|
|
||||||
### 5.2 视频分析(ReportEditor)
|
|
||||||
|
|
||||||
- 上传本地视频 → 生成 object URL
|
|
||||||
- 自动抽帧:按 `systemSettings.framePositions` 百分比位置逐帧截图
|
|
||||||
- 手动截图:点击按钮从当前播放时间捕获
|
|
||||||
- 图片压缩:Canvas 等比缩放至最大 800px 宽,JPEG 质量 0.6
|
|
||||||
- 非阻塞 `setTimeout` 队列式自动帧插入,避免阻塞抽帧循环
|
|
||||||
|
|
||||||
### 5.3 打印系统(utils/print.ts)
|
|
||||||
|
|
||||||
- 创建隐藏 iframe,写入带 A4 打印样式的 HTML
|
|
||||||
- `@page { margin: 15mm 10mm; }` 为**每一页**纸张独立分配边距
|
|
||||||
- `body { padding: 0 }` — 不可用 body padding 代替 @page margin
|
|
||||||
- 打印前临时设置 `document.title` 并注入 iframe `<title>`,确保 PDF 默认文件名正确
|
|
||||||
|
|
||||||
### 5.4 角色权限(RBAC)
|
|
||||||
|
|
||||||
| 角色 | 权限 |
|
|
||||||
|------|------|
|
|
||||||
| `super` | 全部页面、全部数据 |
|
|
||||||
| `admin` | 仅管理本科室用户;可管理模板;不能看系统设置中的 AI 配置 |
|
|
||||||
| `user` | 仅创建/查看/编辑自己的报告;可见被分配模板 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、高风险修改区域
|
|
||||||
|
|
||||||
以下模块**牵一发而动全身**,修改时必须同步检索所有相关文件:
|
|
||||||
|
|
||||||
1. **智能字段结构** → `types.ts`、`defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`、`index.css`、`print.ts`
|
|
||||||
2. **图片占位符(创建/填充/删除恢复)** → `defaultContent.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`
|
|
||||||
3. **打印样式** → `print.ts`、`index.css`(`@media print`)
|
|
||||||
4. **时间/日期格式** → `types.ts`、`ReportEditor.tsx`、`TemplateManage.tsx`
|
|
||||||
5. **数据初始化/默认值** → `Login.tsx`、`SystemSettings.tsx`
|
|
||||||
6. **自动保存/草稿** → `ReportEditor.tsx` 中的 `saveDraftToStorage`、`stateRef`、`contentRef`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、构建与部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发
|
|
||||||
npm run dev # vite --port=3000 --host=0.0.0.0
|
|
||||||
|
|
||||||
# 生产构建
|
|
||||||
npm run build # vite build → dist/
|
|
||||||
npm run preview # vite preview(默认端口 4173)
|
|
||||||
|
|
||||||
# 类型检查
|
|
||||||
npm run lint # tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
**当前部署状态**:通过 `npm run build && npm run preview -- --host` 在本机运行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、安全与限制
|
|
||||||
|
|
||||||
1. **密码明文存储**:`localStorage` 中 `users` 数组明文保存密码,纯前端架构固有限制
|
|
||||||
2. **XSS 风险**:报告/模板内容直接以 HTML 字符串存储并在 `innerHTML` 中渲染
|
|
||||||
3. **Gemini API Key**:通过 Vite `define` 注入客户端,构建后 key 暴露在静态 JS 中(当前源码未实际调用)
|
|
||||||
4. **无 HTTPS 强制**:Docker 部署默认 HTTP 80 端口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、默认账号
|
|
||||||
|
|
||||||
| 账号 | 密码 | 角色 |
|
|
||||||
|------|------|------|
|
|
||||||
| admin | 123456 | super |
|
|
||||||
| manager | 123456 | admin |
|
|
||||||
| doctor / 0001 | 123456 | user |
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 测试方案 — 2026-04-19-02-26-05
|
|
||||||
|
|
||||||
## 1. 测试范围
|
|
||||||
- TemplateManage / ReportEditor 工具栏 AI 区域插入
|
|
||||||
- SystemSettings Kimi API 配置保存
|
|
||||||
- ReportEditor AI 撰写面板全链路(聊天、多模态输入、API调用、Diff确认、注入高亮)
|
|
||||||
- 构建与部署验证
|
|
||||||
|
|
||||||
## 2. 测试步骤与预期结果
|
|
||||||
|
|
||||||
### 场景 1:AI 区域插入(TemplateManage)
|
|
||||||
1. 进入 `/template-manage`,点击工具栏蓝色 Bot 图标
|
|
||||||
预期:弹出 prompt,要求输入区域名称
|
|
||||||
2. 输入 "手术步骤",点击确认
|
|
||||||
预期:编辑器中出现蓝色虚线框区域,右上角有 "手术步骤-AI可编辑区域" 标签
|
|
||||||
3. 再次点击 Bot,再次输入 "手术步骤"
|
|
||||||
预期:alert 提示 "该区域名称已存在..."
|
|
||||||
4. 输入 "术后总结"
|
|
||||||
预期:成功插入第二个区域
|
|
||||||
|
|
||||||
### 场景 2:AI 区域插入(ReportEditor)
|
|
||||||
1. 进入 `/report-editor`,点击工具栏蓝色 Bot 图标
|
|
||||||
预期:与 TemplateManage 行为一致
|
|
||||||
|
|
||||||
### 场景 3:SystemSettings 配置保存
|
|
||||||
1. 进入 `/system-settings`,在 AI 接口集成中输入 API Key 和 Endpoint
|
|
||||||
2. 点击保存
|
|
||||||
预期:提示"设置已保存",刷新后值仍然保留
|
|
||||||
|
|
||||||
### 场景 4:AI 面板基础聊天(纯聊模式)
|
|
||||||
1. 进入 `/report-editor`,确保编辑器内已有 `ai-region`
|
|
||||||
2. 点击右侧 "AI撰写" Tab
|
|
||||||
预期:出现聊天面板,底部有输入框
|
|
||||||
3. 取消勾选 "允许修改正文"
|
|
||||||
4. 输入 "你好",按 Enter
|
|
||||||
预期:出现 user 气泡(右侧蓝色),随后出现 model 气泡(左侧白色),正文未被修改
|
|
||||||
|
|
||||||
### 场景 5:AI 修改正文(Diff 模式)
|
|
||||||
1. 勾选 "允许修改正文"
|
|
||||||
2. 在下拉框中选择一个 AI 区域
|
|
||||||
3. 输入 "请完善这段手术步骤描述"
|
|
||||||
4. 按 Enter
|
|
||||||
预期:AI 返回后弹出 Diff 弹窗,左侧显示原文,右侧显示 AI 版本
|
|
||||||
5. 在右侧编辑几个字,点击"确认并写入报告"
|
|
||||||
预期:弹窗关闭,编辑器对应区域内容更新,区域背景出现深蓝→淡蓝→透明渐变
|
|
||||||
6. 点击"放弃修改"
|
|
||||||
预期:弹窗关闭,正文保持原样
|
|
||||||
|
|
||||||
### 场景 6:多模态输入
|
|
||||||
1. 上传一个本地图片到 AI 面板
|
|
||||||
预期:输入框上方出现小图预览,带删除按钮
|
|
||||||
2. 在视频分析中截取一帧,回到 AI 面板勾选该帧
|
|
||||||
预期:缩略图上有蓝色勾选标记
|
|
||||||
3. 发送消息
|
|
||||||
预期:消息正常发送,图片随请求提交
|
|
||||||
|
|
||||||
### 场景 7:快捷指令
|
|
||||||
1. 点击快捷指令旁的 "⚙️"
|
|
||||||
预期:进入编辑模式,每个指令右上角出现红色删除按钮
|
|
||||||
2. 点击 "+ 添加",输入新的快捷指令
|
|
||||||
预期:新指令出现在列表中
|
|
||||||
3. 点击胶囊按钮
|
|
||||||
预期:指令文本自动填充到输入框
|
|
||||||
|
|
||||||
### 场景 8:路由切换数据保持
|
|
||||||
1. 在 AI 面板中发送几条消息
|
|
||||||
2. 切换到 `/report-manage`,再返回 `/report-editor`
|
|
||||||
预期:AI 聊天记录丢失(因未设计持久化,属于预期行为),但报告正文、AI 区域结构保留
|
|
||||||
|
|
||||||
### 场景 9:构建与部署
|
|
||||||
1. 执行 `npm run lint`
|
|
||||||
预期:无 TypeScript 类型错误
|
|
||||||
2. 执行 `npm run build`
|
|
||||||
预期:构建成功,dist/ 生成
|
|
||||||
3. 执行 `npm run preview -- --host`
|
|
||||||
预期:`http://localhost:4173/` 返回 200
|
|
||||||
|
|
||||||
## 3. 回滚检查
|
|
||||||
- 若测试失败,执行 `git checkout main` 恢复到修改前状态(本次 commit 之前)
|
|
||||||
- 或从 Gitea 拉取上一个可用版本
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 测试方案 — 2026-04-19-02-48-25
|
|
||||||
|
|
||||||
## 1. 测试范围
|
|
||||||
- SystemSettings 数据迁移(旧用户无损升级)
|
|
||||||
- SystemSettings 新 UI(服务商切换、动态表单、保存)
|
|
||||||
- ReportEditor AI 调用解耦(模型名随配置变化)
|
|
||||||
- 类型检查与构建
|
|
||||||
|
|
||||||
## 2. 测试步骤与预期结果
|
|
||||||
|
|
||||||
### 场景 1:旧用户数据迁移
|
|
||||||
1. 在浏览器 DevTools → Application → LocalStorage 中手动保留旧的 `systemSettings`(包含 `kimiApiKey: 'sk-test'` 和 `kimiApiEndpoint: 'https://api.moonshot.cn/v1'`)
|
|
||||||
2. 刷新页面,进入 `/system-settings`
|
|
||||||
预期:
|
|
||||||
- AI 接口集成卡片显示"Kimi"为当前选中服务商
|
|
||||||
- Base URL 自动填充 `https://api.moonshot.cn/v1`
|
|
||||||
- API Key 自动填充 `sk-test`
|
|
||||||
- Model Name 自动填充 `kimi-k2-5`
|
|
||||||
|
|
||||||
### 场景 2:新用户默认初始化
|
|
||||||
1. 清空 localStorage,重新登录
|
|
||||||
预期:进入 `/system-settings` 后,AI 配置默认展示 Kimi,且 Model Name 为 `kimi-k2-5`
|
|
||||||
|
|
||||||
### 场景 3:服务商切换
|
|
||||||
1. 在 `/system-settings` 中将服务商从 Kimi 切换为 DeepSeek
|
|
||||||
预期:下方三个输入框的值自动切换为 DeepSeek 的默认值
|
|
||||||
2. 修改 DeepSeek 的 API Key 为 `sk-ds-test`
|
|
||||||
3. 切换回 Kimi
|
|
||||||
预期:显示 Kimi 的配置(之前填的值保留)
|
|
||||||
4. 切换回 DeepSeek
|
|
||||||
预期:显示 `sk-ds-test`(证明各服务商配置独立存储)
|
|
||||||
|
|
||||||
### 场景 4:自定义服务商
|
|
||||||
1. 选择"自定义"
|
|
||||||
2. 填写 Base URL `http://localhost:11434/v1`、Key `ollama`、Model `llama3`
|
|
||||||
3. 保存
|
|
||||||
预期:保存成功,刷新后值保留
|
|
||||||
|
|
||||||
### 场景 5:ReportEditor 调用解耦
|
|
||||||
1. 在 SystemSettings 中选择 DeepSeek,Model 填 `deepseek-chat`
|
|
||||||
2. 进入 `/report-editor`,插入 AI 区域,切换到 AI撰写 Tab
|
|
||||||
3. 发送一条消息(不需要真实 API Key,只看 Network 请求或 console)
|
|
||||||
预期:
|
|
||||||
- 若 Key 为空,提示"尚未配置 AI API Key"
|
|
||||||
- 若有 Key,请求的 `model` 字段应为 `deepseek-chat`(而非硬编码 `kimi-k2-5`)
|
|
||||||
|
|
||||||
### 场景 6:类型检查与构建
|
|
||||||
1. `npm run lint`
|
|
||||||
预期:0 errors
|
|
||||||
2. `npm run build`
|
|
||||||
预期:成功
|
|
||||||
3. 访问 `http://localhost:4173/`
|
|
||||||
预期:200 OK
|
|
||||||
|
|
||||||
## 3. 回滚检查
|
|
||||||
- 若测试失败,执行 `git checkout main` 或从 Gitea 拉取上一个 commit
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 测试方案 — 2026-04-19-03-03-55
|
|
||||||
|
|
||||||
## 1. 测试范围
|
|
||||||
- API 404 修复(endpoint 尾部斜杠)
|
|
||||||
- 模型名称下拉栏动态切换
|
|
||||||
- AI 聊天记录路由切换持久化
|
|
||||||
|
|
||||||
## 2. 测试步骤与预期结果
|
|
||||||
|
|
||||||
### 场景 1:Endpoint 尾部斜杠修复
|
|
||||||
1. 在 SystemSettings 中故意将 Base URL 末尾多加一个 `/`:`https://api.moonshot.cn/v1/`
|
|
||||||
2. 保存,进入 ReportEditor,发送 AI 消息
|
|
||||||
预期:请求 URL 应为 `https://api.moonshot.cn/v1/chat/completions`(只有 1 个斜杠),不应 404
|
|
||||||
|
|
||||||
### 场景 2:模型名称下拉栏
|
|
||||||
1. 在 SystemSettings 中填写正确的 Base URL 和 API Key
|
|
||||||
2. 点击"测试连接"
|
|
||||||
预期:alert 显示连接成功,下方模型名称自动变为下拉栏,列出所有可用模型
|
|
||||||
3. 选择其中一个模型,保存
|
|
||||||
4. 刷新页面
|
|
||||||
预期:模型名称仍为下拉栏,选中值保留
|
|
||||||
|
|
||||||
### 场景 3:聊天记录持久化
|
|
||||||
1. 进入 ReportEditor,切换到 AI撰写 Tab
|
|
||||||
2. 发送 2-3 条消息(user + model)
|
|
||||||
3. 切换到 `/report-manage`,再返回 `/report-editor`
|
|
||||||
预期:AI撰写 Tab 中聊天记录仍然存在,与离开前一致
|
|
||||||
4. 刷新页面(模拟完全重载)
|
|
||||||
预期:聊天记录仍然从 draft 中恢复
|
|
||||||
|
|
||||||
### 场景 4:类型检查与构建
|
|
||||||
1. `npm run lint`
|
|
||||||
预期:0 errors
|
|
||||||
2. `npm run build`
|
|
||||||
预期:成功
|
|
||||||
|
|
||||||
## 3. 回滚检查
|
|
||||||
- 若测试失败,执行 `git checkout main` 恢复到上一个 commit
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 测试方案 — 2026-04-19-03-19-57
|
|
||||||
|
|
||||||
## 1. 测试范围
|
|
||||||
- chatInput 持久化(路由切换 + 页面刷新)
|
|
||||||
- API content 格式(纯文本 vs 带图片)
|
|
||||||
|
|
||||||
## 2. 测试步骤与预期结果
|
|
||||||
|
|
||||||
### 场景 1:纯文本消息(修复 400)
|
|
||||||
1. 进入 ReportEditor,切换到 AI撰写 Tab
|
|
||||||
2. **不上传任何图片,不勾选任何关键帧**
|
|
||||||
3. 输入 "你好",按 Enter
|
|
||||||
预期:Network 面板中请求体 `messages[1].content` 为字符串 `"【医生指令】: 你好"`,API 返回 200
|
|
||||||
|
|
||||||
### 场景 2:带图片消息(Vision 格式保留)
|
|
||||||
1. 勾选至少 1 个关键帧或上传 1 张本地图片
|
|
||||||
2. 输入 "描述这张图片",按 Enter
|
|
||||||
预期:Network 面板中请求体 `messages[1].content` 为数组,包含 `type: 'image_url'` 和 `type: 'text'` 两项
|
|
||||||
|
|
||||||
### 场景 3:chatInput 持久化
|
|
||||||
1. 在 AI 输入框中输入一段未发送的文字(不要按 Enter)
|
|
||||||
2. 切换到 `/report-manage`,再返回 `/report-editor`
|
|
||||||
预期:输入框中文字仍然存在
|
|
||||||
3. 刷新浏览器
|
|
||||||
预期:输入框中文字仍然从 draft 恢复
|
|
||||||
|
|
||||||
### 场景 4:类型检查与构建
|
|
||||||
1. `npm run lint`
|
|
||||||
预期:0 errors
|
|
||||||
2. `npm run build`
|
|
||||||
预期:成功
|
|
||||||
|
|
||||||
## 3. 回滚检查
|
|
||||||
- 若测试失败,执行 `git checkout main` 恢复到上一个 commit
|
|
||||||
916
工程分析/经验记录.md
916
工程分析/经验记录.md
@@ -1,916 +0,0 @@
|
|||||||
# 经验记录
|
|
||||||
|
|
||||||
> 本文档为项目统一知识库,记录开发过程中遇到的关键问题及解决方案。每次执行修改前必须阅读,防止重复踩坑。
|
|
||||||
> 记录格式:A. 具体问题 → B. 产生问题原因 → C. 解决问题方案 → D. 后续如何避免问题
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 1:report-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` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
|
|
||||||
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 9:contentEditable 中实现标签锁定与输入方格的双向绑定
|
|
||||||
|
|
||||||
**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. 插入智能字段后,字段后方会出现一个可见的空格(由 ` ` 和多行模板字符串中的换行/缩进空白引起)。
|
|
||||||
2. 光标位于 `<p>` 行首且后紧跟 `.smart-field-wrapper` 时按 Backspace,WebKit 内核会直接删除整段 `<p>` 而不是仅删除字段节点。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `insertSmartField` 的 HTML 字符串使用反引号多行模板,缩进和换行被浏览器解析为额外的文本节点;末尾显式拼接了 ` `。
|
|
||||||
2. `contenteditable="false"` 的 inline 元素处于行边界时,WebKit 的默认编辑行为会将整个包含该元素的块级父节点一并删除。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **压缩 HTML 字符串**:将 `insertSmartField` 和 `defaultContent.ts` 的 `smartField` 输出改为单行 HTML,移除所有无意义的换行和缩进,并去掉尾部的 ` `。
|
|
||||||
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 末尾增加 `​`(零宽空格),作为稳定的行内锚点。
|
|
||||||
3. **精准键盘删除**:配合 `Range.selectNode` + `execCommand('delete')`,不再直接 `remove()` DOM 节点。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 在 `contentEditable` 中删除元素时,**优先使用 `Range.selectNode` + `execCommand('delete')`** 而非直接 `remove()`,以确保撤销/重做等原生编辑行为正常工作。
|
|
||||||
- 插入 `inline-block` 或 `inline-flex` 控件时,可在其后追加 `​` 零宽空格,为浏览器提供稳定的行内文本锚点。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 12:TemplateManage 自定义 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. 医院 Logo(65×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` 和任何预置模板文件**,确保静态模板中的占位符结构与运行时插入逻辑保持一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 19:5 项交互修复(虚线框恢复、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 引用比较优化导致不刷新。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 26:API 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 净化逻辑,避免"测试通过、调用失败"的认知落差。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 27:State 未纳入 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 时的检查单。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 28:chatInput 草稿恢复遗漏 + 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`),不能无条件发送数组。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 29:Checkbox 在复杂 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(/​/g, '').trim()` 过滤零宽字符
|
|
||||||
- 重构 prompt 结构:先放「全局参考内容」,再放「目标区域源码」,最后放「医生指令」
|
|
||||||
- 同步优化 systemPrompt,明确告知大模型有两个信息源
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 在复杂 React 组件(尤其是与 contentEditable 共存)中使用 Checkbox 时,**优先使用 `<label>` 直接包裹 `<input>`** 的写法,避免依赖 `id`/`htmlFor`。
|
|
||||||
- 向大模型发送局部修改请求时,**必须同时提供全局上下文**,否则 AI 无法基于文档其他部分的信息进行推理和修改。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 30:AI「只聊天不干活」——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 状态应只影响「如何注入结果」,不应影响「是否要求模型生成结果」。
|
|
||||||
- 任何「目标区域注入」逻辑都必须配备**降级方案**(如光标处插入),防止因用户未选中区域而导致功能完全失效。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 31:AI 修改模式自动锁定目标区域 + 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 中应显式标注当前模式名称(如「修改模式」「对话模式」),大模型对显式标签的遵循度远高于隐式条件推断。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 32:AI 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 中明确使用「必须生成完整...」「不要只改写...」等强制性措辞,否则大模型倾向于做最小化修改。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 33:AI 越界生成——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 中明确声明"仅供理解,严禁输出"。
|
|
||||||
- 避免使用"补充完善""基于全局信息扩展"等容易被大模型过度解读的措辞。大模型会尽其所能地"满足"用户的指令,即使这意味着越界生成。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 34:contentEditable 回车导致段落溢出 .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,左右两侧容器应显式设置相同的默认字体样式,避免依赖内容自带的内联样式造成视觉不一致。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 36:AI 注入后 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 缺少必要的内联样式时,应在**前端后处理阶段**统一注入样式,而不是依赖大模型生成完整的样式代码(大模型对样式生成的稳定性较差)。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 37:AI 二次修改未弹窗 + 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。
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 需求分析 — 2026-04-19-02-26-05
|
|
||||||
|
|
||||||
## 1. 需求背景
|
|
||||||
为 ReportEditor 加入 AI 撰写功能,接入 Kimi-2.5 多模态模型,实现智能化的手术报告辅助撰写。参考 "参考-ReportEditor.tsx" 的设计范式,构建现代化的沉浸式 AI 对话 UI。
|
|
||||||
|
|
||||||
## 2. 需求拆解
|
|
||||||
- [ ] **Task 1**:TemplateManage 工具栏新增 "插入AI可编辑区域" 按钮,支持命名且不可重名
|
|
||||||
- [ ] **Task 2**:ReportEditor 工具栏同步新增同样的 AI 区域插入按钮
|
|
||||||
- [ ] **Task 3**:SystemSettings 完善 AI 接口集成,适配 Kimi-2.5 API(baseURL + apiKey)
|
|
||||||
- [ ] **Task 4**:ReportEditor 右侧新增 "AI撰写" Tab 面板,包含:
|
|
||||||
- 类微信风格的聊天对话 UI(气泡、Loading 动画)
|
|
||||||
- 定向区域锚定(下拉选择 `ai-region`)
|
|
||||||
- "允许修改正文" 沙盒开关
|
|
||||||
- 多模态输入:语音输入(Web Speech API)、视频关键帧选择、本地图片上传
|
|
||||||
- 快捷指令胶囊(可添加/删除/编辑)
|
|
||||||
- Diff 二次确认弹窗(左右对比、可编辑右侧、确认后注入)
|
|
||||||
- 注入后视觉反馈(深蓝→淡蓝→透明渐变高亮)
|
|
||||||
- [ ] **Task 5**:AI 调用逻辑:Fetch API 调用 Kimi API,严格 JSON Schema 输出(reply + updatedHtml)
|
|
||||||
|
|
||||||
## 3. 影响范围
|
|
||||||
| 文件 | 修改类型 | 风险等级 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `src/types.ts` | 修改(扩展 SystemSettings) | 低 |
|
|
||||||
| `src/pages/SystemSettings.tsx` | 修改(AI设置UI) | 中 |
|
|
||||||
| `src/pages/TemplateManage.tsx` | 修改(工具栏+插入逻辑) | 中 |
|
|
||||||
| `src/pages/ReportEditor.tsx` | 大规模修改(工具栏、状态、右侧面板、Diff弹窗、API调用) | **高** |
|
|
||||||
|
|
||||||
## 4. 优先级
|
|
||||||
- P0:ReportEditor AI 面板核心功能(聊天、API调用、Diff确认)
|
|
||||||
- P1:TemplateManage / ReportEditor 工具栏 AI 区域插入
|
|
||||||
- P1:SystemSettings Kimi 接口配置
|
|
||||||
- P2:语音输入、快捷指令编辑等增强体验
|
|
||||||
|
|
||||||
## 5. 验收标准
|
|
||||||
- [ ] 可在 TemplateManage 中插入 `ai-region` 区域,重名时弹窗阻止
|
|
||||||
- [ ] 可在 ReportEditor 中插入 `ai-region` 区域
|
|
||||||
- [ ] SystemSettings 可配置 Kimi API Key 和 Base URL,保存后持久化
|
|
||||||
- [ ] ReportEditor 右侧有 "AI撰写" Tab,点击可展开聊天面板
|
|
||||||
- [ ] 可向 AI 发送文本+图片(关键帧/本地上传),AI 返回 JSON 结构化回复
|
|
||||||
- [ ] 勾选"允许修改正文"时,AI 返回的 HTML 会触发 Diff 弹窗,确认后才注入
|
|
||||||
- [ ] 不勾选"允许修改正文"时,AI 回复仅在聊天气泡中展示,不修改正文
|
|
||||||
- [ ] 注入后目标区域有视觉高亮反馈
|
|
||||||
- [ ] 重新部署后页面可正常访问,无构建错误
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 需求分析 — 2026-04-19-02-48-25
|
|
||||||
|
|
||||||
## 1. 需求背景
|
|
||||||
当前 system-settings 中的 AI 接口配置存在冗余和僵化问题:
|
|
||||||
- 平铺展示 4 个输入框(`apiEndpoint`/`apiKey`/`kimiApiEndpoint`/`kimiApiKey`),用户困惑该填哪个
|
|
||||||
- ReportEditor 中 AI 调用逻辑硬编码 `kimi-k2-5` 模型和 Kimi 接口,无法切换其他模型
|
|
||||||
- 未来需要支持 DeepSeek、OpenAI、本地模型等多种服务商,且模型版本会频繁迭代
|
|
||||||
|
|
||||||
## 2. 需求拆解
|
|
||||||
- [ ] **Task 1**:重构 `types.ts` 中 `SystemSettings` 的数据结构
|
|
||||||
- 废除 `apiEndpoint`、`apiKey`、`kimiApiKey`、`kimiApiEndpoint` 四个散装字段
|
|
||||||
- 新增 `activeAiProvider: string`(当前激活的服务商)
|
|
||||||
- 新增 `aiProviders: Record<string, AiProviderConfig>`(多服务商配置字典)
|
|
||||||
- `AiProviderConfig` 包含:`endpoint`、`apiKey`、`modelName`
|
|
||||||
- [ ] **Task 2**:重构 `SystemSettings.tsx` UI
|
|
||||||
- 改为"服务商选择器 + 动态配置表单"模式
|
|
||||||
- 预设服务商:Kimi、DeepSeek、OpenAI、自定义
|
|
||||||
- 每个服务商可配置:Base URL、API Key、Model Name
|
|
||||||
- `testApi` 改为通用测试,使用当前选中的服务商配置
|
|
||||||
- [ ] **Task 3**:重构 `ReportEditor.tsx` 调用逻辑
|
|
||||||
- `handleAIGenerate` 从 `aiProviders[activeAiProvider]` 动态读取 endpoint/key/model
|
|
||||||
- 消除 `model: 'kimi-k2-5'` 硬编码
|
|
||||||
- 保持 OpenAI 兼容的 messages 组装逻辑不变
|
|
||||||
- [ ] **Task 4**:数据迁移
|
|
||||||
- 首次加载时,若检测到旧的 `kimiApiKey`/`kimiApiEndpoint`,自动迁移到 `aiProviders.kimi`
|
|
||||||
- 默认模型名设为 `kimi-k2-5`
|
|
||||||
|
|
||||||
## 3. 影响范围
|
|
||||||
| 文件 | 修改类型 | 风险等级 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `src/types.ts` | 修改(重构 SystemSettings) | **高**(数据结构变更) |
|
|
||||||
| `src/pages/SystemSettings.tsx` | 修改(UI 重构 + 数据迁移) | **高** |
|
|
||||||
| `src/pages/ReportEditor.tsx` | 修改(解耦 AI 调用) | 中 |
|
|
||||||
| `src/pages/Login.tsx` | 可能修改(默认设置初始化) | 低 |
|
|
||||||
|
|
||||||
## 4. 优先级
|
|
||||||
- P0:数据结构重构 + 迁移逻辑(不做好,旧数据会丢失或报错)
|
|
||||||
- P0:SystemSettings UI 改造
|
|
||||||
- P0:ReportEditor 调用解耦
|
|
||||||
- P1:Login.tsx 默认初始化更新
|
|
||||||
|
|
||||||
## 5. 验收标准
|
|
||||||
- [ ] SystemSettings 中只看到 1 个服务商选择下拉框 + 3 个动态输入框(Base URL / API Key / Model)
|
|
||||||
- [ ] 切换服务商时,输入框的值自动切换为该服务商的配置
|
|
||||||
- [ ] 旧用户(已有 `kimiApiKey`)首次进入 settings,数据自动迁移,配置不丢失
|
|
||||||
- [ ] ReportEditor AI 调用使用当前选中的服务商配置,模型名随配置变化
|
|
||||||
- [ ] `npm run lint` 无类型错误
|
|
||||||
- [ ] `npm run build` 成功,页面可正常访问
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 需求分析 — 2026-04-19-03-03-55
|
|
||||||
|
|
||||||
## 1. 需求背景
|
|
||||||
AI 撰写功能上线后出现三个体验问题:
|
|
||||||
1. **SystemSettings 测试连接成功(返回 13 个模型),但 ReportEditor 调用报 404**:`POST https://api.moonshot.cn/v1/chat/completions 404`
|
|
||||||
2. **模型名称只能手动输入**:用户希望测试连接成功后,模型名称自动变为下拉选择(从 `/models` 返回的列表中选择)
|
|
||||||
3. **AI 聊天记录不持久**:从 ReportEditor 切换到其他页面再返回,AI 撰写面板中的聊天记录全部丢失
|
|
||||||
|
|
||||||
## 2. 需求拆解
|
|
||||||
- [ ] **Task 1:修复 404 错误**
|
|
||||||
- 原因分析:测试按钮读取的是 React state(界面临时值),ReportEditor 读取的是 localStorage。若测试后未点击"保存",两者不一致。另外 `apiEndpoint` 末尾多余斜杠可能导致路径拼接错误(`v1//chat/completions`)
|
|
||||||
- 修复:在 `handleAIGenerate` 中对 `apiEndpoint` 做 `.replace(/\/+$/, '')` 净化
|
|
||||||
- [ ] **Task 2:模型名称下拉栏**
|
|
||||||
- SystemSettings.tsx 增加 `availableModels` 状态
|
|
||||||
- `testApi` 成功后解析 `/models` 响应,填充 `availableModels`
|
|
||||||
- Model Name 输入框在有 availableModels 时自动变为 `<select>` 下拉
|
|
||||||
- [ ] **Task 3:AI 聊天记录持久化**
|
|
||||||
- `stateRef` 增加 `chatMessages` 字段
|
|
||||||
- `saveDraftToStorage` 将 `chatMessages` 存入 draft
|
|
||||||
- 初始化 `useEffect` 恢复 draft 时,同步恢复 `chatMessages`
|
|
||||||
|
|
||||||
## 3. 影响范围
|
|
||||||
| 文件 | 修改类型 | 风险等级 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `src/pages/ReportEditor.tsx` | 修改(endpoint 净化 + 草稿持久化) | 中 |
|
|
||||||
| `src/pages/SystemSettings.tsx` | 修改(testApi + UI 动态切换) | 低 |
|
|
||||||
|
|
||||||
## 4. 优先级
|
|
||||||
- P0:404 修复(功能不可用)
|
|
||||||
- P1:聊天记录持久化(体验问题)
|
|
||||||
- P1:模型名称下拉栏(体验优化)
|
|
||||||
|
|
||||||
## 5. 验收标准
|
|
||||||
- [ ] ReportEditor 中 AI 调用不再因尾部斜杠导致 404
|
|
||||||
- [ ] SystemSettings 测试连接成功后,模型名称自动变为下拉栏,可选模型列表
|
|
||||||
- [ ] 切换页面后返回 ReportEditor,AI 聊天记录保留
|
|
||||||
- [ ] `npm run lint` 无类型错误
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 需求分析 — 2026-04-19-03-19-57
|
|
||||||
|
|
||||||
## 1. 需求背景
|
|
||||||
AI 撰写功能仍存在两个体验与错误问题:
|
|
||||||
1. **AI 输入框内容丢失**:用户在 textarea 中输入了文字但未发送,切换页面再返回后,输入框内容清空
|
|
||||||
2. **API 400 Bad Request**:不带图片发送消息时,Kimi API 返回 400。因为当前代码始终将 `content` 包装为数组(Vision 格式),而纯文本模型要求 `content` 为字符串
|
|
||||||
|
|
||||||
## 2. 需求拆解
|
|
||||||
- [ ] **Task 1:chatInput 持久化**
|
|
||||||
- `stateRef` 增加 `chatInput`
|
|
||||||
- `saveDraftToStorage` 保存 `chatInput`
|
|
||||||
- 草稿恢复时恢复 `chatInput`
|
|
||||||
- `useEffect` 监听 `chatInput` 同步到 `stateRef`
|
|
||||||
- [ ] **Task 2:API content 格式自适应**
|
|
||||||
- 无图片时:`content` 为纯字符串(兼容 Kimi / DeepSeek)
|
|
||||||
- 有图片时:`content` 为 OpenAI Vision 数组格式(兼容 GPT-4o / Qwen-VL)
|
|
||||||
|
|
||||||
## 3. 影响范围
|
|
||||||
| 文件 | 修改类型 | 风险等级 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `src/pages/ReportEditor.tsx` | 修改(持久化 + API 请求体) | 中 |
|
|
||||||
|
|
||||||
## 4. 优先级
|
|
||||||
- P0:API 400 修复(功能完全不可用)
|
|
||||||
- P1:chatInput 持久化(体验优化)
|
|
||||||
|
|
||||||
## 5. 验收标准
|
|
||||||
- [ ] 不带图片发送消息时,Kimi API 返回 200 而非 400
|
|
||||||
- [ ] 带图片发送消息时,仍使用 Vision 数组格式
|
|
||||||
- [ ] 在 AI 输入框输入文字后切换页面再返回,文字保留
|
|
||||||
- [ ] `npm run lint` 无类型错误
|
|
||||||
1360
过往经验/经验记录-1.md
1360
过往经验/经验记录-1.md
File diff suppressed because it is too large
Load Diff
583
过往经验/经验记录-2.md
583
过往经验/经验记录-2.md
@@ -1,583 +0,0 @@
|
|||||||
# 经验记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 1:report-editor 新建报告时显示空白模板
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
超级管理员进入 `/report-editor`(新建报告)时,编辑区域为纯白色空白,顶部模板选择器显示"无",但 system-settings 中已配置了默认模板。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `ReportEditor.tsx` 在组件卸载(如页面切换)时会自动将当前编辑器内容保存为草稿(draft)。即使用户未输入任何内容,保存的 `content` 也是空字符串 `""`。
|
|
||||||
2. 初始化 effect 中判断草稿是否有效的条件仅使用了 `typeof draft.content === 'string'`,空字符串满足该条件,导致编辑器被填充为空白 HTML,并将 `contentLoadedRef.current` 设为 `true`。
|
|
||||||
3. 由于 `contentLoadedRef.current` 已被置为 `true`,后续加载 `settings.defaultTemplate` 的默认模板分支被完全跳过,从而永远显示空白。
|
|
||||||
4. 此外,草稿中未保存 `loadedTemplateId`,即使内容非空时恢复草稿,模板选择器也会因缺少状态而显示"无"。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 在 `saveDraftToStorage` 中将当前 `loadedTemplateId` 一并存入 draft。
|
|
||||||
2. 将四处草稿恢复的判断条件从 `typeof draft.content === 'string'` 收紧为 `typeof draft.content === 'string' && draft.content.trim().length > 0`,使空白草稿不再拦截默认模板加载。
|
|
||||||
3. 恢复草稿时同步执行 `setLoadedTemplateId(draft.loadedTemplateId || '')`,确保模板选择器名称正确。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 在前端使用 contentEditable 的自动保存机制时,保存和恢复草稿都应增加对空/仅空白内容的过滤。
|
|
||||||
- 若草稿与某个业务状态(如当前模板 ID)强关联,应确保两者一并持久化和恢复,避免状态不一致。
|
|
||||||
- 对兜底初始化逻辑(如默认模板加载)增加更严格的防护,防止被无效中间状态提前截断。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 2:关键帧一键插入占位符功能实现
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户希望视频分析面板中的关键帧截图除了拖拽插入外,还能通过点击 "插入" 按钮一键自动填充到编辑器中第一个空置的 `image-placeholder`。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
原先仅支持拖拽方式将关键帧放入占位符。当关键帧数量多或占位符位置较远时,操作不便。且 `handleDrop` 中的填充逻辑未抽离,无法被其他交互方式复用。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 将 `handleDrop` 中的 HTML 填充逻辑抽离为 `fillPlaceholder(placeholder, frame)` 公共函数。
|
|
||||||
2. 新增 `insertFrameToPlaceholder(frame)` 函数:通过 `editorRef.current.querySelector('.image-placeholder:not(.has-image)')` 查找第一个空置占位符,找到则调用 `fillPlaceholder`,未找到则 `alert('没有可插入图片的空位')`。
|
|
||||||
3. 在关键帧卡片底部的 `timeFormatted` 与 "可拖拽" 之间新增 "插入" 按钮,使用 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 保持一致的显隐行为,并通过 `e.stopPropagation()` 避免触发卡片的视频跳转 `onClick`。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 当同一交互效果(如填充占位符)需要支持多种触发方式(拖拽、按钮点击、快捷键等)时,应将核心逻辑抽离为独立函数,避免重复代码。
|
|
||||||
- 在可点击子元素上务必注意事件冒泡控制,防止触发父级不必要的副作用(如此处的视频跳转)。
|
|
||||||
- UI 提示文字(如 "插入"、"可拖拽")的显隐样式应尽量保持一致,减少用户认知成本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 3:关键帧 "插入" 按钮位置与样式优化
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户对已实现的 "插入" 按钮位置和样式提出优化:希望按钮位于图片中央、做成实体按钮样式、颜色与 "可拖拽" 的蓝色有明显区分。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
初次实现时将 "插入" 按钮放在了卡片底部文字区域,采用纯文字链接样式(`text-accent`),视觉上不够醒目,且与 "可拖拽" 提示颜色重叠,辨识度低。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 将 "插入" 按钮从底部文字行移到图片层的 `<div className="relative">` 容器内,使用 `absolute inset-0 m-auto w-fit h-fit` 实现水平和垂直居中。
|
|
||||||
2. 将按钮样式改为实体胶囊按钮:`px-3 py-1.5 bg-emerald-500 text-white rounded-full shadow-md`,hover 时加深为 `bg-emerald-600`。
|
|
||||||
3. 底部文字区域只保留 `timeFormatted` 和 "可拖拽" 提示,"插入" 按钮不再与它们并列。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 对于图片卡片上的核心操作按钮,优先考虑覆盖在图片中央或显著位置,比在底部小字中放置链接更符合用户直觉。
|
|
||||||
- 同一卡片上的多个 hover 提示元素应保持显隐动画一致(`opacity-0 group-hover:opacity-100 transition-opacity`),但颜色上要有区分,避免用户混淆不同功能。
|
|
||||||
- 使用 `absolute inset-0 m-auto w-fit h-fit` 是一种在 Tailwind 中不依赖 flex/grid 的居中技巧,适合在 `relative` 容器内居中不定宽高的元素。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 4:关键帧 "插入" 按钮位置微调(从图片中央移回底部)
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户反馈将 "插入" 按钮放在图片正中央会遮挡图片内容,希望移回卡片底部,但仍保留实体按钮样式和蓝色。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
按钮以 `absolute` 层覆盖在图片中央时,确实会遮挡部分图片内容,对于医学影像类截图可能影响用户预览。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 将 "插入" 按钮从图片层的 absolute 覆盖层移回卡片底部的文字行,放置在 `timeFormatted` 与 "可拖拽" 之间。
|
|
||||||
2. 按钮颜色恢复为蓝色(`bg-accent text-white`),与 "可拖拽" 蓝色保持一致,视觉上统一。
|
|
||||||
3. 保留实体胶囊按钮样式:`px-2 py-0.5 rounded-full shadow-sm`,不再是纯文字链接。
|
|
||||||
4. 显隐行为仍通过 `opacity-0 group-hover:opacity-100 transition-opacity` 与 "可拖拽" 同步。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 对于图片/截图类卡片上的操作按钮,应优先考虑不遮挡核心图片内容的区域(如底部、角落),避免影响预览。
|
|
||||||
- 在 UI 微调过程中,可以通过小步迭代快速验证用户意图,减少一次性大改导致的方向偏差。
|
|
||||||
- 实体按钮比纯文字链接具有更高的可点击性和辨识度,在微小空间中也能提供良好的交互体验。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 5:路由切换后视频分析图片丢失
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
在 `/report-editor` 中上传视频、自动摘取关键帧、手动截图或拖拽截图到 `image-placeholder` 后,切换到 `/report-manage` 等其他页面再返回 `/report-editor`,右侧「视频分析」面板中的所有截图和关键帧全部消失;编辑器中已拖拽到 placeholder 的图片也不可见。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `ReportEditor.tsx` 在组件卸载时通过 `stateRef.current` 保存草稿到 `localStorage`。
|
|
||||||
2. 初始化 `useEffect` 和 `useLayoutEffect` 从 draft 或已保存报告恢复数据时,仅通过 `setState` 更新了 React state(`videos`、`capturedFrames`),但 **没有同步更新 `stateRef.current`**。
|
|
||||||
3. 用户首次进入页面时数据正确显示;离开页面时,`stateRef.current` 仍保存着初始值(空数组),导致 `saveDraftToStorage()` 用空数组覆盖了 localStorage 中的 draft。
|
|
||||||
4. 再次返回页面时,系统优先读取被污染后的 draft,从而丢失了所有视频分析数据。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
在 `ReportEditor.tsx` 的 6 个数据恢复入口(初始化 `useEffect` 的 3 个分支 + `useLayoutEffect` 安全网的 3 个分支)中,恢复 `reportData`、`videos`、`capturedFrames` 后立即同步赋值给 `stateRef.current`,确保后续草稿保存时数据完整。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 当使用 `useRef` 作为「自动保存」的数据快照时,**任何从持久化存储恢复数据到 React state 的操作,必须同步更新对应的 ref**,否则 ref 将始终保存陈旧值。
|
|
||||||
- 在涉及草稿/自动保存的功能中,应定期审查所有数据恢复路径(初始化 effect、安全网 effect、手动导入等),确保 ref 与 state 的一致性。
|
|
||||||
- 对于复杂单文件组件,可考虑将「持久化 ↔ 状态同步」逻辑抽离为统一的数据恢复函数,集中处理 ref 同步,减少遗漏点。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 6:路由切换后报告内容、基本信息、视频分析全部丢失 + 自动帧插入 UI 延迟刷新
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
1. 在 `/report-editor` 中编辑报告后,切换到 `/report-manage` 再返回 `/report-editor`,**报告内容变空、基本信息清空、视频分析数据全部丢失**。
|
|
||||||
2. 开启「自动帧插入」后,自动关键帧摘取过程中右侧关键帧列表和 placeholder 中的图片**不会逐张实时更新**,而是等所有帧全部处理完后一次性批量出现。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. **数据丢失原因**:在初始化 `useEffect` 中,将 `stateRef.current` 的同步赋值放在了 `if (editorRef.current && draft.content.trim().length > 0)` 条件块的内部。当组件首次渲染时 `editorRef` 尚未挂载,或 `draft.content` 为空(新建报告常见场景),`stateRef.current` 就得不到同步,始终保存着初始空值。组件卸载时,空值被保存为 draft,覆盖了用户已有的数据。
|
|
||||||
2. **UI 延迟原因**:`autoCaptureFrames` 是一个 async 函数,内部循环中连续调用 `setCapturedFrames`。由于 React 18 的自动批处理机制,在异步函数中连续的状态更新会被合并,DOM 重渲染被推迟到整个循环结束后才执行一次,导致用户看不到逐帧实时更新的效果。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **修复数据丢失**:在 `ReportEditor.tsx` 初始化 `useEffect` 的 3 个数据恢复分支(draft 恢复已有报告、found 恢复已有报告、draft 恢复新建报告)中,将 `stateRef.current` 的同步赋值**移到 `editorRef.current/content` 判断条件的外部**,确保无论编辑器 DOM 是否已挂载、`content` 是否为空,`reportData`、`videos`、`capturedFrames` 都会立即写入 `stateRef.current`。
|
|
||||||
2. **清理重复代码**:顺带移除了 `found` 恢复分支中 `contentRef.current = found.content;` 的重复赋值。
|
|
||||||
3. **修复 UI 延迟**:在 `autoCaptureFrames` 的 for 循环中,将 `setCapturedFrames` 包裹在 `flushSync(() => { ... })` 中,强制每一帧被摘取后立即触发 DOM 更新,实现逐张实时显示和逐张插入 placeholder。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 当使用 `useRef` 作为自动保存的数据快照时,**ref 的同步赋值绝对不能依赖于任何与 UI 渲染相关的条件判断**(如 `editorRef.current` 是否存在、`content` 是否非空),否则在组件挂载前或内容为空时会导致数据丢失。
|
|
||||||
- 在异步函数中需要让用户看到实时状态更新时,应使用 `flushSync` 强制同步渲染,避免被 React 自动批处理延迟。
|
|
||||||
- 对于复杂单文件组件中的「恢复数据」逻辑,建议将所有 `setState` 和对应的 `ref` 同步集中在一个统一的恢复函数中处理,减少遗漏点和条件嵌套。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 7:重新部署应用(Vite 生产构建 + Vite Preview)
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户要求将最新代码重新部署到生产环境,但当前运行环境中未安装 Docker,无法使用项目自带的 `docker-compose.yaml` 进行容器化部署。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. 当前 Windows 环境缺少 Docker 和 docker-compose CLI;
|
|
||||||
2. 项目本身是基于 Vite 的前端应用,可通过 `npm run build` 生成静态文件后,使用 `vite preview` 或任意静态文件服务器进行部署;
|
|
||||||
3. 系统中已存在旧版本的 `vite preview` 进程在运行,需要先停止旧服务再启动新服务。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 使用 PowerShell 查询并强制停止所有属于当前项目目录的旧 `vite preview` 进程;
|
|
||||||
2. 执行 `npm run build` 重新构建生产包;
|
|
||||||
3. 使用 `cmd /c "start /B npm run preview"` 在后台启动新的 Vite 预览服务器;
|
|
||||||
4. 通过 `Invoke-WebRequest` 访问 `http://localhost:4173/` 验证服务返回 HTTP 200,确认部署成功。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 在无法使用 Docker 的环境中,可将 `npm run build && npm run preview` 作为标准部署脚本;
|
|
||||||
- 重新部署前务必先清理旧的同类型进程,避免端口冲突或多版本服务同时运行导致访问混乱;
|
|
||||||
- 如需固定端口,可在 `package.json` 的 `preview` 脚本中增加 `--port` 参数(如 `vite preview --port 8080`)。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 8:路由切换后所有内容仍然丢失——彻底重构自动保存机制
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
在 `/report-editor` 中编辑报告(填写基本信息、上传视频、自动/手动截取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`,报告编辑器内容、基本信息、视频列表、关键帧截图**全部丢失**。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. 自动保存机制过度依赖 `stateRef` 和 `contentRef` 作为"数据快照"。
|
|
||||||
2. **React 18 `StrictMode`** 在开发/预览环境下会执行"挂载 → 立即卸载 → 重新挂载"。在首次模拟卸载时,`stateRef.current` 仍然是组件创建时的初始空值(`videos: []`、`capturedFrames: []`、默认 `reportData`)。
|
|
||||||
3. 组件卸载(cleanup)时调用保存,用这个空值**覆盖了 localStorage 中已有的正确 draft**。
|
|
||||||
4. 重新挂载后,系统读取了被清空的 draft,导致所有数据全部丢失。
|
|
||||||
5. 此前两次修复仅把 `stateRef.current` 同步移到了更多恢复分支中,但**没有从根本上消除对 ref 的依赖**,因此 `StrictMode` 下的首次卸载仍会覆盖有效 draft。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **彻底重构 `saveDraftToStorage`**:不再读取 `contentRef.current` 和 `stateRef.current`,而是直接从最新的 React state 和 `editorRef.current?.innerHTML` 获取数据。`useCallback` 的 dependency 数组包含 `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId`、`reportId`,确保闭包永远绑定当前渲染周期的最新 state。
|
|
||||||
2. **重构自动保存 effect**:将 `beforeunload` 和 `visibilitychange` 事件处理器直接绑定到 `saveDraftToStorage`,effect 的 dependency 改为 `[saveDraftToStorage]`。这样即使 `StrictMode` 导致组件在首次挂载后立即卸载,cleanup 中调用的 `saveDraftToStorage` 也指向最新数据的闭包,不会用空值覆盖已有 draft。
|
|
||||||
3. **给 `useLayoutEffect` 安全网添加 `[]` 依赖**:防止每次渲染后重复执行,避免潜在的意外覆盖。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- **永远不要将 `useRef` 作为自动保存的唯一数据源**。ref 在 React 18 `StrictMode` 的模拟卸载阶段仍然保持初始值,会导致用空数据覆盖有效持久化数据。
|
|
||||||
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
|
|
||||||
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
|
|
||||||
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 9:编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
在 `/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder)后,切换到 `/report-manage` 再返回 `/report-editor`:
|
|
||||||
- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失;
|
|
||||||
- 视频分析面板中的自动关键帧和手动截图全部丢失。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state(如 `capturedFrames`、`videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。
|
|
||||||
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。
|
|
||||||
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **重构 `saveDraftToStorage` 从 Ref 读取**:
|
|
||||||
- `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`。
|
|
||||||
- `reportData`、`videos`、`capturedFrames`、`activeTab`、`loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。
|
|
||||||
- `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。
|
|
||||||
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick` 的 `document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 对于需要在异步操作或组件卸载时读取的"最新状态",**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
|
|
||||||
- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。
|
|
||||||
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。
|
|
||||||
- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景(React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 10:自动帧插入阻塞关键帧摘取——改为 setTimeout 非阻塞异步插入
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
开启「自动帧插入」后,点击「自动关键帧摘取」时,系统不是快速完成所有关键帧的摘取,而是每摘取一张就停下来等待插入延迟(如 2 秒),插入完成后才继续摘取下一张。整体过程非常缓慢,用户体验卡顿。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
`autoCaptureFrames` 的 `for` 循环内部,自动插入逻辑使用了 `await new Promise<void>(r => setTimeout(...))`:
|
|
||||||
```tsx
|
|
||||||
if ((settings.autoInsertDelay || 0) > 0) {
|
|
||||||
await new Promise<void>(r => setTimeout(r, (settings.autoInsertDelay || 0) * 1000));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`await` 会暂停整个 `for` 循环的执行,导致关键帧摘取和插入变成了串行阻塞流程:必须等插入完成才能摘取下一张。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. 将 `await new Promise(...)` 替换为 `setTimeout(...)`,把插入操作推入事件队列异步执行,`for` 循环不再被阻塞,可以全速完成所有关键帧的摘取。
|
|
||||||
2. 实现延迟叠加(顺序插入):通过 `settings.autoInsertFrameIndices.indexOf(i)` 计算当前帧是第几个需要插入的,延迟时间为 `baseDelay * (insertOrderIndex + 1)`,避免所有图片在同一时刻同时插入。
|
|
||||||
3. `setTimeout` 回调中实时查询 `.image-placeholder:not(.has-image)`,找到则插入,并同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout` 或 `Promise.all` 实现并行/异步解耦。
|
|
||||||
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
|
|
||||||
- 在 `setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 12:5 项交互与默认值优化(占位符尺寸、签名状态、素材预加载)
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户提出 5 个 UI/UX 改进需求:
|
|
||||||
1. 插入图片占位符时两次 `prompt` 弹窗合并为一次,用英文逗号分隔宽高;
|
|
||||||
2. 占位符未指定尺寸时默认显示为 `200×200px`,且样式直接使用 `width`/`height` 而非 `max-width`/`max-height`;
|
|
||||||
3. 系统重置后的默认设置中增加 `autoInsertFrames: true`、`autoInsertDelay: 1`、`autoInsertFrameIndices: [0,1,2,3,4,5]`;
|
|
||||||
4. 用户管理表格在「部门」与「状态」之间新增「签名状态」列,根据 `user.signature` 显示「已上传」/「未上传」;
|
|
||||||
5. 修复系统重置后 `ReportEditor` 的素材库为空的问题,将 logo 预加载逻辑从 `TemplateManage.tsx` 前置到 `Login.tsx` 的 `initData()` 中。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `insertImage()` 在两个编辑器(`TemplateManage`、`ReportEditor`)中均使用两次独立的 `prompt()`,操作冗余且中断感强。
|
|
||||||
2. 旧占位符样式使用 `max-width`/`max-height`,当内容区域大于占位符时,边框和背景不会收缩到指定尺寸,视觉尺寸不可控;且未指定时的 `padding:8px 16px` 导致占位符尺寸随文字变化,不统一。
|
|
||||||
3. `Login.tsx` 初始化 `systemSettings` 时遗漏了自动帧插入相关的 3 个字段,导致新系统首次进入 `/system-settings` 时相关开关为空。
|
|
||||||
4. `UserManage.tsx` 表格缺少签名可视化列,管理员无法一眼辨别哪些医生已上传电子签名。
|
|
||||||
5. `imageAssets` 的预加载仅在 `TemplateManage.tsx` 的 `useEffect` 中执行。若用户首次登录后直接进入 `ReportEditor`,素材库为空,图片选择器无法使用系统默认 logo。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **合并 prompt**:
|
|
||||||
```ts
|
|
||||||
const input = prompt('请输入占位符的最大宽度和高度(px),用英文逗号分隔(如: 100,50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
|
||||||
const parts = input.split(',').map(s => s.trim());
|
|
||||||
```
|
|
||||||
按逗号分割,第一部分为宽度,第二部分为高度。留空或单侧留空时,另一侧自动回退到 `200`。
|
|
||||||
2. **固定尺寸样式**:
|
|
||||||
- 移除 `max-width`/`max-height`,改用 `width:${width}px;` / `height:${height}px;`。
|
|
||||||
- 默认值逻辑:`!widthStr && !heightStr` → `200×200`;`widthStr && !heightStr` → 宽自定义、高 `200`;`!widthStr && heightStr` → 宽 `200`、高自定义。
|
|
||||||
3. **默认设置补全**:在 `Login.tsx` 的 `defaultSettings` 中显式加入:
|
|
||||||
```ts
|
|
||||||
autoInsertFrames: true,
|
|
||||||
autoInsertDelay: 1,
|
|
||||||
autoInsertFrameIndices: [0, 1, 2, 3, 4, 5]
|
|
||||||
```
|
|
||||||
4. **签名状态列**:在 `UserManage.tsx` 表格的 `<th>` 和 `<td>` 中,于「部门」之后、「状态」之前插入:
|
|
||||||
```tsx
|
|
||||||
<span className={`inline-block px-2.5 py-1 rounded-full text-[11px] font-bold ${
|
|
||||||
user.signature ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{user.signature ? '已上传' : '未上传'}
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
5. **素材预加载前置**:将 `fetch('/logo_square.png') → FileReader → storage.set('imageAssets', [...])` 的逻辑从 `TemplateManage.tsx` 迁移到 `Login.tsx` 的 `initData()` 中,并增加 `savedAssets.length === 0` 的判空保护,避免覆盖用户后续上传的素材。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 对于成对的数值输入(如宽高、行列),优先考虑单输入框 + 分隔符,减少弹窗次数;同时做好格式解析和容错(空值、单侧空值、非数字)。
|
|
||||||
- 使用 `width`/`height` 代替 `max-width`/`max-height` 能确保占位符尺寸严格可控,避免 `inline-flex` 内容撑大容器。
|
|
||||||
- 任何需要在多个页面共享的初始化数据(如素材库、默认配置),应放在全局初始化入口(如登录页的 `initData`),而不是分散在各个页面的 `useEffect` 中。
|
|
||||||
- 表格字段变更时,注意保持 `<thead>` 与 `<tbody>` 的列顺序严格一致,避免列错位。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 13:6 项交互优化(placeholder 虚线框、删除按钮、签名尺寸、多选重构)
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户提出 6 个 UI/UX 改进需求:
|
|
||||||
1. 图片插入占位符后虚线框残留——内联 `border:1px dashed #cbd5e1` 优先级高于 `.has-image` CSS class;
|
|
||||||
2. `insertImage` 生成的 placeholder 中 `overflow:hidden` 裁切了绝对定位的删除按钮(`×`);
|
|
||||||
3. 占位符尺寸输入从逗号分隔改为星号(`*`)分隔,格式错误时提示重新输入;
|
|
||||||
4. 默认模板中「手术者签名」占位符固定为 `200×40px`;
|
|
||||||
5. 删除「手术者签名确认」字段及相关的弱阻断确认弹窗;
|
|
||||||
6. 多选组件从 tag 形态重构为纯文本拼接形态,支持多种标点符号拆分并自动保存新选项。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `fillPlaceholderSrc` 仅添加了 `has-image` class,但内联 `style="border:..."` 的优先级永远高于外部 CSS,导致虚线框无法消除。
|
|
||||||
2. `insertImage` 的 `styleStr` 中硬编码了 `overflow:hidden;`,而删除按钮使用 `position:absolute; top:-8px; right:-8px` 之类的定位,必然被父级裁切。
|
|
||||||
3. 英文逗号分隔容易与用户输入的千位分隔符或中文逗号混淆。
|
|
||||||
4. 默认模板中签名占位符使用 `min-width:80px;min-height:24px`,尺寸过小且不一致。
|
|
||||||
5. `isSigned` 字段与签名图片是两个独立的状态,造成医生需要多点一次确认,流程冗余。
|
|
||||||
6. 原多选使用 tag 胶囊形式,每个 tag 带背景色和删除按钮,占用空间大,且无法直接复制粘贴整段文本。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **清除内联样式**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `fillPlaceholderSrc` 中增加:
|
|
||||||
```ts
|
|
||||||
placeholder.style.border = 'none';
|
|
||||||
placeholder.style.background = 'transparent';
|
|
||||||
```
|
|
||||||
同时统一 `defaultContent.ts` 中所有 8 个 placeholder 为 `<span style="display:inline-flex;...">` 格式,表格中的 6 个也统一使用 `width:100%;height:150px;`。
|
|
||||||
2. **移除 overflow:hidden**:从两个 `insertImage` 的 `styleStr` 中删除 `overflow:hidden;`,保留在 `placeholder-text` 子元素上(文字截断仍可用)。
|
|
||||||
3. **星号分隔 + 校验循环**:
|
|
||||||
```ts
|
|
||||||
while (true) {
|
|
||||||
const input = prompt('...用 * 分隔...', '');
|
|
||||||
if (input === null) return;
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (trimmed === '') break;
|
|
||||||
const parts = trimmed.split('*').map(s => s.trim());
|
|
||||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
|
||||||
width = parseInt(parts[0]); height = parseInt(parts[1]); break;
|
|
||||||
}
|
|
||||||
alert('格式错误...');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
4. **签名占位符尺寸**:`defaultContent.ts` 中改为 `width:200px;height:40px;`。
|
|
||||||
5. **移除 `isSigned`**:
|
|
||||||
- `types.ts` 的 `DEFAULT_FORM_FIELDS` 中删除;
|
|
||||||
- `ReportEditor.tsx` 的初始 `reportData` 中删除;
|
|
||||||
- `saveReport` 的完成确认逻辑中删除 `isSigned` 判断;
|
|
||||||
- smart field 同步逻辑中删除 `isSigned` 判断,只要有 `signatureData` 就直接显示签名图。
|
|
||||||
6. **多选重构为文本拼接**:
|
|
||||||
- `displayText = currentValues.join(', ')`;
|
|
||||||
- input 使用 `value={displayText}` 受控组件;
|
|
||||||
- `onChange` 实时解析并更新 `reportData`:`parseMultiInput(text)` 用 `/[,,;;、]/` 正则拆分、去重;
|
|
||||||
- `onBlur` / `Enter` 时调用 `handleMultiCommit`,将拆分出的新选项保存到 `multiSelectOptions` 和 `formFieldsConfig`;
|
|
||||||
- 下拉选择时追加 `, opt` 到现有文本。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 当使用内联样式设置边框/背景时,如需在特定状态下移除,**必须在内联层面重置**(`style.border = 'none'`),不能仅依赖 CSS class 覆盖。
|
|
||||||
- `overflow:hidden` 与绝对定位子元素互斥,若需要裁切文字但保留溢出按钮,应将 `overflow:hidden` 限制在文字子元素上,而非父容器。
|
|
||||||
- 用户输入的格式校验应使用 `while` 循环 + `alert` 重试,避免静默容错导致不可预期的行为。
|
|
||||||
- 删除字段时务必全局搜索(`grep -r 'isSigned'`),确保初始化状态、表单验证、模板绑定等所有引用点都被清理。
|
|
||||||
- 将「标签胶囊」改为「纯文本拼接」时,注意保持 `reportData` 的数据结构仍为数组,UI 层只做 `join/split` 转换。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 11:关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9:
|
|
||||||
```tsx
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
|
||||||
```
|
|
||||||
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB,十几张关键帧即可超过 5MB。
|
|
||||||
2. **静默失败**:`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
|
|
||||||
```typescript
|
|
||||||
} catch {
|
|
||||||
// ignore quota exceeded
|
|
||||||
}
|
|
||||||
```
|
|
||||||
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常,draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **压缩关键帧分辨率与质量**:
|
|
||||||
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
|
|
||||||
```tsx
|
|
||||||
const MAX_WIDTH = 800;
|
|
||||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
|
|
||||||
canvas.width = video.videoWidth * scale;
|
|
||||||
canvas.height = video.videoHeight * scale;
|
|
||||||
```
|
|
||||||
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
|
|
||||||
- 这样单张图片体积可从 500KB 降至 30KB~80KB,有效避免 LocalStorage 超限。
|
|
||||||
|
|
||||||
2. **增加存储错误可见性**:
|
|
||||||
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`:
|
|
||||||
```typescript
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
|
|
||||||
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
|
|
||||||
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
|
|
||||||
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 14:5 项交互修复(虚线框恢复、prompt 文案、删除按钮、多选输入、label 提示)
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户提出 5 个修复需求:
|
|
||||||
1. 删除 `image-placeholder` 中的图片后,虚线框消失——`fillPlaceholderSrc` 中设置了 `border='none'`,但删除图片的代码没有恢复;
|
|
||||||
2. `ReportEditor.tsx` 的 `insertImage` prompt 文案仍显示旧版 "用英文逗号分隔",未同步修改;
|
|
||||||
3. 新生成的 `image-placeholder` 右上角红色 `×` 显示不完全——`ReportEditor.tsx` 的 `insertImage` 中 `overflow:hidden` 未移除;
|
|
||||||
4. 多选框无法输入 `,`、`;`、`,`、`、` 等分隔符——`onChange` 实时调用 `parseMultiInput` + `filter(Boolean)`,末尾的分隔符被瞬间吃掉;
|
|
||||||
5. 多选框 label 缺少 "(可多选)" 提示。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. 上批次修改时,`ReportEditor.tsx` 的 `insertImage` 替换未成功匹配(旧字符串与文件实际内容有微小差异),导致该函数保留了旧代码。删除图片逻辑同样缺少 border/background 恢复。
|
|
||||||
2. `overflow:hidden` 仅在新版 `TemplateManage.tsx` 中被移除,`ReportEditor.tsx` 中仍保留。
|
|
||||||
3. 多选框使用受控 `value={displayText}` + `onChange={handleMultiChange}`,每次输入都会触发 `split(/[,,;;、]/)` 和 `filter(Boolean)`。当用户输入一个逗号时,split 产生空字符串,filter 将其过滤,输入框值立即回退到无逗号状态。
|
|
||||||
4. label 渲染时直接使用 `{field.label}`,未追加多选提示。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **恢复虚线框**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 的删除图片分支中,增加:
|
|
||||||
```ts
|
|
||||||
placeholder.style.border = '1px dashed #cbd5e1';
|
|
||||||
placeholder.style.background = '#f8fafc';
|
|
||||||
```
|
|
||||||
2. **修正 prompt 文案**:将 `ReportEditor.tsx` 的 `insertImage` 重写为与 `TemplateManage.tsx` 一致的新版逻辑(`*` 分隔 + while 循环校验)。
|
|
||||||
3. **移除 overflow:hidden**:从 `ReportEditor.tsx` 的 `styleStr` 中删除 `overflow:hidden;`。
|
|
||||||
4. **多选输入解耦**:引入本地状态 `multiInputText: Record<string, string>`,
|
|
||||||
- `onChange` 仅更新 `multiInputText`,不触发拆分;
|
|
||||||
- `onBlur` 和 `Enter` 时才调用 `handleMultiCommit` 执行 `split` + `filter(Boolean)` + 保存新选项;
|
|
||||||
- 输入框 `value` 优先读取 `multiInputText[field.key]`,无本地缓存时回退到 `displayText`。
|
|
||||||
5. **label 追加提示**:`{field.label}(可多选)`。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 同类型函数在多个文件中存在时,务必逐个文件 grep 确认修改结果,不能假设一次替换就能覆盖所有实例。
|
|
||||||
- 任何 "实时解析输入" 的逻辑都必须警惕 `filter(Boolean)` 对空字符串的过滤效应——如果允许用户输入分隔符,应使用独立状态缓存原始输入,仅在确认时(blur/enter)执行解析。
|
|
||||||
- `StrReplaceFile` 的批量替换若返回 "Applied N edit(s) with M total replacement(s)" 且 M < N,应立即检查未匹配的文件,避免遗漏。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 15:时间/日期字段格式配置与撰写时间动态字段
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户提出 2 个需求:
|
|
||||||
1. TemplateManage 字段管理中,时间/日期字段增加配置:date 可选 `YYYY-MM-DD` / `YYYY年MM月DD日` 显示格式;time 可选 24h / 12h 显示格式;两者均可选「当前时间」或「手动选择」作为默认值策略。
|
|
||||||
2. 默认模板底部写死的「年 月 日」改为动态「撰写时间」智能字段,自动取当前日期。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `FormField` 数据结构缺少格式和默认值配置字段。
|
|
||||||
2. `ReportEditor` 中 time 字段的表单渲染仅支持 `startTime/endTime` 且固定为 24 小时制;smart field 同步时直接显示原始值,不做任何格式转换。
|
|
||||||
3. 模板底部「年 月 日」是纯静态 HTML 文本,没有数据绑定能力。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **扩展数据结构**:`FormField` 增加 `timeFormat?: string` 和 `timeDefault?: 'current' | 'specific'`。现有字段补充默认值(`surgeryDate` → `YYYY-MM-DD`+`specific`,`startTime/endTime` → `24h`+`specific`);新增系统字段 `reportDate`(`YYYY年MM月DD日`+`current`)。
|
|
||||||
2. **TemplateManage UI 增强**:
|
|
||||||
- 新增字段表单:category 为「时间」时显示「默认值」select(手动选择/当前时间)和「显示格式」select(date 提供两种日期格式,time 提供 24h/12h)。
|
|
||||||
- 字段编辑面板:点击已有时间字段进入编辑模式时,可修改上述两项配置。
|
|
||||||
3. **ReportEditor 自动填充**:新增 `useEffect` 监听 `formFields`,对 `timeDefault === 'current'` 且值为空的字段,自动填充系统当前日期/时间。
|
|
||||||
4. **ReportEditor 表单渲染重构**:
|
|
||||||
- `startTime/endTime`:根据 `timeFormat` 选择 hour select 的选项范围(24h: 00-23,12h: 01-12),12h 时额外增加 AM/PM select。存储仍保持 24h(`startHour/startMinute`),转换函数 `to24h`/`from24h` 处理 12h↔24h。
|
|
||||||
- 通用 time 字段(非 startTime/endTime):新增 hour+minute select 渲染,值统一存储为 `HH:MM` 字符串。
|
|
||||||
5. **smart field 同步格式化**:同步 useEffect 中,根据字段定义调用 `formatDateDisplay`/`formatTimeDisplay`,将原始值转换为配置格式后写入编辑器。
|
|
||||||
6. **编辑器反向编辑解析**:`handleEditorInput` 中,当用户直接在编辑器内修改 date/time smart field 时,通过正则解析格式化文本(如 `2026年04月17日` → `2026-04-17`、`02:30 下午` → `14:30`),转回原始值后存入 `reportData`。
|
|
||||||
7. **默认模板更新**:`defaultContent.ts` 底部静态「年 月 日」替换为 `${smartField('reportDate')}`。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 当为字段增加新的配置属性时,务必在 `DEFAULT_FORM_FIELDS` 中为所有已有字段提供合理的默认值,保证向后兼容。
|
|
||||||
- 显示格式与存储格式分离时,必须同时实现「正向格式化」(存储→显示)和「反向解析」(显示→存储),否则用户在编辑器中直接编辑格式化后的值会导致数据格式混乱。
|
|
||||||
- 12h/24h 转换要覆盖所有边界情况:12AM→00、12PM→12、1PM→13,建议用独立纯函数(`to24h`/`from24h`)集中处理,避免在 JSX 中内联复杂计算。
|
|
||||||
- 自动填充当前时间必须增加「仅当值为空时触发」的保护,防止编辑已有报告时覆盖用户数据。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 16:时间字段增强——自定义格式、固定时间默认值、系统锁定标签
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户提出 4 个改进需求:
|
|
||||||
1. 默认模板底部「撰写时间」文字前缀与 smartField 占位符重复,需删除前缀仅保留占位符;
|
|
||||||
2. 多选类和时间类字段在 TemplateManage 字段管理中仍可修改名称,应锁定为系统字段;
|
|
||||||
3. 「手动选择」文案歧义,应改为「固定时间」;
|
|
||||||
4. 时间格式应从固定下拉选项改为支持自定义格式输入(类似单选新增选项策略),并支持为「固定时间」设置默认值。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. `defaultContent.ts` 中底部 HTML 写死了 `撰写时间:${smartField('reportDate')}`,导致编辑器中显示重复文字。
|
|
||||||
2. `DEFAULT_FORM_FIELDS` 中 `surgeryDate`、`startTime`、`endTime`、`surgeon` 等字段的 `isSystemLocked` 为 `false`,字段库允许修改 label。
|
|
||||||
3. 早期实现时默认将时间默认值策略命名为「手动选择」,语义不够精确。
|
|
||||||
4. 日期/时间格式仅通过固定 `<select>` 提供预设选项(如 `YYYY-MM-DD`、`24h`),无法覆盖用户自定义需求(如 `YYYY/MM/DD`、`hh:mm A` 等)。
|
|
||||||
5. 当默认值策略为「固定时间」时,系统无法自动填充用户指定的固定值到报告表单中。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **删除前缀**:`defaultContent.ts` 中将底部 HTML 从 `撰写时间:${smartField('reportDate')}` 改为仅 `${smartField('reportDate')}`。
|
|
||||||
2. **系统锁定**:`types.ts` 中 `DEFAULT_FORM_FIELDS` 的 `surgeryDate`、`startTime`、`endTime`、`reportDate`、`surgeon`、`assistant`、`anesthesiologist` 全部改为 `isSystemLocked: true`。
|
|
||||||
3. **文案修改**:`TemplateManage.tsx` 中所有「手动选择」改为「固定时间」。
|
|
||||||
4. **自定义格式输入**:
|
|
||||||
- `types.ts` 的 `FormField` 增加 `fixedTimeValue?: string`。
|
|
||||||
- `TemplateManage.tsx` 的时间格式 UI 改为「下拉 + 自定义输入」双模式:
|
|
||||||
- `formatInputMode: 'select' | 'custom'`,默认 `select`。
|
|
||||||
- 选择「自定义」时显示 `<input>`,用户可自由输入格式字符串;回车后将输入值加入候选列表并设为当前值。
|
|
||||||
- 预设候选包含常用格式:`YYYY-MM-DD`、`YYYY年MM月DD日`、`YYYY/MM/DD`、`24h`、`12h`、`hh:mm A`、`HH:mm`。
|
|
||||||
- 通用化显示函数:
|
|
||||||
```ts
|
|
||||||
const formatDateDisplay = (isoDate: string, fmt?: string): string => {
|
|
||||||
if (!isoDate || !fmt) return isoDate || '';
|
|
||||||
const [y, m, d] = isoDate.split('-');
|
|
||||||
return fmt.replace(/YYYY/g, y || '').replace(/MM/g, m || '').replace(/DD/g, d || '');
|
|
||||||
};
|
|
||||||
const formatTimeDisplay = (timeStr: string, fmt?: string): string => {
|
|
||||||
if (!timeStr || !fmt) return timeStr || '';
|
|
||||||
const [h24str, mstr] = timeStr.split(':');
|
|
||||||
const h24 = parseInt(h24str) || 0;
|
|
||||||
const isPM = h24 >= 12;
|
|
||||||
let h12 = h24 % 12; if (h12 === 0) h12 = 12;
|
|
||||||
return fmt.replace(/HH/g, String(h24).padStart(2, '0'))
|
|
||||||
.replace(/mm/g, mstr || '00')
|
|
||||||
.replace(/hh/g, String(h12).padStart(2, '0'))
|
|
||||||
.replace(/A/g, isPM ? '下午' : '上午');
|
|
||||||
};
|
|
||||||
```
|
|
||||||
5. **通用化反向解析**:新增 `parseDateFromFormat` / `parseTimeFromFormat`,从格式化文本中通过数字正则提取原始值,确保用户在编辑器中直接编辑格式化后的 smart field 后能正确回存。
|
|
||||||
6. **固定时间默认值自动填充**:`ReportEditor.tsx` 的自动填充 `useEffect` 中增加 `timeDefault === 'specific'` 分支,若字段配置了 `fixedTimeValue` 且当前值为空,则自动填入固定值。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 自定义格式输入必须同时提供「正向格式化」和「反向解析」函数,否则编辑器双向同步会断裂。
|
|
||||||
- 使用占位符替换(如 `fmt.replace(/YYYY/g, y)`)实现通用格式化时,要确保所有可能的 token 都覆盖到,且替换顺序不会相互干扰。
|
|
||||||
- 当某个字段被标记为 `isSystemLocked: true` 后,需在 UI 层面同时禁用 label 输入框,否则用户会困惑「为何修改无效」。
|
|
||||||
- 时间/日期字段的默认值策略文案应直接体现业务含义(如「固定时间」「当前时间」),避免使用技术词汇(如「手动选择」)。
|
|
||||||
- 对于 `startTime`/`endTime` 这类拆分存储(`startHour`+`startMinute`)的遗留字段,在通用化处理时需保留特殊分支,避免破坏现有数据结构。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记录 17:时间字段联动修复——默认格式、固定时间自动填充、12/24h 动态切换
|
|
||||||
|
|
||||||
**A. 具体问题**
|
|
||||||
用户发现 3 个时间字段配置与报告编辑器的联动断层:
|
|
||||||
1. 模板管理中新建日期字段时默认格式为 `YYYY-MM-DD`,缺少中文格式 `YYYY年MM月DD日`;新建时间字段时默认格式为不可解析的 `'24h'`。
|
|
||||||
2. 在模板管理中将时间字段设为「固定时间」并填写固定值后,进入报告编辑器新建报告时,该固定值未自动填充到表单中。
|
|
||||||
3. 在模板管理中将 `startTime` 格式改为 `hh:mm A`(12小时制),报告编辑器中的手术开始时间表单仍显示为 24 小时制下拉框,未联动切换。
|
|
||||||
|
|
||||||
**B. 产生问题原因**
|
|
||||||
1. **默认格式错误**:`TemplateManage.tsx` 中 `newFieldForm.type` 的 `onChange` 将时间字段默认值硬编码为 `'24h'`,而实际通用格式化函数 `formatTimeDisplay` 使用的是 `HH`、`hh`、`mm`、`A` 等 token, `'24h'` 无法被正确解析。
|
|
||||||
2. **固定时间未注入**:`ReportEditor.tsx` 初始 `reportData` 和切换模板时的 `nextReportData` 中,`surgeryDate` 被强制赋值为 `new Date().toISOString().split('T')[0]`,导致后续「仅当值为空时才填充固定时间」的判断被跳过(因为已有值了)。切换模板时也未遍历 `formFields` 读取字段的 `timeDefault`/`fixedTimeValue` 配置来注入默认值。
|
|
||||||
3. **12h 判断写死**:`ReportEditor.tsx` 中 `const is12h = field.timeFormat === '12h';` 仅匹配精确的 `'12h'` 字符串。当用户在模板管理中选择了 `hh:mm A` 或自定义了其他包含 `hh`/`A` 的格式时,判断失败,表单始终渲染为 24 小时制。
|
|
||||||
|
|
||||||
**C. 解决问题方案**
|
|
||||||
1. **修正默认格式**:
|
|
||||||
- `TemplateManage.tsx` 中新建字段的默认格式改为:
|
|
||||||
```ts
|
|
||||||
setNewFieldTimeFormat(t === 'date' ? 'YYYY年MM月DD日' : 'HH:mm');
|
|
||||||
```
|
|
||||||
- 重置表单时的默认值同步修正。
|
|
||||||
2. **注入固定时间默认值**:
|
|
||||||
- `ReportEditor.tsx` 初始 `reportData` 中 `surgeryDate` 从 `new Date()` 改为空字符串 `''`。
|
|
||||||
- 切换模板的 `useEffect` 中,在构建 `nextReportData` 后增加遍历 `formFields` 的逻辑:
|
|
||||||
```ts
|
|
||||||
formFields.forEach(field => {
|
|
||||||
if (field.category === '时间') {
|
|
||||||
if (field.timeDefault === 'specific' && field.fixedTimeValue) {
|
|
||||||
// 按 field.type 和 field.key 注入固定值
|
|
||||||
} else if (field.timeDefault === 'current') {
|
|
||||||
// 注入当前系统时间
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!nextReportData.surgeryDate) {
|
|
||||||
nextReportData.surgeryDate = new Date().toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. **通用化 12h 判断**:
|
|
||||||
- `ReportEditor.tsx` 中:
|
|
||||||
```ts
|
|
||||||
const is12h = field.timeFormat ? (field.timeFormat.includes('hh') || field.timeFormat.includes('A')) : false;
|
|
||||||
```
|
|
||||||
- 这样无论格式是 `12h`、`hh:mm A`、`hh:mm` 还是用户自定义的 `hh时mm分 A`,只要包含 `hh` 或 `A` 就自动切换为 12 小时制表单。
|
|
||||||
|
|
||||||
**D. 后续如何避免问题**
|
|
||||||
- 时间/日期格式的默认值必须与通用格式化函数的 token 体系保持一致,不能使用简写别名(如 `'24h'`、`'12h'`)作为存储值,除非格式化函数也能识别这些别名。
|
|
||||||
- 当字段配置了「固定默认值」或「自动填充当前值」时,必须在所有「创建新数据」的入口(初始 state、切换模板、重置表单等)中显式遍历字段配置并注入,不能依赖单个 `useEffect` 来兜底——因为 `useEffect` 的触发条件可能与数据创建时机不一致。
|
|
||||||
- 对于「格式→UI 形态」的联动判断,应使用**包含性判断**(`includes`)而非**精确匹配**,以兼容用户自定义格式。如果判断逻辑较为复杂,建议抽离为独立工具函数(如 `is12HourFormat(fmt: string): boolean`)。
|
|
||||||
- 当某个字段在初始化时被赋予了「看似合理的默认值」(如 `surgeryDate: new Date()`),必须评估这是否会拦截后续基于字段配置的自动填充逻辑。若会拦截,应改为空值并在最后做兜底赋值。
|
|
||||||
Reference in New Issue
Block a user