Fix report draft save and microphone startup
- Allow draft reports to be saved without patient name or hospital ID while keeping completed reports strictly validated. - Preserve completed-report identity validation when updating existing reports by checking merged old and new values. - Show real API save errors in the report editor and send expired sessions back to login instead of reporting a generic backend outage. - Guard speech startup for missing getUserMedia or AudioContext support and explain localhost/HTTPS microphone requirements. - Add report schema tests covering draft identity fields and completed-report validation. - Update AGENTS and docs for report editor behavior, feature status, progress, and testing coverage.
This commit is contained in:
@@ -343,6 +343,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
|
|||||||
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
|
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
|
||||||
- 后端 Dashboard 统计按角色范围过滤
|
- 后端 Dashboard 统计按角色范围过滤
|
||||||
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
|
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
|
||||||
|
- 后端报告 schema 区分草稿和完成状态,草稿可暂缺患者姓名/住院号,完成报告必须填写
|
||||||
- 后端模板 DTO 和权限资源映射
|
- 后端模板 DTO 和权限资源映射
|
||||||
- 后端用户 DTO 和部门模板授权映射
|
- 后端用户 DTO 和部门模板授权映射
|
||||||
- 后端系统设置 schema 校验
|
- 后端系统设置 schema 校验
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
| 报告基本信息表单 | 真实可用 | `ReportEditor` 管理 `reportData`,支持文本、日期、时间、单选、多选。 |
|
| 报告基本信息表单 | 真实可用 | `ReportEditor` 管理 `reportData`,支持文本、日期、时间、单选、多选。 |
|
||||||
| 正文智能字段绑定 | 真实可用 | 模板 HTML 的 `data-bind` 字段与表单双向同步。 |
|
| 正文智能字段绑定 | 真实可用 | 模板 HTML 的 `data-bind` 字段与表单双向同步。 |
|
||||||
| 富文本编辑 | 真实可用 | 使用 `contentEditable` 和 `document.execCommand`。实现可用,但 API 过时。 |
|
| 富文本编辑 | 真实可用 | 使用 `contentEditable` 和 `document.execCommand`。实现可用,但 API 过时。 |
|
||||||
| 报告草稿 | 真实可用 | 保存到 `reportEditorDraft_${username}`。 |
|
| 报告草稿 | 真实可用 | 编辑过程自动保存到 `reportEditorDraft_${username}`;点击“保存草稿”会优先写后端,草稿状态允许患者姓名和住院号暂空。 |
|
||||||
| 保存/完成报告 | 真实集成 | `ReportEditor` 优先调用 `POST/PATCH /api/reports`,后端写入 PostgreSQL、清洗 HTML、保留历史版本、写审计,并在已完成报告再次修改时递增 `revision`;只有开发/显式回退模式下 API 不可用才回退本地保存。 |
|
| 保存/完成报告 | 真实集成 | `ReportEditor` 优先调用 `POST/PATCH /api/reports`,后端写入 PostgreSQL、清洗 HTML、保留历史版本、写审计,并在已完成报告再次修改时递增 `revision`;完成报告仍强制要求患者姓名和住院号;只有开发/显式回退模式下 API 不可用才回退本地保存。 |
|
||||||
| 报告历史恢复 | 真实可用 | 管理页写 `sessionStorage.restore_${reportId}`,编辑器读取恢复。 |
|
| 报告历史恢复 | 真实可用 | 管理页写 `sessionStorage.restore_${reportId}`,编辑器读取恢复。 |
|
||||||
| 报告管理筛选 | 真实集成 | `ReportManage` 优先调用 `GET /api/reports`,后端按超级管理员/管理员/医生过滤;前端继续支持搜索、状态和时间筛选;只有开发/显式回退模式下 API 不可用才回退本地报告。 |
|
| 报告管理筛选 | 真实集成 | `ReportManage` 优先调用 `GET /api/reports`,后端按超级管理员/管理员/医生过滤;前端继续支持搜索、状态和时间筛选;只有开发/显式回退模式下 API 不可用才回退本地报告。 |
|
||||||
| 报告查看 | 真实集成 | `ReportView` 优先调用 `GET /api/reports/:id`,后端校验查看权限;页面渲染报告 HTML;只有开发/显式回退模式下 API 不可用才回退本地权限检查。 |
|
| 报告查看 | 真实集成 | `ReportView` 优先调用 `GET /api/reports/:id`,后端校验查看权限;页面渲染报告 HTML;只有开发/显式回退模式下 API 不可用才回退本地权限检查。 |
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
|
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
|
||||||
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 |
|
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 |
|
||||||
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
||||||
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 |
|
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、安全上下文(`localhost` 或 HTTPS)、有效配置和网络。 |
|
||||||
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
||||||
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取和保存抽帧、默认模板、AI Provider、语音配置;“恢复演示出厂设置”会二次确认后调用后端 demo reset,清空报告/审计并恢复默认用户、模板和演示配置。只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
|
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取和保存抽帧、默认模板、AI Provider、语音配置;“恢复演示出厂设置”会二次确认后调用后端 demo reset,清空报告/审计并恢复默认用户、模板和演示配置。只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
|
||||||
| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 |
|
| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 |
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
| `src/utils/defaultContent.test.ts` | 默认模板结构、智能字段、图片占位符、AI 区域、字段和 Provider 配置。 |
|
| `src/utils/defaultContent.test.ts` | 默认模板结构、智能字段、图片占位符、AI 区域、字段和 Provider 配置。 |
|
||||||
| `src/utils/print.test.ts` | 浏览器打印导出入口。 |
|
| `src/utils/print.test.ts` | 浏览器打印导出入口。 |
|
||||||
| `server/src/permissions/permissions.policy.test.ts` | 后端报告、模板、用户管理和管理员创建权限策略。 |
|
| `server/src/permissions/permissions.policy.test.ts` | 后端报告、模板、用户管理和管理员创建权限策略。 |
|
||||||
|
| `server/src/reports/reports.schemas.test.ts` | 后端报告创建/更新 schema,覆盖草稿可空患者信息、完成报告必填患者姓名和住院号。 |
|
||||||
| `server/src/reports/report.mapper.test.ts` | 后端 Report 与前端兼容 Report 对象的 metadata、ReportMedia 和历史输出。 |
|
| `server/src/reports/report.mapper.test.ts` | 后端 Report 与前端兼容 Report 对象的 metadata、ReportMedia 和历史输出。 |
|
||||||
| `server/src/templates/template.mapper.test.ts` | 后端 Template 与前端兼容 Template 对象、模板权限资源的映射。 |
|
| `server/src/templates/template.mapper.test.ts` | 后端 Template 与前端兼容 Template 对象、模板权限资源的映射。 |
|
||||||
| `server/src/users/users.mapper.test.ts` | 后端 User 与前端兼容 User 对象、部门模板授权字段的映射。 |
|
| `server/src/users/users.mapper.test.ts` | 后端 User 与前端兼容 User 对象、部门模板授权字段的映射。 |
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ AI 面板支持两种模式:
|
|||||||
|
|
||||||
- 前端连接 `/api/speech/iat`,不再生成讯飞鉴权 URL,也不读取 APPID/APIKey/APISecret。
|
- 前端连接 `/api/speech/iat`,不再生成讯飞鉴权 URL,也不读取 APPID/APIKey/APISecret。
|
||||||
- 浏览器采集麦克风音频,转换为 16k PCM 后发送音频帧。
|
- 浏览器采集麦克风音频,转换为 16k PCM 后发送音频帧。
|
||||||
|
- 启动前会检查浏览器是否支持 `navigator.mediaDevices.getUserMedia` 和 `AudioContext`;如果不是 `localhost` 或 HTTPS 等安全上下文,浏览器会禁止麦克风能力,页面会提示切换访问方式。
|
||||||
- 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT,上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。
|
- 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT,上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。
|
||||||
- 识别结果由后端转发回前端,并追加到 AI 输入框。
|
- 识别结果由后端转发回前端,并追加到 AI 输入框。
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ AI 面板支持两种模式:
|
|||||||
|
|
||||||
- 保存草稿不强制患者姓名和住院号。
|
- 保存草稿不强制患者姓名和住院号。
|
||||||
- 完成报告要求患者姓名和住院号。
|
- 完成报告要求患者姓名和住院号。
|
||||||
|
- 保存失败时会优先展示后端返回的真实错误;如果 Session 失效,会提示重新登录并返回登录页。
|
||||||
- 保存时优先调用 `POST /api/reports` 或 `PATCH /api/reports/:id`;后端会先做 HTML 白名单清洗,再写入 PostgreSQL 并维护历史版本。
|
- 保存时优先调用 `POST /api/reports` 或 `PATCH /api/reports/:id`;后端会先做 HTML 白名单清洗,再写入 PostgreSQL 并维护历史版本。
|
||||||
- 编辑已有已完成报告时,后端会把旧内容追加到历史记录并递增 `revision`。
|
- 编辑已有已完成报告时,后端会把旧内容追加到历史记录并递增 `revision`。
|
||||||
- 只有本地回退开启时,API 不可用才保留原有本地 `localStorage.reports` 保存逻辑;生产构建默认关闭这条路径。
|
- 只有本地回退开启时,API 不可用才保留原有本地 `localStorage.reports` 保存逻辑;生产构建默认关闭这条路径。
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
- 报告编辑、模板选择、字段绑定、富文本编辑已实现。
|
- 报告编辑、模板选择、字段绑定、富文本编辑已实现。
|
||||||
- 视频上传、自动抽帧、手动截帧、关键帧插入已实现;视频和关键帧优先上传到后端 Files API。
|
- 视频上传、自动抽帧、手动截帧、关键帧插入已实现;视频和关键帧优先上传到后端 Files API。
|
||||||
- AI 对话、AI 区域改写、差异确认和调用日志已实现;AI 对话已改为后端 `/api/ai/chat` 代理。
|
- AI 对话、AI 区域改写、差异确认和调用日志已实现;AI 对话已改为后端 `/api/ai/chat` 代理。
|
||||||
- 讯飞语音听写入口已实现,并已改为后端 `/api/speech/iat` WebSocket 代理。
|
- 讯飞语音听写入口已实现,并已改为后端 `/api/speech/iat` WebSocket 代理;前端启动前会检查麦克风采集能力和安全上下文。
|
||||||
- 报告管理、查看、历史恢复、打印、JSON/PDF 导出已实现。
|
- 报告管理、查看、历史恢复、打印、JSON/PDF 导出已实现。
|
||||||
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage`、`ReportView`、`ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
|
- 报告 API 已实现列表、详情、创建、保存、完成修订、历史记录和软删除;`ReportManage`、`ReportView`、`ReportEditor` 已优先调用后端,只有开发/显式回退模式下才保留本地回退。
|
||||||
|
- 后端报告校验已区分草稿和完成状态:草稿允许患者姓名/住院号暂空,完成报告仍强制要求。
|
||||||
- 模板管理、字段库、模板导入导出已实现;模板 API 已支持可用/可管理列表、详情、创建、更新、删除和个人模板;字段库已优先接入 `/api/library/fields`。
|
- 模板管理、字段库、模板导入导出已实现;模板 API 已支持可用/可管理列表、详情、创建、更新、删除和个人模板;字段库已优先接入 `/api/library/fields`。
|
||||||
- 用户管理、部门管理员约束和部门模板授权已优先接入后端 Users/Departments API;签名上传和模板图片资源已通过 Files API 写入后端文件资源。
|
- 用户管理、部门管理员约束和部门模板授权已优先接入后端 Users/Departments API;签名上传和模板图片资源已通过 Files API 写入后端文件资源。
|
||||||
- 系统设置、抽帧策略、AI Provider、语音参数和默认模板已优先接入 Settings API,只有开发/显式回退模式下才保留本地缓存回退。
|
- 系统设置、抽帧策略、AI Provider、语音参数和默认模板已优先接入 Settings API,只有开发/显式回退模式下才保留本地缓存回退。
|
||||||
@@ -75,3 +76,4 @@
|
|||||||
| 2026-05-02 | 新增安装与初始设置文档,补充首次启动、端口规划、数据库初始化、验收步骤和常见问题。 |
|
| 2026-05-02 | 新增安装与初始设置文档,补充首次启动、端口规划、数据库初始化、验收步骤和常见问题。 |
|
||||||
| 2026-05-02 | 新增前端组件结构文档,梳理页面组件、公共组件、API/Auth/Utils 分层、数据流和大组件拆分边界。 |
|
| 2026-05-02 | 新增前端组件结构文档,梳理页面组件、公共组件、API/Auth/Utils 分层、数据流和大组件拆分边界。 |
|
||||||
| 2026-05-02 | 将默认“腹腔镜胆囊切除术报告”后端 seed 与前端默认报告内容对齐,并把系统设置重置改为演示模式恢复出厂设置。 |
|
| 2026-05-02 | 将默认“腹腔镜胆囊切除术报告”后端 seed 与前端默认报告内容对齐,并把系统设置重置改为演示模式恢复出厂设置。 |
|
||||||
|
| 2026-05-02 | 修正报告草稿后端校验和保存失败提示,补充麦克风启动前置检查。 |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ npm run build
|
|||||||
| 权限展示 | 侧边栏和路由守卫会按角色显示或阻止模板管理、用户管理、审计日志等入口。 |
|
| 权限展示 | 侧边栏和路由守卫会按角色显示或阻止模板管理、用户管理、审计日志等入口。 |
|
||||||
| 报告权限 | 医生只看到本人报告,管理员看到本部门报告,超级管理员看到全部后端报告。 |
|
| 报告权限 | 医生只看到本人报告,管理员看到本部门报告,超级管理员看到全部后端报告。 |
|
||||||
| 后端报告映射 | Report API 使用 `metadata` 兼容前端扩展字段,使用 `ReportMedia` 组装视频/关键帧兼容字段。 |
|
| 后端报告映射 | Report API 使用 `metadata` 兼容前端扩展字段,使用 `ReportMedia` 组装视频/关键帧兼容字段。 |
|
||||||
|
| 后端报告 schema | 创建/更新报告时允许草稿暂缺患者姓名和住院号,但完成报告必须包含患者姓名和住院号。 |
|
||||||
| 模板权限 | 医生可使用本部门授权模板和个人模板,不能使用他人个人模板。 |
|
| 模板权限 | 医生可使用本部门授权模板和个人模板,不能使用他人个人模板。 |
|
||||||
| 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 |
|
| 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 |
|
||||||
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
|
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
|
||||||
@@ -86,6 +87,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
|||||||
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
|
| 医生个人模板 E2E | 已覆盖 | `e2e/personal-template.spec.ts` |
|
||||||
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |
|
| 路由守卫和审计日志 E2E | 已覆盖 | `e2e/audit-and-route-guards.spec.ts` |
|
||||||
| 后端报告/模板/用户权限策略 | 已覆盖 | `server/src/permissions/permissions.policy.test.ts` |
|
| 后端报告/模板/用户权限策略 | 已覆盖 | `server/src/permissions/permissions.policy.test.ts` |
|
||||||
|
| 后端报告 schema | 已覆盖 | `server/src/reports/reports.schemas.test.ts` |
|
||||||
| 后端报告兼容映射 | 已覆盖 | `server/src/reports/report.mapper.test.ts` |
|
| 后端报告兼容映射 | 已覆盖 | `server/src/reports/report.mapper.test.ts` |
|
||||||
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
|
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
|
||||||
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
|
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
|
||||||
@@ -99,7 +101,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
|||||||
| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗、审计写入、审计查询权限和演示模式恢复出厂设置。 |
|
| 后端真实数据库集成 | 已覆盖 | `server/src/database.integration.test.ts`,含 Dashboard 角色范围、报告媒体关系表同步、报告 HTML 清洗、审计写入、审计查询权限和演示模式恢复出厂设置。 |
|
||||||
| 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 |
|
| 后端健康检查和认证 API | 已覆盖 | HTTP 集成测试覆盖健康检查、登录 session 和未登录保护;真实数据库集成覆盖 Argon2 登录、禁用账号和数据库 Session Store。 |
|
||||||
| 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 |
|
| 模板编辑器深度交互 | 待 E2E | 依赖 contentEditable 和 execCommand。 |
|
||||||
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本和个人模板;模板切换、字段同步仍待补。 |
|
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本、个人模板和后端草稿/完成报告 schema;模板切换、字段同步仍待补。 |
|
||||||
| 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 |
|
| 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 |
|
||||||
| AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 |
|
| AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 |
|
||||||
| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖后端首帧处理;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 |
|
| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖后端首帧处理;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 |
|
||||||
|
|||||||
43
server/src/reports/reports.schemas.test.ts
Normal file
43
server/src/reports/reports.schemas.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createReportSchema,
|
||||||
|
getCompletedReportIdentityIssues,
|
||||||
|
updateReportSchema,
|
||||||
|
} from './reports.schemas';
|
||||||
|
|
||||||
|
describe('reports schemas', () => {
|
||||||
|
it('allows drafts without patient identity fields', () => {
|
||||||
|
const parsed = createReportSchema.parse({
|
||||||
|
title: '腹腔镜胆囊切除术报告',
|
||||||
|
patientName: '',
|
||||||
|
hospitalId: '',
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.patientName).toBe('');
|
||||||
|
expect(parsed.hospitalId).toBe('');
|
||||||
|
expect(parsed.status).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires patient identity fields when completing reports', () => {
|
||||||
|
expect(() => createReportSchema.parse({
|
||||||
|
title: '腹腔镜胆囊切除术报告',
|
||||||
|
patientName: '',
|
||||||
|
hospitalId: '',
|
||||||
|
status: 'completed',
|
||||||
|
})).toThrow(/患者姓名不能为空/);
|
||||||
|
|
||||||
|
expect(() => updateReportSchema.parse({
|
||||||
|
patientName: '张三',
|
||||||
|
hospitalId: '',
|
||||||
|
status: 'completed',
|
||||||
|
})).toThrow(/住院号不能为空/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports missing identity fields for merged completion validation', () => {
|
||||||
|
expect(getCompletedReportIdentityIssues('completed', '张三', '')).toEqual([
|
||||||
|
{ path: 'hospitalId', message: '住院号不能为空' },
|
||||||
|
]);
|
||||||
|
expect(getCompletedReportIdentityIssues('draft', '', '')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,20 +2,55 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const reportStatusSchema = z.enum(['draft', 'completed']);
|
export const reportStatusSchema = z.enum(['draft', 'completed']);
|
||||||
|
|
||||||
|
type CompletedIdentityIssue = {
|
||||||
|
path: 'patientName' | 'hospitalId';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCompletedReportIdentityIssues(
|
||||||
|
status: z.infer<typeof reportStatusSchema> | undefined,
|
||||||
|
patientName: string | undefined,
|
||||||
|
hospitalId: string | undefined,
|
||||||
|
): CompletedIdentityIssue[] {
|
||||||
|
if (status !== 'completed') return [];
|
||||||
|
|
||||||
|
const issues: CompletedIdentityIssue[] = [];
|
||||||
|
if (!patientName?.trim()) {
|
||||||
|
issues.push({ path: 'patientName', message: '患者姓名不能为空' });
|
||||||
|
}
|
||||||
|
if (!hospitalId?.trim()) {
|
||||||
|
issues.push({ path: 'hospitalId', message: '住院号不能为空' });
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireCompletedIdentity = (
|
||||||
|
value: { status?: z.infer<typeof reportStatusSchema>; patientName?: string; hospitalId?: string },
|
||||||
|
ctx: z.RefinementCtx,
|
||||||
|
) => {
|
||||||
|
for (const issue of getCompletedReportIdentityIssues(value.status, value.patientName, value.hospitalId)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [issue.path],
|
||||||
|
message: issue.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reportBaseSchema = z.object({
|
const reportBaseSchema = z.object({
|
||||||
title: z.string().trim().min(1, '报告标题不能为空'),
|
title: z.string().trim().min(1, '报告标题不能为空'),
|
||||||
patientName: z.string().trim().min(1, '患者姓名不能为空'),
|
patientName: z.string().trim().default(''),
|
||||||
hospitalId: z.string().trim().min(1, '住院号不能为空'),
|
hospitalId: z.string().trim().default(''),
|
||||||
content: z.string().default(''),
|
content: z.string().default(''),
|
||||||
status: reportStatusSchema.default('draft'),
|
status: reportStatusSchema.default('draft'),
|
||||||
templateId: z.string().trim().optional(),
|
templateId: z.string().trim().optional(),
|
||||||
}).passthrough();
|
}).passthrough();
|
||||||
|
|
||||||
export const createReportSchema = reportBaseSchema;
|
export const createReportSchema = reportBaseSchema.superRefine(requireCompletedIdentity);
|
||||||
|
|
||||||
export const updateReportSchema = reportBaseSchema.partial().extend({
|
export const updateReportSchema = reportBaseSchema.partial().extend({
|
||||||
status: reportStatusSchema.optional(),
|
status: reportStatusSchema.optional(),
|
||||||
}).passthrough();
|
}).passthrough().superRefine(requireCompletedIdentity);
|
||||||
|
|
||||||
export const listReportsQuerySchema = z.object({
|
export const listReportsQuerySchema = z.object({
|
||||||
q: z.string().trim().optional(),
|
q: z.string().trim().optional(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './report.mapper.js';
|
} from './report.mapper.js';
|
||||||
import {
|
import {
|
||||||
createReportSchema,
|
createReportSchema,
|
||||||
|
getCompletedReportIdentityIssues,
|
||||||
listReportsQuerySchema,
|
listReportsQuerySchema,
|
||||||
updateReportSchema,
|
updateReportSchema,
|
||||||
type ListReportsQuery,
|
type ListReportsQuery,
|
||||||
@@ -129,6 +130,15 @@ export class ReportsService {
|
|||||||
|
|
||||||
const input = result.data;
|
const input = result.data;
|
||||||
const nextStatus = input.status ? this.toDbStatus(input.status) : old.status;
|
const nextStatus = input.status ? this.toDbStatus(input.status) : old.status;
|
||||||
|
const completionIssues = getCompletedReportIdentityIssues(
|
||||||
|
nextStatus === 'COMPLETED' ? 'completed' : 'draft',
|
||||||
|
input.patientName ?? old.patientName,
|
||||||
|
input.hospitalId ?? old.hospitalId,
|
||||||
|
);
|
||||||
|
if (completionIssues.length > 0) {
|
||||||
|
throw new BadRequestException(completionIssues.map((issue) => issue.message).join(';'));
|
||||||
|
}
|
||||||
|
|
||||||
const nextRevision = old.status === 'COMPLETED' ? old.revision + 1 : old.revision;
|
const nextRevision = old.status === 'COMPLETED' ? old.revision + 1 : old.revision;
|
||||||
const media = extractReportMedia(input);
|
const media = extractReportMedia(input);
|
||||||
const content = input.content === undefined ? old.content : sanitizeReportHtml(input.content);
|
const content = input.content === undefined ? old.content : sanitizeReportHtml(input.content);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { printDocument } from '../utils/print';
|
|||||||
import { storage } from '../utils/storage';
|
import { storage } from '../utils/storage';
|
||||||
import { canEditReport, getUsableTemplates } from '../utils/permissions';
|
import { canEditReport, getUsableTemplates } from '../utils/permissions';
|
||||||
import { getReport, saveReportToApi } from '../api/reports';
|
import { getReport, saveReportToApi } from '../api/reports';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
import { createTemplate, listTemplates } from '../api/templates';
|
import { createTemplate, listTemplates } from '../api/templates';
|
||||||
import { getSystemSettings } from '../api/settings';
|
import { getSystemSettings } from '../api/settings';
|
||||||
import { createAiChatCompletion } from '../api/ai';
|
import { createAiChatCompletion } from '../api/ai';
|
||||||
@@ -23,6 +24,19 @@ import { listFiles, uploadFileResource } from '../api/files';
|
|||||||
import { isLocalFallbackEnabled } from '../config/runtime';
|
import { isLocalFallbackEnabled } from '../config/runtime';
|
||||||
import { diffChars } from 'diff';
|
import { diffChars } from 'diff';
|
||||||
|
|
||||||
|
type AudioWindow = Window & typeof globalThis & {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 401) return '登录状态已失效,请重新登录后再保存。';
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message || fallback;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReportEditor() {
|
export default function ReportEditor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -1104,15 +1118,28 @@ export default function ReportEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const mediaDevices = navigator.mediaDevices;
|
||||||
|
const AudioContextClass = window.AudioContext || (window as AudioWindow).webkitAudioContext;
|
||||||
|
if (!mediaDevices?.getUserMedia) {
|
||||||
|
alert(window.isSecureContext
|
||||||
|
? '当前浏览器不支持麦克风采集,请更换新版 Chrome/Edge 后重试。'
|
||||||
|
: '麦克风需要在 localhost 或 HTTPS 环境下使用。请通过 localhost 访问,或配置 HTTPS 后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!AudioContextClass) {
|
||||||
|
alert('当前浏览器不支持音频采集处理,请更换新版 Chrome/Edge 后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(getSpeechIatWebSocketUrl());
|
const ws = new WebSocket(getSpeechIatWebSocketUrl());
|
||||||
xfWsRef.current = ws;
|
xfWsRef.current = ws;
|
||||||
let frameStatus = 0;
|
let frameStatus = 0;
|
||||||
|
|
||||||
ws.onopen = async () => {
|
ws.onopen = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await mediaDevices.getUserMedia({ audio: true });
|
||||||
xfMediaStreamRef.current = stream;
|
xfMediaStreamRef.current = stream;
|
||||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
const audioContext = new AudioContextClass({ sampleRate: 16000 });
|
||||||
xfAudioContextRef.current = audioContext;
|
xfAudioContextRef.current = audioContext;
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
@@ -1797,9 +1824,11 @@ export default function ReportEditor() {
|
|||||||
try {
|
try {
|
||||||
savedReport = await saveReportToApi(finalReport, reportId || undefined);
|
savedReport = await saveReportToApi(finalReport, reportId || undefined);
|
||||||
apiSaved = true;
|
apiSaved = true;
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (!isLocalFallbackEnabled()) {
|
if (!isLocalFallbackEnabled()) {
|
||||||
alert('保存失败:后端服务不可用');
|
const message = getApiErrorMessage(error, '后端服务不可用');
|
||||||
|
alert(`保存失败:${message}`);
|
||||||
|
if (error instanceof ApiError && error.status === 401) navigate('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1854,9 +1883,11 @@ export default function ReportEditor() {
|
|||||||
try {
|
try {
|
||||||
savedTemplate = await createTemplate(newTemplate);
|
savedTemplate = await createTemplate(newTemplate);
|
||||||
apiSaved = true;
|
apiSaved = true;
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (!isLocalFallbackEnabled()) {
|
if (!isLocalFallbackEnabled()) {
|
||||||
alert('保存个人模板失败:后端服务不可用');
|
const message = getApiErrorMessage(error, '后端服务不可用');
|
||||||
|
alert(`保存个人模板失败:${message}`);
|
||||||
|
if (error instanceof ApiError && error.status === 401) navigate('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user