diff --git a/admin/src/api/followupAudio.ts b/admin/src/api/followupAudio.ts index 4ffca1ade..c81259762 100644 --- a/admin/src/api/followupAudio.ts +++ b/admin/src/api/followupAudio.ts @@ -11,6 +11,15 @@ export interface FollowupField { readonly?: boolean } export type FollowupFields = Record +export type FollowupAudioChannel = 0 | 1 +export type FollowupChannelRoles = 'unconfirmed' | 'left_service' | 'right_service' +export interface FollowupTranscriptSegment { + id: string + start_ms: number + end_ms: number + text: string + channel?: FollowupAudioChannel +} export interface FollowupCapabilities { enabled: boolean audio_verified: boolean @@ -31,7 +40,7 @@ export interface FollowupReviewItem { time_estimated: boolean time_text: string date_text: string - evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment' }> + evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment'; channel?: FollowupAudioChannel }> needs_review: boolean selected: boolean target_id: number | null @@ -68,6 +77,8 @@ export interface FollowupTaskSummary { export interface FollowupTaskDetail extends FollowupTaskSummary { summary: string transcript: string + transcript_segments?: FollowupTranscriptSegment[] + channel_roles?: FollowupChannelRoles uncertainties: unknown[] items: FollowupReviewItem[] fields?: FollowupFields @@ -110,7 +121,7 @@ export async function followupAudioUploadChunk(uploadId: string, index: number, } export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal) export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string }, 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[] }, signal?: AbortSignal) => post('saveDraft', data, signal) +export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post('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 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. */ diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue index 111cb33b4..2b15ec70a 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -54,15 +54,32 @@

{{ detail.summary || '暂无回访摘要' }}

{{ detail.status === 'applied' ? '已写入' : '已选' }} {{ selectedCount }} 项待核实 {{ pendingCount }} 项
- +
+ +

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

+

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

+
+
{{ audioUrl ? '重新加载录音' : '加载录音播放' }} 录音不可播放或已清理 -
-
{{ detail.transcript || '暂无转录' }}
+
+ 原音仅听左声道仅听右声道 + {{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:原音' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }} +
+ +
+

以下按录音区间展示,非逐字对齐;同一区间两侧可能同时发言,不表示发言先后。

+
+
{{ audioTimestamp(segment.start_ms) }}–{{ audioTimestamp(segment.end_ms) }} · {{ followupChannelLabel(segment.channel, savedChannelRoles) }}回听片段
+
{{ segment.text || '(该区间无转写文本)' }}
+
+
+
{{ detail.transcript || '暂无转录' }}
@@ -104,10 +121,11 @@ import { FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate, followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft, followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession, - type FollowupCapabilities, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary + type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary } from '@/api/followupAudio' import FollowupAudioReview from './FollowupAudioReview.vue' -import { changedReviewTarget, cloneReviewItems, createFollowupScope, followupError, followupProgressText, followupTaskStatus, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState' +import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupChannelLabel, followupError, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState' +import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback' const props = withDefaults(defineProps<{ diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities; basicDirty?: boolean; beforeApply?: () => boolean @@ -118,8 +136,14 @@ const detail = ref(null) const reviewItems = ref([]) const selectedId = ref(0) const snapshot = ref('[]') +const channelRoles = ref('unconfirmed') +const roleSnapshot = ref('unconfirmed') +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))) +const hasStereo = computed(() => hasStereoTranscript(transcriptSegments.value)) const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null) -const dirty = computed(() => JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges()) +const dirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges()) const uploadVisible = ref(false) const helpVisible = ref(false) const detailSections = ref([]) @@ -137,13 +161,18 @@ const errorMessage = ref('') const audioUrl = ref('') const audioElement = ref(null) const audioLoading = ref(false) +const playbackChannel = ref('original') +const channelLoading = ref(false) +const playbackError = ref('') const documentVisible = ref(typeof document === 'undefined' || document.visibilityState !== 'hidden') const busy = computed(() => uploading.value || !!mutation.value) const canUpload = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.audio_verified && props.capabilities.can_upload && props.capabilities.models.length > 0) const canEditReview = computed(() => !props.viewOnly && props.capabilities.enabled && props.capabilities.can_apply && detail.value?.status === 'review') 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(() => changedReviewTarget(reviewItems.value, detail.value?.items || []) +const applyIssue = computed(() => roleDirty.value ? '声道角色标注尚未保存,请先保存草稿并重新核对建议' + : hasStereo.value && savedChannelRoles.value === 'unconfirmed' ? '请先回听并确认声道角色,保存草稿后重新核对建议' + : changedReviewTarget(reviewItems.value, detail.value?.items || []) ? '写入目标或记录日期已更改,请先保存审核草稿取得新快照,再核对并采纳' : reviewItems.value.map(item => reviewFieldIssue(item, detail.value?.fields || props.capabilities.fields) || reviewIssue(item, props.capabilities.can_daily)).find(Boolean) || '') const effectiveActive = computed(() => props.active && props.capabilities.enabled && documentVisible.value) @@ -151,25 +180,38 @@ const scope = createFollowupScope() let timer: ReturnType | undefined let pollRunning = false let audioRequest = 0 +let playbackRequest = 0 +let playback: ReturnType | null = null +let playbackElement: HTMLAudioElement | null = null let lastDiagnosis = 0 function clearTimer() { if (timer !== undefined) clearTimeout(timer); timer = undefined } function releaseAudio() { - audioRequest++ + audioRequest++; playbackRequest++ + playback?.dispose(); playback = null; playbackElement = null audioElement.value?.pause() if (audioUrl.value) URL.revokeObjectURL(audioUrl.value) - audioUrl.value = ''; audioLoading.value = false + audioUrl.value = ''; audioLoading.value = false; channelLoading.value = false; playbackChannel.value = 'original'; playbackError.value = '' } function stop() { scope.invalidate(); clearTimer(); releaseAudio() loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false } -function installDetail(fresh: FollowupTaskDetail, preserve: boolean) { +function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) { + const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review' + if (!fresh.audio_available) releaseAudio() 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 +} +function resetRoles() { channelRoles.value = 'unconfirmed'; roleSnapshot.value = 'unconfirmed' } +function setChannelRoles(value: unknown) { + if (!canEditReview.value || busy.value || !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() @@ -212,7 +254,7 @@ async function openTask(id: number) { if (id === selectedId.value || busy.value) return const previous = scope.capture() if (!await confirmLeave() || !scope.current(previous)) return - stop(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = [] + stop(); resetRoles(); selectedId.value = id; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; errorMessage.value = ''; detailSections.value = [] const ticket = scope.capture() try { await readDetail(id) } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) } if (scope.current(ticket)) await refresh() @@ -220,7 +262,7 @@ async function openTask(id: number) { async function backToList() { const ticket = scope.capture() if (!await confirmLeave() || !scope.current(ticket)) return - stop(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = '' + stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = []; errorMessage.value = '' await refresh() } function openUpload() { if (canUpload.value && !busy.value && !createUncertain.value) uploadVisible.value = true } @@ -278,14 +320,15 @@ 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) }, ticket.signal) + 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) + installDetail(saved, true, false) errorMessage.value = '业务记录已变化,版本已刷新;当前编辑已保留,全部选择已取消,请重新核对后保存或采纳。' } else { - installDetail(saved, false); ElMessage.success('审核草稿已保存,尚未写入病历') + installDetail(saved, false); ElMessage.success(changedRoles ? '角色标注已保存,全部建议须重新核对;不会自动重新提炼' : '审核草稿已保存,尚未写入病历') } } catch (error) { if (scope.current(ticket)) await handleReviewError(error) } finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } } @@ -297,7 +340,8 @@ async function applySelected() { if (applyIssue.value) { ElMessage.warning(applyIssue.value); return } const ticket = scope.capture() try { await ElMessageBox.confirm(`将一次性采纳 ${selectedCount.value} 项;任一项冲突则整批不写入。确认已核对原话、日期和时间?`, '确认采纳', { type: 'warning', confirmButtonText: '全部采纳', cancelButtonText: '返回核对' }) } catch { return } - if (!scope.current(ticket) || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return + if (!scope.current(ticket) || !canEditReview.value || !selectedCount.value || props.basicDirty || (props.beforeApply && !props.beforeApply()) || !detail.value) return + if (applyIssue.value) { ElMessage.warning(applyIssue.value); return } const diagnosisId = props.diagnosisId mutation.value = 'apply'; errorMessage.value = ''; clearTimer() try { @@ -320,8 +364,14 @@ async function retryTask() { finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } } } async function reloadAudio() { releaseAudio(); await loadAudio() } -async function loadAudio(milliseconds?: number) { +async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) } +async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) { if (!detail.value?.audio_available) return + const operation = ++playbackRequest + playbackError.value = '' + 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) { @@ -334,9 +384,21 @@ async function loadAudio(milliseconds?: number) { finally { if (scope.current(ticket) && requestId === audioRequest) audioLoading.value = false } } await nextTick() - if (!scope.current(ticket) || id !== selectedId.value || !audioElement.value || typeof milliseconds !== 'number') return - audioElement.value.currentTime = Math.max(0, milliseconds / 1000) - try { await audioElement.value.play() } catch { /* Native controls remain available when autoplay is blocked. */ } + if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value || !audioUrl.value || !audioElement.value) return + const element = audioElement.value + if (playbackElement !== element) { playback?.dispose(); playback = createFollowupAudioPlayback(element); playbackElement = element } + const routing = playback + channelLoading.value = true + try { + if (!routing || !await routing.select(channel ?? 'original')) return + if (!scope.current(ticket) || operation !== playbackRequest || id !== selectedId.value) return + playbackChannel.value = channel ?? 'original' + if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return + element.currentTime = Math.max(0, milliseconds / 1000) + try { await element.play() } catch { if (scope.current(ticket) && operation === playbackRequest) playbackError.value = '播放未启动,请使用播放器控件继续。' } + } catch (error) { + if (scope.current(ticket) && operation === playbackRequest) { playbackChannel.value = 'original'; playbackError.value = followupError(error) } + } finally { if (scope.current(ticket) && operation === playbackRequest) channelLoading.value = false } } function formatExpiry(value: number | string) { if (!value) return '由保留策略控制' @@ -347,7 +409,7 @@ function onVisibility() { documentVisible.value = document.visibilityState !== ' watch([() => props.diagnosisId, effectiveActive], ([id, active]) => { stop() if (id !== lastDiagnosis) { - lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]' + resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]' tasks.value = []; file.value = null; recordedAt.value = ''; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false } if (active) void refresh() @@ -387,6 +449,15 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; } .summary-box > p { margin: 0 0 10px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } .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; } +.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; } +.channel-playback :deep(.el-button + .el-button) { margin-left: 0; } +.transcript-segments { max-height: 320px; overflow-y: auto; margin-top: 12px; } +.transcript-segment { padding: 10px 0; border-bottom: 1px solid #e2e8f0; } +.segment-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--el-text-color-secondary); font-size: 12px; } +.transcript-segment pre { margin: 5px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; } .original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; } .original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); } .original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; } diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue index c1331cc83..39af8981c 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue @@ -42,7 +42,7 @@

{{ editorNotice }}

录音原话
-
{{ quote.text }}{{ evidencePlaybackLabel(quote) }}
+
{{ followupChannelLabel(quote.channel, channelRoles) }}
{{ quote.text }}{{ evidencePlaybackLabel(quote) }}

回听位置为转写片段范围,不是原话的逐字时间戳;请结合原始录音核对。

未提供原话证据,请人工核实。

时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}

@@ -76,11 +76,11 @@