Files
kra-new/web/src/modules/live/view/diagnostics.vue
T
2026-09-22 20:20:23 +08:00

338 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, getRoomDetail } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC'
import { getRoomLinks } from '../utils/roomLinks'
import { copyLiveText } from '../utils/clipboard'
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 roomLinks = computed(() => {
try { return getRoomLinks(route.params.id) }
catch { return { viewer: '', studio: '' } }
})
const viewerURL = computed(() => roomLinks.value.viewer)
const studioURL = computed(() => roomLinks.value.studio)
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 getRoomDetail(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()
if (!viewerURL.value || !studioURL.value) throw new Error('PC 站点地址配置无效,无法生成房间链接')
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 () => {
try { await copyLiveText(JSON.stringify(report(), null, 2)); ElMessage.success('诊断报告已复制') }
catch { ElMessage.warning('复制失败,请检查浏览器剪贴板权限') }
}
const copy = async (value) => {
if (!value) return ElMessage.error('无法生成链接,请检查 PC 站点地址配置')
try { await copyLiveText(value); ElMessage.success('链接已复制') }
catch { ElMessage.warning('复制失败,请选中地址复制') }
}
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>