@@ -208,6 +208,7 @@
width = "780px"
destroy -on -close
class = "detail-dialog"
@close ="onDetailDialogClose"
>
< div v-loading = "detailLoading" class="detail-content" >
< template v-if = "currentMeeting" >
@@ -356,7 +357,7 @@
< template # default = "{ row }" > { { formatBytes ( row . size _bytes ) } } < / template >
< / el-table-column >
< el-table-column prop = "started_at" label = "开始时间" width = "170" align = "center" / >
< el-table-column label = "文件" min -width = " 10 0 " align = "center" >
< el-table-column label = "文件" min -width = " 12 0 " align = "center" >
< template # default = "{ row }" >
< a
v-if = "row.status === 'ready' && row.file_url"
@@ -367,6 +368,63 @@
< span v-else-if = "row.status === 'failed'" class="failure-text" >
{{ row.failure_reason | | ' 失败 ' }}
< / span >
< ! - - recording 兜底 : host 失联 / 未点停止时由 admin 强制结束 - - >
< el-button
v-else-if = "row.status === 'recording'"
type = "danger"
link
size = "small"
:loading = "forceStopMap[row.id]"
@click ="handleForceStopRecording(row)"
> 强制结束 < / el-button >
< span v-else class = "text-muted" > — < / span >
< / template >
< / el-table-column >
< el-table-column label = "转写" width = "170" align = "center" >
< template # default = "{ row }" >
<!-- 仅 ready 状态的录制可触发转写 -- >
< template v-if = "row.status === 'ready'" >
< el -tag
v-if = "transcriptMap[row.id]?.status === 'ready'"
type = "success"
size = "small"
effect = "plain"
class = "transcript-tag"
@click ="viewTranscript(row)"
> 已就绪 · 查看 < / el-tag >
< el-tag
v-else-if = "transcriptMap[row.id]?.status === 'running' || transcriptMap[row.id]?.status === 'pending'"
type = "warning"
size = "small"
effect = "plain"
>
< el-icon class = "is-loading" style = "margin-right:4px" > < Loading / > < / el-icon >
转写中
< / el-tag >
< el-tag
v-else-if = "transcriptMap[row.id]?.status === 'failed'"
type = "danger"
size = "small"
effect = "plain"
@click ="viewTranscript(row)"
> 失败 · 查看 < / el-tag >
< el-button
v-else
type = "primary"
link
size = "small"
:loading = "transcribingMap[row.id]"
@click ="handleTranscribe(row, false)"
> 开始转写 < / el-button >
< el-button
v-if = "transcriptMap[row.id]?.status === 'ready' || transcriptMap[row.id]?.status === 'failed'"
type = "warning"
link
size = "small"
:loading = "transcribingMap[row.id]"
@click ="handleTranscribe(row, true)"
> 重转 < / el-button >
< / template >
< span v-else class = "text-muted" > — < / span >
< / template >
< / el-table-column >
@@ -375,14 +433,69 @@
< / template >
< / div >
< / el-dialog >
<!-- 转写文字稿弹窗 -- >
< el-dialog
v-model = "transcriptVisible"
:title = "transcriptDialogTitle"
width = "720px"
destroy -on -close
class = "transcript-dialog"
>
< div v-if = "currentTranscript" class="transcript-content" >
< div class = "transcript-meta" >
< el-tag :type = "transcriptStatusTag(currentTranscript.status)" size = "small" >
{ { currentTranscript . status _label } }
< / el-tag >
< span v-if = "currentTranscript.provider_code" class="meta-pill" >
提供商 : {{ currentTranscript.provider_code }}
< / span >
< span v-if = "currentTranscript.model_code" class="meta-pill" >
模型 : {{ currentTranscript.model_code }}
< / span >
< span v-if = "currentTranscript.language" class="meta-pill" >
语言 : {{ currentTranscript.language }}
< / span >
< span v-if = "currentTranscript.duration_sec" class="meta-pill" >
音频时长 : {{ formatDuration ( currentTranscript.duration_sec ) }}
< / span >
< / div >
< div v-if = "currentTranscript.status === 'failed'" class="transcript-error" >
< el -icon > < CircleClose / > < / el-icon >
< span > { { currentTranscript . error _msg || '转写失败' } } < / span >
< / div >
< div
v-else-if = "currentTranscript.segments && currentTranscript.segments.length > 0"
class = "transcript-segments"
>
< div
v-for = "(seg, idx) in currentTranscript.segments"
:key = "idx"
class = "segment-row"
>
< span class = "segment-time" > { { formatSegTime ( seg . start ) } } < / span >
< span class = "segment-text" > { { seg . text } } < / span >
< / div >
< / div >
< div v-else-if = "currentTranscript.text" class="transcript-text" >
{{ currentTranscript.text }}
< / div >
< el -empty v-else description = "暂无文字稿内容" :image-size = "60" / >
< / div >
< / el-dialog >
< / div >
< / template >
< script setup >
import { ref , reactive , onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getMeetingList , getMeetingDetail , getMeetingStats } from '@/api/meeting'
import { ref , reactive , computed , onMounted , onBeforeUnmount } from 'vue'
import { Search , Loading , CircleClose } from '@element-plus/icons-vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { getMeetingList , getMeetingDetail , getMeetingStats , forceStopRecording } from '@/api/meeting'
import { getTranscript , submitTranscribe } from '@/api/transcribe'
const AVATAR _COLORS = [ '#2563EB' , '#7C3AED' , '#059669' , '#D97706' , '#DC2626' , '#0891B2' , '#4F46E5' , '#BE185D' ]
@@ -528,9 +641,13 @@ const viewDetail = async (row) => {
detailVisible . value = true
detailLoading . value = true
currentMeeting . value = null
// 切换会议时清空旧的转写状态 + 停止旧轮询,避免上一场会议的状态泄漏
resetTranscriptState ( )
try {
const res = await getMeetingDetail ( row . id )
currentMeeting . value = res . data
// 详情拉到后,对每段已 ready 的录制并发拉一次转写状态
loadAllTranscripts ( )
} catch ( e ) {
ElMessage . error ( e ? . message || '获取会议详情失败' )
} finally {
@@ -538,10 +655,179 @@ const viewDetail = async (row) => {
}
}
// ============== 转写: 状态、API、轮询 ==============
// recording.id → transcript DTO( 含 status/text/segments/...)
const transcriptMap = reactive ( { } )
// recording.id → 是否正在调用 submit( 按钮 loading 用)
const transcribingMap = reactive ( { } )
// recording.id → 是否正在调用强制结束(按钮 loading 用)
const forceStopMap = reactive ( { } )
// 当前展开的转写文字稿(用于弹窗)
const transcriptVisible = ref ( false )
const currentTranscript = ref ( null )
// 轮询定时器: recording.id → setInterval handle
const pollTimers = { }
const transcriptDialogTitle = computed ( ( ) => {
if ( ! currentTranscript . value ) return '转写文字稿'
return ` 转写文字稿 - 录制 # ${ currentTranscript . value . recording _id } `
} )
const transcriptStatusTag = ( s ) => {
switch ( s ) {
case 'ready' : return 'success'
case 'running' :
case 'pending' : return 'warning'
case 'failed' : return 'danger'
default : return 'info'
}
}
// 0.0 → "00:00", 秒级; STT segment 起止以浮点秒
const formatSegTime = ( sec ) => {
if ( sec == null ) return '00:00'
const total = Math . floor ( sec )
const m = Math . floor ( total / 60 )
const s = total % 60
return ` ${ String ( m ) . padStart ( 2 , '0' ) } : ${ String ( s ) . padStart ( 2 , '0' ) } `
}
// 拉取一段录制的转写状态; recording 未触发过转写时后端返回 null
const loadOneTranscript = async ( recordingId ) => {
try {
const res = await getTranscript ( recordingId )
if ( res ? . data ) {
transcriptMap [ recordingId ] = res . data
// 终态 → 停止该 id 的轮询
if ( res . data . status === 'ready' || res . data . status === 'failed' ) {
stopPolling ( recordingId )
}
} else {
// 主动写一个 null, 便于模板 v-if 判定为"尚未发起"
delete transcriptMap [ recordingId ]
}
} catch ( e ) {
// 静默:转写查询失败不影响列表展示
console . warn ( '[transcribe] 查询转写状态失败' , recordingId , e )
}
}
const loadAllTranscripts = ( ) => {
const recordings = currentMeeting . value ? . recordings || [ ]
recordings . forEach ( ( r ) => {
if ( r . status === 'ready' ) {
loadOneTranscript ( r . id )
}
} )
}
// 启动轮询:每 3 秒查一次,直到 ready/failed 或弹窗关闭
const startPolling = ( recordingId ) => {
stopPolling ( recordingId )
pollTimers [ recordingId ] = setInterval ( ( ) => {
loadOneTranscript ( recordingId )
} , 3000 )
}
const stopPolling = ( recordingId ) => {
if ( pollTimers [ recordingId ] ) {
clearInterval ( pollTimers [ recordingId ] )
delete pollTimers [ recordingId ]
}
}
const stopAllPolling = ( ) => {
Object . keys ( pollTimers ) . forEach ( ( id ) => stopPolling ( id ) )
}
const resetTranscriptState = ( ) => {
stopAllPolling ( )
Object . keys ( transcriptMap ) . forEach ( ( k ) => delete transcriptMap [ k ] )
Object . keys ( transcribingMap ) . forEach ( ( k ) => delete transcribingMap [ k ] )
}
// 触发转写: force=true 表示已 ready 也强制重跑
const handleTranscribe = async ( row , force ) => {
if ( transcribingMap [ row . id ] ) return
transcribingMap [ row . id ] = true
try {
const res = await submitTranscribe ( row . id , { force , language : '' } )
transcriptMap [ row . id ] = res . data
if ( res . data ? . status === 'ready' || res . data ? . status === 'failed' ) {
// 极少见:同步就完成(短音频缓存命中)
stopPolling ( row . id )
} else {
ElMessage . success ( '转写任务已提交,正在处理中…' )
startPolling ( row . id )
}
} catch ( e ) {
// 503 → STT 未配置; 400 → 参数 / 录制状态错误;其他 → 默认提示
const status = e ? . response ? . status
const msg = e ? . response ? . data ? . message || e ? . message || '提交转写失败'
if ( status === 503 ) {
ElMessage . warning ( '语音转写服务未配置,请联系管理员检查 LLM 配置中心' )
} else {
ElMessage . error ( msg )
}
} finally {
transcribingMap [ row . id ] = false
}
}
// 强制结束指定录制( admin 兜底)
// 触发场景: host 失联未点停止 / ffmpeg 异常但 webhook 失效 / 长时间停留 recording 状态
// 后端走完整 stop 状态机: media-server stop → MinIO 上传 → DB ready/failed
const handleForceStopRecording = async ( row ) => {
if ( forceStopMap [ row . id ] ) return
try {
await ElMessageBox . confirm (
` 确认强制结束录制 # ${ row . id } ?将立即停止 ffmpeg 并尝试上传当前已写入的片段到 MinIO。 ` ,
'强制结束录制' ,
{ type : 'warning' , confirmButtonText : '确认结束' , cancelButtonText : '取消' }
)
} catch ( _ ) {
return // 用户取消
}
forceStopMap [ row . id ] = true
try {
await forceStopRecording ( row . id )
ElMessage . success ( '已发起强制结束,正在收尾上传…' )
// 后端 StopRecording 是同步上传 MinIO( Phase B1 取舍),返回时一般已是 ready/failed
// 重新拉取详情即可看到终态
if ( currentMeeting . value ? . id ) {
const res = await getMeetingDetail ( currentMeeting . value . id )
currentMeeting . value = res . data
}
} catch ( e ) {
const msg = e ? . response ? . data ? . message || e ? . message || '强制结束失败'
ElMessage . error ( msg )
} finally {
forceStopMap [ row . id ] = false
}
}
// 查看转写文字稿
const viewTranscript = ( row ) => {
const t = transcriptMap [ row . id ]
if ( ! t ) return
currentTranscript . value = t
transcriptVisible . value = true
}
// 详情弹窗关闭时停掉所有轮询,防内存泄漏
const onDetailDialogClose = ( ) => {
stopAllPolling ( )
}
onMounted ( ( ) => {
fetchMeetingList ( )
fetchStats ( )
} )
onBeforeUnmount ( ( ) => {
stopAllPolling ( )
} )
< / script >
< style scoped >
@@ -651,4 +937,49 @@ onMounted(() => {
. file - link { color : # 2563 EB ; font - size : 13 px ; text - decoration : none ; }
. file - link : hover { text - decoration : underline ; }
. failure - text { color : # DC2626 ; font - size : 12 px ; }
/* 转写状态 tag 可点击 */
. transcript - tag { cursor : pointer ; }
. transcript - tag : hover { opacity : 0.85 ; }
/* 转写文字稿弹窗 */
. transcript - content { padding : 4 px 0 8 px ; }
. transcript - meta {
display : flex ; flex - wrap : wrap ; align - items : center ; gap : 8 px ;
padding : 12 px 16 px ; background : # F8FAFC ; border - radius : 8 px ; border : 1 px solid # E2E8F0 ;
margin - bottom : 14 px ;
}
. meta - pill {
font - size : 12 px ; color : # 475569 ; background : # FFFFFF ;
padding : 2 px 10 px ; border - radius : 999 px ; border : 1 px solid # E2E8F0 ;
}
. transcript - error {
display : flex ; align - items : center ; gap : 8 px ;
padding : 16 px ; background : # FEF2F2 ; border : 1 px solid # FECACA ;
border - radius : 8 px ; color : # B91C1C ; font - size : 13 px ;
}
. transcript - segments {
max - height : 480 px ; overflow - y : auto ; padding : 4 px 6 px 4 px 0 ;
border : 1 px solid # E2E8F0 ; border - radius : 8 px ; background : # FFFFFF ;
}
. segment - row {
display : flex ; gap : 12 px ; align - items : flex - start ;
padding : 8 px 14 px ; border - bottom : 1 px dashed # F1F5F9 ;
}
. segment - row : last - child { border - bottom : none ; }
. segment - time {
flex - shrink : 0 ;
font - family : 'SF Mono' , 'Menlo' , monospace ;
font - size : 12 px ; color : # 64748 B ; padding - top : 2 px ; min - width : 48 px ;
}
. segment - text { font - size : 14 px ; color : # 1 E293B ; line - height : 1.6 ; flex : 1 ; }
. transcript - text {
padding : 16 px ; background : # FFFFFF ; border : 1 px solid # E2E8F0 ;
border - radius : 8 px ; font - size : 14 px ; color : # 1 E293B ; line - height : 1.7 ;
white - space : pre - wrap ; word - break : break - word ;
max - height : 480 px ; overflow - y : auto ;
}
< / style >