feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
+103 -6
View File
@@ -52,7 +52,7 @@
</p>
</div>
</template>
<el-tabs v-model="activeTab" class="diagnosis-tabs tcm-diagnosis-tabs">
<el-tabs v-model="activeTab" :before-leave="beforeTabLeave" class="diagnosis-tabs tcm-diagnosis-tabs">
<!-- 基本信息标签页 -->
<el-tab-pane label="病历" name="basic">
<el-form
@@ -660,6 +660,7 @@
lazy
>
<daily-matrix
ref="dailyMatrixRef"
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
:patient-id="Number(formData.patient_id)"
@@ -670,6 +671,21 @@
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<el-tab-pane v-if="followupCapabilities?.enabled && formData.id" label="回访录音" name="followupAudio" lazy>
<FollowupAudioPanel
v-if="visible && formData.id && followupCapabilities"
:key="Number(formData.id)"
ref="followupAudioRef"
:diagnosis-id="Number(formData.id)"
:view-only="viewOnly"
:active="activeTab === 'followupAudio'"
:capabilities="followupCapabilities"
:basic-dirty="basicDirty"
:before-apply="canApplyFollowup"
@applied="handleFollowupApplied"
/>
</el-tab-pane>
<!-- 病历记录标签页(开方后自动显示) -->
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
<div v-if="formData.id" class="bingli-tab-content">
@@ -785,6 +801,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard, diagnosisTrackingNotes } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
@@ -794,6 +811,8 @@ import useAppStore from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import DailyMatrix from './components/DailyMatrix.vue'
import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
@@ -811,6 +830,12 @@ const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appSt
const caseRecordListRef = ref()
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
const prescriptionRef = ref()
const dailyMatrixRef = ref<{ refresh?: () => Promise<void> | void } | null>(null)
const followupAudioRef = ref<{ confirmLeave: () => Promise<boolean> } | null>(null)
const followupCapabilities = ref<FollowupCapabilities | null>(null)
const basicSnapshot = ref('')
let diagnosisGeneration = 0
let capabilitiesRequest: AbortController | undefined
const visible = ref(false)
/** 只读查看:与 consumers 处方等处「查看诊单」一致,全部 Tab 仅展示不可保存 */
@@ -970,6 +995,45 @@ const formData = ref({
external_userid: ''
})
// Include unmasked identity values so merely revealing a number is not an unsaved edit.
const basicFingerprint = () => JSON.stringify({ ...formData.value,
phone: originalPhone.value || formData.value.phone, id_card: originalIdCard.value || formData.value.id_card })
const basicDirty = computed(() => !viewOnly.value && !!basicSnapshot.value && basicFingerprint() !== basicSnapshot.value)
const canApplyFollowup = () => !basicDirty.value && visible.value && !viewOnly.value
async function beforeTabLeave(next: string | number, previous: string | number): Promise<boolean> {
if (next === 'followupAudio' && basicDirty.value) {
feedback.msgWarning('病历有未保存修改,请先保存后再进入回访录音审核,当前输入已保留')
return false
}
if (previous === 'followupAudio') return await followupAudioRef.value?.confirmLeave() ?? true
return true
}
async function handleFollowupApplied(diagnosisId: number) {
if (!visible.value || Number(formData.value.id) !== diagnosisId) return
const generation = diagnosisGeneration
try {
// The response itself is also guarded, including edits made while it is in flight.
await loadDiagnosisDetailIntoForm(diagnosisId, true)
if (generation !== diagnosisGeneration || !visible.value || Number(formData.value.id) !== diagnosisId) return
await dailyMatrixRef.value?.refresh?.()
emit('success')
} catch {
if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看')
}
}
watch([visible, () => formData.value.id], async ([isVisible, id]) => {
capabilitiesRequest?.abort()
followupCapabilities.value = null
if (!isVisible || !id) return
const request = new AbortController()
capabilitiesRequest = request
try {
const result = await followupAudioCapabilities(Number(id), request.signal)
if (!request.signal.aborted && visible.value && String(formData.value.id) === String(id)) followupCapabilities.value = result
} catch { /* Fail closed: an unavailable capability endpoint must not expose this feature. */ }
})
onBeforeUnmount(() => { diagnosisGeneration++; capabilitiesRequest?.abort() })
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
const createSourceOptions = [
{ value: '', label: '未设置' },
@@ -1260,8 +1324,14 @@ const getDictOptions = async () => {
}
}
async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
async function loadDiagnosisDetailIntoForm(id: number, preserveDirty = false): Promise<void> {
const generation = diagnosisGeneration
const data = await tcmDiagnosisDetail({ id })
if (generation !== diagnosisGeneration || !visible.value) return
if (preserveDirty && basicDirty.value) {
feedback.msgWarning('录音已采纳,病历仍保留你的未保存修改;请核对后再保存,未用刷新覆盖输入')
return
}
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
@@ -1277,6 +1347,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
const y = data.diabetes_discovery_year
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
basicSnapshot.value = basicFingerprint()
}
const resetPatientBasicLockState = () => {
@@ -1286,6 +1357,11 @@ const resetPatientBasicLockState = () => {
}
const open = async (type: string, id?: number) => {
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
formData.value.id = ''
viewOnly.value = false
mode.value = type
visible.value = true
@@ -1296,6 +1372,7 @@ const open = async (type: string, id?: number) => {
// 加载字典数据
await getDictOptions()
if (generation !== diagnosisGeneration || !visible.value) return
if (type === 'edit' && id) {
await loadDiagnosisDetailIntoForm(id)
@@ -1310,6 +1387,11 @@ const open = async (type: string, id?: number) => {
/** 只读打开:与编辑同一套界面与全部 Tab,不可保存 */
const openViewOnly = async (id: number) => {
if (!id) return
const generation = ++diagnosisGeneration
submitting.value = false
basicSnapshot.value = ''
followupCapabilities.value = null
formData.value.id = ''
viewOnly.value = true
mode.value = 'edit'
visible.value = true
@@ -1318,13 +1400,17 @@ const openViewOnly = async (id: number) => {
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
if (generation !== diagnosisGeneration || !visible.value) return
await loadDiagnosisDetailIntoForm(id)
}
const handleSubmit = async () => {
if (viewOnly.value) return
if (viewOnly.value || submitting.value) return
const generation = diagnosisGeneration
await formRef.value?.validate()
if (generation !== diagnosisGeneration || !visible.value) return
// A user can keep typing while the request is pending. Only mark submitted bytes saved.
const submittedForm = JSON.parse(basicFingerprint()) as typeof formData.value
submitting.value = true
try {
// 准备提交数据,使用原始完整数据
@@ -1336,6 +1422,7 @@ const handleSubmit = async () => {
if (mode.value === 'add') {
const result = await tcmDiagnosisAdd(submitData)
if (generation !== diagnosisGeneration || !visible.value) return
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
if (result && result.id) {
@@ -1343,11 +1430,14 @@ const handleSubmit = async () => {
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
submittedForm.id = result.id
// 重新获取详情,确保patient_id等字段正确
try {
const detail = await tcmDiagnosisDetail({ id: result.id })
if (generation !== diagnosisGeneration || !visible.value) return
formData.value.patient_id = detail.patient_id
submittedForm.patient_id = detail.patient_id
// 更新原始数据
originalPhone.value = detail.phone || ''
@@ -1367,11 +1457,13 @@ const handleSubmit = async () => {
await tcmDiagnosisEdit(submitData)
}
if (generation !== diagnosisGeneration || !visible.value) return
basicSnapshot.value = JSON.stringify(submittedForm)
emit('success')
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
if (generation === diagnosisGeneration) submitting.value = false
}
}
@@ -1395,7 +1487,12 @@ const handleOpenPrescription = () => {
prescriptionRef.value?.open(data)
}
const handleClose = () => {
const handleClose = async () => {
if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return
diagnosisGeneration++
capabilitiesRequest?.abort()
followupCapabilities.value = null
basicSnapshot.value = ''
formRef.value?.resetFields()
formData.value = {
id: '',