feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
+217
View File
@@ -1206,3 +1206,220 @@ test('task switch and observed audio expiry both close live stereo routing and r
f.dispose()
}
})
const previewCaps = (extra = {}) => ({ ...caps(), audio_verified: false, preview_only: true, preview_ready: true, can_review: true, can_apply: false, test_scope_allowed: true, ...extra })
test('preview readiness is independent of full verification and scope/readiness omissions fail closed', () => {
const recording = { name: 'synthetic.wav', size: 100 }
assert.equal(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps(), false), '')
assert.equal(state.followupCanReview(previewCaps()), true)
assert.equal(state.followupCanApply(previewCaps({ can_apply: true, audio_verified: true })), false)
for (const patch of [{ enabled: false }, { preview_ready: false }, { preview_ready: undefined }, { test_scope_allowed: false }, { test_scope_allowed: undefined }, { can_upload: false }]) {
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps({ audio_verified: true, ...patch }), false), /不可上传/)
}
assert.equal(state.followupCanReview(previewCaps({ can_review: false, can_apply: true })), false)
assert.equal(state.followupCanReview(previewCaps({ can_review: undefined, can_apply: true })), false)
assert.equal(state.followupFeatureEnabled(previewCaps({ test_scope_allowed: false })), false)
const normal = { ...caps(), preview_only: false, preview_ready: false, can_review: false, test_scope_allowed: false }
assert.equal(state.followupAudioReady(normal), true)
assert.equal(state.followupCanReview(normal), true)
assert.equal(state.followupCanApply(normal), true)
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', { ...normal, audio_verified: false, preview_ready: true }, false), /不可上传/)
for (const helper of [state.followupFeatureEnabled, state.followupAudioReady, state.followupCanReview, state.followupCanApply]) assert.equal(helper(null), false)
})
test('preview actual component can upload, view transcript and save draft with full verification false but never apply', async () => {
const f = setupPanel({ followupAudioDetail: async id => task(id, { preview_only: true }), followupAudioSaveDraft: async payload => {
f.calls.push(['save', payload]); return task(payload.id, { preview_only: true, version: payload.version + 1, items: payload.items })
} }, { capabilities: previewCaps() })
await settle()
assert.equal(f.panel.canUpload.value, true)
f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
await f.panel.submitUpload()
assert.ok(f.calls.some(call => call[0] === 'create'))
assert.equal(f.panel.detail.value.transcript, '测试原文')
assert.equal(f.props.capabilities.audio_verified, false)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'save').length, 1)
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
assert.match(f.panel.applyIssue.value, /测试预览/)
f.dispose()
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /v-if="previewOnly"[^>]+title="仅测试识别与提炼,不写入病例"/)
assert.match(source, /<el-button v-if="canApplyReview"[^>]+@click="applySelected">确认写入/)
assert.match(source, /预览就绪不代表完整音频或准确度验收通过/)
})
test('preview roles and review_refreshed drafts remain editable without can_apply and preserve dirty work', async () => {
const current = stereoTask({ preview_only: true })
const sent = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
sent.push(payload)
return { ...current, version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
} }, { capabilities: previewCaps() }, { confirm: async () => { throw new Error('cancel') } })
await settle(); await f.panel.openTask(1)
f.panel.setChannelRoles('left_service'); f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
assert.equal(f.panel.dirty.value, true)
assert.equal(await f.panel.confirmLeave(), false)
await f.panel.saveDraft()
assert.equal(sent[0].channel_roles, 'left_service')
assert.equal(sent[0].version, 1)
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.2)
assert.equal(f.panel.reviewItems.value[0].selected, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
})
test('preview retry uses preview readiness and rechecks readiness after confirmation', async () => {
const failed = task(1, { status: 'failed', preview_only: true, can_retry: true })
const f = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1); await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.props.capabilities = previewCaps({ preview_ready: false, audio_verified: true })
await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.dispose()
const confirm = deferred()
const g = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() }, { confirm: () => confirm.promise })
await settle(); await g.panel.openTask(1)
const retry = g.panel.retryTask(); await settle(); g.props.capabilities.preview_ready = false; confirm.resolve(true); await retry
assert.equal(g.calls.filter(call => call[0] === 'retry').length, 0)
g.dispose()
})
test('preview task origin permanently blocks apply even after global mode becomes normal or can_apply is forged true', async () => {
for (const capabilities of [previewCaps({ can_apply: true, audio_verified: true }), { ...caps(), preview_only: false, can_review: true }]) {
const f = setupPanel({ followupAudioDetail: async () => task(1, { preview_only: true }) }, { capabilities })
await settle(); await f.panel.openTask(1)
assert.equal(f.panel.previewOnly.value, true)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
}
assert.equal(state.followupCanApply(caps(), true), false)
})
test('preview never emits an applied event from polling or a late normal-mode apply response', async () => {
let current = task(1, { preview_only: true })
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1)
current = { ...current, version: 2, status: 'applied' }; f.tick(); await settle()
assert.equal(f.panel.detail.value.status, 'applied')
assert.deepEqual(f.events, [])
f.dispose()
const response = deferred()
const g = setupPanel({ followupAudioApply: async payload => { g.calls.push(['apply', payload]); return response.promise } })
await settle(); await g.panel.openTask(1)
const applying = g.panel.applySelected(); await settle()
assert.equal(g.calls.filter(call => call[0] === 'apply').length, 1, 'request began in normal mode, not preview')
g.props.capabilities = previewCaps(); await settle(); response.resolve({ id: 1, status: 'applied', applied_items: [] }); await applying
assert.deepEqual(g.events, [])
assert.equal(g.notices.filter(([kind]) => kind === 'success').length, 0)
g.dispose()
})
test('outside preview allowlist panel remains hidden and makes no feature requests; revocation fences old responses', async () => {
for (const enabled of [false, true]) {
const f = setupPanel({}, { capabilities: previewCaps({ enabled, test_scope_allowed: false, models: [] }) })
await settle(); await f.panel.openTask(1); f.panel.openUpload(); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.featureEnabled.value, false)
assert.equal(f.panel.uploadVisible.value, false)
assert.deepEqual(f.calls, [])
assert.deepEqual(f.events, [])
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); const opening = g.panel.openTask(1); await settle()
g.props.capabilities = previewCaps({ enabled: false, test_scope_allowed: false, models: [] }); await settle()
pending.resolve(task(1, { preview_only: true, transcript: '旧范围合成文本' })); await opening
assert.equal(g.panel.detail.value, null)
assert.equal(g.timers.size, 0)
assert.equal(g.panel.featureEnabled.value, false)
g.dispose()
assert.match(fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8'), /<section v-if="featureEnabled"/)
})
test('preview readonly and can_review denial cannot save roles or drafts, while old patient saves stay fenced', async () => {
for (const props of [{ viewOnly: true, capabilities: previewCaps() }, { capabilities: previewCaps({ can_review: false, can_apply: true }) }]) {
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }) }, props)
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.calls.filter(call => ['save', 'apply'].includes(call[0])).length, 0)
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }), followupAudioSaveDraft: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('left_service')
const saving = g.panel.saveDraft(); await settle(); g.props.diagnosisId = 20; await settle()
pending.resolve(stereoTask({ preview_only: true, version: 2, channel_roles: 'left_service' })); await saving
assert.equal(g.panel.detail.value, null)
assert.equal(g.panel.channelRoles.value, 'unconfirmed')
assert.deepEqual(g.events, [])
assert.deepEqual(g.notices, [])
g.dispose()
})
test('actual patient editor outside preview allowlist still opens and edits its ordinary diagnosis form', async () => {
const edits = [], warnings = [], events = []
const filename = path.join(componentDir, '../edit.vue')
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
const script = compileScript(descriptor, { id: 'preview-editor-runtime' })
const module = execute(script.content, name => {
if (name === 'vue') return { ...vue, onBeforeUnmount() {} }
if (name === '@/api/tcm') return {
tcmDiagnosisEdit: async payload => { edits.push(payload); return {} },
tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark: '普通原始备注', diabetes_discovery_year: '', show_card: 1 })
}
if (name === '@/api/app') return { getDictData: async () => ({}) }
if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => previewCaps({ enabled: false, test_scope_allowed: false, can_upload: false, can_review: false, models: [] }) }
if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } }
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('.vue')) return { default: {} }
throw new Error(`Unexpected editor import: ${name}`)
})
const scope = vue.effectScope()
try {
const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) }))
await editor.open('edit', 30); await settle()
assert.equal(editor.visible.value, true)
assert.equal(editor.formData.value.id, '30')
assert.equal(editor.followupCapabilities.value.enabled, false)
assert.equal(editor.followupCapabilities.value.test_scope_allowed, false)
assert.equal(editor.basicDirty.value, false)
editor.formData.value.remark = '合成普通表单编辑'
assert.equal(editor.basicDirty.value, true)
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, /v-if="followupCapabilities\?\.enabled && formData.id"/)
} finally { scope.stop() }
})
test('preview upload stops before create when readiness, upload permission or allowed scope is withdrawn', async () => {
for (const withdrawn of [{ preview_ready: false }, { can_upload: false }, { test_scope_allowed: false, enabled: false }]) {
const pending = deferred()
const f = setupPanel({ followupAudioUploadChunk: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
const uploading = f.panel.submitUpload(); await settle()
Object.assign(f.props.capabilities, withdrawn); await settle(); pending.resolve({ received: true }); await uploading
assert.equal(f.calls.filter(call => call[0] === 'complete' || call[0] === 'create').length, 0)
assert.equal(f.panel.uploading.value, false)
assert.deepEqual(f.events, [])
f.dispose()
}
})