直播:后台 JWT 推流、前台画中画;WebRTC 服务与 Nginx WebSocket 代理
Made-with: Cursor
This commit is contained in:
@@ -43,7 +43,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key } from '@element-plus/icons-vue'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getMyPermissions } from '../api/admin'
|
||||
|
||||
@@ -71,6 +71,7 @@ const menuItems = computed(() => {
|
||||
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
||||
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
||||
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
||||
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
||||
{ index: '/files', title: '文件管理', icon: Folder, permission: null },
|
||||
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
|
||||
]
|
||||
|
||||
@@ -66,6 +66,12 @@ const routes = [
|
||||
component: () => import('../views/sites/HomepageEdit.vue'),
|
||||
meta: { title: '首页编辑', permission: 'homepage:edit' }
|
||||
},
|
||||
{
|
||||
path: 'live-broadcast',
|
||||
name: 'LiveBroadcast',
|
||||
component: () => import('../views/sites/LiveBroadcast.vue'),
|
||||
meta: { title: '视频直播开播', permission: 'homepage:edit' }
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'FileManage',
|
||||
|
||||
106
admin/src/utils/liveWebRTC.js
Normal file
106
admin/src/utils/liveWebRTC.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
||||
*/
|
||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||
|
||||
function liveWsURLPublish(token) {
|
||||
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}`
|
||||
if (apiBase) {
|
||||
const base = apiBase.replace(/\/$/, '')
|
||||
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||
return `${wsOrigin}${path}`
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.token 管理员 JWT
|
||||
* @param {(s: string) => void} [opts.onStatus]
|
||||
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
|
||||
*/
|
||||
export function startPublishing(opts = {}) {
|
||||
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
|
||||
if (!token) {
|
||||
onStatus('未登录,无法开播')
|
||||
return { stop: () => {} }
|
||||
}
|
||||
|
||||
const wsUrl = liveWsURLPublish(token)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const pc = new RTCPeerConnection({ iceServers: defaultIce })
|
||||
let stream = null
|
||||
|
||||
const send = (o) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
|
||||
}
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) send({ type: 'ice', candidate: e.candidate.toJSON() })
|
||||
}
|
||||
|
||||
ws.onopen = async () => {
|
||||
onStatus('信令已连接,正在采集摄像头…')
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user' },
|
||||
audio: false
|
||||
})
|
||||
onLocalStream(stream)
|
||||
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
send({ type: 'offer', sdp: offer.sdp })
|
||||
onStatus('已发起推流协商,等待服务端应答…')
|
||||
} catch (err) {
|
||||
onStatus(err.message || '无法打开摄像头')
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = async (ev) => {
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(ev.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (msg.type === 'answer' && msg.sdp) {
|
||||
try {
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp })
|
||||
onStatus('直播中(请勿关闭本页;关闭即结束本场)')
|
||||
} catch (e) {
|
||||
onStatus(e.message || '设置远端描述失败')
|
||||
}
|
||||
}
|
||||
if (msg.type === 'ice' && msg.candidate) {
|
||||
try {
|
||||
await pc.addIceCandidate(msg.candidate)
|
||||
} catch (_) {}
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
onStatus(msg.message || '服务端错误')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => onStatus('信令连接失败(请确认已登录且 Nginx 已配置 WebSocket)')
|
||||
ws.onclose = () => onStatus('信令已断开')
|
||||
|
||||
function stop() {
|
||||
try {
|
||||
ws.close()
|
||||
} catch (_) {}
|
||||
pc.getSenders().forEach((s) => {
|
||||
try {
|
||||
s.track?.stop()
|
||||
} catch (_) {}
|
||||
})
|
||||
pc.close()
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
|
||||
return { pc, ws, stop }
|
||||
}
|
||||
@@ -61,6 +61,23 @@
|
||||
<el-input v-model="form.download_android_url" placeholder="/promotion/downloads/yuheng-android.apk" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">直播(前台 /live)</el-divider>
|
||||
<p class="builder-tip" style="margin: -6px 0 12px">
|
||||
<strong>本站 WebRTC 直播</strong>仅在左侧菜单「视频直播开播」由已登录管理员推流,前台首页左上角画中画与「直播」页播放。
|
||||
<strong>外部直播间</strong>:下方地址用于前台「进入外部直播间」跳转;可留空。
|
||||
</p>
|
||||
<el-form-item label="直播间标题">
|
||||
<el-input v-model="form.live_room_title" placeholder="视频直播" style="max-width: 320px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="直播间地址">
|
||||
<el-input
|
||||
v-model="form.live_room_url"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="https://live.example.com/xxx"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">旧版字段(前台 Vue 已不使用轨道路由)</el-divider>
|
||||
<el-form-item label="下载按钮文案">
|
||||
<el-input v-model="form.download_text" placeholder="下载" />
|
||||
@@ -167,7 +184,9 @@ const defaultForm = () => ({
|
||||
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
|
||||
],
|
||||
footer_text: '© 2024 宇恒一号 · 成都宇信达智能科技有限公司',
|
||||
body_builder: ''
|
||||
body_builder: '',
|
||||
live_room_url: '',
|
||||
live_room_title: '视频直播'
|
||||
})
|
||||
|
||||
const form = reactive(defaultForm())
|
||||
@@ -208,7 +227,11 @@ const fetchData = async () => {
|
||||
platforms: Array.isArray(data.platforms) ? data.platforms : base.platforms,
|
||||
features: Array.isArray(data.features) && data.features.length ? data.features : base.features,
|
||||
download_windows_url: data.download_windows_url || base.download_windows_url,
|
||||
download_android_url: data.download_android_url || base.download_android_url
|
||||
download_android_url: data.download_android_url || base.download_android_url,
|
||||
live_room_url: typeof data.live_room_url === 'string' ? data.live_room_url : base.live_room_url,
|
||||
live_room_title: (typeof data.live_room_title === 'string' && data.live_room_title.trim())
|
||||
? data.live_room_title.trim()
|
||||
: base.live_room_title
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message)
|
||||
|
||||
99
admin/src/views/sites/LiveBroadcast.vue
Normal file
99
admin/src/views/sites/LiveBroadcast.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="live-broadcast">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>官网视频直播(WebRTC)</span>
|
||||
</template>
|
||||
<p class="tip">
|
||||
仅后台可开播:推流后,官网首页左上角以<strong>画中画</strong>自动展示,用户也可打开官网「直播」全屏页观看。需站点使用
|
||||
<strong>HTTPS</strong>;公网复杂网络请在服务端配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
||||
</p>
|
||||
<p class="status">{{ status }}</p>
|
||||
<div class="actions">
|
||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||
</div>
|
||||
<video ref="previewRef" class="preview" playsinline muted autoplay></video>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { startPublishing } from '../../utils/liveWebRTC'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const token = computed(() => authStore.getToken() || '')
|
||||
const previewRef = ref(null)
|
||||
const status = ref('就绪')
|
||||
const session = ref(null)
|
||||
|
||||
function start() {
|
||||
if (!token.value) {
|
||||
status.value = '请先登录'
|
||||
return
|
||||
}
|
||||
status.value = '正在连接…'
|
||||
const { stop } = startPublishing({
|
||||
token: token.value,
|
||||
onStatus: (s) => {
|
||||
status.value = s
|
||||
},
|
||||
onLocalStream: (stream) => {
|
||||
if (previewRef.value) previewRef.value.srcObject = stream
|
||||
}
|
||||
})
|
||||
session.value = { stop }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
session.value?.stop()
|
||||
session.value = null
|
||||
if (previewRef.value) previewRef.value.srcObject = null
|
||||
status.value = '已停止'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.title = '视频直播开播 - 管理后台'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.live-broadcast {
|
||||
max-width: 720px;
|
||||
}
|
||||
.tip {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #606266;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tip code {
|
||||
font-size: 12px;
|
||||
background: #f4f4f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.status {
|
||||
color: #409eff;
|
||||
margin-bottom: 12px;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
.actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.preview {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user