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
@@ -0,0 +1,310 @@
import { onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
import { useUserStore } from '@/pinia/modules/user'
const websocketURL = (token) => {
const override = String(import.meta.env.VITE_WEBSOCKET_URL || '').trim()
const base = override || String(import.meta.env.VITE_BASE_API || '').trim()
let url
if (/^wss?:\/\//i.test(base)) url = new URL(base)
else if (/^https?:\/\//i.test(base)) url = new URL(base)
else url = new URL(base || '/', window.location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
if (!override || !/\/ws\/?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/$/, '')}/ws`.replace(/\/+/g, '/')
url.searchParams.set('token', token)
return url.toString()
}
const waitForIce = (pc) => {
if (pc.iceGatheringState === 'complete') return Promise.resolve()
return new Promise((resolve) => {
const listener = () => {
if (pc.iceGatheringState !== 'complete') return
pc.removeEventListener('icegatheringstatechange', listener)
resolve()
}
pc.addEventListener('icegatheringstatechange', listener)
window.setTimeout(resolve, 5000)
})
}
export function useLiveRTC() {
const userStore = useUserStore()
const state = ref('idle')
const message = ref('')
const viewerCount = ref(0)
const remoteStream = shallowRef(null)
const chatMessages = ref([])
const events = ref([])
const diagnostics = reactive({
websocket: 'closed',
peerConnection: 'new',
iceConnection: 'new',
signaling: 'stable',
publisherReady: false,
localTracks: 0,
remoteTracks: 0,
bytesSent: 0,
bytesReceived: 0,
packetsLost: 0,
roundTripTime: 0,
connectedAt: '',
lastHeartbeatAt: '',
lastError: '',
})
let socket = null
let pc = null
let heartbeat = 0
let statsTimer = 0
let role = ''
let streamId = ''
let localStream = null
let connectResolve = null
let connectReject = null
const record = (level, text, detail = '') => {
events.value = [...events.value.slice(-79), { at: new Date().toISOString(), level, text, detail }]
}
const resetDiagnostics = () => {
Object.assign(diagnostics, {
websocket: 'closed', peerConnection: 'new', iceConnection: 'new', signaling: 'stable', publisherReady: false,
localTracks: 0, remoteTracks: 0, bytesSent: 0, bytesReceived: 0, packetsLost: 0, roundTripTime: 0,
connectedAt: '', lastHeartbeatAt: '', lastError: '',
})
events.value = []
}
const send = (type, payload = {}) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type, payload }))
}
const closePeer = () => {
window.clearInterval(statsTimer)
pc?.close()
pc = null
remoteStream.value = null
diagnostics.peerConnection = 'closed'
diagnostics.iceConnection = 'closed'
diagnostics.remoteTracks = 0
}
const collectStats = async () => {
if (!pc) return
const reports = await pc.getStats()
let sent = 0
let received = 0
let lost = 0
let roundTrip = 0
reports.forEach((report) => {
if (report.type === 'outbound-rtp' && !report.isRemote) sent += Number(report.bytesSent || 0)
if (report.type === 'inbound-rtp' && !report.isRemote) {
received += Number(report.bytesReceived || 0)
lost += Number(report.packetsLost || 0)
}
if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.nominated || report.selected)) {
roundTrip = Number(report.currentRoundTripTime || 0)
}
})
diagnostics.bytesSent = sent
diagnostics.bytesReceived = received
diagnostics.packetsLost = lost
diagnostics.roundTripTime = roundTrip
}
const startStats = () => {
window.clearInterval(statsTimer)
collectStats().catch(() => {})
statsTimer = window.setInterval(() => collectStats().catch(() => {}), 1000)
}
const negotiate = async () => {
closePeer()
const peer = new RTCPeerConnection()
pc = peer
diagnostics.localTracks = role === 'publisher' ? (localStream?.getTracks().length || 0) : 0
diagnostics.peerConnection = peer.connectionState
diagnostics.iceConnection = peer.iceConnectionState
diagnostics.signaling = peer.signalingState
record('info', role === 'publisher' ? '创建主播 PeerConnection' : '创建观众 PeerConnection')
peer.onconnectionstatechange = () => {
if (pc !== peer) return
diagnostics.peerConnection = peer.connectionState
record(peer.connectionState === 'failed' ? 'error' : 'info', `PeerConnection: ${diagnostics.peerConnection}`)
if (peer.connectionState === 'connected') {
state.value = 'connected'
message.value = ''
diagnostics.connectedAt = new Date().toISOString()
startStats()
}
if (['failed', 'disconnected'].includes(peer.connectionState)) {
state.value = 'waiting'
message.value = '连接中断,正在等待恢复'
}
}
peer.oniceconnectionstatechange = () => {
if (pc !== peer) return
diagnostics.iceConnection = peer.iceConnectionState
record(diagnostics.iceConnection === 'failed' ? 'error' : 'info', `ICE: ${diagnostics.iceConnection}`)
}
peer.onsignalingstatechange = () => { if (pc === peer) diagnostics.signaling = peer.signalingState }
if (role === 'publisher') {
localStream?.getTracks().forEach((track) => peer.addTrack(track, localStream))
} else {
const incoming = new MediaStream()
remoteStream.value = incoming
peer.ontrack = (event) => {
const source = event.streams?.[0]
if (source) source.getTracks().forEach((track) => { if (!incoming.getTrackById(track.id)) incoming.addTrack(track) })
else incoming.addTrack(event.track)
diagnostics.remoteTracks = incoming.getTracks().length
record('success', `收到${event.track.kind === 'video' ? '视频' : '音频'}轨道`, event.track.id)
}
peer.addTransceiver('video', { direction: 'recvonly' })
peer.addTransceiver('audio', { direction: 'recvonly' })
}
const offer = await peer.createOffer()
await peer.setLocalDescription(offer)
await waitForIce(peer)
record('info', '本地 SDP 与 ICE 候选已就绪')
send('Offer', { type: peer.localDescription.type, sdp: peer.localDescription.sdp })
}
const handleMessage = async (event) => {
const packet = JSON.parse(event.data)
const payload = packet.payload || {}
switch (packet.type) {
case 'Joined':
record('success', 'WebSocket 加入直播间成功', `${role}:${streamId}`)
state.value = 'negotiating'
message.value = role === 'publisher' ? '正在建立推流连接' : '正在连接直播画面'
await negotiate()
connectResolve?.()
connectResolve = null
connectReject = null
break
case 'Answer':
if (pc) await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp })
record('success', '服务端 SDP 应答已应用')
break
case 'PublisherStatus':
diagnostics.publisherReady = Boolean(payload.ready)
record(payload.ready ? 'success' : 'info', payload.ready ? '主播媒体轨道已就绪' : '主播媒体轨道未就绪', payload.reason || '')
if (role === 'subscriber' && !payload.ready) { state.value = 'waiting'; message.value = '主播暂未开播' }
break
case 'Renegotiate':
if (role === 'subscriber') { record('info', '主播轨道变化,重新协商拉流'); await negotiate() }
break
case 'NewSubscriber':
viewerCount.value += 1
break
case 'SubscriberLeft':
viewerCount.value = Math.max(0, viewerCount.value - 1)
break
case 'ChatHistory':
chatMessages.value = (payload.messages || []).slice(-100)
break
case 'ChatMessage':
case 'GiftMessage':
chatMessages.value = [...chatMessages.value.slice(-99), payload]
break
case 'HeartbeatAck':
diagnostics.lastHeartbeatAt = new Date().toISOString()
break
case 'Kicked':
state.value = 'kicked'
message.value = '账号已在其他位置进入该直播间'
diagnostics.lastError = message.value
record('error', message.value)
disconnect()
break
case 'JoinRejected':
connectReject?.(new Error(payload.message || '直播连接被拒绝'))
connectResolve = null
connectReject = null
state.value = 'error'
message.value = payload.message || '直播连接被拒绝'
diagnostics.lastError = message.value
record('error', message.value, payload.code || '')
break
case 'Error':
state.value = 'error'
message.value = payload.message || '直播连接失败'
diagnostics.lastError = message.value
record('error', message.value, payload.code || '')
break
}
}
const connect = ({ mode, id, media }) => new Promise((resolve, reject) => {
if (!userStore.token) {
state.value = 'auth-required'
message.value = '登录后才能进入实时直播'
reject(new Error(message.value))
return
}
resetDiagnostics()
role = mode
streamId = id
localStream = media || null
state.value = 'connecting'
message.value = '正在连接直播服务'
connectResolve = resolve
connectReject = reject
socket = new WebSocket(websocketURL(userStore.token))
diagnostics.websocket = 'connecting'
record('info', '正在连接直播 WebSocket')
socket.onopen = () => {
diagnostics.websocket = 'open'
record('success', '直播 WebSocket 已连接')
send('Join', { role, streamId })
heartbeat = window.setInterval(() => send('Heartbeat', { streamId }), 15000)
}
socket.onmessage = (event) => handleMessage(event).catch((error) => {
state.value = 'error'
message.value = error.message
diagnostics.lastError = error.message
record('error', error.message)
})
socket.onerror = () => {
diagnostics.websocket = 'error'
diagnostics.lastError = '无法连接直播服务'
record('error', diagnostics.lastError)
state.value = 'error'
message.value = diagnostics.lastError
reject(new Error(message.value))
}
socket.onclose = (event) => {
diagnostics.websocket = 'closed'
record(event.wasClean ? 'info' : 'error', '直播 WebSocket 已关闭', `${event.code}`)
window.clearInterval(heartbeat)
connectReject?.(new Error('直播 WebSocket 在加入房间前关闭'))
connectResolve = null
connectReject = null
if (!['idle', 'kicked'].includes(state.value)) {
state.value = 'closed'
message.value = '直播连接已关闭'
}
}
})
const disconnect = () => {
window.clearInterval(heartbeat)
send('Leave', { streamId })
socket?.close()
socket = null
connectResolve = null
connectReject = null
closePeer()
if (state.value !== 'kicked') state.value = 'idle'
}
const sendChat = (content) => {
const value = String(content || '').trim()
if (!value || socket?.readyState !== WebSocket.OPEN) return false
send('ChatSend', { content: value })
return true
}
onBeforeUnmount(disconnect)
return { state, message, viewerCount, remoteStream, chatMessages, events, diagnostics, connect, disconnect, sendChat, collectStats }
}