feat: live module and jenkins pipeline to harbor deploy

This commit is contained in:
Yvan
2026-09-21 16:45:44 +08:00
parent 451c8e2abc
commit 829d75c657
145 changed files with 8984 additions and 269 deletions
+324
View File
@@ -0,0 +1,324 @@
<template>
<main class="diagnostics-page">
<header class="diagnostics-header">
<div class="diagnostics-title">
<el-button circle :icon="ArrowLeft" title="返回直播间列表" @click="goBack" />
<div>
<h1>{{ room?.name || '直播间诊断' }}</h1>
<p>#{{ room?.id || route.params.id }} · {{ room?.streamId || '-' }} · {{ room?.anchorName || '主播未加载' }}</p>
</div>
</div>
<div class="diagnostics-actions">
<el-button :icon="DocumentCopy" @click="copyReport">复制诊断报告</el-button>
<el-button type="primary" :icon="VideoPlay" :loading="running" @click="runFullTest">一键完整测试</el-button>
</div>
</header>
<section class="diagnostics-summary">
<div v-for="item in testItems" :key="item.key" class="diagnostics-check">
<el-icon :class="`is-${item.status}`"><Loading v-if="item.status === 'running'" /><CircleCheck v-else-if="item.status === 'success'" /><CircleClose v-else-if="item.status === 'error'" /><Warning v-else-if="item.status === 'blocked'" /><Clock v-else /></el-icon>
<div><strong>{{ item.label }}</strong><span>{{ item.detail }}</span></div>
</div>
</section>
<section class="diagnostics-links">
<div class="section-heading"><div><h2>链接测试</h2><p>检查房间数据、运行状态和实际访问入口。</p></div><el-button :loading="tests.link.status === 'running'" @click="testLinks">重新检测</el-button></div>
<div class="link-row"><span>观众链接</span><el-input :model-value="viewerURL" readonly /><el-button :icon="CopyDocument" title="复制观众链接" @click="copy(viewerURL)" /><el-button :icon="TopRight" title="打开观众链接" @click="open(viewerURL)" /></div>
<div class="link-row"><span>主播链接</span><el-input :model-value="studioURL" readonly /><el-button :icon="CopyDocument" title="复制主播链接" @click="copy(studioURL)" /><el-button :icon="TopRight" title="打开主播链接" @click="open(studioURL)" /></div>
<el-descriptions :column="4" border>
<el-descriptions-item label="房间状态"><el-tag :type="room?.status === 'live' ? 'success' : room?.enabled ? 'info' : 'danger'">{{ statusText(room?.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="运行时主播">{{ runtime?.publisherOnline ? `在线(用户 ${runtime.publisherId})` : '离线' }}</el-descriptions-item>
<el-descriptions-item label="观看连接">{{ runtime?.subscriberCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="当前账号">{{ currentUserLabel }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="diagnostics-media">
<article>
<div class="section-heading"><div><h2>主播推流测试</h2><p>验证设备权限、WebSocket、SDP、ICE 和上行媒体。</p></div><el-button :disabled="running || !canPublish" :loading="tests.publisher.status === 'running'" @click="testPublisher">测试推流</el-button></div>
<div class="diagnostics-video">
<video ref="publisherVideo" :srcObject="localMedia" autoplay muted playsinline />
<div v-if="!localMedia" class="video-empty"><el-icon><VideoCamera /></el-icon><span>{{ canPublish ? '等待摄像头和麦克风' : publisherBlockReason }}</span></div>
</div>
<div class="metric-grid">
<div><span>WebSocket</span><strong>{{ publisherRTC.diagnostics.websocket }}</strong></div>
<div><span>Peer</span><strong>{{ publisherRTC.diagnostics.peerConnection }}</strong></div>
<div><span>ICE</span><strong>{{ publisherRTC.diagnostics.iceConnection }}</strong></div>
<div><span>上行数据</span><strong>{{ formatBytes(publisherRTC.diagnostics.bytesSent) }}</strong></div>
</div>
</article>
<article>
<div class="section-heading"><div><h2>观众拉流测试</h2><p>验证进入房间、音视频轨道和下行数据。</p></div><el-button :disabled="running" :loading="tests.viewer.status === 'running'" @click="testViewer">测试拉流</el-button></div>
<div class="diagnostics-video">
<video v-if="room?.streamUrl" ref="externalVideo" :src="room.streamUrl" controls autoplay muted playsinline />
<video v-else ref="viewerVideo" :srcObject="viewerRTC.remoteStream.value" controls autoplay muted playsinline />
<div v-if="!room?.streamUrl && !viewerRTC.remoteStream.value" class="video-empty"><el-icon><Monitor /></el-icon><span>{{ viewerRTC.message.value || '等待主播媒体轨道' }}</span></div>
</div>
<div class="metric-grid">
<div><span>WebSocket</span><strong>{{ viewerRTC.diagnostics.websocket }}</strong></div>
<div><span>Peer</span><strong>{{ viewerRTC.diagnostics.peerConnection }}</strong></div>
<div><span>收到轨道</span><strong>{{ viewerRTC.diagnostics.remoteTracks }}</strong></div>
<div><span>下行数据</span><strong>{{ formatBytes(viewerRTC.diagnostics.bytesReceived) }}</strong></div>
</div>
</article>
</section>
<section class="diagnostics-log">
<div class="section-heading"><div><h2>连接日志</h2><p>按发生顺序显示主播端和观众端的关键事件。</p></div><el-button :icon="Delete" @click="clearLogs">清空</el-button></div>
<el-table :data="combinedEvents" max-height="320" empty-text="尚无连接日志">
<el-table-column label="时间" width="105"><template #default="{ row }">{{ time(row.at) }}</template></el-table-column>
<el-table-column label="端" prop="side" width="70" />
<el-table-column label="级别" width="86"><template #default="{ row }"><el-tag size="small" :type="eventType(row.level)">{{ eventLevel(row.level) }}</el-tag></template></el-table-column>
<el-table-column label="事件" prop="text" min-width="220" />
<el-table-column label="详情" prop="detail" min-width="180" show-overflow-tooltip />
</el-table>
</section>
</main>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ArrowLeft, CircleCheck, CircleClose, Clock, CopyDocument, Delete, DocumentCopy, Loading, Monitor, TopRight, VideoCamera, VideoPlay, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/pinia/modules/user'
import { getLiveStreams, getRoom } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC'
defineOptions({ name: 'LiveDiagnostics' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const publisherRTC = useLiveRTC()
const viewerRTC = useLiveRTC()
const room = ref(null)
const runtime = ref(null)
const localMedia = ref(null)
const publisherVideo = ref()
const viewerVideo = ref()
const externalVideo = ref()
const running = ref(false)
const tests = reactive({
link: { status: 'idle', detail: '等待检测房间和访问链接' },
publisher: { status: 'idle', detail: '等待检测主播上行链路' },
viewer: { status: 'idle', detail: '等待检测观众下行链路' },
})
const base = () => `${window.location.origin}${window.location.pathname}#`
const viewerURL = computed(() => `${base()}/live/room/${route.params.id}`)
const studioURL = computed(() => `${base()}/live/studio/${route.params.id}`)
const currentUserID = computed(() => Number(userStore.userInfo?.ID || 0))
const currentUserLabel = computed(() => userStore.userInfo?.nickName || userStore.userInfo?.userName || `用户 ${currentUserID.value || '-'}`)
const canPublish = computed(() => Boolean(room.value?.enabled && currentUserID.value && Number(room.value?.anchorUserId) === currentUserID.value && !room.value?.streamUrl))
const publisherBlockReason = computed(() => {
if (room.value?.streamUrl) return '该房间使用外部直播流,无需浏览器推流'
if (!room.value?.enabled) return '直播间已停用'
if (!currentUserID.value) return '当前账号信息未加载'
return `当前账号不是绑定主播(主播用户 ${room.value?.anchorUserId || '-'})`
})
const testItems = computed(() => [
{ key: 'link', label: '房间与链接', ...tests.link },
{ key: 'publisher', label: room.value?.streamUrl ? '外部主播流' : '主播推流', ...tests.publisher },
{ key: 'viewer', label: '观众接收流', ...tests.viewer },
])
const combinedEvents = computed(() => [
...publisherRTC.events.value.map((item) => ({ ...item, side: '主播' })),
...viewerRTC.events.value.map((item) => ({ ...item, side: '观众' })),
].sort((a, b) => a.at.localeCompare(b.at)))
watch(() => viewerRTC.remoteStream.value, async (stream) => {
await nextTick()
if (viewerVideo.value && stream) {
viewerVideo.value.srcObject = stream
viewerVideo.value.play().catch(() => {})
}
})
const waitFor = (predicate, timeout, errorMessage) => new Promise((resolve, reject) => {
const started = Date.now()
const timer = window.setInterval(() => {
if (predicate()) { window.clearInterval(timer); resolve() }
else if (Date.now() - started >= timeout) { window.clearInterval(timer); reject(new Error(errorMessage)) }
}, 200)
})
const sleep = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration))
const loadRoom = async () => {
if (userStore.token && !currentUserID.value) await userStore.GetUserInfo()
const response = await getRoom(Number(route.params.id))
if (response.code !== 0 || !response.data) throw new Error(response.msg || '房间接口不可用')
room.value = response.data
return response.data
}
const testLinks = async () => {
tests.link.status = 'running'
tests.link.detail = '正在请求房间接口和运行状态'
try {
const item = await loadRoom()
const streams = await getLiveStreams()
runtime.value = (streams.data?.streams || []).find((stream) => stream.streamId === item.streamId) || null
if (!item.enabled) throw new Error('房间接口正常,但直播间已停用')
tests.link.status = 'success'
tests.link.detail = `房间接口正常,观看与主播链接已生成${runtime.value?.publisherOnline ? ',主播在线' : ''}`
return true
} catch (error) {
tests.link.status = 'error'
tests.link.detail = error.message || '房间与链接检测失败'
return false
}
}
const ensureMedia = async () => {
if (localMedia.value) return localMedia.value
localMedia.value = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true })
await nextTick()
if (publisherVideo.value) publisherVideo.value.srcObject = localMedia.value
return localMedia.value
}
const testPublisher = async () => {
if (!room.value) await loadRoom()
if (room.value.streamUrl) {
tests.publisher.status = 'success'
tests.publisher.detail = '房间配置为外部直播流,已跳过 WebRTC 主播推流'
return true
}
if (!canPublish.value) {
tests.publisher.status = 'blocked'
tests.publisher.detail = publisherBlockReason.value
return false
}
tests.publisher.status = 'running'
tests.publisher.detail = '正在申请设备并建立主播连接'
try {
const media = await ensureMedia()
publisherRTC.disconnect()
await sleep(250)
await publisherRTC.connect({ mode: 'publisher', id: room.value.streamId, media })
await waitFor(() => publisherRTC.state.value === 'connected', 15000, publisherRTC.diagnostics.lastError || '主播 ICE 连接超时')
await waitFor(() => publisherRTC.diagnostics.bytesSent > 0, 10000, '已连接但没有检测到上行音视频数据')
tests.publisher.status = 'success'
tests.publisher.detail = `推流正常,已发送 ${formatBytes(publisherRTC.diagnostics.bytesSent)}`
return true
} catch (error) {
tests.publisher.status = 'error'
tests.publisher.detail = error.message || '主播推流测试失败'
return false
}
}
const waitExternalVideo = async () => {
await nextTick()
const video = externalVideo.value
if (!video) throw new Error('外部直播播放器未创建')
video.load()
await new Promise((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('外部直播流加载超时,请检查格式、跨域和地址有效性')), 15000)
video.addEventListener('loadeddata', () => { window.clearTimeout(timer); resolve() }, { once: true })
video.addEventListener('error', () => { window.clearTimeout(timer); reject(new Error('浏览器无法播放外部直播流')) }, { once: true })
})
}
const testViewer = async () => {
if (!room.value) await loadRoom()
tests.viewer.status = 'running'
tests.viewer.detail = '正在进入直播间并等待媒体数据'
try {
if (room.value.streamUrl) {
await waitExternalVideo()
tests.viewer.status = 'success'
tests.viewer.detail = '外部直播流已加载并收到媒体数据'
return true
}
viewerRTC.disconnect()
await sleep(250)
await viewerRTC.connect({ mode: 'subscriber', id: room.value.streamId })
await waitFor(() => viewerRTC.state.value === 'connected', 15000, viewerRTC.diagnostics.lastError || '观众 ICE 连接超时,主播可能尚未推流')
await waitFor(() => viewerRTC.diagnostics.remoteTracks > 0, 10000, '连接成功但没有收到音视频轨道')
await waitFor(() => viewerRTC.diagnostics.bytesReceived > 0, 10000, '已收到轨道但没有下行音视频数据')
tests.viewer.status = 'success'
tests.viewer.detail = `拉流正常,收到 ${viewerRTC.diagnostics.remoteTracks} 条轨道 / ${formatBytes(viewerRTC.diagnostics.bytesReceived)}`
return true
} catch (error) {
tests.viewer.status = 'error'
tests.viewer.detail = error.message || '观众拉流测试失败'
return false
}
}
const runFullTest = async () => {
running.value = true
stopConnections()
Object.assign(tests.link, { status: 'idle', detail: '等待检测房间和访问链接' })
Object.assign(tests.publisher, { status: 'idle', detail: '等待检测主播上行链路' })
Object.assign(tests.viewer, { status: 'idle', detail: '等待检测观众下行链路' })
try {
const linksOK = await testLinks()
if (!linksOK) return
const publisherOK = await testPublisher()
if (!publisherOK && !runtime.value?.publisherOnline && !room.value?.streamUrl) {
tests.viewer.status = 'blocked'
tests.viewer.detail = '当前没有可用主播流,观众拉流测试未执行'
return
}
await testViewer()
} finally {
running.value = false
}
}
const report = () => ({
checkedAt: new Date().toISOString(),
page: window.location.href,
browser: navigator.userAgent,
room: room.value,
runtime: runtime.value,
tests: JSON.parse(JSON.stringify(tests)),
publisher: { state: publisherRTC.state.value, diagnostics: { ...publisherRTC.diagnostics }, events: publisherRTC.events.value },
viewer: { state: viewerRTC.state.value, diagnostics: { ...viewerRTC.diagnostics }, events: viewerRTC.events.value },
})
const copyReport = async () => { await navigator.clipboard.writeText(JSON.stringify(report(), null, 2)); ElMessage.success('诊断报告已复制') }
const copy = async (value) => { await navigator.clipboard.writeText(value); ElMessage.success('链接已复制') }
const open = (value) => window.open(value, '_blank', 'noopener')
const clearLogs = () => { publisherRTC.events.value = []; viewerRTC.events.value = [] }
const stopConnections = () => { publisherRTC.disconnect(); viewerRTC.disconnect() }
const goBack = () => window.history.length > 1 ? router.back() : router.push('/')
const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已停用' }[value] || value || '-')
const formatBytes = (value) => { const bytes = Number(value || 0); if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB` }
const time = (value) => value ? new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) : '-'
const eventType = (level) => ({ success: 'success', error: 'danger', info: 'info' }[level] || 'warning')
const eventLevel = (level) => ({ success: '成功', error: '错误', info: '信息' }[level] || '警告')
onMounted(testLinks)
onBeforeUnmount(() => {
stopConnections()
localMedia.value?.getTracks().forEach((track) => track.stop())
})
</script>
<style scoped>
.diagnostics-page { min-height: 100vh; padding: 22px; background: #f7f8fa; color: #27252a; }
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; max-width: 1440px; margin: 0 auto 18px; }
.diagnostics-title,.diagnostics-actions { display: flex; align-items: center; gap: 12px; }
.diagnostics-title h1 { margin: 0; font-size: 22px; letter-spacing: 0; }
.diagnostics-title p { margin: 5px 0 0; color: #817b84; font-size: 12px; }
.diagnostics-summary,.diagnostics-links,.diagnostics-media,.diagnostics-log { max-width: 1440px; margin: 0 auto 18px; }
.diagnostics-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #e6e7eb; background: #fff; }
.diagnostics-check { display: flex; min-width: 0; align-items: center; gap: 12px; padding: 17px 18px; border-right: 1px solid #ececef; }
.diagnostics-check:last-child { border-right: 0; }
.diagnostics-check > .el-icon { flex: 0 0 auto; font-size: 25px; color: #a1a3aa; }
.diagnostics-check > .el-icon.is-success { color: #2f9d68; }.diagnostics-check > .el-icon.is-error { color: #d94d57; }.diagnostics-check > .el-icon.is-running { color: #d94d80; animation: rotating 1.4s linear infinite; }.diagnostics-check > .el-icon.is-blocked { color: #d89a30; }
.diagnostics-check div { display: grid; min-width: 0; gap: 4px; }.diagnostics-check strong { font-size: 13px; }.diagnostics-check span { overflow: hidden; color: #77727a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.diagnostics-links,.diagnostics-log,.diagnostics-media article { padding: 20px; border: 1px solid #e6e7eb; background: #fff; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }.section-heading h2 { margin: 0; font-size: 15px; letter-spacing: 0; }.section-heading p { margin: 5px 0 0; color: #817b84; font-size: 11px; }
.link-row { display: grid; grid-template-columns: 72px minmax(0, 1fr) 32px 32px; align-items: center; gap: 8px; margin-bottom: 10px; }.link-row > span { color: #69656c; font-size: 12px; }.link-row .el-button { margin: 0; }
.diagnostics-links :deep(.el-descriptions) { margin-top: 16px; }
.diagnostics-media { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }.diagnostics-media article { min-width: 0; }
.diagnostics-video { position: relative; overflow: hidden; aspect-ratio: 16 / 9; background: #111116; }.diagnostics-video video { width: 100%; height: 100%; object-fit: contain; }.video-empty { position: absolute; inset: 0; display: grid; place-content: center; gap: 10px; padding: 20px; color: #a7a4ac; text-align: center; }.video-empty .el-icon { margin: auto; font-size: 38px; }.video-empty span { max-width: 360px; font-size: 12px; line-height: 1.6; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 12px; border: 1px solid #eeeef1; }.metric-grid div { display: grid; min-width: 0; gap: 4px; padding: 10px 12px; border-right: 1px solid #eeeef1; }.metric-grid div:last-child { border-right: 0; }.metric-grid span { color: #8a858e; font-size: 10px; }.metric-grid strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 900px) { .diagnostics-header { align-items: flex-start; flex-direction: column; }.diagnostics-actions { width: 100%; }.diagnostics-actions .el-button { flex: 1; }.diagnostics-summary,.diagnostics-media { grid-template-columns: 1fr; }.diagnostics-check { border-right: 0; border-bottom: 1px solid #ececef; }.diagnostics-check:last-child { border-bottom: 0; }.metric-grid { grid-template-columns: repeat(2, 1fr); }.metric-grid div:nth-child(2) { border-right: 0; }.metric-grid div:nth-child(-n+2) { border-bottom: 1px solid #eeeef1; } }
@media (max-width: 560px) { .diagnostics-page { padding: 14px; }.diagnostics-actions { align-items: stretch; flex-direction: column; }.link-row { grid-template-columns: 64px minmax(0, 1fr) 32px; }.link-row .el-button:last-child { display: none; }.diagnostics-links,.diagnostics-log,.diagnostics-media article { padding: 15px; } }
</style>