feat: 在直播详情页接入实时弹幕 hub(事件映射、入列、统计更新、生命周期订阅)并优化视图;为直播管理页补充搜索/筛选/排序/分页状态同步、加载/空状态及自动刷新功能

This commit is contained in:
2025-10-27 14:37:25 +08:00
parent 3e76684891
commit e0f57bcaf5
2 changed files with 350 additions and 67 deletions

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DanmakuModel, ResponseLiveInfoModel } from '@/api/api-models' import type { DanmakuModel, EventModel, ResponseLiveInfoModel } from '@/api/api-models'
import { NButton, NEmpty, NSpin, useMessage } from 'naive-ui' import { EventDataTypes } from '@/api/api-models'
import { onActivated, ref } from 'vue' import { NButton, NEmpty, NSpin, NSpace, useMessage } from 'naive-ui'
import { onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccount } from '@/api/account' import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI } from '@/api/query'
import DanmakuContainer from '@/components/DanmakuContainer.vue' import DanmakuContainer from '@/components/DanmakuContainer.vue'
import { useVTsuruHub } from '@/store/useVTsuruHub'
import { LIVE_API_URL } from '@/data/constants' import { LIVE_API_URL } from '@/data/constants'
interface ResponseLiveDetail { interface ResponseLiveDetail {
@@ -17,51 +19,133 @@ const accountInfo = useAccount()
const message = useMessage() const message = useMessage()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const hub = useVTsuruHub()
const isLoading = ref(true) const isLoading = ref(true)
const loadError = ref<string | null>(null)
const liveInfo = ref<ResponseLiveDetail | undefined>(await get()) const liveInfo = ref<ResponseLiveDetail | undefined>()
const danmakuContainerRef = ref<InstanceType<typeof DanmakuContainer> | null>(null)
async function get() { async function get() {
isLoading.value = true isLoading.value = true
loadError.value = null
try { try {
const data = await QueryGetAPI<ResponseLiveDetail>(`${LIVE_API_URL}get`, { const data = await QueryGetAPI<ResponseLiveDetail>(`${LIVE_API_URL}get`, {
id: route.params.id, id: String(route.params.id ?? ''),
useEmoji: true, useEmoji: true,
}) })
if (data.code == 200) { if (data.code == 200) {
return data.data return data.data
} else { } else {
message.error(`无法获取数据: ${data.message}`) const msg = `无法获取数据: ${data.message}`
message.error(msg)
loadError.value = data.message
return undefined return undefined
} }
} catch (err) { } catch (err) {
message.error('无法获取数据') const msg = err instanceof Error ? err.message : '无法获取数据'
message.error(msg)
loadError.value = msg
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
return undefined return undefined
} }
onActivated(async () => { async function loadInitialData() {
if (liveInfo.value?.live.liveId != route.params.id) { const data = await get()
liveInfo.value = 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> </script>
<template> <template>
<NSpin <NSpin :show="isLoading">
v-if="isLoading" <template v-if="!isLoading">
show <NSpace align="center" justify="space-between" wrap style="margin-bottom: 16px">
/>
<template v-else>
<NButton <NButton
text secondary
@click="router.push({ name: 'manage-live' })" @click="router.push({ name: 'manage-live' })"
> >
{{ '< 返回' }} 返回
</NButton> </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 <DanmakuContainer
v-if="liveInfo" v-if="liveInfo"
ref="danmakuContainerRef" ref="danmakuContainerRef"
@@ -80,6 +164,24 @@ onActivated(async () => {
<NEmpty <NEmpty
v-else v-else
description="无数据" description="无数据"
/> >
<template #extra>
<NButton type="primary" @click="loadInitialData">重试</NButton>
</template> </template>
</NEmpty>
</template>
</NSpin>
</template> </template>
<style scoped>
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
</style>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ResponseLiveInfoModel } from '@/api/api-models' import type { ResponseLiveInfoModel } from '@/api/api-models'
import { NAlert, NDivider, NList, NListItem, NPagination, NSpace, useMessage } from 'naive-ui' import { NAlert, NButton, NDivider, NEmpty, NInput, NInputNumber, NSelect, NSkeleton, NList, NListItem, NPagination, NSpace, NSwitch, useMessage } from 'naive-ui'
import { ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core' import { useLocalStorage, useSessionStorage, useStorage } from '@vueuse/core'
import { useAccount } from '@/api/account' import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI } from '@/api/query'
import EventFetcherAlert from '@/components/EventFetcherAlert.vue'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue' import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import LiveInfoContainer from '@/components/LiveInfoContainer.vue' import LiveInfoContainer from '@/components/LiveInfoContainer.vue'
import { LIVE_API_URL } from '@/data/constants' import { LIVE_API_URL } from '@/data/constants'
@@ -17,31 +18,83 @@ const message = useMessage()
const route = useRoute() const route = useRoute()
const router = useRouter() 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 page = useSessionStorage<number>('ManageLive.page', 1)
const pageSize = useStorage<number>('ManageLive.pageSize', 10) 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], () => { watch([lives, pageSize], () => {
const total = lives.value.length const total = filteredAndSortedLives.value.length
const size = pageSize.value || 10 const size = pageSize.value || 10
const maxPage = Math.max(1, Math.ceil(total / size)) const maxPage = Math.max(1, Math.ceil(total / size))
if (page.value > maxPage) page.value = maxPage 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() { async function getAll() {
isLoading.value = true
loadError.value = null
try { try {
const data = await QueryGetAPI<ResponseLiveInfoModel[]>(`${LIVE_API_URL}get-all`) const data = await QueryGetAPI<ResponseLiveInfoModel[]>(`${LIVE_API_URL}get-all`)
if (data.code == 200) { if (data.code == 200) {
return data.data lives.value = data.data
} else { } else {
message.error(`无法获取数据: ${data.message}`) message.error(`无法获取数据: ${data.message}`)
return [] loadError.value = data.message
} }
} catch (err) { } 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) { function OnClickCover(live: ResponseLiveInfoModel) {
@@ -50,6 +103,62 @@ function OnClickCover(live: ResponseLiveInfoModel) {
params: { id: live.liveId }, 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> </script>
<template> <template>
@@ -69,6 +178,69 @@ function OnClickCover(live: ResponseLiveInfoModel) {
尚未进行Bilibili认证 尚未进行Bilibili认证
</NAlert> </NAlert>
<template v-else> <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 <NSpace
vertical vertical
justify="center" justify="center"
@@ -80,17 +252,25 @@ function OnClickCover(live: ResponseLiveInfoModel) {
show-quick-jumper show-quick-jumper
show-size-picker show-size-picker
:page-sizes="[10, 20, 30, 40]" :page-sizes="[10, 20, 30, 40]"
:item-count="lives.length" :item-count="filteredAndSortedLives.length"
/> />
</NSpace> </NSpace>
<NDivider /> <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 <NList
v-else
bordered bordered
hoverable hoverable
clickable clickable
> >
<NListItem <NListItem
v-for="live in lives.slice((page - 1) * pageSize, page * pageSize)" v-for="live in pagedLives"
:key="live.liveId" :key="live.liveId"
@click="OnClickCover(live)" @click="OnClickCover(live)"
> >
@@ -101,4 +281,5 @@ function OnClickCover(live: ResponseLiveInfoModel) {
</NListItem> </NListItem>
</NList> </NList>
</template> </template>
</template>
</template> </template>