mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 在直播详情页接入实时弹幕 hub(事件映射、入列、统计更新、生命周期订阅)并优化视图;为直播管理页补充搜索/筛选/排序/分页状态同步、加载/空状态及自动刷新功能
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { DanmakuModel, ResponseLiveInfoModel } from '@/api/api-models'
|
||||
import { NButton, NEmpty, NSpin, useMessage } from 'naive-ui'
|
||||
import { onActivated, ref } from 'vue'
|
||||
import type { DanmakuModel, EventModel, ResponseLiveInfoModel } from '@/api/api-models'
|
||||
import { EventDataTypes } from '@/api/api-models'
|
||||
import { NButton, NEmpty, NSpin, NSpace, useMessage } from 'naive-ui'
|
||||
import { onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccount } from '@/api/account'
|
||||
import { QueryGetAPI } from '@/api/query'
|
||||
import DanmakuContainer from '@/components/DanmakuContainer.vue'
|
||||
import { useVTsuruHub } from '@/store/useVTsuruHub'
|
||||
import { LIVE_API_URL } from '@/data/constants'
|
||||
|
||||
interface ResponseLiveDetail {
|
||||
@@ -17,51 +19,133 @@ const accountInfo = useAccount()
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const hub = useVTsuruHub()
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
const liveInfo = ref<ResponseLiveDetail | undefined>(await get())
|
||||
const loadError = ref<string | null>(null)
|
||||
const liveInfo = ref<ResponseLiveDetail | undefined>()
|
||||
const danmakuContainerRef = ref<InstanceType<typeof DanmakuContainer> | null>(null)
|
||||
|
||||
async function get() {
|
||||
isLoading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await QueryGetAPI<ResponseLiveDetail>(`${LIVE_API_URL}get`, {
|
||||
id: route.params.id,
|
||||
id: String(route.params.id ?? ''),
|
||||
useEmoji: true,
|
||||
})
|
||||
if (data.code == 200) {
|
||||
return data.data
|
||||
} else {
|
||||
message.error(`无法获取数据: ${data.message}`)
|
||||
const msg = `无法获取数据: ${data.message}`
|
||||
message.error(msg)
|
||||
loadError.value = data.message
|
||||
return undefined
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('无法获取数据')
|
||||
const msg = err instanceof Error ? err.message : '无法获取数据'
|
||||
message.error(msg)
|
||||
loadError.value = msg
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
if (liveInfo.value?.live.liveId != route.params.id) {
|
||||
liveInfo.value = await get()
|
||||
async function loadInitialData() {
|
||||
const data = await get()
|
||||
if (data) {
|
||||
liveInfo.value = data
|
||||
}
|
||||
}
|
||||
|
||||
function eventModelToDanmakuModel(event: EventModel): DanmakuModel {
|
||||
return {
|
||||
ouId: event.ouid,
|
||||
uId: event.uid ?? 0,
|
||||
uName: event.uname,
|
||||
msg: event.msg ?? '',
|
||||
time: event.time,
|
||||
type: event.type,
|
||||
price: event.price,
|
||||
num: event.num,
|
||||
guardLevel: event.guard_level,
|
||||
fansMedalLevel: event.fans_medal_level,
|
||||
fansMedalName: event.fans_medal_name,
|
||||
fansMedalWearingStatus: event.fans_medal_wearing_status,
|
||||
isEmoji: !!event.emoji,
|
||||
emoji: event.emoji,
|
||||
id: `${event.ouid}_${event.time}_${Date.now()}`,
|
||||
} as DanmakuModel
|
||||
}
|
||||
|
||||
function onNewDanmaku(event: EventModel) {
|
||||
if (!liveInfo.value) return
|
||||
|
||||
const danmaku = eventModelToDanmakuModel(event)
|
||||
danmakuContainerRef.value?.InsertDanmakus([danmaku])
|
||||
|
||||
// 更新统计信息
|
||||
if (event.price && event.price > 0) {
|
||||
liveInfo.value.live.totalIncome += event.price
|
||||
}
|
||||
if (event.type === EventDataTypes.Message) {
|
||||
liveInfo.value.live.danmakusCount++
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInitialData()
|
||||
await hub.Init()
|
||||
await hub.on('NewDanmaku', onNewDanmaku)
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (liveInfo.value?.live.liveId != String(route.params.id ?? '')) {
|
||||
await loadInitialData()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
await hub.off('NewDanmaku', onNewDanmaku)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin
|
||||
v-if="isLoading"
|
||||
show
|
||||
/>
|
||||
<template v-else>
|
||||
<NSpin :show="isLoading">
|
||||
<template v-if="!isLoading">
|
||||
<NSpace align="center" justify="space-between" wrap style="margin-bottom: 16px">
|
||||
<NButton
|
||||
text
|
||||
secondary
|
||||
@click="router.push({ name: 'manage-live' })"
|
||||
>
|
||||
{{ '< 返回' }}
|
||||
← 返回
|
||||
</NButton>
|
||||
<NSpace align="center" :size="8">
|
||||
<span style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
border: 1px solid rgba(24, 160, 88, 0.3);
|
||||
border-radius: 16px;
|
||||
color: #18a058;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
">
|
||||
<span style="
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #18a058;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
" />
|
||||
实时接收中
|
||||
</span>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<DanmakuContainer
|
||||
v-if="liveInfo"
|
||||
ref="danmakuContainerRef"
|
||||
@@ -80,6 +164,24 @@ onActivated(async () => {
|
||||
<NEmpty
|
||||
v-else
|
||||
description="无数据"
|
||||
/>
|
||||
>
|
||||
<template #extra>
|
||||
<NButton type="primary" @click="loadInitialData">重试</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
</template>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ResponseLiveInfoModel } from '@/api/api-models'
|
||||
import { NAlert, NDivider, NList, NListItem, NPagination, NSpace, useMessage } from 'naive-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NDivider, NEmpty, NInput, NInputNumber, NSelect, NSkeleton, NList, NListItem, NPagination, NSpace, NSwitch, useMessage } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { useLocalStorage, useSessionStorage, useStorage } from '@vueuse/core'
|
||||
import { useAccount } from '@/api/account'
|
||||
import { QueryGetAPI } from '@/api/query'
|
||||
import EventFetcherAlert from '@/components/EventFetcherAlert.vue'
|
||||
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
|
||||
import LiveInfoContainer from '@/components/LiveInfoContainer.vue'
|
||||
import { LIVE_API_URL } from '@/data/constants'
|
||||
@@ -17,31 +18,83 @@ const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const lives = ref<ResponseLiveInfoModel[]>(await getAll())
|
||||
// state
|
||||
const lives = ref<ResponseLiveInfoModel[]>([])
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// pagination & query sync
|
||||
const page = useSessionStorage<number>('ManageLive.page', 1)
|
||||
const pageSize = useStorage<number>('ManageLive.pageSize', 10)
|
||||
const defaultDanmakusCount = ref(0)
|
||||
|
||||
// search / filter / sort
|
||||
const keyword = useLocalStorage<string>('ManageLive.keyword', '')
|
||||
const statusFilter = useLocalStorage<'all' | 'live' | 'finished'>('ManageLive.status', 'all')
|
||||
const sortKey = useLocalStorage<'startAt' | 'danmakusCount' | 'totalIncome' | 'interactionCount'>('ManageLive.sort', 'startAt')
|
||||
const sortOrder = useLocalStorage<'desc' | 'asc'>('ManageLive.order', 'desc')
|
||||
|
||||
// refresh
|
||||
const enableAutoRefresh = useLocalStorage<boolean>('ManageLive.autoRefresh', false)
|
||||
const refreshSeconds = useLocalStorage<number>('ManageLive.refreshSeconds', 60)
|
||||
let refreshTimer: number | undefined
|
||||
|
||||
watch([lives, pageSize], () => {
|
||||
const total = lives.value.length
|
||||
const total = filteredAndSortedLives.value.length
|
||||
const size = pageSize.value || 10
|
||||
const maxPage = Math.max(1, Math.ceil(total / size))
|
||||
if (page.value > maxPage) page.value = maxPage
|
||||
})
|
||||
|
||||
const filteredAndSortedLives = computed(() => {
|
||||
// filter by status
|
||||
let arr = lives.value.filter(l =>
|
||||
statusFilter.value === 'all'
|
||||
? true
|
||||
: statusFilter.value === 'live'
|
||||
? !l.isFinish
|
||||
: l.isFinish,
|
||||
)
|
||||
// search by title or id
|
||||
if (keyword.value && keyword.value.trim() !== '') {
|
||||
const k = keyword.value.trim().toLowerCase()
|
||||
arr = arr.filter(l => l.title.toLowerCase().includes(k) || l.liveId.toLowerCase().includes(k))
|
||||
}
|
||||
// sort
|
||||
arr = arr.slice().sort((a, b) => {
|
||||
const k = sortKey.value
|
||||
const av = (a as any)[k] ?? 0
|
||||
const bv = (b as any)[k] ?? 0
|
||||
const diff = av > bv ? 1 : av < bv ? -1 : 0
|
||||
return sortOrder.value === 'asc' ? diff : -diff
|
||||
})
|
||||
return arr
|
||||
})
|
||||
|
||||
const pagedLives = computed(() => {
|
||||
const size = pageSize.value || 10
|
||||
const start = Math.max(0, (page.value - 1) * size)
|
||||
const end = start + size
|
||||
return filteredAndSortedLives.value.slice(start, end)
|
||||
})
|
||||
|
||||
async function getAll() {
|
||||
isLoading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await QueryGetAPI<ResponseLiveInfoModel[]>(`${LIVE_API_URL}get-all`)
|
||||
if (data.code == 200) {
|
||||
return data.data
|
||||
lives.value = data.data
|
||||
} else {
|
||||
message.error(`无法获取数据: ${data.message}`)
|
||||
return []
|
||||
loadError.value = data.message
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('无法获取数据')
|
||||
const msg = err instanceof Error ? err.message : '无法获取数据'
|
||||
message.error(msg)
|
||||
loadError.value = msg
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function OnClickCover(live: ResponseLiveInfoModel) {
|
||||
@@ -50,6 +103,62 @@ function OnClickCover(live: ResponseLiveInfoModel) {
|
||||
params: { id: live.liveId },
|
||||
})
|
||||
}
|
||||
|
||||
function applyQueryToState() {
|
||||
const q = route.query
|
||||
if (q.page) page.value = Number(q.page) || 1
|
||||
if (q.pageSize) pageSize.value = Number(q.pageSize) || 10
|
||||
if (q.q) keyword.value = String(q.q)
|
||||
if (q.status && (['all', 'live', 'finished'] as const).includes(q.status as any))
|
||||
statusFilter.value = q.status as any
|
||||
if (q.sort && (['startAt', 'danmakusCount', 'totalIncome', 'interactionCount'] as const).includes(q.sort as any))
|
||||
sortKey.value = q.sort as any
|
||||
if (q.order && (['asc', 'desc'] as const).includes(q.order as any)) sortOrder.value = q.order as any
|
||||
}
|
||||
|
||||
function syncStateToQuery() {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: String(page.value),
|
||||
pageSize: String(pageSize.value),
|
||||
q: keyword.value || undefined,
|
||||
status: statusFilter.value !== 'all' ? statusFilter.value : undefined,
|
||||
sort: sortKey.value !== 'startAt' ? sortKey.value : undefined,
|
||||
order: sortOrder.value !== 'desc' ? sortOrder.value : undefined,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
watch([page, pageSize, keyword, statusFilter, sortKey, sortOrder], syncStateToQuery)
|
||||
|
||||
function setupAutoRefresh() {
|
||||
clearAutoRefresh()
|
||||
if (!enableAutoRefresh.value) return
|
||||
const sec = Math.max(10, Number(refreshSeconds.value) || 60)
|
||||
// @ts-ignore - setInterval returns number in browser
|
||||
refreshTimer = window.setInterval(() => {
|
||||
getAll()
|
||||
}, sec * 1000)
|
||||
}
|
||||
function clearAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch([enableAutoRefresh, refreshSeconds], setupAutoRefresh)
|
||||
|
||||
onMounted(async () => {
|
||||
applyQueryToState()
|
||||
await getAll()
|
||||
setupAutoRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,6 +178,69 @@ function OnClickCover(live: ResponseLiveInfoModel) {
|
||||
尚未进行Bilibili认证
|
||||
</NAlert>
|
||||
<template v-else>
|
||||
<NSpace
|
||||
wrap
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<NSpace align="center" wrap>
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
placeholder="搜索标题或ID"
|
||||
clearable
|
||||
style="min-width: 220px"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="statusFilter"
|
||||
:options="[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '直播中', value: 'live' },
|
||||
{ label: '已结束', value: 'finished' },
|
||||
]"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="sortKey"
|
||||
:options="[
|
||||
{ label: '开始时间', value: 'startAt' },
|
||||
{ label: '弹幕数', value: 'danmakusCount' },
|
||||
{ label: '互动数', value: 'interactionCount' },
|
||||
{ label: '收益', value: 'totalIncome' },
|
||||
]"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="sortOrder"
|
||||
:options="[
|
||||
{ label: '降序', value: 'desc' },
|
||||
{ label: '升序', value: 'asc' },
|
||||
]"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</NSpace>
|
||||
<NSpace align="center">
|
||||
<NSwitch v-model:value="enableAutoRefresh">
|
||||
<template #checked>自动刷新</template>
|
||||
<template #unchecked>自动刷新</template>
|
||||
</NSwitch>
|
||||
<NInputNumber
|
||||
v-model:value="refreshSeconds"
|
||||
style="width: 100px"
|
||||
:min="10"
|
||||
:disabled="!enableAutoRefresh"
|
||||
placeholder="刷新秒数"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
tertiary
|
||||
:loading="isLoading"
|
||||
@click="getAll()"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<NSpace
|
||||
vertical
|
||||
justify="center"
|
||||
@@ -80,17 +252,25 @@ function OnClickCover(live: ResponseLiveInfoModel) {
|
||||
show-quick-jumper
|
||||
show-size-picker
|
||||
:page-sizes="[10, 20, 30, 40]"
|
||||
:item-count="lives.length"
|
||||
:item-count="filteredAndSortedLives.length"
|
||||
/>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
<NSkeleton v-if="isLoading" text :repeat="5" />
|
||||
<template v-else>
|
||||
<NEmpty v-if="!filteredAndSortedLives.length" description="无数据">
|
||||
<template #extra>
|
||||
<NButton type="primary" @click="getAll">重试</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
<NList
|
||||
v-else
|
||||
bordered
|
||||
hoverable
|
||||
clickable
|
||||
>
|
||||
<NListItem
|
||||
v-for="live in lives.slice((page - 1) * pageSize, page * pageSize)"
|
||||
v-for="live in pagedLives"
|
||||
:key="live.liveId"
|
||||
@click="OnClickCover(live)"
|
||||
>
|
||||
@@ -101,4 +281,5 @@ function OnClickCover(live: ResponseLiveInfoModel) {
|
||||
</NListItem>
|
||||
</NList>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user