视频会议

This commit is contained in:
duoaohui
2026-05-19 09:43:12 +08:00
parent b8c2bf2dc4
commit 6c8572577e
4 changed files with 355 additions and 7 deletions

View File

@@ -54,3 +54,16 @@ export function getMeetingStats() {
method: 'get' 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'
})
}

View File

@@ -208,6 +208,7 @@
width="780px" width="780px"
destroy-on-close destroy-on-close
class="detail-dialog" class="detail-dialog"
@close="onDetailDialogClose"
> >
<div v-loading="detailLoading" class="detail-content"> <div v-loading="detailLoading" class="detail-content">
<template v-if="currentMeeting"> <template v-if="currentMeeting">
@@ -356,7 +357,7 @@
<template #default="{ row }">{{ formatBytes(row.size_bytes) }}</template> <template #default="{ row }">{{ formatBytes(row.size_bytes) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="started_at" label="开始时间" width="170" align="center" /> <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 }"> <template #default="{ row }">
<a <a
v-if="row.status === 'ready' && row.file_url" v-if="row.status === 'ready' && row.file_url"
@@ -367,6 +368,63 @@
<span v-else-if="row.status === 'failed'" class="failure-text"> <span v-else-if="row.status === 'failed'" class="failure-text">
{{ row.failure_reason || '失败' }} {{ row.failure_reason || '失败' }}
</span> </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> <span v-else class="text-muted"></span>
</template> </template>
</el-table-column> </el-table-column>
@@ -375,14 +433,69 @@
</template> </template>
</div> </div>
</el-dialog> </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { Search } from '@element-plus/icons-vue' import { Search, Loading, CircleClose } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getMeetingList, getMeetingDetail, getMeetingStats } from '@/api/meeting' 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'] const AVATAR_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2', '#4F46E5', '#BE185D']
@@ -528,9 +641,13 @@ const viewDetail = async (row) => {
detailVisible.value = true detailVisible.value = true
detailLoading.value = true detailLoading.value = true
currentMeeting.value = null currentMeeting.value = null
// 切换会议时清空旧的转写状态 + 停止旧轮询,避免上一场会议的状态泄漏
resetTranscriptState()
try { try {
const res = await getMeetingDetail(row.id) const res = await getMeetingDetail(row.id)
currentMeeting.value = res.data currentMeeting.value = res.data
// 详情拉到后,对每段已 ready 的录制并发拉一次转写状态
loadAllTranscripts()
} catch (e) { } catch (e) {
ElMessage.error(e?.message || '获取会议详情失败') ElMessage.error(e?.message || '获取会议详情失败')
} finally { } 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 是同步上传 MinIOPhase 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(() => { onMounted(() => {
fetchMeetingList() fetchMeetingList()
fetchStats() fetchStats()
}) })
onBeforeUnmount(() => {
stopAllPolling()
})
</script> </script>
<style scoped> <style scoped>
@@ -651,4 +937,49 @@ onMounted(() => {
.file-link { color: #2563EB; font-size: 13px; text-decoration: none; } .file-link { color: #2563EB; font-size: 13px; text-decoration: none; }
.file-link:hover { text-decoration: underline; } .file-link:hover { text-decoration: underline; }
.failure-text { color: #DC2626; font-size: 12px; } .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> </style>

View File

@@ -59,5 +59,7 @@ func RegisterRoutes(
adminGroup.GET("/meetings/stats", meetingManageCtrl.GetMeetingStats) adminGroup.GET("/meetings/stats", meetingManageCtrl.GetMeetingStats)
adminGroup.GET("/meetings", meetingManageCtrl.GetMeetingList) adminGroup.GET("/meetings", meetingManageCtrl.GetMeetingList)
adminGroup.GET("/meetings/:id", meetingManageCtrl.GetMeetingDetail) adminGroup.GET("/meetings/:id", meetingManageCtrl.GetMeetingDetail)
// 录制兜底操作admin 强制结束):用于 host 失联 / ffmpeg 崩 webhook 失效等场景
adminGroup.POST("/meetings/recordings/:id/force-stop", meetingManageCtrl.ForceStopRecording)
} }
} }

View File

@@ -84,8 +84,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub) messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
messageManageController := controller2.NewMessageManageController(messageManageService) messageManageController := controller2.NewMessageManageController(messageManageService)
meetingManageDAO := dao2.NewMeetingManageDAO(gormDB) meetingManageDAO := dao2.NewMeetingManageDAO(gormDB)
meetingManageService := service2.NewMeetingManageService(meetingManageDAO) // admin meetingManageService 依赖 meetingRecordingService强制结束录制兜底延后到 meetingRecordingService 构造后再实例化
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
serverConfig := provideServerConfig(cfg) serverConfig := provideServerConfig(cfg)
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, serverConfig, onlineService, authService) handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, serverConfig, onlineService, authService)
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
@@ -116,6 +115,9 @@ func InitializeApp(cfg *config.Config) (*App, error) {
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator, meetingLifecycleService) meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator, meetingLifecycleService)
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, meetingLifecycleService) meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, meetingLifecycleService)
meetingRecordingService := service7.NewMeetingRecordingService(meetingRoomDAO, meetingParticipantDAO, meetingRecordingDAO, client, meetingBroadcaster, httpMediaOrchestrator, minioClient, minioConfig) meetingRecordingService := service7.NewMeetingRecordingService(meetingRoomDAO, meetingParticipantDAO, meetingRecordingDAO, client, meetingBroadcaster, httpMediaOrchestrator, minioClient, minioConfig)
// admin 会议管理服务在 recording service 构造后再创建,从而支持"强制结束录制"兜底
meetingManageService := service2.NewMeetingManageService(meetingManageDAO, meetingRecordingService)
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
meetingController := controller7.NewMeetingController(meetingService) meetingController := controller7.NewMeetingController(meetingService)
meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService) meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService)
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub) meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)