@@ -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 10 px ; line - height : 1.7 ; white - space : pre - wrap ; overflow - wrap : anywhere ; }
. summary - counts { display : flex ; gap : 20 px ; color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. summary - counts strong { color : var ( -- el - text - color - primary ) ; font - weight : 600 ; }
. channel - role - box { display : grid ; gap : 6 px ; font - size : 12 px ; }
. channel - role - box label { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 10 px ; }
. channel - role - box : deep ( . el - select ) { width : 260 px ; max - width : 100 % ; }
. channel - playback { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 6 px ; margin : 8 px 0 ; }
. channel - playback : deep ( . el - button + . el - button ) { margin - left : 0 ; }
. transcript - segments { max - height : 320 px ; overflow - y : auto ; margin - top : 12 px ; }
. 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 ; }
. 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 ; }