feat: preserve stereo channels and require reviewed speaker roles

This commit is contained in:
2026-10-08 16:15:41 +08:00
parent 13948f5982
commit a20d3565af
28 changed files with 1134 additions and 113 deletions
+13 -2
View File
@@ -11,6 +11,15 @@ export interface FollowupField {
readonly?: boolean
}
export type FollowupFields = Record<FollowupKind, FollowupField[]>
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<FollowupTaskDetail>('saveDraft', 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 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. */
@@ -54,15 +54,32 @@
<p>{{ detail.summary || '暂无回访摘要' }}</p>
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
</div>
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" @seek="loadAudio" />
<section v-if="hasStereo" class="channel-role-box" aria-label="声道角色人工标注">
<label>声道角色(人工确认)<el-select :model-value="channelRoles" :disabled="!canEditReview || busy" @update:model-value="setChannelRoles"><el-option label="角色待确认" value="unconfirmed" /><el-option label="左客服端 / 右患者或家属端" value="left_service" /><el-option label="右客服端 / 左患者或家属端" value="right_service" /></el-select></label>
<p class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
<p v-if="roleDirty" class="review-error" role="status">角色标注尚未保存,请先保存草稿,再逐项核对。</p>
</section>
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" @seek="loadAudio" />
<el-collapse v-model="detailSections" class="original-collapse">
<el-collapse-item title="录音原文" name="original">
<div class="audio-player">
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
<span v-else class="muted">录音不可播放或已清理</span>
<audio v-if="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
<audio v-if="audioUrl" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
</div>
<pre class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
<div v-if="hasStereo && detail.audio_available" class="channel-playback" aria-label="声道回听">
<el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel('original')">原音</el-button><el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel(0)">仅听左声道</el-button><el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel(1)">仅听右声道</el-button>
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:原音' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
</div>
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
<div v-if="transcriptSegments.length" class="transcript-segments">
<p class="muted">以下按录音区间展示,非逐字对齐;同一区间两侧可能同时发言,不表示发言先后。</p>
<section v-for="segment in transcriptSegments" :key="segment.id" class="transcript-segment">
<div class="segment-heading"><span>{{ audioTimestamp(segment.start_ms) }}–{{ audioTimestamp(segment.end_ms) }} · {{ followupChannelLabel(segment.channel, savedChannelRoles) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadAudio(segment.start_ms, segment.channel)">回听片段</el-button></div>
<pre>{{ segment.text || '(该区间无转写文本)' }}</pre>
</section>
</div>
<pre v-else class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
</el-collapse-item>
</el-collapse>
<div v-if="detail.status === 'review'" class="review-actions">
@@ -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<FollowupTaskDetail | null>(null)
const reviewItems = ref<FollowupReviewItem[]>([])
const selectedId = ref(0)
const snapshot = ref('[]')
const channelRoles = ref<FollowupChannelRoles>('unconfirmed')
const roleSnapshot = ref<FollowupChannelRoles>('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<string[]>([])
@@ -137,13 +161,18 @@ const errorMessage = ref('')
const audioUrl = ref('')
const audioElement = ref<HTMLAudioElement | null>(null)
const audioLoading = ref(false)
const playbackChannel = ref<FollowupPlaybackChannel>('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<typeof setTimeout> | undefined
let pollRunning = false
let audioRequest = 0
let playbackRequest = 0
let playback: ReturnType<typeof createFollowupAudioPlayback> | 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; }
@@ -42,7 +42,7 @@
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
<div class="evidence">
<div class="evidence-title">录音原话</div>
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;请结合原始录音核对。</p>
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
@@ -76,11 +76,11 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
import type { FollowupAudioChannel, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
import FollowupAudioField from './FollowupAudioField.vue'
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[] }>()
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number): void }>()
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupChannelLabel, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[]; channelRoles?: FollowupChannelRoles }>()
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number, channel?: FollowupAudioChannel): void }>()
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
type Editable = Partial<Pick<FollowupReviewItem, 'values' | 'record_date' | 'record_time' | 'time_period' | 'selected' | 'target_id' | 'needs_review'>>
const group = ref<Group>('all')
@@ -156,7 +156,9 @@ function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<Followu
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
}
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
if (evidencePlaybackLabel(quote) && typeof quote.start_ms === 'number') emit('seek', quote.start_ms)
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
if (quote.channel === 0 || quote.channel === 1) emit('seek', quote.start_ms, quote.channel)
else emit('seek', quote.start_ms)
}
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
@@ -0,0 +1,59 @@
export type FollowupPlaybackChannel = 'original' | 0 | 1
function browserAudioContext(): AudioContext | null {
const browser = globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }
const Constructor = browser.AudioContext || browser.webkitAudioContext
return Constructor ? new Constructor() : null
}
/** One authorized HTML audio element owns one lazy graph; isolated audio is duplicated to both speakers. */
export function createFollowupAudioPlayback(element: HTMLMediaElement, createContext: () => AudioContext | null = browserAudioContext) {
let context: AudioContext | null = null
let source: MediaElementAudioSourceNode | null = null
let splitter: ChannelSplitterNode | null = null
let merger: ChannelMergerNode | null = null
let disposed = false
let generation = 0
function disconnect() { source?.disconnect(); splitter?.disconnect(); merger?.disconnect() }
return {
async select(channel: FollowupPlaybackChannel): Promise<boolean> {
if (disposed) return false
const ticket = ++generation
element.pause()
if (channel === 'original' && !context) return true
try {
if (!context) {
context = createContext()
if (!context) throw new Error('当前浏览器不支持 Web Audio,无法隔离声道;请使用原音播放。')
source = context.createMediaElementSource(element)
splitter = context.createChannelSplitter(2)
merger = context.createChannelMerger(2)
}
if (!source) throw new Error('音频播放节点未就绪')
disconnect()
if (channel === 'original') source.connect(context.destination)
else {
if (!splitter || !merger) throw new Error('声道播放节点未就绪')
source.connect(splitter)
splitter.connect(merger, channel, 0)
splitter.connect(merger, channel, 1)
merger.connect(context.destination)
}
await context.resume()
if (!disposed && ticket === generation && context.state !== 'running') throw new Error('音频上下文未运行')
return !disposed && ticket === generation
} catch (error) {
if (disposed || ticket !== generation) return false
disconnect()
// Keep an explicit original-audio fallback; never autoplay it after failed isolation.
if (source && context) source.connect(context.destination)
throw new Error(`无法隔离声道,已停止本次回听;可选择原音播放。${error instanceof Error ? error.message : ''}`)
}
},
dispose() {
if (disposed) return
disposed = true; generation++; element.pause(); disconnect()
if (context) void context.close().catch(() => { /* Element is already detached and all nodes disconnected. */ })
}
}
}
@@ -1,4 +1,4 @@
import type { FollowupCapabilities, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupValue } from '@/api/followupAudio'
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, FollowupValue } from '@/api/followupAudio'
export const FOLLOWUP_CHUNK_BYTES = 2097152
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
@@ -24,12 +24,23 @@ export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
}
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
const timestamp = (ms: number) => { const seconds = Math.floor(ms / 1000); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
if (quote.position_type === 'segment') {
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
return `回听片段 ${timestamp(quote.start_ms)}–${timestamp(quote.end_ms)}`
return `回听片段 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}${quote.channel === undefined ? '' : ` · ${quote.channel === 0 ? '左' : '右'}声道`}`
}
return `播放 ${timestamp(quote.start_ms)}`
return `播放 ${audioTimestamp(quote.start_ms)}`
}
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
if (channel !== 0 && channel !== 1) return '未标注声道'
const side = channel === 0 ? '左声道' : '右声道'
const confirmed = normalizedChannelRoles(roles)
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
}
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {