feat: preserve stereo channels and require reviewed speaker roles
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ function execute(source, dependencies, globals = {}) {
|
||||
}
|
||||
function loadTs(filename, dependencies = require) { return execute(fs.readFileSync(filename, 'utf8'), dependencies) }
|
||||
const state = loadTs(path.join(componentDir, 'followupAudioState.ts'))
|
||||
const playbackModule = loadTs(path.join(componentDir, 'followupAudioPlayback.ts'))
|
||||
const apiModule = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: {} }))
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve() }
|
||||
@@ -48,6 +49,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
|
||||
if (name === 'element-plus') return { ElMessage: Object.fromEntries(['success', 'warning', 'error'].map(kind => [kind, msg => notices.push([kind, msg])])), ElMessageBox: { confirm: options.confirm || (async () => true) } }
|
||||
if (name === '@/api/followupAudio') return deps
|
||||
if (name === './followupAudioState') return state
|
||||
if (name === './followupAudioPlayback') return options.playback || playbackModule
|
||||
if (name === './FollowupAudioReview.vue') return { default: {} }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}, {
|
||||
@@ -866,3 +868,341 @@ test('summary is explicitly an unverified AI draft even after selected rows have
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /<div class="summary-box">\s*<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情<\/div>/)
|
||||
})
|
||||
|
||||
const stereoSegments = () => [
|
||||
{ id: 'left-0', channel: 0, start_ms: 0, end_ms: 15000, text: '合成左声道文本' },
|
||||
{ id: 'right-0', channel: 1, start_ms: 0, end_ms: 15000, text: '合成右声道文本' },
|
||||
{ id: 'left-1', channel: 0, start_ms: 15000, end_ms: 30000, text: '合成左侧后续' },
|
||||
{ id: 'right-1', channel: 1, start_ms: 15000, end_ms: 30000, text: '合成右侧后续' }
|
||||
]
|
||||
const stereoTask = (extra = {}) => task(1, { transcript_segments: stereoSegments(), channel_roles: 'unconfirmed', ...extra })
|
||||
function fakeAudioContext(options = {}) {
|
||||
const nodes = [], events = []
|
||||
const node = name => {
|
||||
const result = { name, edges: [], connect(target, output = 0, input = 0) { this.edges.push({ target, output, input }); events.push(['connect', name, target.name, output, input]) }, disconnect() { this.edges = []; events.push(['disconnect', name]) } }
|
||||
nodes.push(result); return result
|
||||
}
|
||||
const context = {
|
||||
state: 'suspended', destination: node('destination'),
|
||||
createMediaElementSource(element) { events.push(['media', element]); return node('media') },
|
||||
createChannelSplitter(count) { events.push(['splitter', count]); return node('splitter') },
|
||||
createChannelMerger(count) { events.push(['merger', count]); return node('merger') },
|
||||
async resume() { events.push(['resume']); if (options.resume) await options.resume(); context.state = 'running' },
|
||||
async close() { events.push(['close']); context.state = 'closed' }
|
||||
}
|
||||
const element = { currentTime: 0, paused: true, pause() { this.paused = true; events.push(['pause']) }, async play() { this.paused = false; events.push(['play']) } }
|
||||
return { context, element, nodes, events, playback: { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => context) } }
|
||||
}
|
||||
|
||||
test('stereo transcript order is window/channel, roles are unconfirmed until explicitly saved, legacy stays plain', async () => {
|
||||
const response = stereoTask({ transcript_segments: [stereoSegments()[3], stereoSegments()[1], stereoSegments()[2], stereoSegments()[0]] })
|
||||
const f = setupPanel({ followupAudioDetail: async () => response })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
assert.deepEqual(f.panel.transcriptSegments.value.map(segment => segment.id), ['left-0', 'right-0', 'left-1', 'right-1'])
|
||||
assert.equal(response.transcript_segments[0].id, 'right-1', 'display sorting never mutates server evidence')
|
||||
assert.equal(f.panel.hasStereo.value, true)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.match(state.followupChannelLabel(0), /左声道 · 角色待确认/)
|
||||
assert.match(state.followupChannelLabel(1, 'left_service'), /右声道 · 患者\/家属端(人工标注)/)
|
||||
assert.match(state.followupChannelLabel(1, 'right_service'), /右声道 · 客服端(人工标注)/)
|
||||
assert.match(state.followupChannelLabel(0, 'invalid'), /角色待确认/)
|
||||
assert.equal(state.followupChannelLabel(undefined, 'left_service'), '未标注声道')
|
||||
assert.equal(state.hasStereoTranscript([{ ...stereoSegments()[0], channel: undefined }]), false)
|
||||
assert.equal(state.hasStereoTranscript([stereoSegments()[0]]), false)
|
||||
assert.match(f.panel.applyIssue.value, /确认声道角色/)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /同一区间两侧可能同时发言,不表示发言先后/)
|
||||
assert.match(source, /<pre v-else class="transcript-text">/)
|
||||
})
|
||||
|
||||
test('stereo role draft is dirty, blocks apply and leave, saves exact role/version then requires item re-review', async () => {
|
||||
let current = stereoTask()
|
||||
const saves = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
saves.push(payload)
|
||||
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
return current
|
||||
} }, {}, { confirm: async () => { throw new Error('cancel') } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.setChannelRoles('left_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'unconfirmed', 'unsaved role never labels a transcript as confirmed')
|
||||
assert.equal(await f.panel.confirmLeave(), false)
|
||||
await f.panel.applySelected(); assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(saves.length, 1)
|
||||
assert.equal(saves[0].channel_roles, 'left_service')
|
||||
assert.equal(saves[0].version, 1)
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.selectedCount.value, 0)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
assert.match(f.notices.at(-1)[1], /不会自动重新提炼/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('poll and stale save preserve unsaved role; refreshed save installs authoritative role and clears selections', async () => {
|
||||
let current = stereoTask()
|
||||
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current,
|
||||
followupAudioSaveDraft: async () => { throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') } })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('right_service')
|
||||
current = { ...current, version: 2, channel_roles: 'left_service' }
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
f.dispose()
|
||||
const g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async payload => stereoTask({ version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: [item({ selected: false, needs_review: true })] }) })
|
||||
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service'); await g.panel.saveDraft()
|
||||
assert.equal(g.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(g.panel.savedChannelRoles.value, 'right_service')
|
||||
assert.equal(g.panel.roleDirty.value, false)
|
||||
assert.equal(g.panel.reviewItems.value[0].selected, false)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('stereo roles honor permission/status gates and mono saves omit channel_roles', async () => {
|
||||
for (const props of [{ viewOnly: true }, { capabilities: { ...caps(), can_apply: false } }]) {
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, props)
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(f.calls.filter(call => call[0] === 'save').length, 0)
|
||||
f.dispose()
|
||||
}
|
||||
const f = setupPanel()
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
assert.equal(f.panel.hasStereo.value, false)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(Object.hasOwn(f.calls.find(call => call[0] === 'save')[1], 'channel_roles'), false)
|
||||
f.dispose()
|
||||
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ status: 'applied', channel_roles: 'left_service' }) })
|
||||
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service')
|
||||
assert.equal(g.panel.channelRoles.value, 'left_service')
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('permission or unsaved roles changed during apply confirmation prevent sending apply', async () => {
|
||||
for (const change of [f => f.panel.setChannelRoles('right_service'), f => { f.props.viewOnly = true }]) {
|
||||
const confirm = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { confirm: () => confirm.promise })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
const applying = f.panel.applySelected(); await settle(); change(f); confirm.resolve(true); await applying
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('old stereo role save/detail completions cannot populate a new patient or emit apply', async () => {
|
||||
const pending = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async () => pending.promise })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service')
|
||||
const saving = f.panel.saveDraft(); await settle()
|
||||
f.props.diagnosisId = 20; await settle()
|
||||
pending.resolve(stereoTask({ version: 2, channel_roles: 'left_service' })); await saving
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.deepEqual(f.panel.transcriptSegments.value, [])
|
||||
assert.deepEqual(f.events, [])
|
||||
assert.deepEqual(f.notices, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('WebAudio real routing helper connects only chosen splitter output to both speakers, switches and disposes', async () => {
|
||||
const f = fakeAudioContext()
|
||||
const playback = playbackModule.createFollowupAudioPlayback(f.element, () => f.context)
|
||||
assert.equal(await playback.select('original'), true)
|
||||
assert.equal(f.events.filter(event => event[0] === 'media').length, 0, 'legacy original playback does not require Web Audio')
|
||||
for (const channel of [0, 1]) {
|
||||
assert.equal(await playback.select(channel), true)
|
||||
const media = f.nodes.find(node => node.name === 'media'), split = f.nodes.find(node => node.name === 'splitter'), merge = f.nodes.find(node => node.name === 'merger')
|
||||
assert.deepEqual(media.edges.map(edge => edge.target.name), ['splitter'])
|
||||
assert.deepEqual(split.edges.map(({ target, output, input }) => [target.name, output, input]), [['merger', channel, 0], ['merger', channel, 1]])
|
||||
assert.deepEqual(merge.edges.map(edge => edge.target.name), ['destination'])
|
||||
}
|
||||
assert.equal(f.events.filter(event => event[0] === 'media').length, 1)
|
||||
assert.equal(await playback.select('original'), true)
|
||||
assert.deepEqual(f.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
|
||||
assert.deepEqual(f.nodes.find(node => node.name === 'splitter').edges, [])
|
||||
playback.dispose(); playback.dispose()
|
||||
assert.ok(f.nodes.every(node => node.edges.length === 0))
|
||||
assert.equal(f.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(await playback.select(0), false)
|
||||
})
|
||||
|
||||
test('WebAudio unsupported/resume failure never claims isolated playback and pending resume is fenced after dispose', async () => {
|
||||
const unsupported = fakeAudioContext()
|
||||
const missing = playbackModule.createFollowupAudioPlayback(unsupported.element, () => null)
|
||||
await assert.rejects(missing.select(0), /无法隔离声道/)
|
||||
assert.equal(unsupported.element.paused, true)
|
||||
assert.equal(await missing.select('original'), true)
|
||||
missing.dispose()
|
||||
const failed = fakeAudioContext({ resume: async () => { throw new Error('NotAllowedError') } })
|
||||
const route = playbackModule.createFollowupAudioPlayback(failed.element, () => failed.context)
|
||||
await assert.rejects(route.select(1), /无法隔离声道/)
|
||||
assert.deepEqual(failed.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
|
||||
assert.equal(failed.events.filter(event => event[0] === 'play').length, 0)
|
||||
route.dispose()
|
||||
const resumed = deferred(), pending = fakeAudioContext({ resume: () => resumed.promise })
|
||||
const delayed = playbackModule.createFollowupAudioPlayback(pending.element, () => pending.context)
|
||||
const selecting = delayed.select(1); delayed.dispose(); resumed.resolve(); assert.equal(await selecting, false)
|
||||
assert.ok(pending.nodes.every(node => node.edges.length === 0))
|
||||
})
|
||||
|
||||
test('actual panel selects evidence channel, seeks original timeline and releases routing on hide and task switch', async () => {
|
||||
const audio = fakeAudioContext()
|
||||
const f = setupPanel({ followupAudioDetail: async id => stereoTask({ id, channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
await f.panel.loadAudio(17000, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 1)
|
||||
assert.equal(audio.element.currentTime, 17)
|
||||
assert.equal(audio.element.paused, false)
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]])
|
||||
assert.equal(f.panel.playbackError.value, '')
|
||||
await f.panel.playChannel(0)
|
||||
assert.equal(f.panel.playbackChannel.value, 0)
|
||||
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 1)
|
||||
f.props.active = false; await settle()
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
f.props.active = true; await settle(); await f.panel.openTask(2)
|
||||
assert.equal(f.panel.selectedId.value, 2)
|
||||
assert.equal(f.panel.channelLoading.value, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('actual panel reports unavailable isolation, keeps original playback and never falls back to mixed autoplay', async () => {
|
||||
const audio = fakeAudioContext()
|
||||
const unsupported = { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => null) }
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: unsupported })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
await f.panel.loadAudio(1000, 0)
|
||||
assert.match(f.panel.playbackError.value, /无法隔离声道/)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
|
||||
await f.panel.playChannel('original')
|
||||
assert.equal(f.panel.playbackError.value, '')
|
||||
assert.equal(audio.element.paused, false)
|
||||
f.dispose()
|
||||
const mono = setupPanel()
|
||||
await settle(); await mono.panel.openTask(1); mono.panel.audioElement.value = audio.element
|
||||
await mono.panel.loadAudio(1000, 1)
|
||||
assert.match(mono.panel.playbackError.value, /未取得可验证的双声道/)
|
||||
assert.equal(mono.calls.filter(call => call[0] === 'audio').length, 0)
|
||||
mono.dispose()
|
||||
})
|
||||
|
||||
test('late stereo blob and routing resume do not play or relabel another patient', async () => {
|
||||
const pending = deferred(), audio = fakeAudioContext({ resume: () => pending.promise })
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
const play = f.panel.loadAudio(5000, 1); await settle()
|
||||
assert.equal(f.panel.channelLoading.value, true)
|
||||
f.props.diagnosisId = 20; await settle(); pending.resolve(); await play
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(f.panel.channelLoading.value, false)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
f.dispose()
|
||||
const blob = deferred(), g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioBlob: async () => blob.promise }, {}, { playback: audio.playback })
|
||||
await settle(); await g.panel.openTask(1); g.panel.audioElement.value = audio.element
|
||||
const loading = g.panel.loadAudio(5000, 0); await settle(); g.props.diagnosisId = 30; await settle()
|
||||
blob.resolve(new Blob(['synthetic'])); await loading
|
||||
assert.deepEqual(g.urls, [])
|
||||
assert.equal(g.panel.detail.value, null)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('channel evidence is immutable through editor/draft and seek emits channel without guessing a role', async () => {
|
||||
const quote = { text: '合成右侧原话', segment_id: 'right-1', start_ms: 15000, end_ms: 30000, position_type: 'segment', channel: 1 }
|
||||
const f = setupReview({ items: [item({ evidence: [quote] })], channelRoles: 'unconfirmed' })
|
||||
f.review.openReview('blood-1'); f.review.seekEvidence(f.review.editor.value.evidence[0])
|
||||
assert.deepEqual(f.events, [['seek', 15000, 1]])
|
||||
assert.equal(state.evidencePlaybackLabel(quote), '回听片段 0:15–0:30 · 右声道')
|
||||
f.review.editor.value.evidence[0].channel = 0
|
||||
f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.deepEqual(f.props.items[0].evidence, [quote])
|
||||
const panel = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'right_service', items: f.props.items }) })
|
||||
await settle(); await panel.panel.openTask(1); await panel.panel.saveDraft()
|
||||
assert.equal(panel.calls.find(call => call[0] === 'save')[1].items[0].evidence[0].channel, 1)
|
||||
f.dispose(); panel.dispose()
|
||||
})
|
||||
|
||||
test('API forwards optional channel_roles only in draft and stereo apply uses server roles after explicit re-review', 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: stereoTask({ channel_roles: 'left_service' }) } } } }))
|
||||
await api.followupAudioSaveDraft({ id: 1, version: 1, items: [item()], channel_roles: 'left_service' })
|
||||
assert.equal(requests[0].config.url, '/tcm.followupAudio/saveDraft')
|
||||
assert.equal(requests[0].config.data.channel_roles, 'left_service')
|
||||
assert.equal(requests[0].options.isOpenRetry, false)
|
||||
let current = stereoTask()
|
||||
const applied = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
current = { ...current, version: 2, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
return current
|
||||
}, followupAudioApply: async payload => { applied.push(payload); current = { ...current, status: 'applied', items: payload.items }; return { id: 1, status: 'applied', applied_items: [] } } })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
await f.panel.applySelected(); assert.equal(applied.length, 0)
|
||||
const review = setupReview({ items: f.panel.reviewItems.value })
|
||||
review.review.openReview('blood-1'); review.review.confirmReview(true); review.review.saveReview(true)
|
||||
f.panel.reviewItems.value = review.props.items
|
||||
await f.panel.applySelected()
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].version, 2)
|
||||
assert.equal(Object.hasOwn(applied[0], 'channel_roles'), false, 'apply uses authoritative server-saved role mapping')
|
||||
assert.equal(f.panel.detail.value.status, 'applied')
|
||||
assert.deepEqual(f.events, [['applied', 10]])
|
||||
review.dispose(); f.dispose()
|
||||
})
|
||||
|
||||
test('rapid opposing evidence seeks use only the newest channel and original-audio reload closes the old graph', async () => {
|
||||
const resume = deferred(), audio = fakeAudioContext({ resume: () => resume.promise })
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
const first = f.panel.loadAudio(2000, 0); await settle()
|
||||
const second = f.panel.loadAudio(9000, 1); await settle(); resume.resolve(); await Promise.all([first, second])
|
||||
assert.equal(f.panel.playbackChannel.value, 1)
|
||||
assert.equal(audio.element.currentTime, 9)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 1)
|
||||
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges.map(edge => edge.output), [1, 1])
|
||||
await f.panel.reloadAudio()
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 2)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('task switch and observed audio expiry both close live stereo routing and revoke the authorized blob', async () => {
|
||||
for (const transition of ['task', 'expired']) {
|
||||
const audio = fakeAudioContext()
|
||||
let current = stereoTask({ channel_roles: 'left_service' })
|
||||
const f = setupPanel({ followupAudioDetail: async id => ({ ...current, id }), followupAudioLists: async () => ({ items: [current] }) }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element; await f.panel.loadAudio(1000, 1)
|
||||
if (transition === 'task') await f.panel.openTask(2)
|
||||
else {
|
||||
current = { ...current, version: 2, status: 'expired', audio_available: false, transcript: '', transcript_segments: [] }
|
||||
f.tick(); await settle()
|
||||
assert.deepEqual(f.panel.transcriptSegments.value, [])
|
||||
}
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user