diff --git a/admin/src/views/tcm/diagnosis/edit.vue b/admin/src/views/tcm/diagnosis/edit.vue index 0f7b45a51..0c13e82cf 100644 --- a/admin/src/views/tcm/diagnosis/edit.vue +++ b/admin/src/views/tcm/diagnosis/edit.vue @@ -671,9 +671,14 @@ - + + +
+ + 重新加载 +
+
@@ -833,6 +845,9 @@ const prescriptionRef = ref() const dailyMatrixRef = ref<{ refresh?: () => Promise | void } | null>(null) const followupAudioRef = ref<{ confirmLeave: () => Promise } | null>(null) const followupCapabilities = ref(null) +const followupCapabilitiesLoading = ref(false) +const followupCapabilitiesError = ref('') +const followupCapabilitiesDiagnosisId = ref(null) const basicSnapshot = ref('') let diagnosisGeneration = 0 let capabilitiesRequest: AbortController | undefined @@ -1021,18 +1036,49 @@ async function handleFollowupApplied(diagnosisId: number) { if (generation === diagnosisGeneration) feedback.msgWarning('录音已采纳,但病历或日常记录刷新失败,请手动刷新查看') } } -watch([visible, () => formData.value.id], async ([isVisible, id]) => { +function resetFollowupCapabilities() { capabilitiesRequest?.abort() + capabilitiesRequest = undefined followupCapabilities.value = null - if (!isVisible || !id) return + followupCapabilitiesDiagnosisId.value = null + followupCapabilitiesLoading.value = false + followupCapabilitiesError.value = '' +} +async function loadFollowupCapabilities(): Promise { + // A rapid second click must not create another request for the same context. + if (followupCapabilitiesLoading.value) return + resetFollowupCapabilities() + const id = Number(formData.value.id) + if (!visible.value || !id) return + const generation = diagnosisGeneration const request = new AbortController() capabilitiesRequest = request + followupCapabilitiesLoading.value = true + const isCurrent = () => capabilitiesRequest === request && !request.signal.aborted + && generation === diagnosisGeneration && visible.value && Number(formData.value.id) === id 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() }) + const result = await followupAudioCapabilities(id, request.signal) + if (!isCurrent()) return + if (typeof result?.enabled !== 'boolean') throw new Error('Invalid capability response') + followupCapabilities.value = result + followupCapabilitiesDiagnosisId.value = id + } catch { + if (!isCurrent()) return + // No endpoint failure (including permission denial) may mount the panel. + followupCapabilitiesError.value = '回访录音状态加载失败,请重新加载;若仍失败,请联系管理员检查权限或服务状态' + } finally { + if (isCurrent()) { + followupCapabilitiesLoading.value = false + capabilitiesRequest = undefined + } + } +} +watch([visible, () => formData.value.id], () => { + // Invalidate synchronously so an old patient's capabilities never survive a switch. + resetFollowupCapabilities() + void loadFollowupCapabilities() +}, { flush: 'sync' }) +onBeforeUnmount(() => { diagnosisGeneration++; resetFollowupCapabilities() }) /** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */ const createSourceOptions = [ @@ -1360,7 +1406,7 @@ const open = async (type: string, id?: number) => { const generation = ++diagnosisGeneration submitting.value = false basicSnapshot.value = '' - followupCapabilities.value = null + resetFollowupCapabilities() formData.value.id = '' viewOnly.value = false mode.value = type @@ -1390,7 +1436,7 @@ const openViewOnly = async (id: number) => { const generation = ++diagnosisGeneration submitting.value = false basicSnapshot.value = '' - followupCapabilities.value = null + resetFollowupCapabilities() formData.value.id = '' viewOnly.value = true mode.value = 'edit' @@ -1490,8 +1536,7 @@ const handleOpenPrescription = () => { const handleClose = async () => { if (followupAudioRef.value && !await followupAudioRef.value.confirmLeave()) return diagnosisGeneration++ - capabilitiesRequest?.abort() - followupCapabilities.value = null + resetFollowupCapabilities() basicSnapshot.value = '' formRef.value?.resetFields() formData.value = { diff --git a/admin/tests/followup-audio.test.cjs b/admin/tests/followup-audio.test.cjs index 9b23c5862..a5c61e5b0 100644 --- a/admin/tests/followup-audio.test.cjs +++ b/admin/tests/followup-audio.test.cjs @@ -268,7 +268,7 @@ test('authenticated audio object URLs revoked on hide and stale blobs never crea test('editor capability gate, dirty guard and successful apply hook wire diagnosis plus daily refresh', () => { const source = fs.readFileSync(path.join(componentDir, '../edit.vue'), 'utf8') - assert.match(source, /v-if="followupCapabilities\?\.enabled && formData.id"/) + assert.match(source, / { + if (name === 'vue') return { ...vue, onBeforeUnmount: callback => { hooks.unmount = callback } } + if (name === '@/api/tcm') return { tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), phone: '', id_card: '', remark: '原始备注' }) } + if (name === '@/api/app') return { getDictData: async () => ({}) } + if (name === '@/api/followupAudio') return { followupAudioCapabilities: (id, signal) => { const pending = deferred(); requests.push({ id, signal, ...pending }); return pending.promise } } + if (name === '@/utils/feedback') return { default: { msgWarning: message => warnings.push(message) } } + if (name === '@/utils/perm') return { hasPermission: () => true } + if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) } + if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) } + if (name === 'element-plus') return { ElMessage: { warning() {} } } + if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {} + if (name.endsWith('FollowupAudioPanel.vue')) return { default: panelComponent } + if (name.endsWith('.vue')) return { default: {} } + throw new Error(`Unexpected entry import: ${name}`) + }) + const tab = descriptor.template.content.match(/]+name="followupAudio"[\s\S]*?<\/el-tab-pane>/)[0] + const compiled = compileTemplate({ source: tab, filename, id: 'entry-tab', compilerOptions: { bindingMetadata: script.bindings } }) + assert.deepEqual(compiled.errors, []) + const render = execute(compiled.code, () => ({ ...vue, resolveComponent: name => name })).render + const scope = vue.effectScope() + const editor = scope.run(() => module.default.setup({}, { expose: value => Object.assign(exposed, value), emit() {} })) + const nodes = () => { + const collected = [] + const visit = node => { + if (!node || typeof node !== 'object') return + if (Array.isArray(node)) { node.forEach(visit); return } + collected.push(node) + visit(node.children) + if (node.children?.default) visit(node.children.default()) + } + visit(render({}, [], {}, vue.proxyRefs(editor))) + return collected + } + return { editor, exposed, requests, warnings, nodes, hasTab: () => nodes().some(node => node.type === 'el-tab-pane'), hasPanel: () => nodes().some(node => node.type === panelComponent), dispose: () => { hooks.unmount(); scope.stop() } } +} + +test('saved diagnosis keeps audio entry during pending/error; manual retry recovers without losing dirty input', async () => { + const f = setupAudioEntry() + try { + await f.editor.open('edit', 10) + assert.equal(f.hasTab(), true) + assert.equal(f.hasPanel(), false) + assert.equal(f.editor.followupCapabilitiesLoading.value, true) + assert.ok(f.nodes().some(node => node.props?.title === '正在检查回访录音状态…')) + f.requests[0].reject(new Error('network offline')); await settle() + assert.equal(f.hasTab(), true) + assert.equal(f.hasPanel(), false) + assert.equal(f.editor.followupCapabilitiesLoading.value, false) + assert.match(f.editor.followupCapabilitiesError.value, /加载失败/) + assert.equal(f.requests.length, 1, 'no automatic retry') + f.editor.formData.value.remark = '保留未保存输入' + assert.equal(await f.editor.beforeTabLeave('followupAudio', 'basic'), false) + const retryButton = f.nodes().find(node => node.type === 'el-button') + const retry = retryButton.props.onClick() + const duplicate = f.editor.loadFollowupCapabilities() + assert.equal(f.requests.length, 2, 'rapid retries share the in-flight context') + assert.equal(f.editor.followupCapabilitiesError.value, '') + assert.equal(f.hasPanel(), false) + f.requests[1].resolve(caps()); await retry; await duplicate + assert.equal(f.hasTab(), true) + assert.equal(f.hasPanel(), true) + assert.equal(f.editor.formData.value.remark, '保留未保存输入') + assert.equal(f.editor.basicDirty.value, true) + assert.equal(f.editor.canApplyFollowup(), false) + assert.equal(await f.editor.beforeTabLeave('followupAudio', 'basic'), false) + } finally { f.dispose() } +}) + +test('disabled/scope-denied and permission-denied capability responses preserve entry without a panel', async () => { + for (const response of [{ ...caps(), enabled: false }, { ...caps(), enabled: false, test_scope_allowed: false }, new Error('403 permission denied'), null]) { + const f = setupAudioEntry() + try { + await f.editor.open('edit', 10) + if (response instanceof Error) f.requests[0].reject(response) + else f.requests[0].resolve(response) + await settle() + assert.equal(f.hasTab(), true) + assert.equal(f.hasPanel(), false) + assert.equal(f.editor.followupCapabilitiesLoading.value, false) + const title = f.nodes().find(node => node.type === 'el-alert').props.title + assert.match(title, response instanceof Error || response === null ? /加载失败/ : /未启用|未开放/) + assert.equal(f.requests.length, 1) + } finally { f.dispose() } + } +}) + +test('old capability success/error/finally cannot affect a new patient or same-ID reopened context', async () => { + for (const oldFails of [false, true]) for (const id of [10, 20]) { + const f = setupAudioEntry() + try { + await f.editor.open('edit', 10) + const old = f.requests[0] + const opening = f.editor.open('edit', id) + assert.equal(old.signal.aborted, true, 'open resets before awaiting dictionaries') + assert.equal(f.editor.followupCapabilities.value, null) + await opening + assert.equal(f.requests.length, 2) + if (oldFails) old.reject(new Error('late old error')) + else old.resolve(caps()) + await settle() + assert.equal(f.editor.followupCapabilities.value, null) + assert.equal(f.editor.followupCapabilitiesError.value, '') + assert.equal(f.editor.followupCapabilitiesLoading.value, true, 'old finally cannot end new loading') + assert.equal(f.hasPanel(), false) + f.requests[1].resolve({ ...caps(), enabled: false }); await settle() + assert.equal(f.editor.followupCapabilitiesDiagnosisId.value, id) + assert.equal(f.editor.followupCapabilities.value.enabled, false) + assert.equal(f.hasPanel(), false) + } finally { f.dispose() } + } +}) + +test('successful capabilities are immediately invalidated on patient switch; late retry completion stays isolated', async () => { + const f = setupAudioEntry() + try { + await f.editor.open('edit', 10); f.requests[0].resolve(caps()); await settle() + assert.equal(f.hasPanel(), true) + f.editor.formData.value.id = '20' + assert.equal(f.hasPanel(), false) + assert.equal(f.editor.followupCapabilities.value, null) + f.requests[1].reject(new Error('second patient error')); await settle() + const retry = f.editor.loadFollowupCapabilities() + await f.editor.openViewOnly(30) + assert.equal(f.requests[2].signal.aborted, true) + f.requests[3].resolve(caps()); await settle() + f.requests[2].reject(new Error('old retry error')); await retry + assert.equal(f.hasPanel(), true) + assert.equal(f.editor.followupCapabilitiesDiagnosisId.value, 30) + assert.equal(f.editor.followupCapabilitiesError.value, '') + assert.equal(f.editor.viewOnly.value, true) + } finally { f.dispose() } +}) + +test('close and unmount abort pending capabilities and prevent any late state resurrection', async () => { + for (const unmount of [false, true]) for (const fails of [false, true]) { + const f = setupAudioEntry() + try { + await f.editor.open('edit', 10) + if (unmount) f.dispose() + else await f.editor.handleClose() + assert.equal(f.requests[0].signal.aborted, true) + if (fails) f.requests[0].reject(new Error('late close failure')) + else f.requests[0].resolve(caps()) + await settle() + assert.equal(f.editor.followupCapabilities.value, null) + assert.equal(f.editor.followupCapabilitiesLoading.value, false) + assert.equal(f.editor.followupCapabilitiesError.value, '') + assert.equal(f.hasPanel(), false) + if (!unmount) assert.equal(f.hasTab(), false) + } finally { f.dispose() } + } +})