feat(followup-audio): soft delete unadopted recordings and expose extraction doubts

This commit is contained in:
2026-10-12 10:39:12 +08:00
parent 70ddc1a70a
commit 14e1722291
13 changed files with 426 additions and 18 deletions
+3
View File
@@ -75,6 +75,8 @@ export interface FollowupTaskSummary {
version: number
created_at: number | string
expires_at: number | string
can_delete?: boolean
delete_block_reason?: string
can_retry: boolean
audio_available: boolean
preview_only?: boolean
@@ -130,6 +132,7 @@ export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSign
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<FollowupTaskDetail>('saveDraft', data, signal)
export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal)
export const followupAudioDelete = (data: { id: number; version: number }, signal?: AbortSignal) => post<{ id: number; status: 'deleted'; version: number }>('delete', data, signal)
export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal)
/** The existing request interceptor puts the token in a header, never in the audio URL. */
export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise<Blob> {
@@ -34,8 +34,8 @@
<el-table-column label="状态" width="145">
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></template>
</el-table-column>
<el-table-column label="操作" width="65" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button></template>
<el-table-column label="操作" width="112" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button><el-button v-if="!viewOnly && row.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(row)" :title="row.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(row)">删除</el-button></template>
</el-table-column>
</el-table>
</div>
@@ -43,7 +43,7 @@
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
<div class="detail-heading">
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
<el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag>
<div class="detail-actions"><el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag><el-button v-if="!viewOnly && detail.can_delete !== undefined" link type="danger" :disabled="busy || !canDeleteTask(detail)" :title="detail.delete_block_reason || '删除未入单录音任务'" @click="deleteRecording(detail)">删除录音</el-button></div>
</div>
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
@@ -56,6 +56,7 @@
<p>{{ detail.summary || '暂无回访摘要' }}</p>
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
</div>
<el-alert v-if="detail.status === 'review' && !reviewItems.length" type="warning" :closable="false" title="未生成可写入信息" :description="detail.uncertainties.length ? `本次提炼保留了 ${detail.uncertainties.length} 条待核实原因,已展示在下方。请结合原文回听核对,尤其注意声道角色是否相符。` : '请查看回访摘要并回听录音;本次没有可供确认写入的结构化候选。'" show-icon />
<section v-if="hasStereo" class="channel-role-box" aria-label="声道角色人工标注">
<div v-if="savedChannelRoles !== 'unconfirmed' && !editingRoles && !roleDirty" class="role-summary"><span>{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认</span><el-button v-if="canEditReview" link type="primary" @click="editingRoles = true">修改</el-button></div>
<label v-else>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || (busy && mutation !== 'save')" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
@@ -144,7 +145,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate,
FollowupAudioError, followupAudioDelete, followupAudioApply, followupAudioBlob, followupAudioCreate,
followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft,
followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession,
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
@@ -194,7 +195,7 @@ const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStage = ref('')
const createUncertain = ref(false)
const mutation = ref<'save' | 'apply' | 'retry' | ''>('')
const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | ''>('')
const errorMessage = ref('')
const audioUrl = ref('')
const audioElement = ref<HTMLAudioElement | null>(null)
@@ -270,9 +271,25 @@ function setChannelRoles(value: unknown) {
if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return
channelRoles.value = value as FollowupChannelRoles
}
async function readDetail(id: number, preserve = false) {
function taskUnavailable(error: unknown): boolean {
return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'))
}
function forgetUnavailableTask(id: number) {
if (selectedId.value !== id) return
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。'
void refresh()
}
async function readDetail(id: number, preserve = false, verifyOnly = false) {
const ticket = scope.capture()
const fresh = await followupAudioDetail(id, ticket.signal)
let fresh: FollowupTaskDetail
try { fresh = await followupAudioDetail(id, ticket.signal) }
catch (error) {
if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return }
throw error
}
if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return
if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return
preserve = preserve || (!!detail.value && dirty.value)
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
@@ -292,6 +309,9 @@ async function refresh() {
const updated = tasks.value.find(task => task.id === selectedId.value)
if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) {
await readDetail(updated.id, dirty.value)
} else if (!updated && selectedId.value && detail.value) {
// A limited list can omit older tasks; only an authoritative detail denial revokes them.
await readDetail(selectedId.value, false, true)
}
} catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
finally {
@@ -382,6 +402,7 @@ async function submitUpload() {
}
async function handleReviewError(error: unknown) {
const ticket = scope.capture()
if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return }
errorMessage.value = followupError(error)
const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW'
if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) {
@@ -488,6 +509,35 @@ async function applySelected() {
} catch (error) { if (scope.current(ticket)) await handleReviewError(error) }
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
}
function canDeleteTask(task: FollowupTaskSummary): boolean {
return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status)
}
async function deleteRecording(task: FollowupTaskSummary) {
if (busy.value || !canDeleteTask(task)) return
const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version
const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId
clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = ''
let confirmed = false
try {
await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' })
if (!current() || !canDeleteTask(task)) return
confirmed = true
await followupAudioDelete({ id, version }, ticket.signal)
if (!current()) return
// Fence pending playback, autosaves and reads; deletion never revives a stale draft.
if (selectedId.value === id) {
stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []
}
tasks.value = tasks.value.filter(row => row.id !== id)
errorMessage.value = ''
ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。')
await refresh()
} catch (error) {
if (confirmed && current()) errorMessage.value = followupError(error)
} finally {
if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() }
}
}
async function retryTask() {
if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return
const ticket = scope.capture()
@@ -577,6 +627,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
.detail-heading { align-items: flex-start; }
.recording-title { min-width: 0; }
.recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; }
.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.detail-heading :deep(.el-tag) { flex-shrink: 0; }
.detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); }
.processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
@@ -32,8 +32,8 @@
</el-table-column>
</el-table>
</div>
<section v-if="group === 'pending' && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
<strong>原文中还需补充的信息</strong>
<section v-if="(group === 'pending' || !items.length) && uncertainties?.length" class="uncertainty-list" aria-label="原文待核实信息">
<strong>本次未能确认的信息与原因</strong>
<ul><li v-for="(value, index) in uncertainties" :key="index">{{ uncertaintyText(value) }}</li></ul>
</section>
<section ref="reviewWorkspace" class="review-workspace" tabindex="-1" aria-label="同页核对与回听">
+55
View File
@@ -38,6 +38,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
followupAudioUploadChunk: async (id, index, blob) => { calls.push(['chunk', id, index, blob.size]); return { index, received: true } },
followupAudioUploadComplete: async id => { calls.push(['complete', id]); return { upload_id: id, duration_seconds: 10, sha256: 'test' } },
followupAudioCreate: async payload => { calls.push(['create', payload]); return { task_id: 2, status: 'queued' } },
followupAudioDelete: async payload => { calls.push(['delete', payload]); return { id: payload.id, status: 'deleted', version: payload.version + 1 } },
followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } },
...overrides
}
@@ -1793,3 +1794,57 @@ test('detail fetch begun before an edit preserves that edit when the new snapsho
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
f.dispose()
})
test('delete requires actor-aware capability and blocks applied/running/unknown tasks', async () => {
const f=setupPanel();await settle()
for (const row of [task(), task(1,{can_delete:false}),task(1,{can_delete:true,status:'applied'}),task(1,{can_delete:true,status:'running'}),task(1,{can_delete:true,status:'needs_reconciliation'})]) await f.panel.deleteRecording(row)
assert.equal(f.calls.filter(c=>c[0]==='delete').length,0)
f.props.viewOnly=true;await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.calls.filter(c=>c[0]==='delete').length,0);f.dispose()
})
test('confirmed deletion sends optimistic version once, clears selected editor and never applies', async () => {
let removed=false;const current=task(1,{can_delete:true})
const f=setupPanel({followupAudioDetail:async()=>current,followupAudioLists:async()=>({items:removed?[]:[current]}),followupAudioDelete:async p=>{f.calls.push(['delete',p]);removed=true;return {id:p.id,status:'deleted',version:p.version+1}}})
await settle();await f.panel.openTask(1);await f.panel.deleteRecording(current)
assert.deepEqual(f.calls.find(c=>c[0]==='delete')[1],{id:1,version:1});assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.deepEqual(f.panel.tasks.value,[]);assert.deepEqual(f.panel.reviewItems.value,[])
assert.equal(f.calls.some(c=>c[0]==='apply'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
})
test('delete cancellation keeps recording and drafts, patient switch before confirm sends nothing', async () => {
const f=setupPanel({}, {}, {confirm:async()=>{throw new Error('cancel')}});await settle();await f.panel.openTask(1);await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.calls.some(c=>c[0]==='delete'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
const answer=deferred();const g=setupPanel({}, {}, {confirm:()=>answer.promise});await settle();const pending=g.panel.deleteRecording(task(1,{can_delete:true}));g.props.diagnosisId=20;await settle();answer.resolve();await pending;assert.equal(g.calls.some(c=>c[0]==='delete'),false);g.dispose()
})
test('late delete result cannot clear another patient and errors preserve current recording', async () => {
const response=deferred();const f=setupPanel({followupAudioDelete:async p=>{f.calls.push(['delete',p]);return response.promise}});await settle();await f.panel.openTask(1)
const deleting=f.panel.deleteRecording(task(1,{can_delete:true}));await settle();const duplicate=f.panel.deleteRecording(task(1,{can_delete:true}));await duplicate;assert.equal(f.calls.filter(c=>c[0]==='delete').length,1)
f.props.diagnosisId=20;await settle();await f.panel.openTask(2);response.resolve({id:1,status:'deleted',version:2});await deleting;assert.equal(f.panel.selectedId.value,2);assert.equal(f.panel.detail.value.id,2);f.dispose()
const g=setupPanel({followupAudioDelete:async()=>{throw new apiModule.FollowupAudioError('版本已变化')}});await settle();await g.panel.openTask(1);await g.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(g.panel.selectedId.value,1);assert.match(g.panel.errorMessage.value,/版本已变化/);g.dispose()
})
test('delete API has no automatic retry and empty results expose uncertainty reasons without tab switching', async () => {
const requests=[];const api=loadTs(path.join(root,'src/api/followupAudio.ts'),()=>({default:{post:async(config,options)=>{requests.push({config,options});return {code:1,data:{id:1,status:'deleted',version:2}}}}}))
await api.followupAudioDelete({id:1,version:1});assert.equal(requests[0].config.url,'/tcm.followupAudio/delete');assert.equal(requests[0].options.isOpenRetry,false)
const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8');assert.match(source,/\(group === 'pending' \|\| !items.length\) && uncertainties\?\.length/)
const panelSource=fs.readFileSync(path.join(componentDir,'FollowupAudioPanel.vue'),'utf8');assert.match(panelSource,/未生成可写入信息/);assert.match(panelSource,/不删除或回滚病例/)
})
test('remote deletion revalidates missing detail and releases cached audio/editor', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioDetail:async id=>{if(deleted)throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE');return task(id)}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();assert.equal(f.panel.audioUrl.value,'blob:private-0')
deleted=true;await f.panel.refresh();await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.equal(f.panel.audioUrl.value,'');assert.deepEqual(f.revoked,['blob:private-0']);assert.deepEqual(f.panel.reviewItems.value,[]);assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})
test('list pagination omission alone never discards current unsaved edits', async () => {
let omit=false;const f=setupPanel({followupAudioLists:async()=>({items:omit?[]:[task()]})});await settle();await f.panel.openTask(1)
f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;omit=true;await f.panel.refresh()
assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9);assert.notEqual(f.panel.saveState.value,'conflict');f.dispose()
})
test('deleting another row resumes current task autosave without clearing its draft', async () => {
const f=setupPanel();await settle();await f.panel.openTask(1);f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9
await f.panel.deleteRecording(task(2,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9)
f.tick();await settle();assert.ok(f.calls.some(c=>c[0]==='save'&&c[1].id===1&&c[1].items[0].values.fasting_blood_sugar===6.9));f.dispose()
})
test('autosave denial for remotely deleted task clears local playback without another save', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioSaveDraft:async()=>{deleted=true;throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE')}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;await f.panel.saveDraft(true);await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.audioUrl.value,'');assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})