338 lines
21 KiB
Vue
338 lines
21 KiB
Vue
<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>
|