视频会议
This commit is contained in:
@@ -54,3 +54,16 @@ export function getMeetingStats() {
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制结束指定录制(admin 兜底操作)
|
||||
* 适用:host 失联未点停止 / ffmpeg 异常但 webhook 失效 / 长时间停留 recording 状态
|
||||
* 终态记录调用此接口幂等返回成功
|
||||
* @param {number} recordingID
|
||||
*/
|
||||
export function forceStopRecording(recordingID) {
|
||||
return request({
|
||||
url: `/api/v1/admin/meetings/recordings/${recordingID}/force-stop`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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="100" align="center">
|
||||
<el-table-column label="文件" min-width="120" 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: #2563EB; font-size: 13px; text-decoration: none; }
|
||||
.file-link:hover { text-decoration: underline; }
|
||||
.failure-text { color: #DC2626; font-size: 12px; }
|
||||
|
||||
/* 转写状态 tag 可点击 */
|
||||
.transcript-tag { cursor: pointer; }
|
||||
.transcript-tag:hover { opacity: 0.85; }
|
||||
|
||||
/* 转写文字稿弹窗 */
|
||||
.transcript-content { padding: 4px 0 8px; }
|
||||
.transcript-meta {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 12px 16px; background: #F8FAFC; border-radius: 8px; border: 1px solid #E2E8F0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.meta-pill {
|
||||
font-size: 12px; color: #475569; background: #FFFFFF;
|
||||
padding: 2px 10px; border-radius: 999px; border: 1px solid #E2E8F0;
|
||||
}
|
||||
|
||||
.transcript-error {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 16px; background: #FEF2F2; border: 1px solid #FECACA;
|
||||
border-radius: 8px; color: #B91C1C; font-size: 13px;
|
||||
}
|
||||
|
||||
.transcript-segments {
|
||||
max-height: 480px; overflow-y: auto; padding: 4px 6px 4px 0;
|
||||
border: 1px solid #E2E8F0; border-radius: 8px; background: #FFFFFF;
|
||||
}
|
||||
.segment-row {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 8px 14px; border-bottom: 1px dashed #F1F5F9;
|
||||
}
|
||||
.segment-row:last-child { border-bottom: none; }
|
||||
.segment-time {
|
||||
flex-shrink: 0;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px; color: #64748B; padding-top: 2px; min-width: 48px;
|
||||
}
|
||||
.segment-text { font-size: 14px; color: #1E293B; line-height: 1.6; flex: 1; }
|
||||
|
||||
.transcript-text {
|
||||
padding: 16px; background: #FFFFFF; border: 1px solid #E2E8F0;
|
||||
border-radius: 8px; font-size: 14px; color: #1E293B; line-height: 1.7;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
max-height: 480px; overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user