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:
2026-05-02 03:21:45 +08:00
parent 911b96b883
commit 285dbd2023
9 changed files with 142 additions and 15 deletions

View File

@@ -343,6 +343,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
- 后端权限策略覆盖报告、模板、用户管理和管理员创建规则
- 后端 Dashboard 统计按角色范围过滤
- 后端报告 metadata 兼容映射和 `ReportMedia` 视频/关键帧组装
- 后端报告 schema 区分草稿和完成状态,草稿可暂缺患者姓名/住院号,完成报告必须填写
- 后端模板 DTO 和权限资源映射
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验

View File

@@ -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 对象、部门模板授权字段的映射。 |

View File

@@ -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` 保存逻辑;生产构建默认关闭这条路径。

View File

@@ -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 | 修正报告草稿后端校验和保存失败提示,补充麦克风启动前置检查。 |

View File

@@ -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 集成测试、麦克风权限和测试凭证。 |

View 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([]);
});
});

View File

@@ -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<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({
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(),

View File

@@ -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);

View File

@@ -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;
}
}