对接
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Live Room Links
|
||||
|
||||
Room links point to the PC client, not the administration application:
|
||||
|
||||
- Viewer: `/#/live/:id`
|
||||
- Publisher: `/#/studio/:id`
|
||||
|
||||
Production defaults to the current origin root, matching the deployment with
|
||||
the administration application at `/admin/`. Development defaults to the same
|
||||
hostname on the PC Vite port `5173`.
|
||||
|
||||
When the client uses another origin, port, or base path, set
|
||||
`VITE_LIVE_CLIENT_URL` in the administration build environment, for example:
|
||||
|
||||
```dotenv
|
||||
VITE_LIVE_CLIENT_URL=https://live.example.com/
|
||||
```
|
||||
|
||||
For a PC development server on another port:
|
||||
|
||||
```dotenv
|
||||
VITE_LIVE_CLIENT_URL=http://localhost:5174/
|
||||
```
|
||||
|
||||
Publisher links contain no authentication credentials. The recipient must log
|
||||
in to the PC client as the user assigned to that enabled room. A room configured
|
||||
with an external stream continues to use its external publisher.
|
||||
|
||||
Run the link regression tests from `web`:
|
||||
|
||||
```text
|
||||
node --test src/modules/live/utils/*.test.js
|
||||
```
|
||||
@@ -15,9 +15,7 @@ export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`,
|
||||
export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
|
||||
|
||||
export const getRooms = (params) => get('/live/admin/rooms', params)
|
||||
export const getPublicRooms = (params) => get('/live/rooms', params)
|
||||
export const getRoom = (id) => get(`/live/rooms/${id}`)
|
||||
export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params)
|
||||
export const getRoomDetail = (id) => get(`/live/admin/rooms/${id}`)
|
||||
export const createRoom = (data) => post('/live/admin/rooms', data)
|
||||
export const updateRoom = (id, data) => put(`/live/admin/rooms/${id}`, data)
|
||||
export const deleteRoom = (id) => remove(`/live/admin/rooms/${id}`)
|
||||
@@ -25,20 +23,17 @@ export const setRoomStatus = (id, enabled) => patch(`/live/admin/rooms/${id}/sta
|
||||
export const saveRoomSort = (values) => put('/live/admin/rooms/sort', { values })
|
||||
|
||||
export const getGifts = (params) => get('/live/admin/gifts', params)
|
||||
export const getPublicGifts = () => get('/live/gifts')
|
||||
export const createGift = (data) => post('/live/admin/gifts', data)
|
||||
export const updateGift = (id, data) => put(`/live/admin/gifts/${id}`, data)
|
||||
export const deleteGift = (id) => remove(`/live/admin/gifts/${id}`)
|
||||
export const setGiftStatus = (id, enabled) => patch(`/live/admin/gifts/${id}/status`, { enabled })
|
||||
export const saveGiftSort = (values) => put('/live/admin/gifts/sort', { values })
|
||||
export const sendGift = (roomId, data) => post(`/live/rooms/${roomId}/gifts`, data)
|
||||
export const getGiftSends = (params) => get('/live/admin/sends', params)
|
||||
|
||||
export const getRechargePlans = () => get('/live/recharge/plans')
|
||||
export const saveRechargePlans = (plans) => put('/live/admin/recharge/plans', { plans })
|
||||
export const getOrders = (params) => get('/live/admin/orders', params)
|
||||
export const getOrder = (orderNo) => get(`/live/admin/orders/${orderNo}`)
|
||||
export const createRechargeOrder = (data) => post('/live/recharge/orders', data)
|
||||
|
||||
export const getAssetUsers = (params) => get('/live/admin/users', params)
|
||||
export const getAssetUser = (id) => get(`/live/admin/users/${id}`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export async function copyLiveText(value, {
|
||||
clipboard = navigator.clipboard,
|
||||
documentRef = document,
|
||||
} = {}) {
|
||||
const text = String(value)
|
||||
if (clipboard?.writeText) {
|
||||
try { await clipboard.writeText(text); return }
|
||||
catch { /* Older or restricted browsers can still support a selection copy. */ }
|
||||
}
|
||||
const previousFocus = documentRef.activeElement
|
||||
const input = documentRef.createElement('textarea')
|
||||
input.value = text
|
||||
input.readOnly = true
|
||||
input.style.position = 'fixed'
|
||||
input.style.left = '-9999px'
|
||||
documentRef.body.appendChild(input)
|
||||
try {
|
||||
input.select()
|
||||
if (!documentRef.execCommand('copy')) throw new Error('Clipboard copy failed')
|
||||
} finally {
|
||||
input.remove()
|
||||
previousFocus?.focus?.({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { copyLiveText } from './clipboard.js'
|
||||
|
||||
function legacyDocument(success) {
|
||||
const input = { style: {}, select() { this.selected = true }, remove() { this.removed = true } }
|
||||
return {
|
||||
input,
|
||||
activeElement: { focus() {} },
|
||||
createElement: () => input,
|
||||
body: { appendChild() {} },
|
||||
execCommand: () => success,
|
||||
}
|
||||
}
|
||||
|
||||
test('copies an absolute link through the modern clipboard', async () => {
|
||||
let copied
|
||||
await copyLiveText('https://live.example.com/#/studio/2', {
|
||||
clipboard: { async writeText(value) { copied = value } },
|
||||
documentRef: {},
|
||||
})
|
||||
assert.equal(copied, 'https://live.example.com/#/studio/2')
|
||||
})
|
||||
|
||||
test('uses a selection fallback when clipboard access is denied', async () => {
|
||||
const documentRef = legacyDocument(true)
|
||||
await copyLiveText('viewer-link', {
|
||||
clipboard: { async writeText() { throw new Error('denied') } },
|
||||
documentRef,
|
||||
})
|
||||
assert.equal(documentRef.input.value, 'viewer-link')
|
||||
assert.equal(documentRef.input.selected, true)
|
||||
assert.equal(documentRef.input.removed, true)
|
||||
})
|
||||
|
||||
test('does not report success when both browser copy mechanisms fail', async () => {
|
||||
const documentRef = legacyDocument(false)
|
||||
await assert.rejects(copyLiveText('viewer-link', { clipboard: null, documentRef }), /copy failed/)
|
||||
assert.equal(documentRef.input.removed, true)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export function getRoomLinks(roomId, {
|
||||
origin = window.location.origin,
|
||||
clientBaseUrl = import.meta.env?.VITE_LIVE_CLIENT_URL,
|
||||
development = import.meta.env?.DEV,
|
||||
} = {}) {
|
||||
const id = Number(roomId)
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Invalid live room ID')
|
||||
|
||||
const clientBase = String(clientBaseUrl || '').trim()
|
||||
const url = new URL(clientBase || '/', origin)
|
||||
if (!clientBase && development) url.port = '5173'
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('Invalid live client URL')
|
||||
}
|
||||
url.search = ''
|
||||
if (!/\.html?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/+$/, '')}/`
|
||||
url.hash = `/live/${id}`
|
||||
const viewer = url.toString()
|
||||
url.hash = `/studio/${id}`
|
||||
return { viewer, studio: url.toString() }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { getRoomLinks } from './roomLinks.js'
|
||||
|
||||
test('production links target the PC routes, not the administration path', () => {
|
||||
assert.deepEqual(getRoomLinks(2, { origin: 'https://live.example.com', development: false }), {
|
||||
viewer: 'https://live.example.com/#/live/2',
|
||||
studio: 'https://live.example.com/#/studio/2',
|
||||
})
|
||||
})
|
||||
|
||||
test('custom client origins and base paths discard unrelated query and hash state', () => {
|
||||
assert.deepEqual(getRoomLinks('12', {
|
||||
origin: 'https://admin.example.com',
|
||||
clientBaseUrl: 'https://client.example.com/player?token=unused#/old',
|
||||
}), {
|
||||
viewer: 'https://client.example.com/player/#/live/12',
|
||||
studio: 'https://client.example.com/player/#/studio/12',
|
||||
})
|
||||
})
|
||||
|
||||
test('development links use the PC dev server and support an explicit port override', () => {
|
||||
assert.equal(getRoomLinks(1, { origin: 'http://localhost:8080', development: true }).viewer, 'http://localhost:5173/#/live/1')
|
||||
assert.equal(getRoomLinks(1, {
|
||||
origin: 'http://localhost:8080',
|
||||
development: true,
|
||||
clientBaseUrl: 'http://localhost:5174/',
|
||||
}).studio, 'http://localhost:5174/#/studio/1')
|
||||
})
|
||||
|
||||
test('client base supports a relative path and a static HTML entrypoint', () => {
|
||||
assert.equal(getRoomLinks(3, {
|
||||
origin: 'https://live.example.com',
|
||||
clientBaseUrl: '/pc/index.html',
|
||||
}).viewer, 'https://live.example.com/pc/index.html#/live/3')
|
||||
})
|
||||
|
||||
test('invalid room IDs, executable schemes and URL credentials are rejected', () => {
|
||||
for (const id of [0, -1, 1.2, '2/other', undefined]) {
|
||||
assert.throws(() => getRoomLinks(id, { origin: 'https://live.example.com' }))
|
||||
}
|
||||
for (const clientBaseUrl of ['javascript:alert(1)', 'https://user:password@example.com/']) {
|
||||
assert.throws(() => getRoomLinks(2, { origin: 'https://live.example.com', clientBaseUrl }))
|
||||
}
|
||||
})
|
||||
@@ -83,8 +83,10 @@ import { ArrowLeft, CircleCheck, CircleClose, Clock, CopyDocument, Delete, Docum
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getLiveStreams, getRoom } from '../api/live'
|
||||
import { getLiveStreams, getRoomDetail } from '../api/live'
|
||||
import { useLiveRTC } from '../composables/useLiveRTC'
|
||||
import { getRoomLinks } from '../utils/roomLinks'
|
||||
import { copyLiveText } from '../utils/clipboard'
|
||||
|
||||
defineOptions({ name: 'LiveDiagnostics' })
|
||||
|
||||
@@ -106,9 +108,12 @@ const tests = reactive({
|
||||
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 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))
|
||||
@@ -147,7 +152,7 @@ const sleep = (duration) => new Promise((resolve) => window.setTimeout(resolve,
|
||||
|
||||
const loadRoom = async () => {
|
||||
if (userStore.token && !currentUserID.value) await userStore.GetUserInfo()
|
||||
const response = await getRoom(Number(route.params.id))
|
||||
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
|
||||
@@ -158,6 +163,7 @@ const testLinks = async () => {
|
||||
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('房间接口正常,但直播间已停用')
|
||||
@@ -280,8 +286,15 @@ const report = () => ({
|
||||
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 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() }
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<main class="live-client-page">
|
||||
<div class="viewer-stage">
|
||||
<video v-if="room?.streamUrl" ref="videoRef" :src="room.streamUrl" controls autoplay playsinline />
|
||||
<video v-else-if="rtc.remoteStream.value" ref="videoRef" :srcObject="rtc.remoteStream.value" autoplay playsinline controls />
|
||||
<video v-else-if="room?.replayUrl && room?.status !== 'live'" :src="getUrl(room.replayUrl)" controls autoplay playsinline />
|
||||
<el-image v-else-if="room?.coverUrl" :src="getUrl(room.coverUrl)" fit="cover" class="viewer-cover" />
|
||||
<div v-if="rtc.message.value" class="viewer-state"><el-icon><VideoPause /></el-icon><strong>{{ rtc.message.value }}</strong><el-button v-if="rtc.state.value === 'auth-required'" type="primary" @click="goLogin">去登录</el-button></div>
|
||||
<header class="viewer-top"><button class="viewer-back" title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div class="viewer-room"><el-avatar :size="42" :src="getUrl(room?.anchorAvatar)">{{ room?.anchorName?.slice(0, 1) }}</el-avatar><div><strong>{{ room?.name || '直播间' }}</strong><span>{{ room?.anchorName || '主播' }} · {{ room?.viewerCount || 0 }} 人观看</span></div></div></header>
|
||||
</div>
|
||||
<aside class="viewer-sidebar"><div class="viewer-info"><h1>{{ room?.name }}</h1><p>{{ room?.intro || '主播暂未填写直播简介。' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div><div class="gift-panel"><div class="gift-heading"><strong>送礼物</strong><span>余额 {{ number(balance) }} 钻石</span></div><div class="gift-grid"><button v-for="gift in gifts" :key="gift.id" :class="{ active: selectedGift?.id === gift.id }" @click="selectedGift = gift"><span v-if="!isImage(gift.icon)">{{ gift.icon }}</span><img v-else :src="getUrl(gift.icon)"><strong>{{ gift.name }}</strong><small>{{ gift.price }} 钻石</small></button></div><div class="gift-send"><el-select v-model="quantity"><el-option v-for="tier in selectedGift?.tiers || [1]" :key="tier" :label="`x${tier}`" :value="tier" /></el-select><el-button type="primary" :disabled="!selectedGift" @click="submitGift">赠送</el-button></div></div><section class="chat-panel"><div class="chat-heading"><strong>聊天室</strong><span>{{ rtc.chatMessages.value.length }} 条消息</span></div><div class="chat-list"><div v-for="item in rtc.chatMessages.value" :key="`${item.id}-${item.createdAt}`" class="chat-item"><el-avatar :size="24" :src="getUrl(item.avatar)">{{ (item.nickname || item.username || '用').slice(0, 1) }}</el-avatar><div><strong>{{ item.nickname || item.username || `用户 ${item.userId}` }}</strong><small>{{ formatChatTime(item.createdAt) }}</small><p v-if="item.type === 'gift'">送出 {{ item.giftIcon }} {{ item.giftName }} x{{ item.quantity }}</p><p v-else>{{ item.content }}</p></div></div><el-empty v-if="!rtc.chatMessages.value.length" description="暂无聊天消息" :image-size="45" /></div><div class="chat-compose"><el-input v-model="chatDraft" maxlength="100" show-word-limit placeholder="说点什么" @keyup.enter="sendChatMessage" /><el-button type="primary" :disabled="!chatDraft.trim()" @click="sendChatMessage">发送</el-button></div></section></aside>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowLeft, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getAssetUser, getChatHistory, getPublicGifts, getRoom, sendGift } from '../api/live'
|
||||
import { useLiveRTC } from '../composables/useLiveRTC'
|
||||
defineOptions({ name: 'LiveViewer' })
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), gifts = ref([]), selectedGift = ref(null), quantity = ref(1), balance = ref(0), chatDraft = ref(''), videoRef = ref()
|
||||
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), isImage = (v) => /^(https?:|\/|data:)/.test(String(v || ''))
|
||||
watch(() => rtc.remoteStream.value, async (stream) => { await nextTick(); if (videoRef.value && stream) { videoRef.value.srcObject = stream; videoRef.value.play().catch(() => {}) } })
|
||||
const load = async () => { const id = Number(route.params.id); if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo(); const [roomRes, giftRes, chatRes] = await Promise.all([getRoom(id), getPublicGifts(), getChatHistory(id, { limit: 100 })]); if (roomRes.code === 0) room.value = roomRes.data; if (giftRes.code === 0) { gifts.value = giftRes.data.list || []; selectedGift.value = gifts.value[0]; quantity.value = selectedGift.value?.tiers?.[0] || 1 } if (chatRes.code === 0) rtc.chatMessages.value = chatRes.data.list || []; if (userStore.token && userStore.userInfo?.ID) { const asset = await getAssetUser(userStore.userInfo.ID); if (asset.code === 0) balance.value = asset.data.diamondBalance } if (room.value && !room.value.streamUrl && room.value.status === 'live') rtc.connect({ mode: 'subscriber', id: room.value.streamId }).catch(() => {}) }
|
||||
const submitGift = async () => { if (!selectedGift.value) return; const total = selectedGift.value.price * quantity.value; if (total > balance.value) return ElMessage.warning('钻石余额不足'); const res = await sendGift(room.value.id, { giftId: selectedGift.value.id, quantity: quantity.value }); if (res.code === 0) { balance.value -= total; ElMessage.success(`已送出 ${quantity.value} 个${selectedGift.value.name}`) } }
|
||||
const sendChatMessage = () => { if (!userStore.token) return goLogin(); if (!rtc.sendChat(chatDraft.value)) return ElMessage.warning(room.value?.status === 'live' ? '正在连接聊天室,请稍后再试' : '直播未开始,暂时不能发言'); chatDraft.value = '' }
|
||||
const formatChatTime = (value) => value ? new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
const goBack = () => window.history.length > 1 ? router.back() : router.push('/'), goLogin = () => router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>
|
||||
.live-client-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #101014; color: #fff; }.viewer-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; background: #08080b; }.viewer-stage video,.viewer-cover { width: 100%; height: 100vh; object-fit: contain; }.viewer-cover { opacity: .42; filter: blur(2px); }.viewer-top { position: absolute; z-index: 2; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 14px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 65%), transparent); }.viewer-back { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 12%); color: #fff; cursor: pointer; }.viewer-room { display: flex; align-items: center; gap: 10px; }.viewer-room div { display: grid; gap: 4px; }.viewer-room strong { font-size: 15px; }.viewer-room span { color: rgb(255 255 255 / 70%); font-size: 11px; }.viewer-state { position: absolute; z-index: 2; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; background: rgb(0 0 0 / 26%); }.viewer-state .el-icon { font-size: 42px; color: rgb(255 255 255 / 60%); }.viewer-sidebar { min-height: 100vh; background: #fff; color: #2a2025; }.viewer-info { padding: 25px 22px; border-bottom: 1px solid #f0e4e8; }.viewer-info h1 { margin: 0; font-size: 20px; letter-spacing: 0; }.viewer-info p { min-height: 42px; margin: 10px 0; color: #7a6d72; font-size: 13px; line-height: 1.6; }.gift-panel { padding: 20px 18px; }.gift-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }.gift-heading span { color: #a37e8c; font-size: 12px; }.gift-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }.gift-grid button { display: grid; min-width: 0; place-items: center; gap: 4px; padding: 10px 5px; border: 1px solid #f0e0e6; border-radius: 6px; background: #fff; color: inherit; cursor: pointer; }.gift-grid button.active { border-color: #e95f91; background: #fff4f8; }.gift-grid button > span,.gift-grid button > img { width: 36px; height: 36px; font-size: 28px; object-fit: cover; }.gift-grid strong { overflow: hidden; max-width: 100%; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.gift-grid small { color: #d54c7e; font-size: 10px; }.gift-send { display: grid; grid-template-columns: 1fr 110px; gap: 8px; margin-top: 14px; }
|
||||
.chat-panel { display: grid; grid-template-rows: auto minmax(150px, 1fr) auto; min-height: 300px; padding: 18px; border-top: 1px solid #f0e4e8; }
|
||||
.chat-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.chat-heading span { color: #a37e8c; font-size: 11px; }
|
||||
.chat-list { min-height: 0; max-height: 340px; overflow: auto; }
|
||||
.chat-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 0; }
|
||||
.chat-item > div { min-width: 0; }
|
||||
.chat-item strong { font-size: 11px; }
|
||||
.chat-item small { margin-left: 6px; color: #b2a2a8; font-size: 10px; }
|
||||
.chat-item p { margin: 3px 0 0; color: #615158; font-size: 12px; line-height: 1.45; word-break: break-word; }
|
||||
.chat-compose { display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 8px; margin-top: 10px; }
|
||||
@media (max-width: 850px) { .live-client-page { display: block; }.viewer-stage,.viewer-stage video,.viewer-cover { min-height: 56vh; height: 56vh; }.viewer-sidebar { min-height: 44vh; }.viewer-info { padding: 17px; }.gift-panel { padding: 16px; } }
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusText(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="!compactTable" label="观看人数" min-width="82" prop="viewerCount" align="right" />
|
||||
<el-table-column v-if="!compactTable" label="创建时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="share" :icon="Share">复制分享链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="studio" :icon="CopyDocument">复制主播链接</el-dropdown-item><el-dropdown-item command="viewer" :icon="Share">复制观众链接</el-dropdown-item><el-dropdown-item command="links" :icon="Connection">查看链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div>
|
||||
</div>
|
||||
@@ -46,18 +46,30 @@
|
||||
<el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="linksDialog" title="直播间链接" width="min(640px, calc(100vw - 32px))">
|
||||
<p class="room-links-name">{{ linkedRoom?.name }} <span>#{{ linkedRoom?.id }}</span></p>
|
||||
<div v-for="item in linkItems" :key="item.key" class="room-link-row">
|
||||
<label :for="`room-link-${item.key}`">{{ item.label }}</label>
|
||||
<el-input :id="`room-link-${item.key}`" :model-value="roomLinks[item.key]" readonly @focus="$event.target.select()" />
|
||||
<el-tooltip :content="`复制${item.label}`"><el-button :icon="CopyDocument" :aria-label="`复制${item.label}`" @click="copyLink(linkedRoom, item.key)" /></el-tooltip>
|
||||
<el-tooltip :content="`打开${item.label}`"><el-button :icon="TopRight" :aria-label="`打开${item.label}`" @click="openLink(roomLinks[item.key])" /></el-tooltip>
|
||||
</div>
|
||||
<template #footer><el-button @click="linksDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Connection, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { Connection, CopyDocument, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, TopRight, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import ImageUploader from '../components/ImageUploader.vue'
|
||||
import VideoUploader from '../components/VideoUploader.vue'
|
||||
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
|
||||
import { getRoomLinks } from '../utils/roomLinks'
|
||||
import { copyLiveText } from '../utils/clipboard'
|
||||
|
||||
defineOptions({ name: 'LiveRooms' })
|
||||
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' })
|
||||
@@ -67,6 +79,8 @@ const compactTable = computed(() => width.value < 1180)
|
||||
const page = ref(1), pageSize = ref(10), total = ref(0), editingId = ref(0), editingStatus = ref('')
|
||||
const blank = () => ({ name: '', categoryId: undefined, coverUrl: '', intro: '', sort: 0, anchorUserId: undefined, streamUrl: '', replayUrl: '', enabled: true })
|
||||
const form = reactive(blank())
|
||||
const linksDialog = ref(false), linkedRoom = ref(null), roomLinks = ref({ viewer: '', studio: '' })
|
||||
const linkItems = [{ key: 'studio', label: '主播链接' }, { key: 'viewer', label: '观众链接' }]
|
||||
const rules = { name: [{ required: true, message: '请输入直播间名称', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }], anchorUserId: [{ required: true, message: '请选择主播', trigger: 'change' }], coverUrl: [{ required: true, message: '请上传直播封面', trigger: 'change' }] }
|
||||
const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已禁用' }[value] || value)
|
||||
const statusType = (value) => ({ live: 'success', offline: 'info', disabled: 'danger' }[value] || 'info')
|
||||
@@ -77,14 +91,48 @@ const search = () => { page.value = 1; load() }
|
||||
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() }
|
||||
const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true }
|
||||
const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true }
|
||||
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form); if (res.code === 0) { ElMessage.success('直播间已保存'); drawer.value = false; load() } } finally { saving.value = false } }
|
||||
const submit = async () => {
|
||||
await formRef.value?.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('直播间已保存')
|
||||
drawer.value = false
|
||||
showLinks(res.data || { ...form, id: editingId.value })
|
||||
load()
|
||||
}
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
const removeRow = async (row) => { await ElMessageBox.confirm(`确认删除直播间“${row.name}”吗?`, '删除直播间', { type: 'warning' }); const res = await deleteRoom(row.id); if (res.code === 0) { ElMessage.success('已删除'); load() } }
|
||||
const persistSort = async () => { const values = Object.fromEntries(rows.value.map((item) => [item.id, item.sort])); const res = await saveRoomSort(values); if (res.code === 0) { ElMessage.success('排序已保存'); load() } }
|
||||
const shareUrl = (row, studio = false) => `${window.location.origin}${window.location.pathname}#/live/${studio ? 'studio' : 'room'}/${row.id}`
|
||||
const copyLink = async (row) => { await navigator.clipboard.writeText(shareUrl(row)); ElMessage.success('观看链接已复制') }
|
||||
const openStudio = (row) => window.open(shareUrl(row, true), '_blank')
|
||||
const showLinks = (row) => {
|
||||
try {
|
||||
roomLinks.value = getRoomLinks(row.id)
|
||||
linkedRoom.value = row
|
||||
linksDialog.value = true
|
||||
} catch { ElMessage.error('无法生成直播间链接,请检查 PC 站点地址配置') }
|
||||
}
|
||||
const copyLink = async (row, kind) => {
|
||||
try {
|
||||
await copyLiveText(getRoomLinks(row.id)[kind])
|
||||
ElMessage.success(kind === 'studio' ? '主播链接已复制' : '观众链接已复制')
|
||||
} catch {
|
||||
showLinks(row)
|
||||
ElMessage.warning('复制失败,请在链接窗口中选中地址复制')
|
||||
}
|
||||
}
|
||||
const openLink = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
||||
const openStudio = (row) => {
|
||||
try { openLink(getRoomLinks(row.id).studio) }
|
||||
catch { ElMessage.error('无法生成主播链接,请检查 PC 站点地址配置') }
|
||||
}
|
||||
const openDiagnostics = (row) => window.open(`${window.location.origin}${window.location.pathname}#/live/diagnostics/${row.id}`, '_blank', 'noopener')
|
||||
const handleRoomAction = (command, row) => command === 'share' ? copyLink(row) : removeRow(row)
|
||||
const handleRoomAction = (command, row) => {
|
||||
if (command === 'studio' || command === 'viewer') return copyLink(row, command)
|
||||
if (command === 'links') return showLinks(row)
|
||||
if (command === 'delete') return removeRow(row)
|
||||
}
|
||||
onMounted(() => { loadOptions(); load() })
|
||||
</script>
|
||||
|
||||
@@ -94,4 +142,7 @@ onMounted(() => { loadOptions(); load() })
|
||||
.anchor-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }.anchor-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-slot { display: grid; width: 100%; height: 100%; place-items: center; color: var(--el-text-color-placeholder); font-size: 11px; }
|
||||
.room-delete-action { color: var(--el-color-danger); }
|
||||
.room-links-name { margin: 0 0 18px; overflow-wrap: anywhere; }.room-links-name span { margin-left: 6px; color: var(--el-text-color-secondary); }
|
||||
.room-link-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) 32px 32px; gap: 8px; align-items: center; margin: 12px 0; }.room-link-row .el-button { width: 32px; margin: 0; }.room-link-row label { font-size: 12px; }
|
||||
@media (max-width: 480px) { .room-link-row { grid-template-columns: minmax(0, 1fr) 32px 32px; }.room-link-row label { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live'
|
||||
import { getAssetUser, getGiftSends, getGifts, getRoomDetail } from '../api/live'
|
||||
import { ElMessage } from 'element-plus'
|
||||
defineOptions({ name: 'LiveSends' })
|
||||
const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }), dateRange = ref([]), gifts = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), summary = ref({ count: 0, diamonds: 0, amountCents: 0 })
|
||||
@@ -31,7 +31,7 @@ const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize:
|
||||
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }); dateRange.value = []; search() }
|
||||
onMounted(() => { loadGifts(); load() })
|
||||
const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } }
|
||||
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoom(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
|
||||
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoomDetail(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@use '../styles/live.scss';
|
||||
|
||||
@@ -5,33 +5,91 @@
|
||||
<div v-if="!media" class="studio-placeholder"><el-icon><VideoCamera /></el-icon><strong>摄像头预览将在这里显示</strong></div>
|
||||
<div class="studio-status"><span :class="`is-${rtc.state.value}`" />{{ statusText }}</div>
|
||||
<header><button title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div><strong>{{ room?.name || '主播开播台' }}</strong><span>{{ room?.streamId }}</span></div></header>
|
||||
<footer><button :class="{ off: !audioEnabled }" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live" class="go-live" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">结束直播</button></footer>
|
||||
<footer><button :class="{ off: !audioEnabled }" :disabled="!media" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" :disabled="!media" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live && !starting" class="go-live" :disabled="loading || Boolean(permissionError) || !media" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">{{ live ? '结束直播' : '取消连接' }}</button></footer>
|
||||
</section>
|
||||
<aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ live ? '直播中' : '未开播' }}</strong></div></div></section></aside>
|
||||
<aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form><el-button v-if="mediaError" :disabled="!canPublish" :loading="mediaLoading" @click="refreshMedia">重试设备</el-button></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ statusText }}</strong></div></div></section></aside>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { ArrowLeft, Microphone, Mute, VideoCamera, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import { getRoom } from '../api/live'
|
||||
import { getRoomDetail } from '../api/live'
|
||||
import { useLiveRTC } from '../composables/useLiveRTC'
|
||||
defineOptions({ name: 'LiveStudio' })
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = ref(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false), permissionError = ref('')
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = shallowRef(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false)
|
||||
const loading = ref(true), starting = ref(false), mediaLoading = ref(false), roomError = ref(''), mediaError = ref('')
|
||||
let disposed = false
|
||||
const canPublish = computed(() => Boolean(!loading.value && !roomError.value && room.value?.enabled && !room.value.streamUrl && userStore.userInfo?.ID && Number(room.value.anchorUserId) === Number(userStore.userInfo.ID)))
|
||||
const permissionError = computed(() => {
|
||||
if (roomError.value) return roomError.value
|
||||
if (loading.value) return ''
|
||||
if (!room.value?.enabled) return '该直播间已停用,不能开播。'
|
||||
if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) return '当前账号不是该直播间绑定的主播。'
|
||||
if (room.value.streamUrl) return '该直播间使用外部直播流,请通过已配置的推流端开播。'
|
||||
return mediaError.value
|
||||
})
|
||||
const statusText = computed(() => ({ idle: '准备开播', connecting: '连接中', negotiating: '建立推流', connected: '直播中', error: rtc.message.value, closed: '连接已关闭' }[rtc.state.value] || rtc.message.value || '准备开播'))
|
||||
const enumerate = async () => { const devices = await navigator.mediaDevices.enumerateDevices(); cameras.value = devices.filter((item) => item.kind === 'videoinput'); microphones.value = devices.filter((item) => item.kind === 'audioinput'); if (!cameraId.value) cameraId.value = cameras.value[0]?.deviceId || ''; if (!microphoneId.value) microphoneId.value = microphones.value[0]?.deviceId || '' }
|
||||
const refreshMedia = async () => { if (live.value) return ElMessage.warning('直播中不能切换设备'); media.value?.getTracks().forEach((track) => track.stop()); try { media.value = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true }); await nextTick(); if (preview.value) preview.value.srcObject = media.value; await enumerate() } catch (error) { permissionError.value = '无法使用摄像头或麦克风,请检查浏览器权限和设备占用情况。'; throw error } }
|
||||
const startLive = async () => { if (permissionError.value || !media.value) return ElMessage.warning('请先允许使用摄像头和麦克风'); if (Number(room.value?.anchorUserId) !== Number(userStore.userInfo?.ID)) return ElMessage.error('只有该直播间绑定的主播可以开播'); try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }); live.value = true; ElMessage.success('正在开始直播') } catch { live.value = false } }
|
||||
const stopLive = async () => { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }); rtc.disconnect(); live.value = false; ElMessage.success('直播已结束') }
|
||||
const refreshMedia = async () => {
|
||||
if (!canPublish.value || live.value || starting.value || mediaLoading.value) return
|
||||
mediaError.value = ''
|
||||
media.value?.getTracks().forEach((track) => track.stop())
|
||||
media.value = null
|
||||
mediaLoading.value = true
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error('media unavailable')
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true })
|
||||
if (disposed || !canPublish.value) { stream.getTracks().forEach((track) => track.stop()); return }
|
||||
media.value = stream
|
||||
stream.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value })
|
||||
stream.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value })
|
||||
await nextTick()
|
||||
if (preview.value) preview.value.srcObject = media.value
|
||||
await enumerate()
|
||||
} catch {
|
||||
mediaError.value = '无法使用摄像头或麦克风,请检查 HTTPS、浏览器权限和设备占用情况。'
|
||||
} finally { mediaLoading.value = false }
|
||||
}
|
||||
const startLive = async () => {
|
||||
if (!canPublish.value || permissionError.value || !media.value || starting.value) return
|
||||
starting.value = true
|
||||
try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }) }
|
||||
catch { starting.value = false; live.value = false }
|
||||
}
|
||||
const stopLive = async () => {
|
||||
try { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }) }
|
||||
catch { return }
|
||||
rtc.disconnect()
|
||||
starting.value = false
|
||||
live.value = false
|
||||
ElMessage.success('直播已结束')
|
||||
}
|
||||
const toggleAudio = () => { audioEnabled.value = !audioEnabled.value; media.value?.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value }) }, toggleVideo = () => { videoEnabled.value = !videoEnabled.value; media.value?.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value }) }
|
||||
const goBack = () => router.back()
|
||||
onMounted(async () => { const res = await getRoom(Number(route.params.id)); if (res.code !== 0) return; room.value = res.data; if (!room.value.enabled) permissionError.value = '该直播间已停用,不能开播。'; else if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) permissionError.value = '当前账号不是该直播间绑定的主播。'; await refreshMedia().catch(() => {}) })
|
||||
onBeforeUnmount(() => media.value?.getTracks().forEach((track) => track.stop()))
|
||||
watch(() => rtc.state.value, (state) => {
|
||||
if (state === 'connected') { live.value = true; starting.value = false }
|
||||
else if (['idle', 'error', 'closed', 'kicked', 'auth-required'].includes(state)) { live.value = false; starting.value = false }
|
||||
})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo()
|
||||
if (!userStore.userInfo?.ID) { roomError.value = '账号信息加载失败,请重新登录。'; return }
|
||||
const res = await getRoomDetail(Number(route.params.id))
|
||||
if (disposed) return
|
||||
if (res.code !== 0 || !res.data) { roomError.value = res.msg || '直播间信息加载失败。'; return }
|
||||
room.value = res.data
|
||||
} catch { roomError.value = '直播间信息加载失败,请稍后重试。' }
|
||||
finally { loading.value = false }
|
||||
if (!disposed && canPublish.value) await refreshMedia()
|
||||
})
|
||||
onBeforeUnmount(() => { disposed = true; rtc.disconnect(); media.value?.getTracks().forEach((track) => track.stop()) })
|
||||
</script>
|
||||
<style scoped>
|
||||
.studio-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #111116; color: #fff; }.studio-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; }.studio-stage video { width: 100%; height: 100vh; background: #09090c; object-fit: contain; }.studio-stage header { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 12px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 70%), transparent); }.studio-stage header button,.studio-stage footer button { display: grid; width: 42px; height: 42px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 15%); color: #fff; cursor: pointer; }.studio-stage header div { display: grid; gap: 3px; }.studio-stage header span { color: rgb(255 255 255 / 60%); font-size: 11px; }.studio-placeholder { position: absolute; inset: 0; display: grid; place-content: center; gap: 12px; color: rgb(255 255 255 / 55%); text-align: center; }.studio-placeholder .el-icon { margin: auto; font-size: 50px; }.studio-status { position: absolute; top: 22px; right: 22px; display: flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 4px; background: rgb(0 0 0 / 50%); font-size: 12px; }.studio-status span { width: 8px; height: 8px; border-radius: 50%; background: #999; }.studio-status .is-connected { background: #ff417f; box-shadow: 0 0 0 4px rgb(255 65 127 / 20%); }.studio-stage footer { position: absolute; bottom: 25px; left: 50%; display: flex; align-items: center; gap: 12px; transform: translateX(-50%); }.studio-stage footer button.off { background: #a83842; }.studio-stage footer .go-live,.studio-stage footer .end-live { width: auto; min-width: 130px; padding: 0 24px; border-radius: 22px; background: #ec5b8d; font-weight: 600; }.studio-stage footer .end-live { background: #d94a53; }.studio-panel { min-height: 100vh; padding: 22px; background: #fff; color: #2a2025; }.studio-room { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 14px; padding-bottom: 20px; border-bottom: 1px solid #f0e4e8; }.studio-room .el-image { width: 100px; height: 72px; border-radius: 5px; }.studio-room h1 { margin: 0; font-size: 17px; letter-spacing: 0; }.studio-room p { display: -webkit-box; overflow: hidden; margin: 7px 0; color: #807176; font-size: 12px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.studio-panel section { padding: 20px 0; border-bottom: 1px solid #f0e4e8; }.studio-panel h2 { margin: 0 0 14px; font-size: 14px; }.studio-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.studio-metrics div { padding: 14px; background: #fff4f8; }.studio-metrics span { display: block; color: #9a7d87; font-size: 11px; }.studio-metrics strong { display: block; margin-top: 5px; color: #d74679; font-size: 19px; }
|
||||
@media (max-width: 850px) { .studio-page { display: block; }.studio-stage,.studio-stage video { min-height: 62vh; height: 62vh; }.studio-panel { min-height: 38vh; }.studio-stage footer { bottom: 18px; } }
|
||||
.studio-stage footer button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"/src/modules/live/view/diagnostics.vue": "LiveDiagnostics",
|
||||
"/src/modules/live/view/gifts.vue": "LiveGifts",
|
||||
"/src/modules/live/view/orders.vue": "LiveOrders",
|
||||
"/src/modules/live/view/room.vue": "LiveViewer",
|
||||
"/src/modules/live/view/rooms.vue": "LiveRooms",
|
||||
"/src/modules/live/view/sends.vue": "LiveSends",
|
||||
"/src/modules/live/view/settings.vue": "LiveSettings",
|
||||
|
||||
@@ -30,12 +30,6 @@ const routes = [
|
||||
component: () => import('@/view/system/security/forceChangePassword.vue'),
|
||||
meta: { title: '修改密码' }
|
||||
},
|
||||
{
|
||||
path: '/live/room/:id',
|
||||
name: 'LiveViewer',
|
||||
component: () => import('@/modules/live/view/room.vue'),
|
||||
meta: { title: '直播间', client: true }
|
||||
},
|
||||
{
|
||||
path: '/live/studio/:id',
|
||||
name: 'LiveStudio',
|
||||
|
||||
Reference in New Issue
Block a user