From 443e501da5f053866b77e88b2b31b65f3e794f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 31 Aug 2026 16:35:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=AD=E9=9F=B3=E5=BC=80=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doctor/doctor-reception/api/index.ts | 39 ++ .../components/AiPrescriptionDrawer.vue | 508 ++++++++++++++++-- .../views/doctor/doctor-reception/index.vue | 47 +- .../doctor-reception/utils/encodeWav.ts | 73 +++ .../system/ai/generation/config/constants.ts | 1 + .../components/ai-model-config-panel.vue | 332 ++++++++++-- 6 files changed, 914 insertions(+), 86 deletions(-) create mode 100644 apps/web-antd/src/views/doctor/doctor-reception/utils/encodeWav.ts diff --git a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts index 064a8e5f..5731d6e4 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts +++ b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts @@ -442,10 +442,49 @@ export async function aiGeneratePrescriptionApi(data: { use_entrusted_process?: 0 | 1 | boolean; /** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */ process_rule_id?: number; + /** 语音辨证:dialectic */ + voice_mode?: string; + transcript?: string; + asr_provider?: string; + asr_duration_ms?: number; + return_dosage_process_detail?: 0 | 1 | boolean; }) { return requestClient.post(`${prefix}ai-generate-prescription`, data); } +/** + * 语音转写(multipart:audio 文件) + * 字段名必须为 audio,与后端 request()->file('audio') 对齐 + */ +export async function aiVoiceAsrApi(file: File, durationMs = 0) { + const fd = new FormData(); + fd.append('audio', file); + if (durationMs > 0) { + fd.append('duration_ms', String(durationMs)); + } + return requestClient.post(`${prefix}ai-voice-asr`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +/** 语音口述落方(只抽药名剂量) */ +export async function aiGenerateVoicePrescriptionApi(data: { + register_id: number; + store_id?: number; + prescription_type: number; + transcript: string; + asr_provider?: string; + asr_duration_ms?: number; + use_entrusted_process?: 0 | 1 | boolean; + process_rule_id?: number; + return_dosage_process_detail?: 0 | 1 | boolean; +}) { + return requestClient.post( + `${prefix}ai-generate-voice-prescription`, + data, + ); +} + /** AI 功能门闸(醒目提示 + 是否已签署知情同意) */ export async function aiFeatureGateApi(data: { feature_code: string }) { return requestClient.get(`${prefix}ai-feature-gate`, { params: data }); diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue index 1659495f..ceda67d4 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue @@ -11,12 +11,15 @@ import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag, import { aiGeneratePrescriptionApi, + aiGenerateVoicePrescriptionApi, aiListGenerationsApi, aiMarkGenerationReadApi, aiMatchPrescriptionDrugsApi, aiSavePrescriptionDrugSelectionApi, + aiVoiceAsrApi, getProcessRuleList, } from '../api'; +import { encodeBlobToWav16k } from '../utils/encodeWav'; import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue'; import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate'; import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue'; @@ -137,6 +140,19 @@ const editingKey = ref(''); /** 醒目提示文案(系统配置 / 门闸下发) */ const disclaimerText = ref(''); +/** 语音开方:录音 / 转写 / A·B 模式 */ +const voiceRecording = ref(false); +const voiceAsrLoading = ref(false); +const voiceTranscript = ref(''); +const voiceAsrProvider = ref(''); +const voiceAsrDurationMs = ref(0); +/** dialectic | dictation;默认口述落方(医生口述药味更常见) */ +const voiceMode = ref<'dialectic' | 'dictation'>('dictation'); +let voiceMediaRecorder: MediaRecorder | null = null; +let voiceAudioChunks: Blob[] = []; +let voiceStream: MediaStream | null = null; +let voiceRecordStartedAt = 0; + const sexLabel = computed(() => { const s = Number(patientSex.value); if (s === 1) return '男'; @@ -193,6 +209,8 @@ const [Drawer, drawerApi] = useVbenDrawer({ patientAge?: number; chiefComplaint?: string; medicalRecord?: Record; + /** assist=携带病历辅助出方;voice=工具栏语音入口,同意后自动开语音弹窗 */ + entry?: 'assist' | 'voice'; }>(); registerId.value = Number(data?.registerId || 0); storeId.value = Number(data?.storeId || 0); @@ -205,6 +223,7 @@ const [Drawer, drawerApi] = useVbenDrawer({ data?.medicalRecord && typeof data.medicalRecord === 'object' ? { ...data.medicalRecord } : {}; + const entryMode = data?.entry === 'voice' ? 'voice' : 'assist'; resetPreview(); // 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉 try { @@ -223,6 +242,14 @@ const [Drawer, drawerApi] = useVbenDrawer({ return; } void bootstrapHistory(); + // 工具栏「语音开方」:同意后直接进入语音弹窗,默认口述落方 + if (entryMode === 'voice') { + voiceMode.value = 'dictation'; + voiceTranscript.value = ''; + voiceAsrProvider.value = ''; + voiceAsrDurationMs.value = 0; + voiceModalApi.open(); + } }, }); @@ -235,6 +262,28 @@ const [GenModal, genModalApi] = useVbenModal({ onConfirm: async () => confirmGenerateAndClose(), }); +/** + * 语音开方弹窗:录音转写 → 卡片选 A/B → 确认生成 + * 未选模式时确认按钮禁用(由 onConfirm 内校验兜底) + */ +const [VoiceModal, voiceModalApi] = useVbenModal({ + title: '语音开方', + class: 'w-[640px]', + confirmText: '确认生成', + cancelText: '取消', + onConfirm: async () => confirmVoiceGenerate(), + onOpenChange(isOpen: boolean) { + if (!isOpen) { + stopVoiceRecording(true); + voiceTranscript.value = ''; + voiceMode.value = 'dictation'; + voiceAsrProvider.value = ''; + voiceAsrDurationMs.value = 0; + voiceAsrLoading.value = false; + } + }, +}); + function resetPreview() { matchedList.value = []; unmatchedList.value = []; @@ -268,8 +317,12 @@ function resetState() { disclaimerText.value = ''; genProcessRuleId.value = undefined; processRuleOptions.value = []; + stopVoiceRecording(true); + voiceTranscript.value = ''; + voiceMode.value = 'dictation'; resetPreview(); genModalApi.close(); + voiceModalApi.close(); } function formatTime(ts: number) { @@ -617,6 +670,217 @@ async function confirmGenerateAndClose(): Promise { return true; } +/** + * 打开语音开方弹窗(录音 → 转写 → 选 A/B) + */ +async function openVoiceModal() { + if (generating.value) return; + if (!prescriptionType.value) { + message.warning('请先选择处方类型'); + return; + } + if (!registerId.value) { + message.warning('挂号无效'); + return; + } + voiceTranscript.value = ''; + voiceMode.value = 'dictation'; + voiceAsrProvider.value = ''; + voiceAsrDurationMs.value = 0; + // 与生成弹窗一致:恢复委托调剂本地记忆并拉制剂要求 + if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) { + void loadTopProcessRules(); + } + voiceModalApi.open(); +} + +/** + * 开始 / 停止录音(点击切换) + */ +async function toggleVoiceRecording() { + if (voiceAsrLoading.value) return; + if (voiceRecording.value) { + stopVoiceRecording(false); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + voiceStream = stream; + voiceAudioChunks = []; + voiceRecordStartedAt = Date.now(); + const mime = MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : ''; + voiceMediaRecorder = mime + ? new MediaRecorder(stream, { mimeType: mime }) + : new MediaRecorder(stream); + voiceMediaRecorder.ondataavailable = (ev) => { + if (ev.data && ev.data.size > 0) voiceAudioChunks.push(ev.data); + }; + voiceMediaRecorder.onstop = () => { + void handleVoiceRecordStop(); + }; + voiceMediaRecorder.start(); + voiceRecording.value = true; + } catch (e: any) { + message.error(e?.message || '无法访问麦克风'); + voiceRecording.value = false; + } +} + +/** + * 停止录音;discard=true 时丢弃数据不转写 + */ +function stopVoiceRecording(discard = false) { + if (!voiceRecording.value && !voiceMediaRecorder) { + cleanupVoiceStream(); + return; + } + voiceRecording.value = false; + if (discard) { + if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') { + voiceMediaRecorder.ondataavailable = null; + voiceMediaRecorder.onstop = () => cleanupVoiceStream(); + try { + voiceMediaRecorder.stop(); + } catch { + cleanupVoiceStream(); + } + } else { + cleanupVoiceStream(); + } + voiceAudioChunks = []; + voiceMediaRecorder = null; + return; + } + if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') { + try { + voiceMediaRecorder.stop(); + } catch { + cleanupVoiceStream(); + } + } +} + +function cleanupVoiceStream() { + if (voiceStream) { + voiceStream.getTracks().forEach((t) => t.stop()); + voiceStream = null; + } + voiceMediaRecorder = null; +} + +/** + * 录音结束 → 转 wav → ASR + */ +async function handleVoiceRecordStop() { + const chunks = voiceAudioChunks.slice(); + const started = voiceRecordStartedAt; + cleanupVoiceStream(); + voiceAudioChunks = []; + if (!chunks.length) { + message.warning('未录到有效音频'); + return; + } + const rawBlob = new Blob(chunks, { type: chunks[0]?.type || 'audio/webm' }); + const durationMs = Math.max(0, Date.now() - started); + voiceAsrLoading.value = true; + try { + const wavBlob = await encodeBlobToWav16k(rawBlob); + const file = new File([wavBlob], `voice_${Date.now()}.wav`, { + type: 'audio/wav', + }); + const data = await aiVoiceAsrApi(file, durationMs); + const text = String(data?.text || '').trim(); + if (!text) { + message.warning('未识别到有效语音'); + return; + } + voiceTranscript.value = text; + voiceAsrProvider.value = String(data?.provider || 'xunfei'); + voiceAsrDurationMs.value = Number(data?.duration_ms || durationMs); + message.success('转写完成,请选择开方方式'); + } catch (e: any) { + message.error(e?.message || e?.msg || '语音转写失败'); + } finally { + voiceAsrLoading.value = false; + } +} + +/** + * 语音弹窗确认:按 A/B 调对应生成接口 + */ +async function confirmVoiceGenerate() { + const text = voiceTranscript.value.trim(); + if (!text) { + message.warning('请先完成语音转写或填写文本'); + return; + } + if (!voiceMode.value) { + message.warning('请选择辨证出方或口述落方'); + return; + } + if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) { + message.warning('请选择制剂要求'); + return; + } + const mode = voiceMode.value; + const transcript = text; + const asrProvider = voiceAsrProvider.value; + const asrDuration = voiceAsrDurationMs.value; + voiceModalApi.close(); + if (mode === 'dialectic') { + // A:转写当主诉,走现有辨证出方 + chiefComplaint.value = transcript; + void runGenerateInDrawer(transcript, { + voice_mode: 'dialectic', + transcript, + asr_provider: asrProvider, + asr_duration_ms: asrDuration, + }); + return; + } + // B:口述落方 + void runVoiceDictationGenerate(transcript, asrProvider, asrDuration); +} + +/** + * B 口述落方生成 + */ +async function runVoiceDictationGenerate( + transcript: string, + asrProvider: string, + asrDuration: number, +) { + beginGeneratingPlaceholder(); + generating.value = true; + multiPrescriptions.value = []; + multiActiveIndex.value = 0; + try { + const req: Record = { + register_id: registerId.value, + store_id: storeId.value || undefined, + prescription_type: prescriptionType.value, + transcript, + asr_provider: asrProvider || undefined, + asr_duration_ms: asrDuration || undefined, + }; + if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) { + req.use_entrusted_process = 1; + req.process_rule_id = genProcessRuleId.value; + req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0; + } + const data = await aiGenerateVoicePrescriptionApi(req); + await applyGenerateResponse(data, '口述识别'); + } catch (e: any) { + endGeneratingPlaceholder(); + resetPreview(); + message.error(e?.message || e?.msg || '口述识别失败'); + } finally { + generating.value = false; + } +} + /** * 本地把某条历史标为已读(同步 multi / historyList) */ @@ -682,7 +946,15 @@ function onMultiTabChange(key: string | number) { applyMultiSlot(slot, idx); } -async function runGenerateInDrawer(chief: string) { +async function runGenerateInDrawer( + chief: string, + voiceMeta?: { + voice_mode?: string; + transcript?: string; + asr_provider?: string; + asr_duration_ms?: number; + }, +) { beginGeneratingPlaceholder(); generating.value = true; multiPrescriptions.value = []; @@ -704,50 +976,16 @@ async function runGenerateInDrawer(chief: string) { req.process_rule_id = genProcessRuleId.value; req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0; } + if (voiceMeta?.voice_mode) { + req.voice_mode = voiceMeta.voice_mode; + req.transcript = voiceMeta.transcript || chief; + if (voiceMeta.asr_provider) req.asr_provider = voiceMeta.asr_provider; + if (voiceMeta.asr_duration_ms) { + req.asr_duration_ms = voiceMeta.asr_duration_ms; + } + } const data = await aiGeneratePrescriptionApi(req); - const durationText = formatDurationMs(data?.duration_ms); - const list = Array.isArray(data?.prescriptions) ? data.prescriptions : []; - // 业务软失败:HTTP 成功但 ok=false,警告提示而非 error - if (data?.ok === false) { - endGeneratingPlaceholder(); - resetPreview(); - if (list.length > 1) { - multiPrescriptions.value = list; - const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false); - const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0; - applyMultiSlot(list[useIdx], useIdx); - } - if (data?.generation_id) { - await loadHistory(); - activeId.value = Number(data.generation_id); - } - message.warning(String(data?.message || '生成未成功')); - return; - } - await loadHistory(); - if (list.length > 1) { - multiPrescriptions.value = list; - const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false); - const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0; - applyMultiSlot(list[useIdx], useIdx); - const okN = list.filter((p: any) => p && p.ok !== false).length; - const failN = list.length - okN; - const baseMsg = - failN > 0 - ? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比` - : `已生成 ${okN} 套方案,可切换对比`; - message.success(durationText ? `${baseMsg}(耗时 ${durationText})` : baseMsg); - return; - } - activeId.value = Number(data?.generation_id || 0); - applyMatch(data?.match || null); - applyConflict(data?.conflict || null); - applySafetyFlags(data?.safety_flags || null); - applyRxMeta(data); - void markReadIfNeeded(activeId.value, 0); - message.success( - durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入', - ); + await applyGenerateResponse(data, '生成'); } catch (e: any) { endGeneratingPlaceholder(); resetPreview(); @@ -757,6 +995,62 @@ async function runGenerateInDrawer(chief: string) { } } +/** + * 统一处理生成/口述接口返回(多方案 / 单方案 / 软失败) + */ +async function applyGenerateResponse(data: any, actionLabel: string) { + const durationText = formatDurationMs(data?.duration_ms); + const list = Array.isArray(data?.prescriptions) ? data.prescriptions : []; + if (data?.ok === false) { + endGeneratingPlaceholder(); + resetPreview(); + if (list.length > 1) { + multiPrescriptions.value = list; + const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false); + const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0; + applyMultiSlot(list[useIdx], useIdx); + } + if (data?.generation_id) { + await loadHistory(); + activeId.value = Number(data.generation_id); + } + message.warning(String(data?.message || `${actionLabel}未成功`)); + return; + } + await loadHistory(); + if (list.length > 1) { + multiPrescriptions.value = list; + const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false); + const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0; + applyMultiSlot(list[useIdx], useIdx); + const okN = list.filter((p: any) => p && p.ok !== false).length; + const failN = list.length - okN; + const baseMsg = + failN > 0 + ? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比` + : `已生成 ${okN} 套方案,可切换对比`; + message.success(durationText ? `${baseMsg}(耗时 ${durationText})` : baseMsg); + return; + } + activeId.value = Number(data?.generation_id || 0); + applyMatch(data?.match || null); + applyConflict(data?.conflict || null); + applySafetyFlags(data?.safety_flags || null); + applyRxMeta(data); + void markReadIfNeeded(activeId.value, 0); + const unmatchedN = Array.isArray(data?.match?.unmatched) + ? data.match.unmatched.length + : unmatchedList.value.length; + const okMsg = durationText + ? `已${actionLabel}(耗时 ${durationText})` + : `已${actionLabel},请确认导入`; + if (unmatchedN > 0) { + message.warning(`${okMsg};${unmatchedN} 味未在药库找到,已标红`); + } else { + message.success(okMsg); + } +} + async function onSelectHistory(row: any) { if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return; // 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰 @@ -866,6 +1160,10 @@ function onConfirmImport() { message.warning('请先勾选已对照药品'); return; } + const skipN = unmatchedList.value.length; + if (skipN > 0) { + message.warning(`已跳过 ${skipN} 味未对照药品(标红项不会导入)`); + } const importPayload: { rows: typeof drugs; dosage?: number; @@ -914,8 +1212,13 @@ defineExpose({ patientAge?: number; chiefComplaint?: string; medicalRecord?: Record; + /** assist 默认;voice=工具栏语音入口 */ + entry?: 'assist' | 'voice'; }) { - drawerApi.setData(payload); + drawerApi.setData({ + ...payload, + entry: payload.entry === 'voice' ? 'voice' : 'assist', + }); drawerApi.open(); }, }); @@ -1175,16 +1478,23 @@ defineExpose({
- 未对照({{ unmatchedList.length }}) + 未对照({{ unmatchedList.length }})— 药库未找到,已标红,不会导入
-
{{ u.ai_name }}
+
{{ u.ai_name }}
+
+ {{ u.dose }}{{ u.unit }} + · {{ u.usage }} +
+ + + +
+ +
+ + + 说完后点结束,系统会转写成文字供修改 + +
+
+
转写文本(可改)
+ +
+
+
+ 请选择开方方式 +
+
+ + +
+
+
+ + 委托调剂 + +
+
+
制剂要求
+
+ 加载中… +
+
+ {{ opt.label }} +
+
+
+
+
+