From 70ddc1a70a011c6f04f1fe5c8028f2187cc5127a Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Mon, 12 Oct 2026 09:30:43 +0800 Subject: [PATCH] feat(followup-audio): inline review with serialized draft autosave --- .../components/FollowupAudioPanel.vue | 165 +++++++++--- .../components/FollowupAudioReview.vue | 62 ++++- admin/tests/followup-audio-inline.test.cjs | 64 +++++ admin/tests/followup-audio.test.cjs | 250 +++++++++++++++++- 4 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 admin/tests/followup-audio-inline.test.cjs diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue index 41a6be0d9..7d0570075 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -57,14 +57,14 @@
{{ detail.status === 'applied' ? '已写入' : '已选' }} {{ selectedCount }} 项待核实 {{ pendingCount }} 项
- -

声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。

+
{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认修改
+ +

声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。

当前角色已确认,无需再次选择;只有左右标错时才需修改。

-

角色标注尚未保存,请先保存草稿,再逐项核对。

+

角色标注正在等待保存;保存后请逐项重新核对。

- - - + + + + +

双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。

@@ -93,9 +97,9 @@
-
已选 {{ selectedCount }} 项{{ dirty ? '有未保存修改' : '草稿已同步' }}
+
已选 {{ selectedCount }} 项{{ saveStatusText }}
- 保存草稿 + {{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }} 确认写入
@@ -119,9 +123,13 @@ 已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。 + + + + @@ -156,6 +164,16 @@ const selectedId = ref(0) const snapshot = ref('[]') const channelRoles = ref('unconfirmed') const roleSnapshot = ref('unconfirmed') +const editingRoles = ref(false) +const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'conflict'>('idle') +const persistedRevision = ref(0) +const committedDirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value) +const saveStatusText = computed(() => saveState.value === 'saving' ? '正在自动保存…' + : saveState.value === 'error' ? '保存失败,修改仍保留;请重试保存' + : saveState.value === 'conflict' ? '自动保存已暂停;请重新核对后重试保存' + : committedDirty.value ? '等待自动保存…' + : reviewRef.value?.hasUnsavedChanges() ? '当前单项尚未完成核对' + : saveState.value === 'saved' ? '审核草稿已自动保存' : '草稿已同步') const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value) const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles)) const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1))) @@ -195,6 +213,9 @@ const canApplyReview = computed(() => !props.viewOnly && followupCanApply(props. const selectedCount = computed(() => reviewItems.value.filter(item => item.selected).length) const pendingCount = computed(() => reviewItems.value.filter(item => reviewPending(item, detail.value?.fields || props.capabilities.fields, props.capabilities.can_daily)).length + (detail.value?.uncertainties.length || 0)) const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审核草稿,不允许确认写入病历' + : saveState.value === 'conflict' ? '记录版本已变化,请重新核对后重试保存草稿' + : saveState.value === 'error' ? '草稿保存失败,请重试保存成功后再确认写入' + : saveState.value === 'saving' || committedDirty.value ? '审核草稿尚未保存完成,请稍候再确认写入' : roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议' : hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议' : changedReviewTarget(reviewItems.value, detail.value?.items || []) @@ -203,6 +224,9 @@ const applyIssue = computed(() => previewOnly.value ? '测试预览仅保存审 const effectiveActive = computed(() => props.active && featureEnabled.value && documentVisible.value) const scope = createFollowupScope() let timer: ReturnType | undefined +let autosaveTimer: ReturnType | undefined +let hydrating = false +let savePromise: Promise | null = null let pollRunning = false let audioRequest = 0 let playbackRequest = 0 @@ -218,7 +242,9 @@ function releaseAudio() { audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = '' } function stop() { - scope.invalidate(); clearTimer(); releaseAudio() + scope.invalidate(); clearTimer(); clearAutosave(); releaseAudio() + savePromise = null + if (saveState.value === 'saving') { saveState.value = 'error'; errorMessage.value = '保存结果尚未确认;修改仍保留,请刷新核对版本后重试保存。' } loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false } function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) { @@ -229,25 +255,30 @@ function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRol if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) { if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original'] } - detail.value = fresh - reviewItems.value = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items) - snapshot.value = JSON.stringify(fresh.items) - roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles) - if (!keepRoles) channelRoles.value = roleSnapshot.value + hydrating = true + try { + detail.value = fresh + const nextItems = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items) + if (JSON.stringify(nextItems) !== JSON.stringify(reviewItems.value)) reviewItems.value = nextItems + snapshot.value = JSON.stringify(fresh.items) + roleSnapshot.value = normalizedChannelRoles(fresh.channel_roles) + if (!keepRoles) channelRoles.value = roleSnapshot.value + } finally { hydrating = false } } -function resetRoles() { channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed' } +function resetRoles() { hydrating = true; channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed'; hydrating = false; editingRoles.value = false; saveState.value = 'idle'; persistedRevision.value = 0 } function setChannelRoles(value: unknown) { - if (!canEditReview.value || busy.value || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return + 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) { const ticket = scope.capture() const fresh = await followupAudioDetail(id, ticket.signal) 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' installDetail(fresh, preserve) if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId) - if (preserve && fresh.status === 'review') errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。' + if (preserve && fresh.status === 'review') { clearAutosave(); saveState.value = 'conflict'; errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。自动保存已暂停,请重新核对后重试保存草稿。' } else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。' } async function refresh() { @@ -354,25 +385,88 @@ async function handleReviewError(error: unknown) { errorMessage.value = followupError(error) const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW' if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) { + clearAutosave(); saveState.value = 'conflict' try { await readDetail(selectedId.value, true) } catch { if (scope.current(ticket)) errorMessage.value += ';刷新失败,当前编辑仍已保留,请稍后刷新。' } } } -async function saveDraft() { - if (!canEditReview.value || !detail.value || busy.value) return - if (reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return } - const ticket = scope.capture(); mutation.value = 'save'; errorMessage.value = ''; clearTimer() - const changedRoles = roleDirty.value - try { - const saved = await followupAudioSaveDraft({ id: detail.value.id, version: detail.value.version, items: cloneReviewItems(reviewItems.value), ...(changedRoles && hasStereo.value ? { channel_roles: channelRoles.value } : {}) }, ticket.signal) - if (!scope.current(ticket)) return - if (saved.review_refreshed) { - installDetail(saved, true, false) - errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。' - } else { - installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历') +function clearAutosave() { if (autosaveTimer !== undefined) clearTimeout(autosaveTimer); autosaveTimer = undefined } +function scheduleAutosave() { + clearAutosave() + if (hydrating || !effectiveActive.value || !canEditReview.value || !committedDirty.value || ['error', 'conflict'].includes(saveState.value)) return + if (mutation.value === 'save') return + saveState.value = 'pending' + autosaveTimer = setTimeout(() => { autosaveTimer = undefined; void saveDraft(true) }, 650) +} +// Merge only changes made after the request began. Server-owned evidence and hashes always win. +function mergeSaveResponse(sent: FollowupReviewItem[], saved: FollowupReviewItem[], local: FollowupReviewItem[]) { + const before = new Map(sent.map(item => [item.id, item])) + const latest = new Map(local.map(item => [item.id, item])) + return cloneReviewItems(saved).map(item => { + const previous = before.get(item.id), edit = latest.get(item.id) + if (!previous || !edit) return item + const serverRequiresReview = (item.needs_review && !previous.needs_review) || (previous.selected && !item.selected) + for (const key of ['values', 'record_date', 'record_time', 'time_period', 'target_id', 'selected', 'needs_review'] as const) { + if (serverRequiresReview && (key === 'selected' || key === 'needs_review')) continue + if (JSON.stringify(previous[key]) !== JSON.stringify(edit[key])) Object.assign(item, { [key]: edit[key] }) } - } catch (error) { if (scope.current(ticket)) await handleReviewError(error) } - finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } } + return item + }) +} +async function saveDraft(automatic = false): Promise { + if (savePromise) return savePromise + if (!canEditReview.value || !detail.value || busy.value || !effectiveActive.value) return + if (!automatic && reviewRef.value?.hasUnsavedChanges()) { ElMessage.warning('请先保存或取消正在核对的单项修改'); return } + if (automatic && (!committedDirty.value || ['error', 'conflict'].includes(saveState.value))) return + clearAutosave() + const ticket = scope.capture(), taskId = detail.value.id, diagnosisId = props.diagnosisId + const current = () => scope.current(ticket) && selectedId.value === taskId && props.diagnosisId === diagnosisId + const changedRoles = roleDirty.value, sentRoles = channelRoles.value + const sentItems = cloneReviewItems(reviewItems.value), version = detail.value.version + mutation.value = 'save'; saveState.value = 'saving'; errorMessage.value = ''; clearTimer() + const operation = (async () => { + try { + const saved = await followupAudioSaveDraft({ id: taskId, version, items: sentItems, ...(changedRoles && hasStereo.value ? { channel_roles: sentRoles } : {}) }, ticket.signal) + if (!current()) return + if (detail.value && saved.version < detail.value.version) { + saveState.value = 'conflict'; errorMessage.value = '服务端已有更新版本;自动保存已暂停,请核对当前记录后重试保存。'; return + } + if (saved.review_refreshed) { + const latestRoles = channelRoles.value + installDetail(saved, true, false) + if (latestRoles !== sentRoles) { hydrating = true; channelRoles.value = latestRoles; hydrating = false } + saveState.value = 'conflict' + errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消。自动保存已暂停,请重新核对后重试保存草稿。' + } else { + const latestRoles = channelRoles.value + const merged = changedRoles ? rebaseReviewItems(reviewItems.value, saved.items) : mergeSaveResponse(sentItems, saved.items, reviewItems.value) + hydrating = true + try { + detail.value = saved; snapshot.value = JSON.stringify(saved.items) + roleSnapshot.value = normalizedChannelRoles(saved.channel_roles) + channelRoles.value = latestRoles === sentRoles ? roleSnapshot.value : latestRoles + if (JSON.stringify(merged) !== JSON.stringify(reviewItems.value)) reviewItems.value = merged + } finally { hydrating = false } + editingRoles.value = roleDirty.value + saveState.value = committedDirty.value ? 'pending' : 'saved' + if (!committedDirty.value) persistedRevision.value++ + if (!automatic) ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : previewOnly.value ? '测试审核草稿已保存,不会写入病历' : '审核草稿已保存,尚未写入病历') + } + } catch (error) { + if (current()) { + saveState.value = 'error' + await handleReviewError(error) + if (current() && !['conflict'].includes(saveState.value)) errorMessage.value += ';自动保存已暂停,修改仍保留,请重试保存草稿。' + } + } finally { + if (current()) { + mutation.value = ''; savePromise = null + scheduleAutosave() + await refresh() + } + } + })() + savePromise = operation + return operation } async function applySelected() { if (!canApplyReview.value || !detail.value || busy.value || !selectedCount.value) return @@ -414,7 +508,6 @@ async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) if (channel !== undefined && (!hasStereo.value || (channel !== 0 && channel !== 1))) { releaseAudio(); playbackError.value = '未取得可验证的双声道片段,无法隔离声道;请使用原音播放。'; return } - if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original'] const ticket = scope.capture(); const id = detail.value.id if (!audioUrl.value) { const requestId = ++audioRequest; audioLoading.value = true @@ -454,8 +547,9 @@ watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.prev resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]' tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false } - if (active) void refresh() + if (active) { void refresh(); scheduleAutosave() } }, { immediate: true }) +watch([() => JSON.stringify(reviewItems.value), channelRoles], () => { if (!hydrating) scheduleAutosave() }, { flush: 'sync' }) watch(() => props.capabilities.models, models => { if (!models.some(model => model.value === modelKey.value)) modelKey.value = models[0]?.value || '' }) @@ -479,7 +573,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; } .filename-button { max-width: 100%; height: auto; text-align: left; white-space: normal; word-break: break-word; line-height: 1.6; } .filename-button :deep(span) { overflow-wrap: anywhere; } .task-time, .recording-title > span { display: block; color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; } -.task-detail { padding: 16px; display: grid; gap: 14px; } +.task-detail { padding: 16px; display: grid; gap: 14px; overflow: visible; } .detail-heading { align-items: flex-start; } .recording-title { min-width: 0; } .recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; } @@ -492,6 +586,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; } .summary-counts { display: flex; gap: 20px; color: var(--el-text-color-secondary); font-size: 12px; } .summary-counts strong { color: var(--el-text-color-primary); font-weight: 600; } .channel-role-box { display: grid; gap: 6px; font-size: 12px; } +.role-summary { display: flex; align-items: center; gap: 12px; } .channel-role-box label { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } .channel-role-box :deep(.el-select) { width: 260px; max-width: 100%; } .channel-playback { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; } diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue index 04c42b5d1..c6a2b94d1 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue @@ -36,7 +36,10 @@ 原文中还需补充的信息
  • {{ uncertaintyText(value) }}
- +
+
+

{{ navigationNotice }}

+
{{ applied || disabled ? '查看审核结果' : '核对提炼内容' }} · {{ editorPosition }} / {{ items.length }}{{ editorDirty ? '取消本项修改' : '收起核对' }}
{{ followupKindLabels[editor.kind] }}{{ itemStatus(editor).label }}

{{ editorNotice }}

@@ -56,7 +59,7 @@
-

日期或目标已更改。请先保存本项,再保存草稿刷新目标当前值,之后重新核对并选择。

+

日期或目标已更改。保存本项后将自动同步草稿并刷新目标当前值,请依据新值重新核对。

字段{{ applied ? '写入前值' : targetChanged ? '旧目标值(待刷新)' : '当前值' }}{{ applied || disabled ? '审核值' : '拟采纳值' }}
@@ -68,25 +71,35 @@ 我已核对原话、冲突 / 重复及日期时间,确认本项内容

{{ editorIssue }}

- - + +