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">
|
<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>
|
</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">
|
<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)"
|
||||||
>
|
>
|
||||||
@@ -102,3 +282,4 @@ function OnClickCover(live: ResponseLiveInfoModel) {
|
|||||||
</NList>
|
</NList>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user