feat(followup-audio): add independent assignable recording permission
This commit is contained in:
@@ -671,7 +671,7 @@
|
||||
<el-empty v-else description="请先保存诊单后查看" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="formData.id" label="回访录音" name="followupAudio" lazy>
|
||||
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
|
||||
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
|
||||
<div v-else-if="followupCapabilitiesError" role="alert">
|
||||
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
|
||||
@@ -862,6 +862,7 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
const latestPrescriptionOrder = ref<{
|
||||
@@ -1049,7 +1050,7 @@ async function loadFollowupCapabilities(): Promise<void> {
|
||||
if (followupCapabilitiesLoading.value) return
|
||||
resetFollowupCapabilities()
|
||||
const id = Number(formData.value.id)
|
||||
if (!visible.value || !id) return
|
||||
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
|
||||
const generation = diagnosisGeneration
|
||||
const request = new AbortController()
|
||||
capabilitiesRequest = request
|
||||
@@ -1073,8 +1074,9 @@ async function loadFollowupCapabilities(): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
watch([visible, () => formData.value.id], () => {
|
||||
// Invalidate synchronously so an old patient's capabilities never survive a switch.
|
||||
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
|
||||
// Invalidate synchronously on patient switches and permission changes.
|
||||
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
|
||||
resetFollowupCapabilities()
|
||||
void loadFollowupCapabilities()
|
||||
}, { flush: 'sync' })
|
||||
|
||||
@@ -269,7 +269,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, /<el-tab-pane v-if="formData.id" label="回访录音"/)
|
||||
assert.match(source, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
|
||||
assert.match(source, /:before-leave="beforeTabLeave"/)
|
||||
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
|
||||
assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/)
|
||||
@@ -1411,7 +1411,7 @@ test('actual patient editor outside preview allowlist still opens and edits its
|
||||
await editor.handleSubmit()
|
||||
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
|
||||
assert.equal(edits[0].remark, '合成普通表单编辑')
|
||||
assert.match(descriptor.template.content, /<el-tab-pane v-if="formData.id" label="回访录音"/)
|
||||
assert.match(descriptor.template.content, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
|
||||
} finally { scope.stop() }
|
||||
})
|
||||
|
||||
@@ -1851,7 +1851,9 @@ test('autosave denial for remotely deleted task clears local playback without an
|
||||
})
|
||||
|
||||
// Execute the real editor script and its actual tab template, with deferred network I/O.
|
||||
function setupAudioEntry() {
|
||||
function setupAudioEntry(permissions = ['*']) {
|
||||
const userStore = vue.reactive({ perms: [...permissions], userInfo: { id: 1 } })
|
||||
const permissionModule = loadTs(path.join(root, 'src/utils/perm.ts'), () => ({ default: () => userStore }))
|
||||
const requests = [], warnings = [], hooks = {}, exposed = {}
|
||||
const filename = path.join(componentDir, '../edit.vue')
|
||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
||||
@@ -1863,8 +1865,8 @@ function setupAudioEntry() {
|
||||
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 === '@/utils/perm') return permissionModule
|
||||
if (name === '@/stores/modules/user') return { default: () => userStore }
|
||||
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 {}
|
||||
@@ -1878,7 +1880,11 @@ function setupAudioEntry() {
|
||||
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 dailyTab = descriptor.template.content.match(/<el-tab-pane\s+v-if="hasDailyRecordPermission"[\s\S]*?<\/el-tab-pane>/)[0]
|
||||
const dailyCompiled = compileTemplate({ source: dailyTab, filename, id: 'daily-tab', compilerOptions: { bindingMetadata: script.bindings } })
|
||||
assert.deepEqual(dailyCompiled.errors, [])
|
||||
const renderDaily = execute(dailyCompiled.code, () => ({ ...vue, resolveComponent: name => name })).render
|
||||
const nodes = (renderTab = render) => {
|
||||
const collected = []
|
||||
const visit = node => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
@@ -1887,10 +1893,10 @@ function setupAudioEntry() {
|
||||
visit(node.children)
|
||||
if (node.children?.default) visit(node.children.default())
|
||||
}
|
||||
visit(render({}, [], {}, vue.proxyRefs(editor)))
|
||||
visit(renderTab({}, [], {}, 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() } }
|
||||
return { editor, exposed, requests, warnings, nodes, userStore, hasDailyTab: () => nodes(renderDaily).some(node => node.type === 'el-tab-pane'), 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 () => {
|
||||
@@ -2051,3 +2057,70 @@ test('duplicate diagnosis suggestions are blocked clearly without losing distinc
|
||||
assert.equal(state.reviewBatchIssue([item(),item({id:'another',record_time:'12:00'})],fields),'')
|
||||
assert.equal(a.values.symptoms,'口干');assert.equal(b.values.symptoms,'睡眠中断')
|
||||
})
|
||||
|
||||
|
||||
test('grouped audio permission independently gates saved diagnosis entry and capabilities requests', async () => {
|
||||
for (const permissions of [[], ['tcm.diagnosis/dailyRecord'], ['tcm.diagnosis/detail', 'tcm.diagnosis/edit'], ['tcm.diagnosis/followupaudio']]) {
|
||||
const f = setupAudioEntry(permissions)
|
||||
try {
|
||||
await f.editor.open('edit', 10)
|
||||
assert.equal(f.hasTab(), false)
|
||||
assert.equal(f.hasPanel(), false)
|
||||
assert.equal(f.hasDailyTab(), permissions.includes('tcm.diagnosis/dailyRecord'))
|
||||
await f.editor.loadFollowupCapabilities()
|
||||
assert.equal(f.requests.length, 0, 'denied entry makes no capabilities request, including direct reload')
|
||||
assert.equal(f.editor.followupCapabilitiesLoading.value, false)
|
||||
assert.equal(f.editor.followupCapabilitiesError.value, '')
|
||||
} finally { f.dispose() }
|
||||
}
|
||||
for (const permissions of [['tcm.diagnosis/followupAudio'], ['tcm.diagnosis/followupAudio', 'tcm.diagnosis/dailyRecord'], ['*']]) {
|
||||
const f = setupAudioEntry(permissions)
|
||||
try {
|
||||
await f.editor.open('add')
|
||||
assert.equal(f.hasTab(), false, 'unsaved diagnoses have no audio entry even with permission')
|
||||
assert.equal(f.requests.length, 0)
|
||||
await f.editor.open('edit', 10)
|
||||
assert.equal(f.hasTab(), true)
|
||||
assert.equal(f.hasDailyTab(), permissions.includes('*') || permissions.includes('tcm.diagnosis/dailyRecord'))
|
||||
assert.equal(f.requests.length, 1)
|
||||
f.requests[0].reject(new Error('transient network failure')); await settle()
|
||||
assert.equal(f.hasTab(), true, 'granted entry never depends on capabilities success')
|
||||
assert.equal(f.hasPanel(), false)
|
||||
assert.match(f.editor.followupCapabilitiesError.value, /加载失败/)
|
||||
assert.equal(f.requests.length, 1)
|
||||
const retry = f.editor.loadFollowupCapabilities()
|
||||
f.requests[1].resolve({ ...caps(), can_upload: false, can_apply: false, can_daily: false })
|
||||
await retry
|
||||
assert.equal(f.hasPanel(), true)
|
||||
const panel = f.nodes().find(node => node.props?.capabilities)
|
||||
assert.equal(panel.props.capabilities.can_apply, false, 'entry grant never overrides server write capability')
|
||||
assert.equal(panel.props.capabilities.can_upload, false)
|
||||
} finally { f.dispose() }
|
||||
}
|
||||
})
|
||||
|
||||
test('permission revocation aborts pending capabilities and grant refreshes current saved diagnosis only', async () => {
|
||||
const f = setupAudioEntry(['tcm.diagnosis/followupAudio'])
|
||||
try {
|
||||
await f.editor.open('edit', 10)
|
||||
f.editor.activeTab.value = 'followupAudio'
|
||||
const old = f.requests[0]
|
||||
f.userStore.perms = ['tcm.diagnosis/dailyRecord']
|
||||
assert.equal(old.signal.aborted, true)
|
||||
assert.equal(f.hasTab(), false)
|
||||
assert.equal(f.hasDailyTab(), true)
|
||||
assert.equal(f.editor.activeTab.value, 'basic')
|
||||
old.resolve(caps()); await settle()
|
||||
assert.equal(f.editor.followupCapabilities.value, null)
|
||||
assert.equal(f.requests.length, 1)
|
||||
f.userStore.perms.push('tcm.diagnosis/followupAudio')
|
||||
assert.equal(f.hasTab(), true)
|
||||
assert.equal(f.requests.length, 2)
|
||||
f.requests[1].resolve(caps()); await settle()
|
||||
assert.equal(f.hasPanel(), true)
|
||||
f.userStore.perms = []
|
||||
assert.equal(f.hasPanel(), false)
|
||||
assert.equal(f.editor.followupCapabilities.value, null)
|
||||
assert.equal(f.requests.length, 2)
|
||||
} finally { f.dispose() }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user