@@ -61,24 +61,31 @@
< 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 " / >
< 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 ="loadDialogue " / >
< el-collapse v-model = "detailSections" class="original-collapse" >
< el -collapse -item title = "录音原文 " name = "original" >
< 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" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" / >
< / div >
< 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 >
< el -button size = "small" : type = "playback Channel === 'original' ? 'primary' : 'default'" : aria -pressed = " playbackChannel = = = ' original ' " : disabled = "audioLoading || channelLoading" @click ="playChannel('original' )" > 完整对话 < / el -button >
< el-button v-for = "channel in ([0, 1] as const)" :key="channel" size="small" :type="playbackChannel === channel ? 'primary' : 'default'" :aria-pressed="playbackChannel === channel" :disabled="audioLoading || channelLoading" @click="playChannel(channel)" > {{ channelPlaybackLabel ( channel , savedChannelRoles ) }} < / 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 >
< p v-if = "detail.audio_available" class="muted" > 回听对话默认从该区间前 3 秒开始 , 连续播放双方声音 ; 可随时切换为只听一方 。 < / p >
< div v-if = "transcriptSegments.length" class="transcript-segments" >
< p class = "muted" > 以下按录音区间展示 , 非逐字对齐 ; 同一区间两侧可能同时发言 , 不表示发言先后 。 < / p >
< section v-for = "segment in transcriptSegment s" :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 >
< p class = "muted" > 双方转写按同一时间区间并列展示 , 不表示发言先后 ; 重叠发言与具体问答请结合录音核对 。 < / p >
< section v-for = "window in conversationWindow s" :key="window .id" class="transcript-segment" >
< div class = "segment-heading" > < span > { { audioTimestamp ( window . start _ms ) } } – { { audioTimestamp ( window . end _ms ) } } < / span > < el-button v-if = "detail.audio_available" link type="primary" @click="loadDialogue(window.start_ms )" > 回听双方对话 < / el -button > < / div >
< div class = "dialogue-sides" >
< div v-for = "segment in window.segments" :key="segment.id" class="dialogue-side" >
< strong > { { followupChannelLabel ( segment . channel , savedChannelRoles ) } } < / strong >
< pre > { { segment . text || '(该区间无转写文本,不代表没有说话)' } } < / pre >
< / div >
< / div >
< / section >
< / div >
< pre v-else class = "transcript-text" > { { detail . transcript || '暂无转录' } } < / pre >
@@ -103,6 +110,14 @@
< el-form-item label = "实际通话时间(北京时间)" >
< el-date-picker v-model = "recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" / >
< / el-form-item >
< el-form-item label = "本次录音的声道角色" >
< el-select v-model = "uploadChannelRoles" aria-label="本次录音的声道角色" :disabled="!canUpload || busy || createUncertain" >
< el -option label = "暂不确定 / 单声道" value = "unconfirmed" / >
< el-option label = "左客服 / 右患者或家属" value = "left_service" / >
< el-option label = "右客服 / 左患者或家属" value = "right_service" / >
< / el-select >
< small > 已知角色会在提炼前提供给模型 ; 不确定请勿猜测 。 单声道不按左右区分身份 。 < / small >
< / el-form-item >
< el-form-item label = "分析模型" >
< el-select v-model = "modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" / > < / el-select >
< / el-form-item >
@@ -126,7 +141,7 @@ import {
type FollowupAudioChannel , type FollowupCapabilities , type FollowupChannelRoles , type FollowupReviewItem , type FollowupTaskDetail , type FollowupTaskSummary
} from '@/api/followupAudio'
import FollowupAudioReview from './FollowupAudioReview.vue'
import { audioTimestamp , changedReviewTarget , cloneReviewItems , createFollowupScope , followupAudioReady , followupCanApply , followupCanReview , followupChannelLabel , followupError , followupFeatureEnabled , followupProgressText , followupTaskStatus , hasStereoTranscript , normalizedChannelRoles , rebaseReviewItems , retainedTranscriptNotice , reviewFieldIssue , reviewIssue , reviewPending , uploadChunks , uploadIssue } from './followupAudioState'
import { channelPlaybackLabel , dialogueWindows , dialoguePlaybackStart , audioTimestamp , changedReviewTarget , cloneReviewItems , createFollowupScope , followupAudioReady , followupCanApply , followupCanReview , followupChannelLabel , followupError , followupFeatureEnabled , 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 ;
@@ -143,6 +158,7 @@ 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 conversationWindows = computed ( ( ) => dialogueWindows ( transcriptSegments . value ) )
const hasStereo = computed ( ( ) => hasStereoTranscript ( transcriptSegments . value ) )
const reviewRef = ref < { hasUnsavedChanges : ( ) => boolean } | null > ( null )
const dirty = computed ( ( ) => roleDirty . value || JSON . stringify ( reviewItems . value ) !== snapshot . value || ! ! reviewRef . value ? . hasUnsavedChanges ( ) )
@@ -152,6 +168,7 @@ const detailSections = ref<string[]>([])
const file = ref < File | null > ( null )
const fileInput = ref < HTMLInputElement | null > ( null )
const recordedAt = ref ( '' )
const uploadChannelRoles = ref < FollowupChannelRoles > ( 'unconfirmed' )
const modelKey = ref ( props . capabilities . models [ 0 ] ? . value || '' )
const loading = ref ( false )
const uploading = ref ( false )
@@ -283,7 +300,7 @@ function statusType(status: string) {
if ( status === 'review' ) return 'warning'
return 'info'
}
function chooseFile ( event : Event ) { file . value = ( event . target as HTMLInputElement ) . files ? . [ 0 ] || null ; uploadProgress . value = 0 ; uploadStage . value = '' }
function chooseFile ( event : Event ) { uploadChannelRoles . value = 'unconfirmed' ; file . value = ( event . target as HTMLInputElement ) . files ? . [ 0 ] || null ; uploadProgress . value = 0 ; uploadStage . value = '' }
async function submitUpload ( ) {
if ( busy . value || createUncertain . value ) return
const problem = uploadIssue ( file . value , recordedAt . value , modelKey . value , props . capabilities , props . viewOnly )
@@ -306,7 +323,7 @@ async function submitUpload() {
await followupAudioUploadComplete ( session . upload _id , ticket . signal )
if ( ! scope . current ( ticket ) || ! canUpload . value ) return
uploadStage . value = '正在创建分析任务' ; creating = true ; createUncertain . value = true
const result = await followupAudioCreate ( { diagnosis _id : props . diagnosisId , upload _id : session . upload _id , recorded _at : recordedAt . value , model _key : modelKey . value } , ticket . signal )
const result = await followupAudioCreate ( { diagnosis _id : props . diagnosisId , upload _id : session . upload _id , recorded _at : recordedAt . value , model _key : modelKey . value , channel _roles : uploadChannelRoles . value } , ticket . signal )
if ( ! scope . current ( ticket ) ) return
createUncertain . value = false ; uploading . value = false ; uploadVisible . value = false ; file . value = null ; if ( fileInput . value ) fileInput . value . value = ''
uploadStage . value = result . reused
@@ -373,6 +390,7 @@ async function retryTask() {
catch ( error ) { if ( scope . current ( ticket ) ) errorMessage . value = followupError ( error ) }
finally { if ( scope . current ( ticket ) ) { mutation . value = '' ; await refresh ( ) } }
}
async function loadDialogue ( milliseconds : number ) { await loadAudio ( dialoguePlaybackStart ( milliseconds ) ) }
async function reloadAudio ( ) { releaseAudio ( ) ; await loadAudio ( ) }
async function playChannel ( channel : FollowupPlaybackChannel ) { await loadAudio ( ( audioElement . value ? . currentTime || 0 ) * 1000 , channel === 'original' ? undefined : channel ) }
async function loadAudio ( milliseconds ? : number , channel ? : FollowupAudioChannel ) {
@@ -420,7 +438,7 @@ watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.prev
stop ( )
if ( id !== lastDiagnosis ) {
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
tasks . value = [ ] ; file . value = null ; recordedAt . value = '' ; uploadChannelRoles . value = 'unconfirmed' ; uploadVisible . value = false ; detailSections . value = [ ] ; helpVisible . value = false ; errorMessage . value = '' ; uploadStage . value = '' ; createUncertain . value = false
}
if ( active ) void refresh ( )
} , { immediate : true } )
@@ -468,6 +486,9 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
. transcript - segment { padding : 10 px 0 ; border - bottom : 1 px solid # e2e8f0 ; }
. segment - heading { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. transcript - segment pre { margin : 5 px 0 0 ; white - space : pre - wrap ; overflow - wrap : anywhere ; font - family : inherit ; }
. dialogue - sides { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( min ( 100 % , 240 px ) , 1 fr ) ) ; gap : 10 px ; margin - top : 8 px ; }
. dialogue - side { padding : 10 px ; background : var ( -- el - fill - color - light ) ; border - radius : 6 px ; min - width : 0 ; }
. dialogue - side strong { font - size : 12 px ; color : var ( -- el - text - color - secondary ) ; }
. original - collapse { -- el - collapse - border - color : # e2e8f0 ; border - top : 0 ; }
. original - collapse : deep ( . el - collapse - item _ _header ) { height : 38 px ; font - size : 13 px ; color : var ( -- el - text - color - regular ) ; }
. original - collapse : deep ( . el - collapse - item _ _content ) { padding - bottom : 10 px ; }