feat(followup-audio): soft delete unadopted recordings and expose extraction doubts
This commit is contained in:
@@ -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="同页核对与回听">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user