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