feat: live module and jenkins pipeline to harbor deploy

This commit is contained in:
Yvan
2026-09-23 17:42:54 +08:00
parent e376ff02aa
commit 53e8599bdd
145 changed files with 8984 additions and 269 deletions
+51
View File
@@ -0,0 +1,51 @@
import service from '@/utils/request'
const get = (url, params) => service({ url, method: 'get', params })
const post = (url, data) => service({ url, method: 'post', data })
const put = (url, data) => service({ url, method: 'put', data })
const patch = (url, data) => service({ url, method: 'patch', data })
const remove = (url) => service({ url, method: 'delete' })
export const getDashboard = (params) => get('/live/admin/dashboard', params)
export const getCategories = (params) => get('/live/admin/categories', params)
export const getPublicCategories = () => get('/live/categories')
export const createCategory = (data) => post('/live/admin/categories', data)
export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`, data)
export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
export const getRooms = (params) => get('/live/admin/rooms', params)
export const getPublicRooms = () => get('/live/rooms')
export const getRoom = (id) => get(`/live/rooms/${id}`)
export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params)
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}`)
export const setRoomStatus = (id, enabled) => patch(`/live/admin/rooms/${id}/status`, { enabled })
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}`)
export const setAssetUserStatus = (id, enabled) => patch(`/live/admin/users/${id}/status`, { enabled })
export const adjustAssetBalance = (id, data) => post(`/live/admin/users/${id}/balance`, data)
export const getLiveSettings = () => get('/live/settings')
export const saveLiveSettings = (data) => put('/live/admin/settings', data)
export const getLiveStreams = () => get('/live/streams')
@@ -0,0 +1,48 @@
<template>
<div class="live-uploader">
<el-upload
:action="`${getBaseUrl()}/fileUploadAndDownload/upload`"
:headers="{ 'x-token': userStore.token }"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="onSuccess"
>
<div v-if="modelValue" class="live-upload-preview">
<el-image :src="getUrl(modelValue)" fit="cover" />
<span>更换图片</span>
</div>
<el-button v-else :icon="Upload">上传图片</el-button>
</el-upload>
</div>
</template>
<script setup>
import { Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { getBaseUrl } from '@/utils/format'
import { getUrl } from '@/utils/image'
import { getUploadErrorMessage } from '@/utils/uploadResponse'
defineOptions({ name: 'LiveImageUploader' })
const props = defineProps({ modelValue: { type: String, default: '' }, maxMb: { type: Number, default: 2 } })
const emit = defineEmits(['update:modelValue'])
const userStore = useUserStore()
const beforeUpload = (file) => {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG、WEBP 图片')
return false
}
if (file.size > props.maxMb * 1024 * 1024) {
ElMessage.warning(`图片不能超过 ${props.maxMb} MB`)
return false
}
return true
}
const onSuccess = (response) => {
const error = getUploadErrorMessage(response)
if (error) return ElMessage.error(error)
const url = response?.data?.file?.url
if (url) emit('update:modelValue', url)
}
</script>
@@ -0,0 +1,14 @@
<template>
<header class="live-page-heading">
<div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div class="live-page-actions"><slot /></div>
</header>
</template>
<script setup>
defineOptions({ name: 'LivePageHeader' })
defineProps({ title: { type: String, required: true }, description: { type: String, default: '' } })
</script>
@@ -0,0 +1,37 @@
<template>
<div class="live-video-uploader">
<el-upload :auto-upload="false" :show-file-list="false" accept="video/mp4" :on-change="selectFile">
<el-button :icon="Upload">上传回放视频</el-button>
</el-upload>
<div v-if="task" class="live-upload-progress">
<span>{{ task.name }}</span>
<el-progress :percentage="task.progress" :status="task.status === 'error' ? 'exception' : task.status === 'done' ? 'success' : ''" />
</div>
<el-input v-if="modelValue" :model-value="modelValue" readonly class="live-upload-url" />
</div>
</template>
<script setup>
import { computed, watch } from 'vue'
import { Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useChunkUpload } from '@/composables/useChunkUpload'
defineOptions({ name: 'LiveVideoUploader' })
defineProps({ modelValue: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue'])
const uploader = useChunkUpload()
const task = computed(() => uploader.tasks[0])
const selectFile = ({ raw }) => {
if (raw.type !== 'video/mp4') return ElMessage.warning('回放视频仅支持 MP4')
if (raw.size > 500 * 1024 * 1024) return ElMessage.warning('回放视频不能超过 500 MB')
uploader.tasks.splice(0)
uploader.add([raw])
}
watch(() => task.value?.status, (status) => {
if (status !== 'done') return
const media = task.value?.media
const url = media?.url || media?.fileUrl || media?.path
if (url) emit('update:modelValue', url)
})
</script>
@@ -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 }
}
+231
View File
@@ -0,0 +1,231 @@
.live-page {
min-width: 0;
}
.live-page > .kra-search-box,
.live-page > .kra-table-box {
min-width: 0;
}
.live-search-form {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
.live-search-form .el-form-item {
margin-right: 18px;
}
.live-search-form .el-input-number {
width: 208px;
}
.live-search-form .el-date-editor--daterange {
width: 320px;
}
.live-search-form .live-search-actions {
margin-right: 0;
}
.live-table-box .live-sort-input {
width: 72px;
}
.live-table-box .el-button + .el-dropdown,
.live-table-box .el-dropdown + .el-button {
margin-left: 8px;
}
.live-summary-box {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.live-summary-item {
min-width: 0;
padding: 4px 0;
}
.live-summary-item + .live-summary-item {
padding-left: 24px;
border-left: 1px solid var(--el-border-color-lighter);
}
.live-summary-item span {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.live-summary-item strong {
display: block;
margin-top: 10px;
color: var(--el-text-color-primary);
font-size: 24px;
font-weight: 600;
letter-spacing: 0;
}
.live-summary-item small {
margin-left: 5px;
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 400;
}
.live-cover {
width: 82px;
height: 48px;
border-radius: 4px;
background: var(--el-fill-color);
}
.live-icon-preview {
width: 42px;
height: 42px;
border-radius: 4px;
background: var(--el-fill-color);
object-fit: cover;
}
.live-uploader .live-upload-preview {
position: relative;
width: 180px;
height: 104px;
overflow: hidden;
border: 1px dashed var(--el-border-color);
border-radius: var(--kra-radius, 6px);
}
.live-upload-preview .el-image {
width: 100%;
height: 100%;
}
.live-upload-preview span {
position: absolute;
inset: auto 0 0;
padding: 6px;
background: rgb(0 0 0 / 55%);
color: #fff;
font-size: 12px;
text-align: center;
}
.live-upload-progress {
display: grid;
grid-template-columns: minmax(0, 180px) minmax(180px, 1fr);
align-items: center;
gap: 12px;
margin-top: 10px;
font-size: 12px;
}
.live-upload-progress span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-upload-url {
margin-top: 8px;
}
.live-money {
font-variant-numeric: tabular-nums;
}
.live-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.live-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
}
.live-drawer-header > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.live-drawer-header .el-button + .el-button {
margin-left: 0;
}
.live-drawer-header strong {
font-size: 16px;
font-weight: 500;
}
.live-drawer-form {
padding-top: 4px;
}
.live-drawer-form .el-input,
.live-drawer-form .el-input-number,
.live-drawer-form .el-select,
.live-dialog-form .el-input,
.live-dialog-form .el-input-number,
.live-dialog-form .el-select,
.live-dialog-form .el-segmented {
width: 100%;
}
.live-form-section {
margin: 8px 0 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
}
.live-empty {
padding: 48px 20px;
text-align: center;
}
@media (max-width: 1100px) {
.live-summary-box {
grid-template-columns: 1fr;
gap: 14px;
}
.live-summary-item + .live-summary-item {
padding-top: 14px;
padding-left: 0;
border-top: 1px solid var(--el-border-color-lighter);
border-left: 0;
}
}
@media (max-width: 680px) {
.live-search-form {
display: block;
}
.live-search-form .el-form-item,
.live-search-form .el-form-item > .el-input,
.live-search-form .el-form-item .el-select,
.live-search-form .el-form-item .el-input-number,
.live-search-form .el-form-item .el-date-editor {
width: 100%;
margin-right: 0;
}
.live-search-form .live-search-actions {
display: flex;
}
.live-upload-progress {
grid-template-columns: 1fr;
}
}
+41
View File
@@ -0,0 +1,41 @@
<template>
<div class="live-page">
<div class="kra-search-box">
<el-form :inline="true" :model="query" class="live-search-form">
<el-form-item label="分类名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item>
<el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box live-table-box">
<div class="kra-btn-list"><el-button type="primary" :icon="Plus" @click="openCreate">新建分类</el-button></div>
<el-table v-loading="loading" :data="rows" row-key="id"><el-table-column label="排序号" min-width="72" prop="sort" align="right" /><el-table-column label="分类名称" min-width="120" prop="name" show-overflow-tooltip /><el-table-column label="分类图标" min-width="82" align="center"><template #default="{ row }"><el-image v-if="isImage(row.icon)" class="live-icon-preview" :src="getUrl(row.icon)" fit="cover" /><span v-else class="category-emoji">{{ row.icon || '•' }}</span></template></el-table-column><el-table-column label="关联直播间数" min-width="112" prop="roomCount" align="right" /><el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><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="190" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-button link type="primary" @click="toggle(row, !row.enabled)">{{ row.enabled ? '停用' : '启用' }}</el-button><el-button link type="danger" :icon="Delete" @click="removeRow(row)">删除</el-button></template></el-table-column></el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" layout="total, prev, pager, next" @change="load" /></div>
</div>
<el-drawer v-model="drawer" size="480px" :show-close="false"><template #header><div class="live-drawer-header"><strong>{{ editingId ? '编辑分类' : '新建分类' }}</strong><div><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">保存</el-button></div></div></template><el-form ref="formRef" :model="form" :rules="rules" class="live-drawer-form" label-width="90px"><el-form-item label="分类名称" prop="name"><el-input v-model="form.name" maxlength="30" show-word-limit /></el-form-item><el-form-item label="分类图标"><div class="category-icon-editor"><ImageUploader v-model="form.icon" :max-mb="1" /><el-input v-model="form.icon" placeholder="也可以填写 emoji" /></div></el-form-item><el-form-item label="展示排序"><el-input-number v-model="form.sort" :min="0" :max="9999" /></el-form-item><el-form-item label="启用分类"><el-switch v-model="form.enabled" /></el-form-item></el-form></el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Plus, Refresh, Search } 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 { createCategory, deleteCategory, getCategories, updateCategory } from '../api/live'
defineOptions({ name: 'LiveCategories' })
const query = reactive({ name: '', enabled: undefined }), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref()
const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400)
const form = reactive({ name: '', icon: '', sort: 0, enabled: true }), rules = { name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] }
const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || '')), format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getCategories({ page: page.value, pageSize: pageSize.value, orderBy: 'sort_asc', ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { name: '', enabled: undefined }); search() }
const openCreate = () => { editingId.value = 0; Object.assign(form, { name: '', icon: '', sort: 0, enabled: true }); drawer.value = true }, openEdit = (row) => { editingId.value = row.id; Object.assign(form, row); drawer.value = true }
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateCategory(editingId.value, form) : await createCategory(form); if (res.code === 0) { ElMessage.success('分类已保存'); drawer.value = false; load() } } finally { saving.value = false } }
const toggle = async (row, enabled) => { const res = await updateCategory(row.id, { name: row.name, icon: row.icon, sort: row.sort, enabled }); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '分类已启用' : '分类已停用') } }
const removeRow = async (row) => { await ElMessageBox.confirm(row.roomCount ? `该分类下仍有 ${row.roomCount} 个直播间,无法删除。` : `确认删除分类“${row.name}”吗?`, '删除分类', { type: 'warning', confirmButtonText: row.roomCount ? '知道了' : '确认删除', showCancelButton: !row.roomCount }); if (row.roomCount) return; const res = await deleteCategory(row.id); if (res.code === 0) { ElMessage.success('分类已删除'); load() } }
onMounted(load)
</script>
<style lang="scss">@use '../styles/live.scss'; .category-emoji { font-size: 25px; }.category-icon-editor { display: grid; width: 100%; gap: 10px; }</style>
+324
View File
@@ -0,0 +1,324 @@
<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, getRoom } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC'
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 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 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 getRoom(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()
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 () => { await navigator.clipboard.writeText(JSON.stringify(report(), null, 2)); ElMessage.success('诊断报告已复制') }
const copy = async (value) => { await navigator.clipboard.writeText(value); ElMessage.success('链接已复制') }
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>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div class="live-page">
<div class="kra-search-box">
<el-form :inline="true" :model="query" class="live-search-form" @submit.prevent="search"><el-form-item label="礼物名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item><el-form-item label="最低钻石"><el-input-number v-model="query.minPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="最高钻石"><el-input-number v-model="query.maxPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="已上架" :value="true" /><el-option label="已下架" :value="false" /></el-select></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form>
</div>
<div class="kra-table-box live-table-box">
<div class="kra-btn-list"><el-button type="primary" :icon="Plus" @click="openCreate">新建礼物</el-button><el-button :icon="Sort" @click="persistSort">保存排序</el-button></div>
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column label="排序号" min-width="78" align="center"><template #default="{ row }"><el-input-number v-model="row.sort" class="live-sort-input" :min="0" :max="9999" size="small" controls-position="right" /></template></el-table-column>
<el-table-column label="礼物图标" min-width="82" align="center"><template #default="{ row }"><el-image v-if="isImage(row.icon)" class="live-icon-preview" :src="getUrl(row.icon)" fit="cover" /><span v-else class="gift-emoji">{{ row.icon }}</span></template></el-table-column>
<el-table-column label="礼物名称" prop="name" min-width="100" show-overflow-tooltip />
<el-table-column label="钻石单价" min-width="100" align="right"><template #default="{ row }"><b class="gift-price">{{ number(row.price) }}</b></template></el-table-column>
<el-table-column label="折合人民币" min-width="100" align="right"><template #default="{ row }">¥{{ money(row.price) }}</template></el-table-column>
<el-table-column label="数量档位" min-width="170"><template #default="{ row }"><el-tag v-for="tier in row.tiers" :key="tier" class="tier-tag" effect="plain">x{{ tier }}</el-tag></template></el-table-column>
<el-table-column label="状态" min-width="82" align="center"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已上架' : '已下架' }}</el-tag></template></el-table-column>
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.description || '暂无说明' }}</template></el-table-column>
<el-table-column v-if="!compactTable" label="赠送次数" min-width="88" align="right" prop="sendCount" />
<el-table-column v-if="!compactTable" label="更新时间" min-width="150"><template #default="{ row }">{{ format(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-button link type="primary" @click="toggle(row, !row.enabled)">{{ row.enabled ? '下架' : '上架' }}</el-button><el-button link type="danger" :icon="Delete" @click="removeRow(row)">删除</el-button></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]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div>
</div>
<el-drawer v-model="drawer" destroy-on-close size="560px" :show-close="false">
<template #header><div class="live-drawer-header"><strong>{{ editingId ? '编辑礼物' : '新建礼物' }}</strong><div><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">保存</el-button></div></div></template>
<el-form ref="formRef" :model="form" :rules="rules" class="live-drawer-form" label-width="100px">
<el-form-item label="礼物名称" prop="name"><el-input v-model="form.name" maxlength="30" show-word-limit /></el-form-item>
<el-form-item label="礼物图标" prop="icon"><div class="gift-icon-editor"><ImageUploader v-model="form.icon" :max-mb="2" /><span>也可以直接填写 emoji 或图片地址</span><el-input v-model="form.icon" placeholder="例如 🌹 或 /uploads/..." /></div></el-form-item>
<el-form-item label="钻石价格" prop="price"><el-input-number v-model="form.price" :min="1" :max="99999999" /></el-form-item><el-form-item label="展示排序"><el-input-number v-model="form.sort" :min="0" :max="9999" /></el-form-item>
<el-form-item label="礼物描述"><el-input v-model="form.description" type="textarea" :rows="3" maxlength="200" show-word-limit /></el-form-item>
<el-form-item label="数量档位" prop="tiers"><el-select v-model="form.tiers" multiple allow-create filterable default-first-option placeholder="输入数量并回车"><el-option v-for="tier in commonTiers" :key="tier" :label="`x${tier}`" :value="tier" /></el-select></el-form-item>
<el-form-item label="立即上架"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Plus, Refresh, Search, Sort } 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 { createGift, deleteGift, getGifts, getLiveSettings, saveGiftSort, setGiftStatus, updateGift } from '../api/live'
defineOptions({ name: 'LiveGifts' })
const query = reactive({ name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined })
const rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref(), diamondRateCents = ref(10)
const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400)
const commonTiers = [1, 10, 66, 99, 520, 1314]
const blank = () => ({ name: '', icon: '', price: 1, description: '', tiers: [1, 10, 66, 1314], sort: 0, enabled: true })
const form = reactive(blank())
const rules = { name: [{ required: true, message: '请输入礼物名称', trigger: 'blur' }], icon: [{ required: true, message: '请设置礼物图标', trigger: 'change' }], price: [{ required: true, message: '请输入钻石价格', trigger: 'change' }], tiers: [{ type: 'array', required: true, min: 1, message: '至少设置一个数量档位', trigger: 'change' }] }
const number = (value) => Number(value || 0).toLocaleString('zh-CN')
const money = (value) => (Number(value || 0) * diamondRateCents.value / 100).toFixed(2)
const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || ''))
const load = async () => { loading.value = true; try { const [res, settings] = await Promise.all([getGifts({ page: page.value, pageSize: pageSize.value, ...query }), getLiveSettings()]); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } if (settings.code === 0) diamondRateCents.value = Number(settings.data?.diamondRateCents || 10) } finally { loading.value = false } }
const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined }); search() }
const openCreate = () => { editingId.value = 0; Object.assign(form, blank()); drawer.value = true }
const openEdit = (row) => { editingId.value = row.id; Object.assign(form, { name: row.name, icon: row.icon, price: row.price, description: row.description, tiers: [...row.tiers], sort: row.sort, enabled: row.enabled }); drawer.value = true }
const normalizedForm = () => ({ ...form, tiers: [...new Set(form.tiers.map(Number).filter((item) => item > 0))].sort((a, b) => a - b) })
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const payload = normalizedForm(); const res = editingId.value ? await updateGift(editingId.value, payload) : await createGift(payload); if (res.code === 0) { ElMessage.success('礼物已保存'); drawer.value = false; load() } } finally { saving.value = false } }
const toggle = async (row, enabled) => { const res = await setGiftStatus(row.id, enabled); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '礼物已上架' : '礼物已下架') } }
const removeRow = async (row) => { await ElMessageBox.confirm(`删除“${row.name}”后,历史赠送记录仍会保留礼物快照。确认继续吗?`, '删除礼物', { type: 'warning' }); const res = await deleteGift(row.id); if (res.code === 0) { ElMessage.success('礼物已删除'); load() } }
const persistSort = async () => { const res = await saveGiftSort(Object.fromEntries(rows.value.map((item) => [item.id, item.sort]))); if (res.code === 0) { ElMessage.success('排序已保存'); load() } }
onMounted(load)
</script>
<style lang="scss">
@use '../styles/live.scss';
.gift-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.gift-cell > div { display: grid; min-width: 0; gap: 4px; }
.gift-cell strong { display: block; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.gift-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.gift-emoji { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 4px; background: var(--el-fill-color-light); font-size: 25px; }
.gift-price { color: rgb(var(--primary-color)); font-size: 13px; }
.tier-tag { margin: 2px 4px 2px 0; }
.gift-icon-editor { display: grid; width: 100%; gap: 8px; }
.gift-icon-editor > span { color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="live-page">
<div class="kra-table-box live-summary-box">
<div class="live-summary-item"><span>筛选订单</span><strong>{{ number(summary.count) }} <small>笔</small></strong></div>
<div class="live-summary-item"><span>已入账订单</span><strong>{{ number(summary.paidCount) }} <small>笔</small></strong></div>
<div class="live-summary-item"><span>入账金额</span><strong>¥{{ money(summary.amountCents) }}</strong></div>
</div>
<div class="kra-search-box">
<el-form :inline="true" :model="query" class="live-search-form">
<el-form-item label="订单号"><el-input v-model="query.orderNo" clearable placeholder="输入充值订单号" /></el-form-item>
<el-form-item label="用户"><el-input v-model="query.userKeyword" clearable placeholder="用户名或昵称" /></el-form-item>
<el-form-item label="支付状态">
<el-select v-model="query.paymentStatus" clearable placeholder="全部状态">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="支付渠道">
<el-select v-model="query.provider" clearable filterable allow-create placeholder="全部渠道">
<el-option v-for="item in providers" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item class="live-search-actions">
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="kra-table-box live-table-box">
<div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div>
<el-table v-loading="loading" :data="rows" row-key="orderNo">
<el-table-column label="订单号" prop="orderNo" min-width="180">
<template #default="{ row }"><el-button link type="primary" @click="showDetail(row)">{{ row.orderNo }}</el-button></template>
</el-table-column>
<el-table-column label="用户账号" prop="username" min-width="120" show-overflow-tooltip />
<el-table-column label="用户昵称" prop="nickname" min-width="110" show-overflow-tooltip />
<el-table-column label="钻石数" min-width="92" align="right"><template #default="{ row }">{{ number(row.diamonds) }}</template></el-table-column>
<el-table-column label="支付金额" min-width="100" align="right"><template #default="{ row }">¥{{ money(row.amountCents) }}</template></el-table-column>
<el-table-column label="支付方式" min-width="110"><template #default="{ row }">{{ providerText(row.provider) }}</template></el-table-column>
<el-table-column label="支付状态" min-width="96"><template #default="{ row }"><el-tag :type="paymentStatusType(row.paymentStatus)">{{ paymentStatusText(row.paymentStatus) }}</el-tag></template></el-table-column>
<el-table-column label="下单时间" min-width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
<el-table-column label="到账时间" min-width="155"><template #default="{ row }">{{ format(row.creditedAt) }}</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>
<el-drawer v-model="drawer" title="充值订单详情" size="560px">
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="订单号">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item label="渠道交易号">{{ detail.providerTradeNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户">{{ detail.nickname || detail.username }} (#{{ detail.userId }})</el-descriptions-item>
<el-descriptions-item label="充值钻石">{{ number(detail.diamonds) }}</el-descriptions-item>
<el-descriptions-item label="订单金额">¥{{ money(detail.amountCents) }}</el-descriptions-item>
<el-descriptions-item label="支付渠道">{{ providerText(detail.provider) }}</el-descriptions-item>
<el-descriptions-item label="支付状态"><el-tag :type="paymentStatusType(detail.paymentStatus)">{{ paymentStatusText(detail.paymentStatus) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="发货状态"><el-tag :type="fulfillmentStatusType(detail.fulfillmentStatus)">{{ fulfillmentStatusText(detail.fulfillmentStatus) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="退款状态">{{ refundStatusText(detail.refundStatus) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ format(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="入账时间">{{ format(detail.creditedAt) }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import { getOrder, getOrders } from '../api/live'
defineOptions({ name: 'LiveOrders' })
const statuses = [
{ label: '初始化', value: 'initialized' },
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已关闭', value: 'closed' },
{ label: '部分退款', value: 'partially_refunded' },
{ label: '已退款', value: 'refunded' }
]
const providers = [
{ label: '微信支付 V3', value: 'wechat-v3' },
{ label: '微信支付 V2', value: 'wechat-v2' },
{ label: '支付宝 V3', value: 'alipay-v3' },
{ label: '支付宝', value: 'alipay' },
{ label: 'Apple IAP', value: 'apple-iap' },
{ label: 'PayPal', value: 'paypal' }
]
const query = reactive({ orderNo: '', userKeyword: '', paymentStatus: '', provider: '' })
const dateRange = ref([])
const rows = ref([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const summary = ref({ count: 0, paidCount: 0, amountCents: 0 })
const drawer = ref(false)
const detail = ref(null)
const number = (value) => Number(value || 0).toLocaleString('zh-CN')
const money = (value) => (Number(value || 0) / 100).toFixed(2)
const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const providerText = (value) => providers.find((item) => item.value === value)?.label || value || '-'
const paymentStatusText = (value) => ({ initialized: '初始化', pending: '待支付', paid: '已支付', failed: '支付失败', closed: '已关闭', partially_refunded: '部分退款', refunded: '已退款' }[value] || value || '-')
const paymentStatusType = (value) => ({ paid: 'success', pending: 'warning', initialized: 'info', failed: 'danger', closed: 'info', partially_refunded: 'warning', refunded: 'info' }[value] || 'info')
const fulfillmentStatusText = (value) => ({ pending: '待发货', processing: '发货中', succeeded: '已入账', failed: '入账失败' }[value] || value || '-')
const fulfillmentStatusType = (value) => ({ succeeded: 'success', processing: 'warning', pending: 'info', failed: 'danger' }[value] || 'info')
const refundStatusText = (value) => ({ none: '无退款', processing: '处理中', pending: '待确认', partial: '部分退款', succeeded: '已退款', failed: '退款失败' }[value] || value || '-')
const params = () => ({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] })
const load = async () => {
loading.value = true
try {
const res = await getOrders(params())
if (res.code === 0) {
rows.value = res.data.list || []
total.value = res.data.total || 0
summary.value = res.data.summary || summary.value
}
} finally {
loading.value = false
}
}
const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { orderNo: '', userKeyword: '', paymentStatus: '', provider: '' }); dateRange.value = []; search() }
const showDetail = async (row) => {
const res = await getOrder(row.orderNo)
if (res.code === 0) {
detail.value = res.data
drawer.value = true
}
}
onMounted(load)
</script>
<style lang="scss">
@use '../styles/live.scss';
.order-user { display: grid; gap: 4px; }
.order-user span { color: var(--el-text-color-secondary); font-size: 11px; }
</style>
+47
View File
@@ -0,0 +1,47 @@
<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>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="live-page">
<div class="kra-search-box">
<el-form :inline="true" :model="query" class="live-search-form" @submit.prevent="search">
<el-form-item label="直播间"><el-input v-model="query.name" clearable placeholder="名称或房间号" /></el-form-item>
<el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item>
<el-form-item label="分类"><el-select v-model="query.categoryId" clearable placeholder="全部分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="直播状态"><el-select v-model="query.status" clearable placeholder="全部状态"><el-option label="直播中" value="live" /><el-option label="未开播" value="offline" /><el-option label="已禁用" value="disabled" /></el-select></el-form-item>
<el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box live-table-box">
<div class="kra-btn-list">
<el-button type="primary" :icon="Plus" @click="openCreate">新建直播间</el-button>
<el-button :icon="Sort" @click="persistSort">保存排序</el-button>
</div>
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column label="排序号" min-width="78" align="center"><template #default="{ row }"><el-input-number v-model="row.sort" class="live-sort-input" :min="0" :max="9999" controls-position="right" size="small" /></template></el-table-column>
<el-table-column label="房间编号" prop="id" min-width="82" align="right" />
<el-table-column label="直播间名称" min-width="170" show-overflow-tooltip><template #default="{ row }"><strong>{{ row.name }}</strong></template></el-table-column>
<el-table-column v-if="!compactTable" label="封面" min-width="88"><template #default="{ row }"><el-image class="live-cover" :src="getUrl(row.coverUrl)" fit="cover"><template #error><div class="image-slot">暂无</div></template></el-image></template></el-table-column>
<el-table-column label="所属分类" prop="categoryName" min-width="110" show-overflow-tooltip />
<el-table-column label="主播" min-width="130"><template #default="{ row }"><div class="anchor-cell"><el-avatar :size="30" :src="getUrl(row.anchorAvatar)">{{ row.anchorName?.slice(0, 1) }}</el-avatar><span>{{ row.anchorName || `用户 ${row.anchorUserId}` }}</span></div></template></el-table-column>
<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>
<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>
<el-drawer v-model="drawer" destroy-on-close size="640px" :show-close="false">
<template #header><div class="live-drawer-header"><strong>{{ editingId ? '编辑直播间' : '新建直播间' }}</strong><div><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">保存</el-button></div></div></template>
<el-form ref="formRef" :model="form" :rules="rules" class="live-drawer-form" label-width="110px">
<div class="live-form-section">基础信息</div>
<el-form-item label="直播间名称" prop="name"><el-input v-model="form.name" maxlength="40" show-word-limit /></el-form-item>
<el-form-item label="展示排序"><el-input-number v-model="form.sort" :min="0" :max="9999" /></el-form-item>
<el-form-item label="直播分类" prop="categoryId"><el-select v-model="form.categoryId" :disabled="editingStatus === 'live'" placeholder="选择分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select><div v-if="editingStatus === 'live'" class="live-muted">直播过程中不能更换分类</div></el-form-item>
<el-form-item label="主播" prop="anchorUserId"><el-select v-model="form.anchorUserId" filterable placeholder="选择 Kra 用户"><el-option v-for="item in users" :key="item.userId" :label="`${item.nickname || item.username} (#${item.userId})`" :value="item.userId" /></el-select></el-form-item>
<el-form-item label="直播封面" prop="coverUrl"><ImageUploader v-model="form.coverUrl" :max-mb="5" /></el-form-item>
<el-form-item label="直播简介"><el-input v-model="form.intro" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
<div class="live-form-section">播放与状态</div>
<el-form-item label="外部直播流地址"><el-input v-model="form.streamUrl" placeholder="可选,填写后观看端优先播放此地址" /></el-form-item>
<el-form-item label="回放视频"><VideoUploader v-model="form.replayUrl" /></el-form-item>
<el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
</el-drawer>
</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 { 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'
defineOptions({ name: 'LiveRooms' })
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '' })
const rows = ref([]), categories = ref([]), users = ref([]), loading = ref(false), saving = ref(false), drawer = ref(false), formRef = ref()
const { width } = useWindowSize()
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 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')
const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getRooms({ page: page.value, pageSize: pageSize.value, ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
const loadOptions = async () => { const [c, u] = await Promise.all([getCategories({ page: 1, pageSize: 100, orderBy: 'sort_asc' }), getAssetUsers({ page: 1, pageSize: 100, orderBy: 'created_desc' })]); if (c.code === 0) categories.value = c.data.list || []; if (u.code === 0) users.value = u.data.list || [] }
const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '' }); 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 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 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)
onMounted(() => { loadOptions(); load() })
</script>
<style lang="scss">
@use '../styles/live.scss';
.room-name { display: grid; min-width: 0; gap: 5px; }.room-name strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.room-name span { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.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); }
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="live-page">
<div class="kra-table-box live-summary-box"><div class="live-summary-item"><span>赠送次数</span><strong>{{ number(summary.count) }} <small>次</small></strong></div><div class="live-summary-item"><span>消费钻石</span><strong>{{ number(summary.diamonds) }}</strong></div><div class="live-summary-item"><span>折合金额</span><strong>¥{{ money(summary.amountCents) }}</strong></div></div>
<div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="直播间"><el-input v-model="query.roomName" clearable placeholder="直播间名称" /></el-form-item><el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item><el-form-item label="赠送用户"><el-input v-model="query.userKeyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="礼物"><el-select v-model="query.giftId" clearable filterable placeholder="全部礼物"><el-option v-for="item in gifts" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="赠送日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div>
<div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="serialNo">
<el-table-column label="流水号" prop="serialNo" min-width="160" show-overflow-tooltip />
<el-table-column label="赠送时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
<el-table-column label="赠送用户" min-width="130" show-overflow-tooltip><template #default="{ row }"><button class="live-link-button" type="button" @click="openUser(row.userId)">{{ row.nickname || row.username }}</button></template></el-table-column>
<el-table-column label="直播间" min-width="170" show-overflow-tooltip><template #default="{ row }"><button v-if="!row.roomDeleted" class="live-link-button" type="button" @click="openRoom(row.roomId)">{{ row.roomName }}</button><span v-else>{{ row.roomName }} <el-tag size="small" type="info">已删除</el-tag></span></template></el-table-column>
<el-table-column label="主播" prop="anchorName" min-width="110" show-overflow-tooltip />
<el-table-column label="礼物" min-width="120"><template #default="{ row }"><div class="send-gift"><span>{{ row.giftIcon }}</span><strong>{{ row.giftName }}</strong></div></template></el-table-column>
<el-table-column label="钻石单价" min-width="92" align="right"><template #default="{ row }">{{ number(row.giftPrice) }}</template></el-table-column>
<el-table-column label="数量" min-width="62" align="right"><template #default="{ row }">x{{ row.quantity }}</template></el-table-column>
<el-table-column label="钻石小计" min-width="96" align="right"><template #default="{ row }"><b class="send-diamond">{{ number(row.diamondTotal) }}</b></template></el-table-column>
<el-table-column label="折合人民币" min-width="100" align="right"><template #default="{ row }">¥{{ money(row.equivalentCents) }}</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>
<el-drawer v-model="drawer" size="560px" :title="linkedUser ? '用户详情' : '直播间详情'"><template v-if="linkedUser"><el-descriptions :column="1" border><el-descriptions-item label="用户编号">{{ linkedUser.userId }}</el-descriptions-item><el-descriptions-item label="账号">{{ linkedUser.username }}</el-descriptions-item><el-descriptions-item label="昵称">{{ linkedUser.nickname || '-' }}</el-descriptions-item><el-descriptions-item label="钻石余额">{{ number(linkedUser.diamondBalance) }}</el-descriptions-item><el-descriptions-item label="累计充值">¥{{ money(linkedUser.totalRechargeCents) }}</el-descriptions-item><el-descriptions-item label="累计消费">{{ number(linkedUser.totalSpentDiamonds) }} 钻石</el-descriptions-item></el-descriptions></template><template v-else-if="linkedRoom"><el-descriptions :column="1" border><el-descriptions-item label="房间编号">{{ linkedRoom.id }}</el-descriptions-item><el-descriptions-item label="直播间名称">{{ linkedRoom.name }}</el-descriptions-item><el-descriptions-item label="分类">{{ linkedRoom.categoryName }}</el-descriptions-item><el-descriptions-item label="主播">{{ linkedRoom.anchorName || '-' }}</el-descriptions-item><el-descriptions-item label="状态">{{ linkedRoom.status }}</el-descriptions-item></el-descriptions></template></el-drawer>
</div>
</template>
<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 { ElMessage } from 'element-plus'
defineOptions({ name: 'LiveSends' })
const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined }), 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 })
const drawer = ref(false), linkedUser = ref(null), linkedRoom = ref(null)
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getGiftSends({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0; summary.value = res.data.summary || summary.value } } finally { loading.value = false } }
const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize: 100 }); if (res.code === 0) gifts.value = res.data.list || [] }
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined }); 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 || '直播间不存在') }
</script>
<style lang="scss">
@use '../styles/live.scss';
.live-link-button { padding: 0; border: 0; background: transparent; color: var(--el-color-primary); cursor: pointer; }
.send-stack { display: grid; min-width: 0; gap: 4px; }
.send-stack strong, .send-stack span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.send-stack span { color: var(--el-text-color-secondary); font-size: 11px; }
.send-gift { display: flex; align-items: center; gap: 8px; }
.send-gift > span { font-size: 24px; }
.send-gift div { display: grid; gap: 3px; }
.send-gift small { color: var(--el-text-color-secondary); font-size: 11px; }
.send-diamond { color: rgb(var(--primary-color)); }
</style>
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="live-page">
<div class="kra-table-box live-settings">
<div class="kra-btn-list">
<el-button type="primary" :icon="Check" :loading="saving" @click="saveAll">保存设置</el-button>
<el-button :icon="Plus" :disabled="plans.length >= 8" @click="addPlan">添加充值档位</el-button>
</div>
<el-form class="live-settings-form" label-width="120px">
<div class="settings-title"><strong>钻石换算比例</strong><span>用于礼物流水人民币等值和运营数据统计</span></div>
<el-form-item label="1 钻石等于">
<div class="rate-control"><el-input-number v-model="rateYuan" :min="0.01" :max="1" :precision="2" :step="0.01" /><span>元人民币</span><small>赠送 100 钻石折合 ¥{{ (rateYuan * 100).toFixed(2) }}</small></div>
</el-form-item>
</el-form>
<el-divider content-position="left">充值档位</el-divider>
<el-table :data="plans" :row-key="(row) => row.key || row.id">
<el-table-column label="排序" width="90" align="center"><template #default="{ $index }">{{ $index + 1 }}</template></el-table-column>
<el-table-column label="钻石数量" min-width="220"><template #default="{ row }"><el-input-number v-model="row.diamonds" :min="1" :max="99999" /></template></el-table-column>
<el-table-column label="支付金额" min-width="240"><template #default="{ row }"><div class="plan-amount"><el-input-number v-model="row.amountYuan" :min="0.01" :max="9999" :precision="2" :step="1" /><span>元</span></div></template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ $index }"><el-button link type="primary" :icon="Delete" :disabled="plans.length <= 1" @click="removePlan($index)">删除</el-button></template></el-table-column>
</el-table>
<div class="settings-hint">可设置 1-8 个档位,前台按照表格顺序展示。</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Check, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getLiveSettings, getRechargePlans, saveLiveSettings, saveRechargePlans } from '../api/live'
defineOptions({ name: 'LiveSettings' })
const rateYuan = ref(0.1), plans = ref([]), saving = ref(false), key = ref(0)
const addPlan = () => { if (plans.value.length < 8) plans.value.push({ key: ++key.value, diamonds: 60, amountYuan: 6 }) }, removePlan = (index) => { if (plans.value.length > 1) plans.value.splice(index, 1) }
const load = async () => { const [settings, recharge] = await Promise.all([getLiveSettings(), getRechargePlans()]); if (settings.code === 0) rateYuan.value = Number(settings.data.diamondRateCents || 1) / 100; if (recharge.code === 0) plans.value = (recharge.data.list || []).map((item) => ({ ...item, amountYuan: item.amountCents / 100 })); if (!plans.value.length) addPlan() }
const saveAll = async () => { if (!plans.value.length || plans.value.length > 8) return ElMessage.warning('充值档位数量必须为 1-8 个'); if (plans.value.some((item) => !item.diamonds || !item.amountYuan)) return ElMessage.warning('请完整填写充值档位'); saving.value = true; try { const payload = plans.value.map((item, index) => ({ id: item.id || 0, sort: index + 1, diamonds: Number(item.diamonds), amountCents: Math.round(Number(item.amountYuan) * 100), enabled: true })); const [a, b] = await Promise.all([saveLiveSettings({ diamondRateCents: Math.round(rateYuan.value * 100) }), saveRechargePlans(payload)]); if (a.code === 0 && b.code === 0) { ElMessage.success('全站设置已保存'); load() } } finally { saving.value = false } }
onMounted(load)
</script>
<style lang="scss">
@use '../styles/live.scss';
.settings-title { display: flex; align-items: baseline; gap: 12px; margin: 8px 0 20px; }
.settings-title strong { font-size: 15px; }
.settings-title span, .settings-hint, .rate-control small { color: var(--el-text-color-secondary); font-size: 12px; }
.rate-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.rate-control .el-input-number { width: 180px; }
.rate-control small { width: 100%; }
.live-settings .el-divider { margin: 28px 0 20px; }
.live-settings .el-table .el-input-number { width: 180px; }
.plan-amount { display: flex; align-items: center; gap: 8px; }
.settings-hint { margin-top: 14px; }
</style>
+37
View File
@@ -0,0 +1,37 @@
<template>
<main class="studio-page">
<section class="studio-stage">
<video ref="preview" :srcObject="media" autoplay muted playsinline />
<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>
</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>
</main>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } 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 { 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 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 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()))
</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; } }
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="live-page">
<div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="用户搜索"><el-input v-model="query.keyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="用户 ID"><el-input-number v-model="query.userId" :min="1" controls-position="right" /></el-form-item><el-form-item label="用户状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="正常" :value="true" /><el-option label="已禁用" :value="false" /></el-select></el-form-item><el-form-item label="注册日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div>
<div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="userId"><el-table-column label="用户编号" prop="userId" min-width="82" align="right" /><el-table-column label="账号" prop="username" min-width="120" show-overflow-tooltip /><el-table-column label="昵称" prop="nickname" min-width="110" show-overflow-tooltip /><el-table-column label="头像" min-width="70" align="center"><template #default="{ row }"><el-avatar :size="30" :src="getUrl(row.avatar)">{{ (row.nickname || row.username)?.slice(0, 1) }}</el-avatar></template></el-table-column><el-table-column label="钻石余额" min-width="100" align="right"><template #default="{ row }"><b class="asset-balance">{{ number(row.diamondBalance) }}</b></template></el-table-column><el-table-column label="累计充值" min-width="105" align="right"><template #default="{ row }">¥{{ money(row.totalRechargeCents) }}</template></el-table-column><el-table-column label="累计消费钻石" min-width="120" align="right"><template #default="{ row }">{{ number(row.totalSpentDiamonds) }}</template></el-table-column><el-table-column v-if="!compactTable" label="注册时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column><el-table-column v-if="!compactTable" label="最近登录" min-width="150"><template #default="{ row }">{{ format(row.lastLoginAt) }}</template></el-table-column><el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '正常' : '禁用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="View" @click="openDetail(row)">详情</el-button><el-button link type="primary" :icon="Coin" @click="openAdjust(row)">调账</el-button><el-button link :type="row.enabled ? 'danger' : 'primary'" :disabled="row.userId === currentUserId" @click="toggle(row, !row.enabled)">{{ row.enabled ? '禁用' : '启用' }}</el-button></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>
<el-drawer v-model="detailDrawer" size="720px" title="用户资产详情"><template v-if="detail"><div class="asset-detail-head"><div class="asset-user"><el-avatar :size="52" :src="getUrl(detail.avatar)">{{ (detail.nickname || detail.username)?.slice(0, 1) }}</el-avatar><div><strong>{{ detail.nickname || detail.username }}</strong><span>{{ detail.username }} · 用户 ID {{ detail.userId }}</span><small>注册于 {{ format(detail.createdAt) }} · 最近登录 {{ format(detail.lastLoginAt) }}</small></div></div><div class="asset-detail-balance"><span>钻石余额</span><strong>{{ number(detail.diamondBalance) }}</strong><div><el-button link type="primary" :icon="Coin" @click="openAdjust(detail)">调账</el-button><el-switch :model-value="detail.enabled" :disabled="detail.userId === currentUserId" @change="toggle(detail, $event)" /></div></div></div><div class="asset-detail-stats"><div><span>累计充值</span><strong>¥{{ money(detail.totalRechargeCents) }}</strong></div><div><span>累计消费</span><strong>{{ number(detail.totalSpentDiamonds) }} 钻石</strong></div><div><span>账号状态</span><strong>{{ detail.enabled ? '正常' : '已禁用' }}</strong></div></div><el-tabs><el-tab-pane label="最近充值"><el-table :data="detail.recentOrders || []" table-layout="auto"><el-table-column prop="orderNo" label="订单号" width="180" show-overflow-tooltip /><el-table-column label="钻石" prop="diamonds" width="90" align="right" /><el-table-column label="金额" width="100" align="right"><template #default="{ row }">¥{{ money(row.amountCents) }}</template></el-table-column><el-table-column label="状态" prop="status" width="90" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane><el-tab-pane label="最近送礼"><el-table :data="detail.recentSends || []" table-layout="auto"><el-table-column prop="serialNo" label="流水号" width="160" show-overflow-tooltip /><el-table-column prop="roomName" label="直播间" show-overflow-tooltip /><el-table-column label="礼物"><template #default="{ row }">{{ row.giftIcon }} {{ row.giftName }}</template></el-table-column><el-table-column prop="diamondTotal" label="钻石" width="90" align="right" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane></el-tabs></template></el-drawer>
<el-dialog v-model="adjustDialog" title="人工调整钻石" width="460px"><el-form ref="adjustRef" :model="adjust" :rules="adjustRules" class="live-dialog-form" label-width="90px"><el-alert :title="`当前余额:${number(adjustUser?.diamondBalance)} 钻石`" type="info" :closable="false" class="adjust-alert" /><el-form-item label="调整方式" prop="type"><el-segmented v-model="adjust.type" :options="[{ label: '增加', value: 'increase' }, { label: '扣减', value: 'decrease' }]" /></el-form-item><el-form-item label="调整数量" prop="amount"><el-input-number v-model="adjust.amount" :min="1" :max="99999" /></el-form-item><el-form-item label="调整原因" prop="reason"><el-input v-model="adjust.reason" type="textarea" :rows="3" minlength="5" maxlength="50" show-word-limit placeholder="请输入 5-50 个字的操作原因" /></el-form-item></el-form><template #footer><el-button @click="adjustDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitAdjust">确认调整</el-button></template></el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { Coin, Refresh, Search, View } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUrl } from '@/utils/image'
import { useUserStore } from '@/pinia/modules/user'
import { adjustAssetBalance, getAssetUser, getAssetUsers, setAssetUserStatus } from '../api/live'
defineOptions({ name: 'LiveUsers' })
const userStore = useUserStore(), currentUserId = computed(() => Number(userStore.userInfo?.ID || 0))
const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400)
const query = reactive({ keyword: '', userId: undefined, enabled: undefined }), dateRange = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), detailDrawer = ref(false), detail = ref(null), adjustDialog = ref(false), adjustUser = ref(null), adjustRef = ref(), saving = ref(false)
const adjust = reactive({ type: 'increase', amount: 1, reason: '' }), adjustRules = { type: [{ required: true }], amount: [{ required: true, message: '请输入调整数量', trigger: 'change' }], reason: [{ required: true, min: 5, max: 50, message: '请输入 5-50 个字的操作原因', trigger: 'blur' }] }
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getAssetUsers({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { keyword: '', userId: undefined, enabled: undefined }); dateRange.value = []; search() }
const toggle = async (row, enabled) => { if (!enabled) await ElMessageBox.confirm(`禁用后“${row.nickname || row.username}”将无法使用直播业务,确认继续吗?`, '禁用用户', { type: 'warning' }); const res = await setAssetUserStatus(row.userId, enabled); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '用户已启用' : '用户已禁用') } }
const openDetail = async (row) => { const res = await getAssetUser(row.userId); if (res.code === 0) { detail.value = res.data; detailDrawer.value = true } }
const openAdjust = (row) => { adjustUser.value = row; Object.assign(adjust, { type: 'increase', amount: 1, reason: '' }); adjustDialog.value = true }
const submitAdjust = async () => { await adjustRef.value?.validate(); if (adjust.type === 'decrease' && adjust.amount > adjustUser.value.diamondBalance) return ElMessage.warning('扣减数量不能超过当前余额'); saving.value = true; try { const res = await adjustAssetBalance(adjustUser.value.userId, adjust); if (res.code === 0) { ElMessage.success('钻石余额已调整并记录审计'); adjustDialog.value = false; load() } } finally { saving.value = false } }
onMounted(load)
</script>
<style lang="scss">
@use '../styles/live.scss';
.asset-user { display: flex; align-items: center; gap: 10px; }
.asset-user > div { display: grid; min-width: 0; gap: 4px; }
.asset-user strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.asset-user span { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.asset-user small { color: var(--el-text-color-secondary); font-size: 11px; }
.asset-balance { color: rgb(var(--primary-color)); }
.asset-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 20px; padding: 18px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--kra-radius, 6px); background: var(--el-fill-color-light); }
.asset-detail-balance { display: grid; justify-items: end; gap: 5px; text-align: right; }
.asset-detail-balance > span { color: var(--el-text-color-secondary); font-size: 11px; }
.asset-detail-balance > strong { color: rgb(var(--primary-color)); font-size: 25px; }
.asset-detail-balance > div { display: flex; align-items: center; gap: 10px; }
.asset-detail-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 18px; border: 1px solid var(--el-border-color-lighter); }
.asset-detail-stats div { display: grid; gap: 6px; padding: 12px 14px; border-left: 1px solid var(--el-border-color-lighter); }
.asset-detail-stats div:first-child { border-left: 0; }
.asset-detail-stats span { color: var(--el-text-color-secondary); font-size: 11px; }
.asset-detail-stats strong { font-size: 14px; }
.adjust-alert { margin-bottom: 20px; }
@media (max-width: 560px) { .asset-detail-head { align-items: flex-start; flex-direction: column; }.asset-detail-balance { justify-items: start; text-align: left; }.asset-detail-stats { grid-template-columns: 1fr; }.asset-detail-stats div { border-top: 1px solid var(--el-border-color-lighter); border-left: 0; }.asset-detail-stats div:first-child { border-top: 0; } }
</style>
+14 -1
View File
@@ -65,5 +65,18 @@
"/src/view/systemTools/version/version.vue": "SysVersion",
"/src/modules/announcement/form/info.vue": "InfoForm",
"/src/modules/announcement/view/info.vue": "Info",
"/src/modules/email/view/index.vue": "Email"
"/src/modules/email/view/index.vue": "Email",
"/src/modules/live/components/ImageUploader.vue": "LiveImageUploader",
"/src/modules/live/components/LivePageHeader.vue": "LivePageHeader",
"/src/modules/live/components/VideoUploader.vue": "LiveVideoUploader",
"/src/modules/live/view/categories.vue": "LiveCategories",
"/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",
"/src/modules/live/view/studio.vue": "LiveStudio",
"/src/modules/live/view/users.vue": "LiveUsers"
}
+18
View File
@@ -30,6 +30,24 @@ 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',
component: () => import('@/modules/live/view/studio.vue'),
meta: { title: '主播开播台' }
},
{
path: '/live/diagnostics/:id',
name: 'LiveDiagnostics',
component: () => import('@/modules/live/view/diagnostics.vue'),
meta: { title: '直播间诊断' }
},
{
path: '/:catchAll(.*)',
meta: {
+102 -1
View File
@@ -11,6 +11,47 @@
<div class="p-2"><div class="text-sm text-muted-foreground">{{ item.title }}</div><div class="mt-3 text-3xl font-semibold text-base-text">{{ item.value }}</div></div>
</kra-card>
</div>
<section class="live-dashboard-section">
<div class="live-dashboard-heading">
<div>
<h2>宇宙直播数据</h2>
<p>直播间、充值和礼物业务的实时概览</p>
</div>
<div class="live-dashboard-controls">
<el-segmented v-model="liveDays" :options="livePeriods" @change="loadLiveDashboard" />
<el-button :icon="Refresh" :loading="liveLoading" @click="loadLiveDashboard">刷新</el-button>
</div>
</div>
<div class="live-stat-grid">
<div v-for="item in liveStats" :key="item.title" class="live-stat-item">
<span>{{ item.title }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.detail }}</small>
</div>
</div>
<div class="live-dashboard-grid">
<div class="live-dashboard-panel">
<div class="live-panel-title"><div><h3>近 7 日充值趋势</h3><p>按支付完成金额统计</p></div><strong>¥{{ liveMoney(liveTrendTotal) }}</strong></div>
<div v-if="liveData.rechargeTrend?.length" class="live-trend-chart">
<div v-for="item in liveData.rechargeTrend" :key="item.date" class="live-trend-column">
<span>{{ liveShortMoney(item.amountCents) }}</span>
<div class="live-trend-track"><i :style="{ height: `${liveBarHeight(item.amountCents)}%` }" /></div>
<small>{{ String(item.date || '').slice(5) }}</small>
</div>
</div>
<el-empty v-else description="暂无充值数据" :image-size="60" />
</div>
<div class="live-dashboard-panel">
<div class="live-panel-title"><div><h3>直播间礼物榜</h3><p>当前筛选周期 Top 5</p></div></div>
<div v-if="liveData.giftRanking?.length" class="live-ranking-list">
<div v-for="(item, index) in liveData.giftRanking" :key="item.roomId" class="live-ranking-row">
<b>{{ index + 1 }}</b><div><strong>{{ item.roomName || `直播间 ${item.roomId}` }}</strong><small>{{ item.anchorName || '未设置主播' }}</small></div><span>{{ liveNumber(item.diamonds) }} 钻石</span>
</div>
</div>
<el-empty v-else description="暂无礼物数据" :image-size="60" />
</div>
</div>
</section>
<div class="grid grid-cols-1 items-stretch gap-2 xl:grid-cols-2">
<kra-card title="快捷功能" show-action custom-class="min-h-[300px]"><kra-quick-link /></kra-card>
<kra-card title="公告" show-action custom-class="min-h-[300px]"><kra-notice /></kra-card>
@@ -21,18 +62,78 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { useUserStore } from '@/pinia/modules/user'
import { getUserList } from '@/api/user'
import { getSysOperationRecordList } from '@/api/sysOperationRecord'
import { getFileList } from '@/api/fileUploadAndDownload'
import { getInfoList } from '@/modules/announcement/api/info'
import { getDashboard } from '@/modules/live/api/live'
import { KraNotice, KraQuickLink, KraCard } from './components'
defineOptions({ name: 'Dashboard' })
const userStore = useUserStore()
const today = computed(() => new Date().toLocaleDateString('zh-CN'))
const statistics = ref([{ title: '系统用户', value: '—' }, { title: '操作记录', value: '—' }, { title: '媒体文件', value: '—' }, { title: '系统公告', value: '—' }])
const livePeriods = [{ label: '今日', value: 1 }, { label: '昨日', value: 0 }, { label: '近 7 天', value: 7 }, { label: '近 30 天', value: 30 }]
const liveDays = ref(7)
const liveLoading = ref(false)
const liveData = ref({ roomTotal: 0, roomLive: 0, rechargeCents: 0, paidOrderCount: 0, giftDiamonds: 0, giftSendCount: 0, diamondRateCents: 0, rechargeTrend: [], giftRanking: [] })
const liveMoney = (value) => (Number(value || 0) / 100).toFixed(2)
const liveNumber = (value) => Number(value || 0).toLocaleString('zh-CN')
const liveTrendTotal = computed(() => liveData.value.rechargeTrend?.reduce((sum, item) => sum + Number(item.amountCents || 0), 0) || 0)
const liveMaxTrend = computed(() => Math.max(1, ...(liveData.value.rechargeTrend || []).map((item) => Number(item.amountCents || 0))))
const liveBarHeight = (value) => Math.max(6, Math.round((Number(value || 0) / liveMaxTrend.value) * 100))
const liveShortMoney = (value) => Number(value || 0) >= 100000 ? `¥${(Number(value) / 100000).toFixed(1)}k` : `¥${liveMoney(value)}`
const liveStats = computed(() => [
{ title: '直播间', value: liveNumber(liveData.value.roomTotal), detail: `共 ${liveNumber(liveData.value.roomLive)} 个直播中` },
{ title: '充值金额', value: `¥${liveMoney(liveData.value.rechargeCents)}`, detail: `${liveNumber(liveData.value.paidOrderCount)} 笔已支付` },
{ title: '礼物钻石', value: liveNumber(liveData.value.giftDiamonds), detail: `${liveNumber(liveData.value.giftSendCount)} 次赠送` },
{ title: '礼物折合收入', value: `¥${liveMoney(liveData.value.giftDiamonds * liveData.value.diamondRateCents)}`, detail: `1 钻石 = ¥${liveMoney(liveData.value.diamondRateCents)}` }
])
const loadLiveDashboard = async () => {
liveLoading.value = true
try {
const response = await getDashboard({ days: liveDays.value })
if (response?.code === 0 && response.data) liveData.value = response.data
} finally {
liveLoading.value = false
}
}
onMounted(async () => {
const results = await Promise.allSettled([getUserList({ page: 1, pageSize: 1 }), getSysOperationRecordList({ page: 1, pageSize: 1 }), getFileList({ page: 1, pageSize: 1 }), getInfoList({ page: 1, pageSize: 1 })])
const results = await Promise.allSettled([getUserList({ page: 1, pageSize: 1 }), getSysOperationRecordList({ page: 1, pageSize: 1 }), getFileList({ page: 1, pageSize: 1 }), getInfoList({ page: 1, pageSize: 1 }), loadLiveDashboard()])
results.forEach((result, index) => { if (result.status === 'fulfilled' && result.value?.code === 0) statistics.value[index].value = result.value.data?.total ?? 0 })
})
</script>
<style scoped lang="scss">
.live-dashboard-section { padding: 20px; border: 1px solid rgb(226 232 240 / 80%); border-radius: 12px; background: var(--el-bg-color); }
.live-dashboard-heading, .live-dashboard-controls, .live-panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.live-dashboard-heading { margin-bottom: 18px; }
.live-dashboard-heading h2, .live-panel-title h3 { margin: 0; color: var(--el-text-color-primary); font-size: 16px; font-weight: 600; }
.live-dashboard-heading p, .live-panel-title p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
.live-dashboard-controls { align-items: center; }
.live-stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 1px solid var(--el-border-color-lighter); }
.live-stat-item { min-width: 0; padding: 6px 18px 18px; border-left: 1px solid var(--el-border-color-lighter); }
.live-stat-item:first-child { padding-left: 0; border-left: 0; }
.live-stat-item span, .live-stat-item small { color: var(--el-text-color-secondary); font-size: 12px; }
.live-stat-item strong { display: block; margin: 9px 0 5px; color: var(--el-text-color-primary); font-size: 23px; font-weight: 600; }
.live-stat-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.live-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(320px, .65fr); gap: 16px; margin-top: 18px; }
.live-dashboard-panel { min-height: 290px; padding: 18px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; background: var(--el-fill-color-blank); }
.live-panel-title > strong { color: rgb(var(--primary-color)); font-size: 17px; }
.live-trend-chart { display: flex; align-items: flex-end; gap: 10px; height: 220px; padding-top: 24px; }
.live-trend-column { display: grid; flex: 1; height: 100%; grid-template-rows: 20px 1fr 22px; align-items: end; min-width: 0; text-align: center; }
.live-trend-column > span, .live-trend-column small { color: var(--el-text-color-secondary); font-size: 10px; }
.live-trend-track { display: flex; align-items: flex-end; height: 100%; border-bottom: 1px solid var(--el-border-color-lighter); }
.live-trend-track i { display: block; width: min(42px, 100%); margin: 0 auto; border-radius: 4px 4px 0 0; background: rgb(var(--primary-color)); opacity: .72; }
.live-ranking-list { margin-top: 12px; }
.live-ranking-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.live-ranking-row:last-child { border-bottom: 0; }
.live-ranking-row > b { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 4px; background: var(--el-fill-color); color: var(--el-text-color-secondary); font-size: 12px; }
.live-ranking-row div { display: grid; min-width: 0; gap: 3px; }
.live-ranking-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.live-ranking-row small { color: var(--el-text-color-secondary); font-size: 11px; }
.live-ranking-row > span { color: rgb(var(--primary-color)); font-size: 12px; white-space: nowrap; }
@media (max-width: 1000px) { .live-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.live-stat-item:nth-child(3) { padding-left: 0; border-left: 0; }.live-stat-item:nth-child(n+3) { padding-top: 15px; border-top: 1px solid var(--el-border-color-lighter); }.live-dashboard-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .live-dashboard-heading { align-items: stretch; flex-direction: column; }.live-dashboard-controls { align-items: stretch; flex-direction: column; }.live-dashboard-controls .el-segmented { width: 100%; }.live-stat-grid { grid-template-columns: 1fr; }.live-stat-item, .live-stat-item:nth-child(n+3) { padding: 12px 0; border-top: 1px solid var(--el-border-color-lighter); border-left: 0; }.live-stat-item:first-child { padding-top: 0; border-top: 0; } }
</style>