feat: 语音开方

This commit is contained in:
李琦
2026-08-31 16:36:43 +08:00
parent 8aafb0114b
commit 87f21de8de
3 changed files with 596 additions and 102 deletions

View File

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

View File

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

View File

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