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

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