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' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认修改
+
+ 声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。
当前角色已确认,无需再次选择;只有左右标错时才需修改。
- 角色标注尚未保存,请先保存草稿,再逐项核对。
+ 角色标注正在等待保存;保存后请逐项重新核对。
-
-
-
+
+
{{ audioUrl ? '重新加载录音' : '加载录音播放' }}
录音不可播放或已清理
@@ -77,6 +77,10 @@
{{ playbackError }}
回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。
+
+
+
+
双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。
@@ -93,9 +97,9 @@
-
已选 {{ selectedCount }} 项{{ dirty ? '有未保存修改' : '草稿已同步' }}
+
已选 {{ selectedCount }} 项{{ saveStatusText }}
- 保存草稿
+ {{ saveState === 'error' || saveState === 'conflict' ? '重试保存草稿' : '立即保存草稿' }}
确认写入
{{ applyIssue }}
@@ -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 @@
- 日期或目标已更改。请先保存本项,再保存草稿刷新目标当前值,之后重新核对并选择。
+ 日期或目标已更改。保存本项后将自动同步草稿并刷新目标当前值,请依据新值重新核对。
@@ -68,25 +71,35 @@
confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容
{{ editorIssue }}
-
-
-
-
+
+