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

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