feat: 语音开方
This commit is contained in:
@@ -620,6 +620,32 @@ export function aiGeneratePrescriptionApi(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转写(multipart:字段名 audio)
|
||||
* @param {{ filePath: string, duration_ms?: number }} data
|
||||
*/
|
||||
export function aiVoiceAsrApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/ai-voice-asr',
|
||||
method: 'POST',
|
||||
type: 'upload',
|
||||
filePath: data.filePath,
|
||||
name: 'audio',
|
||||
formData: {
|
||||
duration_ms: String(data.duration_ms || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 语音口述落方(只抽药名剂量) */
|
||||
export function aiGenerateVoicePrescriptionApi(data) {
|
||||
return req.request({
|
||||
url: '/newApi/doctor-reception-wx/ai-generate-voice-prescription',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(轻量列表) */
|
||||
export function aiListGenerationsApi(data) {
|
||||
return req.request({
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
<text class="ai-consent-body">{{ consentText }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-else-if="conflictView.length && !genModalVisible" class="ai-conflict">
|
||||
<view v-else-if="conflictView.length && !genModalVisible && !voiceModalVisible" class="ai-conflict">
|
||||
<text class="ai-conflict-title">配伍禁忌提示</text>
|
||||
<view v-for="(line, ci) in conflictView" :key="ci" class="ai-conflict-line">· {{ line }}</view>
|
||||
</view>
|
||||
<!-- 主内容:历史 + 对照 -->
|
||||
<view v-if="!consentVisible && !genModalVisible" class="ai-rx-body flex-row">
|
||||
<view v-if="!consentVisible && !genModalVisible && !voiceModalVisible" class="ai-rx-body flex-row">
|
||||
<scroll-view scroll-y class="ai-rx-hist">
|
||||
<view class="ai-hist-hd">
|
||||
<text class="fs-24 font-bold">历史</text>
|
||||
@@ -162,13 +162,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="ai-sec-title m-t-16">未对照({{ unmatchedCount }})</view>
|
||||
<view class="ai-sec-title m-t-16 ai-unmatched-title">未对照({{ unmatchedCount }})— 药库未找到,已标红</view>
|
||||
<view
|
||||
v-for="u in unmatchedView"
|
||||
:key="u.ui"
|
||||
class="ai-match-row ai-match-row--warn"
|
||||
class="ai-match-row ai-match-row--danger"
|
||||
>
|
||||
<text class="fs-26">{{ u.ai_name }}</text>
|
||||
<text class="fs-26 ai-unmatched-name">{{ u.ai_name }}</text>
|
||||
<text class="ai-link m-t-8" :data-ui="u.ui" @click="onSearchUnmatchedTap">去手动搜索</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -315,6 +315,83 @@
|
||||
<view class="ai-gen-scroll-pad" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- 二级:语音开方面板(录音转写 + A/B 卡片,不用第二个 page-container) -->
|
||||
<view v-else-if="!consentVisible && voiceModalVisible" class="ai-gen-panel">
|
||||
<view class="ai-gen-hd flex-row flex-jus-sp flex-ali-center">
|
||||
<text class="fs-30 font-bold">语音开方</text>
|
||||
<text class="ai-rx-sub" @click="closeVoiceModal">返回</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="ai-gen-scroll">
|
||||
<view class="ai-voice-rec flex-row flex-ali-center">
|
||||
<view
|
||||
class="ai-btn ai-btn--ghost"
|
||||
:class="{ 'ai-btn--recording': voiceRecording }"
|
||||
@click="onToggleVoiceRecord"
|
||||
>
|
||||
{{ voiceAsrLoading ? '转写中…' : (voiceRecording ? '点击结束录音' : '点击开始录音') }}
|
||||
</view>
|
||||
<text class="fs-22 color-sub m-l-16">说完后点结束,可改字</text>
|
||||
</view>
|
||||
<view class="ai-gen-row ai-gen-row--full m-t-16">
|
||||
<text class="ai-gen-label">转写文本(可改)</text>
|
||||
<textarea
|
||||
class="ai-gen-textarea"
|
||||
:value="voiceTranscript"
|
||||
maxlength="2000"
|
||||
auto-height
|
||||
placeholder="录音转写结果会显示在这里"
|
||||
:disabled="voiceRecording || voiceAsrLoading"
|
||||
@input="onVoiceTranscriptInput"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="voiceTranscriptTrimmed" class="m-t-16">
|
||||
<text class="fs-24 font-bold">请选择开方方式</text>
|
||||
<view
|
||||
class="ai-mode-card m-t-12"
|
||||
:class="{ 'ai-mode-card--on': voiceMode == 'dialectic' }"
|
||||
data-mode="dialectic"
|
||||
@click="onSelectVoiceModeTap"
|
||||
>
|
||||
<text class="fs-28 font-bold">辨证出方</text>
|
||||
<text class="fs-22 color-sub m-t-8">根据口述病情,AI 辅助生成处方</text>
|
||||
</view>
|
||||
<view
|
||||
class="ai-mode-card m-t-12"
|
||||
:class="{ 'ai-mode-card--on': voiceMode == 'dictation' }"
|
||||
data-mode="dictation"
|
||||
@click="onSelectVoiceModeTap"
|
||||
>
|
||||
<text class="fs-28 font-bold">口述落方</text>
|
||||
<text class="fs-22 color-sub m-t-8">识别口述药名剂量,对照药库后导入</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="isTcmRx" class="ai-entrust-box m-t-16">
|
||||
<view class="flex-row flex-ali-center flex-jus-sp">
|
||||
<text class="fs-24">委托调剂</text>
|
||||
<switch
|
||||
:checked="useEntrustedProcess"
|
||||
color="#6ACDBB"
|
||||
style="transform: scale(0.75)"
|
||||
@change="onEntrustedSwitch"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="useEntrustedProcess" class="m-t-12">
|
||||
<text class="fs-22 color-sub">制剂要求</text>
|
||||
<text v-if="processRuleLoading" class="fs-22 color-sub m-t-8" style="display:block">加载中…</text>
|
||||
<view v-else class="ai-process-pill-group m-t-8">
|
||||
<view
|
||||
v-for="item in processRuleList"
|
||||
:key="item.id"
|
||||
class="ai-process-pill"
|
||||
:class="{ active: genProcessRuleId == item.id }"
|
||||
@click="onProcessRuleTagClick(item)"
|
||||
>{{ item.name || item.title || ('#' + item.id) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ai-gen-scroll-pad" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view class="ai-rx-footer safe-area-inset-bottom">
|
||||
<view v-if="consentVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onConsentCancel">取消</view>
|
||||
@@ -322,6 +399,12 @@
|
||||
{{ consentSubmitting ? '提交中…' : '我已知悉并同意' }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="voiceModalVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="closeVoiceModal">返回</view>
|
||||
<view class="ai-btn ai-btn--primary" @click="onConfirmVoiceGenerate">
|
||||
{{ generating ? '生成中…' : '确认生成' }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!genModalVisible" class="ai-actions">
|
||||
<view class="ai-btn ai-btn--ghost" @click="onClose">关闭</view>
|
||||
<view class="ai-btn ai-btn--ghost" @click="openGenModal">生成处方</view>
|
||||
@@ -343,10 +426,12 @@ import {
|
||||
aiAgreeConsentApi,
|
||||
aiFeatureGateApi,
|
||||
aiGeneratePrescriptionApi,
|
||||
aiGenerateVoicePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMarkGenerationReadApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
aiSavePrescriptionDrugSelectionApi,
|
||||
aiVoiceAsrApi,
|
||||
getProcessRuleList,
|
||||
} from '@/api/reception.js'
|
||||
|
||||
@@ -391,11 +476,23 @@ export default {
|
||||
patientAge: { type: [Number, String], default: 0 },
|
||||
chiefComplaint: { type: String, default: '' },
|
||||
medicalRecord: { type: Object, default: () => ({}) },
|
||||
/** assist=携带病历;voice=工具栏语音入口,同意后直接进语音面板 */
|
||||
entry: { type: String, default: 'assist' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
genModalVisible: false,
|
||||
/** 语音开方二级面板 */
|
||||
voiceModalVisible: false,
|
||||
voiceRecording: false,
|
||||
voiceAsrLoading: false,
|
||||
voiceTranscript: '',
|
||||
voiceTranscriptTrimmed: '',
|
||||
/** dialectic | dictation;默认口述落方 */
|
||||
voiceMode: 'dictation',
|
||||
voiceAsrProvider: '',
|
||||
voiceAsrDurationMs: 0,
|
||||
aiChief: '',
|
||||
localMr: {},
|
||||
editingKey: '',
|
||||
@@ -496,6 +593,8 @@ export default {
|
||||
this.aiChief = String(this.chiefComplaint || '').trim()
|
||||
this.localMr = Object.assign({}, this.medicalRecord || {})
|
||||
this.genModalVisible = false
|
||||
this.voiceModalVisible = false
|
||||
this.resetVoiceState()
|
||||
this.editingKey = ''
|
||||
this.consentVisible = false
|
||||
this.consentSubmitting = false
|
||||
@@ -506,6 +605,18 @@ export default {
|
||||
this.resetPreview()
|
||||
this.ensureConsentThenBootstrap()
|
||||
},
|
||||
/**
|
||||
* 重置语音开方状态并停止录音
|
||||
*/
|
||||
resetVoiceState() {
|
||||
this.stopVoiceRecording(true)
|
||||
this.voiceAsrLoading = false
|
||||
this.voiceTranscript = ''
|
||||
this.voiceTranscriptTrimmed = ''
|
||||
this.voiceMode = 'dictation'
|
||||
this.voiceAsrProvider = ''
|
||||
this.voiceAsrDurationMs = 0
|
||||
},
|
||||
/**
|
||||
* 首次使用 AI 出方:未签署则先展示知情同意
|
||||
*/
|
||||
@@ -528,6 +639,13 @@ export default {
|
||||
},
|
||||
afterConsentReady() {
|
||||
this.consentVisible = false
|
||||
// 工具栏「语音开方」:同意后直接进语音面板,默认口述落方
|
||||
if (this.entry === 'voice') {
|
||||
this.voiceMode = 'dictation'
|
||||
this.openVoiceModal()
|
||||
void this.loadHistory()
|
||||
return
|
||||
}
|
||||
this.loadHistory().then(() => {
|
||||
if (this.historyList.length) {
|
||||
this.selectHistoryById(Number(this.historyList[0].id))
|
||||
@@ -581,17 +699,36 @@ export default {
|
||||
this.rebuildMatchView()
|
||||
},
|
||||
onClose() {
|
||||
this.resetVoiceState()
|
||||
this.visible = false
|
||||
this.genModalVisible = false
|
||||
this.voiceModalVisible = false
|
||||
},
|
||||
/**
|
||||
* Laravel jok 业务在 result;兼容 Yii data / 已解包数组
|
||||
* Laravel jok 业务在 result;兼容 Yii data / 已解包数组 / upload 字符串 JSON
|
||||
*/
|
||||
unwrapBiz(res) {
|
||||
if (res == null) return null
|
||||
if (res.result != null) return res.result
|
||||
if (res.data != null) return res.data
|
||||
return res
|
||||
let body = res
|
||||
if (typeof res === 'string') {
|
||||
try {
|
||||
body = JSON.parse(res)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// uni.uploadFile 常见形态:{ data: '{"code":0,"result":...}' }
|
||||
if (body && typeof body.data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body.data)
|
||||
if (parsed && typeof parsed === 'object') body = parsed
|
||||
} catch (e2) {
|
||||
/* keep body */
|
||||
}
|
||||
}
|
||||
if (body && body.result != null) return body.result
|
||||
if (body && body.data != null && typeof body.data === 'object') return body.data
|
||||
return body
|
||||
},
|
||||
formatTime(ts) {
|
||||
// 已是格式化字符串则直接展示(查询器可能一级格式化)
|
||||
@@ -950,6 +1087,346 @@ export default {
|
||||
this.genModalVisible = false
|
||||
this.editingKey = ''
|
||||
},
|
||||
/**
|
||||
* 打开语音开方面板
|
||||
*/
|
||||
openVoiceModal() {
|
||||
if (this.generating) return
|
||||
if (!Number(this.prescriptionType || 0)) {
|
||||
uni.showToast({ title: '请先选择处方类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.resetVoiceState()
|
||||
this.useEntrustedProcess = this.loadUseEntrustedProcessPref()
|
||||
this.returnDosageProcessDetail = this.loadReturnDosageProcessDetailPref()
|
||||
if (this.isTcmRx && this.useEntrustedProcess) {
|
||||
if (!this.processRuleList.length) {
|
||||
this.loadTopProcessRules()
|
||||
} else {
|
||||
this.applyProcessRulePrefFromStorage()
|
||||
}
|
||||
} else {
|
||||
this.genProcessRuleId = 0
|
||||
}
|
||||
this.voiceModalVisible = true
|
||||
},
|
||||
closeVoiceModal() {
|
||||
this.resetVoiceState()
|
||||
this.voiceModalVisible = false
|
||||
},
|
||||
onVoiceTranscriptInput(e) {
|
||||
const v = (e && e.detail && e.detail.value) || ''
|
||||
this.voiceTranscript = v
|
||||
this.voiceTranscriptTrimmed = String(v || '').trim()
|
||||
},
|
||||
onSelectVoiceModeTap(e) {
|
||||
const mode = e && e.currentTarget && e.currentTarget.dataset
|
||||
? e.currentTarget.dataset.mode
|
||||
: ''
|
||||
if (mode === 'dialectic' || mode === 'dictation') {
|
||||
this.voiceMode = mode
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 确保 RecorderManager 已初始化(mp3 16k,适配讯飞 lame)
|
||||
*/
|
||||
ensureRecorder() {
|
||||
if (this._voiceRecorder) return this._voiceRecorder
|
||||
const rm = uni.getRecorderManager()
|
||||
rm.onStop((res) => {
|
||||
this.voiceRecording = false
|
||||
const path = (res && res.tempFilePath) || ''
|
||||
const duration = Number((res && res.duration) || 0)
|
||||
if (!path) {
|
||||
uni.showToast({ title: '未录到有效音频', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.handleVoiceAsr(path, duration)
|
||||
})
|
||||
rm.onError(() => {
|
||||
this.voiceRecording = false
|
||||
uni.showToast({ title: '录音失败', icon: 'none' })
|
||||
})
|
||||
this._voiceRecorder = rm
|
||||
return rm
|
||||
},
|
||||
onToggleVoiceRecord() {
|
||||
if (this.voiceAsrLoading) return
|
||||
if (this.voiceRecording) {
|
||||
this.stopVoiceRecording(false)
|
||||
return
|
||||
}
|
||||
uni.authorize({
|
||||
scope: 'scope.record',
|
||||
success: () => this.startVoiceRecording(),
|
||||
fail: () => {
|
||||
uni.showModal({
|
||||
title: '需要录音权限',
|
||||
content: '请在设置中开启麦克风权限后重试',
|
||||
success: (r) => {
|
||||
if (r.confirm) uni.openSetting({})
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
startVoiceRecording() {
|
||||
const rm = this.ensureRecorder()
|
||||
this.voiceRecording = true
|
||||
rm.start({
|
||||
duration: 60000,
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
encodeBitRate: 96000,
|
||||
format: 'mp3',
|
||||
})
|
||||
},
|
||||
/**
|
||||
* @param {boolean} discard 丢弃本次录音(关闭面板时)
|
||||
*/
|
||||
stopVoiceRecording(discard) {
|
||||
if (!this.voiceRecording && !this._voiceRecorder) return
|
||||
if (discard && this._voiceRecorder) {
|
||||
// 丢弃:先卸 onStop 再 stop,避免触发转写
|
||||
try {
|
||||
this._voiceRecorder.onStop(() => {})
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
this._voiceRecorder.stop()
|
||||
} catch (e2) {
|
||||
/* ignore */
|
||||
}
|
||||
this.voiceRecording = false
|
||||
this._voiceRecorder = null
|
||||
return
|
||||
}
|
||||
if (this.voiceRecording && this._voiceRecorder) {
|
||||
this._voiceRecorder.stop()
|
||||
}
|
||||
},
|
||||
async handleVoiceAsr(filePath, durationMs) {
|
||||
this.voiceAsrLoading = true
|
||||
try {
|
||||
const res = await aiVoiceAsrApi({
|
||||
filePath,
|
||||
duration_ms: durationMs || 0,
|
||||
})
|
||||
// upload 拦截器可能直接返回字符串 JSON
|
||||
let body = res
|
||||
if (typeof res === 'string') {
|
||||
try {
|
||||
body = JSON.parse(res)
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
if (body && typeof body.data === 'string') {
|
||||
try {
|
||||
body = JSON.parse(body.data)
|
||||
} catch (e2) {
|
||||
/* keep */
|
||||
}
|
||||
}
|
||||
if (body && body.code != null && Number(body.code) !== 0) {
|
||||
throw new Error(body.message || body.msg || '语音转写失败')
|
||||
}
|
||||
const data = this.unwrapBiz(body != null ? body : res) || {}
|
||||
const text = String(data.text || '').trim()
|
||||
if (!text) {
|
||||
uni.showToast({ title: '未识别到有效语音', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.voiceTranscript = text
|
||||
this.voiceTranscriptTrimmed = text
|
||||
this.voiceAsrProvider = String(data.provider || 'xunfei')
|
||||
this.voiceAsrDurationMs = Number(data.duration_ms || durationMs || 0)
|
||||
uni.showToast({ title: '转写完成,请选择方式', icon: 'none' })
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) || (e && e.msg) || '语音转写失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.voiceAsrLoading = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 语音面板确认:A 辨证 / B 口述
|
||||
*/
|
||||
async onConfirmVoiceGenerate() {
|
||||
if (this.generating) return
|
||||
const text = String(this.voiceTranscript || '').trim()
|
||||
if (!text) {
|
||||
uni.showToast({ title: '请先完成语音转写', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!this.voiceMode) {
|
||||
uni.showToast({ title: '请选择辨证出方或口述落方', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && !this.genProcessRuleId) {
|
||||
uni.showToast({ title: '请选择制剂要求', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const mode = this.voiceMode
|
||||
const transcript = text
|
||||
const asrProvider = this.voiceAsrProvider
|
||||
const asrDuration = this.voiceAsrDurationMs
|
||||
this.voiceModalVisible = false
|
||||
if (mode === 'dialectic') {
|
||||
this.aiChief = transcript
|
||||
await this.runGenerateWithChief(transcript, {
|
||||
voice_mode: 'dialectic',
|
||||
transcript,
|
||||
asr_provider: asrProvider,
|
||||
asr_duration_ms: asrDuration,
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.runVoiceDictationGenerate(transcript, asrProvider, asrDuration)
|
||||
},
|
||||
/**
|
||||
* 抽取原 onGenerate 核心,供文字生成与语音辨证复用
|
||||
*/
|
||||
async runGenerateWithChief(chief, voiceMeta) {
|
||||
const type = Number(this.prescriptionType || 0)
|
||||
if (!type) {
|
||||
uni.showToast({ title: '请先选择处方类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.beginGeneratingPlaceholder()
|
||||
this.generating = true
|
||||
this.multiPrescriptions = []
|
||||
this.multiActiveIndex = 0
|
||||
this.rebuildMultiTabView()
|
||||
try {
|
||||
const req = {
|
||||
register_id: Number(this.registerId),
|
||||
store_id: Number(this.storeId || 0) || undefined,
|
||||
prescription_type: type,
|
||||
chief_complaint: chief,
|
||||
medical_record: Object.assign({}, this.localMr || {}, {
|
||||
chief_complaint: chief,
|
||||
}),
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && this.genProcessRuleId) {
|
||||
req.use_entrusted_process = 1
|
||||
req.process_rule_id = this.genProcessRuleId
|
||||
req.return_dosage_process_detail = this.returnDosageProcessDetail ? 1 : 0
|
||||
}
|
||||
if (voiceMeta && 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 res = await aiGeneratePrescriptionApi(req)
|
||||
await this.applyGenerateResponse(this.unwrapBiz(res) || {}, '生成')
|
||||
} catch (e) {
|
||||
this.endGeneratingPlaceholder()
|
||||
this.resetPreview()
|
||||
const msg = (e && e.message) || (e && e.msg) || '生成失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
},
|
||||
async runVoiceDictationGenerate(transcript, asrProvider, asrDuration) {
|
||||
this.beginGeneratingPlaceholder()
|
||||
this.generating = true
|
||||
this.multiPrescriptions = []
|
||||
this.multiActiveIndex = 0
|
||||
this.rebuildMultiTabView()
|
||||
try {
|
||||
const req = {
|
||||
register_id: Number(this.registerId),
|
||||
store_id: Number(this.storeId || 0) || undefined,
|
||||
prescription_type: Number(this.prescriptionType || 0),
|
||||
transcript,
|
||||
asr_provider: asrProvider || undefined,
|
||||
asr_duration_ms: asrDuration || undefined,
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && this.genProcessRuleId) {
|
||||
req.use_entrusted_process = 1
|
||||
req.process_rule_id = this.genProcessRuleId
|
||||
req.return_dosage_process_detail = this.returnDosageProcessDetail ? 1 : 0
|
||||
}
|
||||
const res = await aiGenerateVoicePrescriptionApi(req)
|
||||
await this.applyGenerateResponse(this.unwrapBiz(res) || {}, '口述识别')
|
||||
} catch (e) {
|
||||
this.endGeneratingPlaceholder()
|
||||
this.resetPreview()
|
||||
const msg = (e && e.message) || (e && e.msg) || '口述识别失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 统一处理生成/口述返回
|
||||
*/
|
||||
async applyGenerateResponse(data, actionLabel) {
|
||||
const durationText = this.formatDurationMs(data.duration_ms)
|
||||
const list = Array.isArray(data.prescriptions) ? data.prescriptions : []
|
||||
if (data.ok === false) {
|
||||
this.endGeneratingPlaceholder()
|
||||
this.resetPreview()
|
||||
if (list.length > 1) {
|
||||
this.multiPrescriptions = list
|
||||
this.rebuildMultiTabView()
|
||||
}
|
||||
if (data.generation_id) {
|
||||
await this.loadHistory()
|
||||
this.activeId = Number(data.generation_id)
|
||||
}
|
||||
uni.showToast({
|
||||
title: String(data.message || actionLabel + '未成功'),
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.loadHistory()
|
||||
if (list.length > 1) {
|
||||
this.multiPrescriptions = list
|
||||
let firstOk = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].ok !== false) {
|
||||
firstOk = i
|
||||
break
|
||||
}
|
||||
}
|
||||
this.applyMultiSlot(list[firstOk], firstOk)
|
||||
let okN = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].ok !== false) okN++
|
||||
}
|
||||
const failN = list.length - okN
|
||||
const base =
|
||||
failN > 0
|
||||
? '已生成 ' + okN + ' 套可用方案(' + failN + ' 套失败)'
|
||||
: '已生成 ' + okN + ' 套方案'
|
||||
uni.showToast({
|
||||
title: durationText ? base + '(' + durationText + ')' : base,
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
this.activeId = Number(data.generation_id || 0)
|
||||
this.applyMatch(data.match || null)
|
||||
this.applyConflict(data.conflict || null)
|
||||
this.applyRxMeta(data)
|
||||
this.markReadIfNeeded(this.activeId, 0)
|
||||
const unmatchedN = this.unmatchedCount || 0
|
||||
let tip = durationText
|
||||
? '已' + actionLabel + '(' + durationText + ')'
|
||||
: '已' + actionLabel + ',请确认导入'
|
||||
if (unmatchedN > 0) {
|
||||
tip += ';' + unmatchedN + ' 味未找到已标红'
|
||||
}
|
||||
uni.showToast({ title: tip, icon: 'none' })
|
||||
},
|
||||
onStartEditTap(e) {
|
||||
const key = e && e.currentTarget && e.currentTarget.dataset
|
||||
? e.currentTarget.dataset.key
|
||||
@@ -1049,99 +1526,12 @@ export default {
|
||||
uni.showToast({ title: '请填写主诉后再生成', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const type = Number(this.prescriptionType || 0)
|
||||
if (!type) {
|
||||
uni.showToast({ title: '请先选择处方类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && !this.genProcessRuleId) {
|
||||
uni.showToast({ title: '请选择制剂要求', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 先关二级弹窗,回主抽屉;取消旧选中并插入生成中占位
|
||||
this.closeGenModal()
|
||||
this.beginGeneratingPlaceholder()
|
||||
this.generating = true
|
||||
this.multiPrescriptions = []
|
||||
this.multiActiveIndex = 0
|
||||
this.rebuildMultiTabView()
|
||||
try {
|
||||
const req = {
|
||||
register_id: Number(this.registerId),
|
||||
store_id: Number(this.storeId || 0) || undefined,
|
||||
prescription_type: type,
|
||||
chief_complaint: chief,
|
||||
medical_record: Object.assign({}, this.localMr || {}, {
|
||||
chief_complaint: chief,
|
||||
}),
|
||||
}
|
||||
if (this.isTcmRx && this.useEntrustedProcess && this.genProcessRuleId) {
|
||||
req.use_entrusted_process = 1
|
||||
req.process_rule_id = this.genProcessRuleId
|
||||
req.return_dosage_process_detail = this.returnDosageProcessDetail ? 1 : 0
|
||||
}
|
||||
const res = await aiGeneratePrescriptionApi(req)
|
||||
const data = this.unwrapBiz(res) || {}
|
||||
const durationText = this.formatDurationMs(data.duration_ms)
|
||||
const list = Array.isArray(data.prescriptions) ? data.prescriptions : []
|
||||
// 业务软失败:HTTP 成功但 ok=false,toast 警告而非失败
|
||||
if (data.ok === false) {
|
||||
this.endGeneratingPlaceholder()
|
||||
this.resetPreview()
|
||||
if (list.length > 1) {
|
||||
this.multiPrescriptions = list
|
||||
this.rebuildMultiTabView()
|
||||
}
|
||||
if (data.generation_id) {
|
||||
await this.loadHistory()
|
||||
this.activeId = Number(data.generation_id)
|
||||
}
|
||||
uni.showToast({ title: String(data.message || '生成未成功'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
await this.loadHistory()
|
||||
if (list.length > 1) {
|
||||
this.multiPrescriptions = list
|
||||
let firstOk = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].ok !== false) {
|
||||
firstOk = i
|
||||
break
|
||||
}
|
||||
}
|
||||
this.applyMultiSlot(list[firstOk], firstOk)
|
||||
let okN = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].ok !== false) okN++
|
||||
}
|
||||
const failN = list.length - okN
|
||||
const base =
|
||||
failN > 0
|
||||
? '已生成' + okN + '套可用(' + failN + '套失败)'
|
||||
: '已生成' + okN + '套方案'
|
||||
uni.showToast({
|
||||
title: durationText ? base + '(' + durationText + ')' : base,
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
this.activeId = Number(data.generation_id || 0)
|
||||
this.applyMatch(data.match || null)
|
||||
this.applyConflict(data.conflict || null)
|
||||
this.applyRxMeta(data)
|
||||
this.markReadIfNeeded(this.activeId, 0)
|
||||
uni.showToast({
|
||||
title: durationText ? ('已生成(耗时 ' + durationText + ')') : '已生成',
|
||||
icon: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
this.endGeneratingPlaceholder()
|
||||
this.resetPreview()
|
||||
const msg = (e && e.message) || (e && e.msg) || '生成失败'
|
||||
uni.showToast({ title: String(msg), icon: 'none' })
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
await this.runGenerateWithChief(chief)
|
||||
},
|
||||
onSelectHistoryTap(e) {
|
||||
const raw = e && e.currentTarget && e.currentTarget.dataset
|
||||
@@ -1308,6 +1698,13 @@ export default {
|
||||
uni.showToast({ title: '请先勾选已对照药品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const skipN = this.unmatchedCount || 0
|
||||
if (skipN > 0) {
|
||||
uni.showToast({
|
||||
title: '已跳过' + skipN + '味未对照药品',
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
this.$emit('import', {
|
||||
rows: drugs,
|
||||
// 中药导入必须带剂数/每日次数,缺省用处方页默认 7/2
|
||||
@@ -1317,6 +1714,7 @@ export default {
|
||||
process_rule_id: this.rxProcessRuleId > 0 ? this.rxProcessRuleId : undefined,
|
||||
child_process_rule_id: this.rxChildProcessRuleId > 0 ? this.rxChildProcessRuleId : undefined,
|
||||
process_rule_note_id: this.rxProcessRuleNoteId > 0 ? this.rxProcessRuleNoteId : undefined,
|
||||
generation_id: Number(this.activeId || 0) > 0 ? Number(this.activeId) : undefined,
|
||||
})
|
||||
this.visible = false
|
||||
},
|
||||
@@ -1575,6 +1973,40 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
border-bottom: none;
|
||||
}
|
||||
.ai-match-row--danger {
|
||||
margin-bottom: 12rpx;
|
||||
padding: 16rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.18);
|
||||
border-bottom: none;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
.ai-unmatched-title {
|
||||
color: #ef4444;
|
||||
}
|
||||
.ai-unmatched-name {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-mode-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
.ai-mode-card--on {
|
||||
border: 1px solid rgba(106, 205, 187, 0.45);
|
||||
box-shadow: 0 0 6px rgba(106, 205, 187, 0.22);
|
||||
background: rgba(106, 205, 187, 0.08);
|
||||
}
|
||||
.ai-btn--recording {
|
||||
border-color: #ef4444 !important;
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
.ai-voice-rec {
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
.ai-match-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -628,6 +628,14 @@
|
||||
<text class="rx-ai-btn__icon">✦</text>
|
||||
<text class="rx-ai-btn__text">AI辅助出方</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="!isSpecialPrescriptionCartLocked && !isSalespersonTransferMode && !isCommonPrescription && canUseAiPrescription"
|
||||
class="rx-ai-btn"
|
||||
@click="handleOpenVoicePrescription"
|
||||
>
|
||||
<text class="rx-ai-btn__icon">音</text>
|
||||
<text class="rx-ai-btn__text">语音开方</text>
|
||||
</view>
|
||||
<u-button
|
||||
:throttle-time="0"
|
||||
@click="handleSendPrescription"
|
||||
@@ -809,6 +817,7 @@
|
||||
:patient-age="(patientInfo && patientInfo.age) || 0"
|
||||
:chief-complaint="aiRxChiefComplaint"
|
||||
:medical-record="aiRxMedicalRecord"
|
||||
:entry="aiRxEntry"
|
||||
@overlay-change="onAiPrescriptionOverlayChange"
|
||||
@import="handleImportAiPrescription"
|
||||
@search-drug="handleAiSearchUnmatchedDrug"
|
||||
@@ -1009,6 +1018,8 @@ export default {
|
||||
aiRxChiefComplaint: '',
|
||||
/** 打开 AI 出方时带入的完整病历快照 */
|
||||
aiRxMedicalRecord: {},
|
||||
/** assist=携带病历;voice=工具栏语音入口 */
|
||||
aiRxEntry: 'assist',
|
||||
showWesternMedicineModal: false,
|
||||
westernModalInitialKeyword: '',
|
||||
showChineseMedicineModal: false,
|
||||
@@ -1270,15 +1281,15 @@ export default {
|
||||
},
|
||||
/**
|
||||
* 底部发送按钮宽度
|
||||
* 与病历一致:右侧有 AI 胶囊时主按钮收窄;无 AI 时拉宽
|
||||
* 与病历一致:右侧有 AI/语音胶囊时主按钮收窄;无则拉宽
|
||||
*/
|
||||
bottomSendBtnWidth() {
|
||||
if (this.isSalespersonTransferMode || this.isCommonPrescription) {
|
||||
return '686rpx';
|
||||
}
|
||||
// 右侧 AI 胶囊 + 发送
|
||||
// 右侧「AI辅助」+「语音开方」两枚胶囊 + 发送
|
||||
if (!this.isSpecialPrescriptionCartLocked && this.canUseAiPrescription) {
|
||||
return '240rpx';
|
||||
return '180rpx';
|
||||
}
|
||||
return '480rpx';
|
||||
},
|
||||
@@ -2394,6 +2405,30 @@ export default {
|
||||
if (this.medicalAdvice) payload.doctor_order = this.medicalAdvice;
|
||||
this.aiRxChiefComplaint = String((payload && payload.chief_complaint) || '');
|
||||
this.aiRxMedicalRecord = Object.assign({}, payload || {});
|
||||
this.aiRxEntry = 'assist';
|
||||
this.showAiPrescriptionDrawer = true;
|
||||
},
|
||||
/**
|
||||
* 工具栏「语音开方」:打开同一抽屉后直接进语音面板,默认口述落方
|
||||
* 不强依赖病历内容;患者上下文由服务端按 register 拉取
|
||||
*/
|
||||
handleOpenVoicePrescription() {
|
||||
if (!this.canUseAiPrescription) {
|
||||
this.$toast('当前门店未开通AI辅助出方VIP功能');
|
||||
return;
|
||||
}
|
||||
if (guardSpecialPrescriptionCartEdit(this)) return;
|
||||
if (!this.registerId) {
|
||||
this.$toast('挂号无效');
|
||||
return;
|
||||
}
|
||||
if (!this.activeCategory) {
|
||||
this.$toast('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
this.aiRxChiefComplaint = '';
|
||||
this.aiRxMedicalRecord = {};
|
||||
this.aiRxEntry = 'voice';
|
||||
this.showAiPrescriptionDrawer = true;
|
||||
},
|
||||
onAiPrescriptionOverlayChange(open) {
|
||||
@@ -4304,11 +4339,12 @@ export default {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 28rpx;
|
||||
margin-right: 16rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(106, 205, 187, 0.1);
|
||||
border: 1rpx solid rgba(106, 205, 187, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rx-ai-btn__icon {
|
||||
font-size: 22rpx;
|
||||
|
||||
Reference in New Issue
Block a user