Files
vtsuru.live/src/views/ManageLayout.vue
Megghy f90f2057bb feat: 添加弹幕投票相关功能, 修复礼物兑换外部链接bug
- 在api-models.ts中定义弹幕投票相关类型
- 在constants.ts中添加VOTE_API_URL常量
- 在路由中添加弹幕投票管理和OBS视图
- 更新组件以支持弹幕投票功能
2025-05-05 02:01:01 +08:00

868 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NavigateToNewTab, isDarkMode } from '@/Utils'
import { cookie, isLoadingAccount, useAccount } from '@/api/account'
import { ThemeType } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
import { checkUpdateNote } from '@/data/UpdateNote';
import { ACCOUNT_API_URL } from '@/data/constants'
import { useBiliAuth } from '@/store/useBiliAuth'
import { useMusicRequestProvider } from '@/store/useMusicRequest'
import {
BookCoins20Filled,
CalendarClock24Filled,
Chat24Filled,
Info24Filled,
Live24Filled,
Lottery24Filled,
PeopleQueue24Filled,
Person48Filled,
PersonFeedback24Filled,
TabletSpeaker24Filled,
VehicleShip24Filled,
VideoAdd20Filled,
Mail24Filled,
} from '@vicons/fluent'
import { AnalyticsSharp, BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, Eye } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
NAlert,
NBackTop,
NBadge,
NButton,
NCountdown,
NDivider,
NElement,
NFlex,
NIcon,
NLayout,
NLayoutContent,
NLayoutFooter,
NLayoutHeader,
NLayoutSider,
NMenu,
NPageHeader,
NPopconfirm,
NScrollbar,
NSpace,
NSpin,
NSwitch,
NTag,
NText,
NTooltip,
useMessage,
NCard,
} from 'naive-ui'
import { computed, h, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
// @ts-ignore
import APlayer from 'vue3-aplayer'
// 全局状态和工具
const accountInfo = useAccount()
const message = useMessage()
const route = useRoute()
const windowWidth = window.innerWidth
const themeType = useStorage('Settings.Theme', ThemeType.Auto)
// 侧边栏和布局相关
const sider = ref()
const { width } = useElementSize(sider)
// 页面类型计算
const type = computed(() => route.meta.danmaku ? 'danmaku' : '')
// 音乐请求服务相关
const musicRquestStore = useMusicRequestProvider()
const aplayerHeight = computed(() =>
musicRquestStore.originMusics.length === 0 ? '0' : '80'
)
const aplayer = ref()
watch(aplayer, () => {
musicRquestStore.aplayerRef = aplayer.value
})
// 邮箱验证相关
const canResendEmail = ref(false)
const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
// 图标渲染函数 - 用于菜单项
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
// 菜单配置
const menuOptions = computed(() => {
return [
{
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(AnalyticsSharp),
},
{
label: () => h(RouterLink, { to: { name: 'manage-live' } }, { default: () => '直播记录' }),
key: 'manage-live',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Live24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-analyze' } }, { default: () => '直播数据' }),
key: 'manage-analyze',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Eye),
},
{
label: () => h(RouterLink, { to: { name: 'manage-event' } }, { default: () => '舰长和SC' }),
key: 'manage-event',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(VehicleShip24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-point' } }, { default: () => '积分和礼物' }),
key: 'manage-point',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(BookCoins20Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-schedule' } }, { default: () => '日程' }),
key: 'manage-schedule',
icon: renderIcon(CalendarClock24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-songList' } }, { default: () => '歌单' }),
key: 'manage-songList',
icon: renderIcon(MusicalNote),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-questionBox' } }, { default: () => '棉花糖 (提问箱' }),
key: 'manage-questionBox',
icon: renderIcon(Chatbox),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-videoCollect' } }, { default: () => '视频征集' }),
key: 'manage-videoCollect',
icon: renderIcon(VideoAdd20Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-lottery' } }, { default: () => '动态抽奖' }),
key: 'manage-lottery',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
NText,
() => [
'弹幕相关',
h(
NTooltip,
{ style: 'padding: 0;' },
{
trigger: () => h(NIcon, { component: Info24Filled }),
default: () => h(
NAlert,
{
type: 'warning',
size: 'small',
title: '可用性警告',
style: 'max-width: 600px;',
},
() => h('div', {}, [
' 当浏览器在后台运行时, 定时器和 Websocket 连接将受到严格限制, 这会导致弹幕接收功能无法正常工作 (详见',
h(
NButton,
{
text: true,
tag: 'a',
href: 'https://developer.chrome.com/blog/background_tabs/',
target: '_blank',
type: 'info',
},
() => '此文章',
),
'), 虽然本站已经针对此问题做出了处理, 一般情况下即使掉线了也会重连, 不过还是有可能会遗漏事件',
h('br'),
'为避免这种情况, 建议注册本站账后使用',
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
tag: 'a',
href: 'https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs',
target: '_blank',
},
() => 'VtsuruEventFetcher',
),
', 否则请在使用功能时尽量保持网页在前台运行, 同时关闭浏览器的 页面休眠/内存节省 功能',
h('br'),
'Chrome: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81',
target: '_blank',
},
() => '让特定网站保持活动状态',
),
', Edge: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25',
target: '_blank',
},
() => '永远不想进入睡眠状态的网站',
),
]),
),
},
),
]
),
default: () => isBiliVerified.value
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false || !isBiliVerified.value,
children: [
{
label: () => !isBiliVerified.value ? '弹幕机' : h(
NBadge,
{ value: '新', offset: [15, 12], type: 'info' },
() => h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
),
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}
)
),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
},
{
label: () => !isBiliVerified.value ? '抽奖' : h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点播' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点歌' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '排队' : h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '读弹幕' : h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
},
/*{
label: () => !isBiliVerified.value ? '弹幕投票' : h(
RouterLink,
{ to: { name: 'manage-danmakuVote' } },
{ default: () => '弹幕投票' },
),
key: 'manage-danmakuVote',
icon: renderIcon(Chat24Filled),
disabled: !isBiliVerified.value,
},*/
],
},
]
})
// 重发验证邮件
async function resendEmail() {
try {
const data = await QueryGetAPI(ACCOUNT_API_URL + 'send-verify-email')
if (data.code === 200) {
canResendEmail.value = false
message.success('发送成功, 请检查你的邮箱. 如果没有收到, 请检查垃圾邮件')
if (accountInfo.value && accountInfo.value.nextSendEmailTime) {
accountInfo.value.nextSendEmailTime += 1000 * 60
}
} else {
message.error('发送失败: ' + data.message)
}
} catch (err) {
message.error('发送失败')
}
}
// 登出操作
function logout() {
cookie.value = undefined
window.location.reload()
}
// 播放下一首音乐
function onNextMusic() {
musicRquestStore.nextMusic()
}
// 跳转到认证页面
function gotoAuthPage() {
if (!accountInfo.value?.biliUserAuthInfo) {
message.error('你尚未进行 Bilibili 认证, 请前往面板进行认证和绑定')
return
}
useBiliAuth()
.setCurrentAuth(accountInfo.value?.biliUserAuthInfo.token)
.then(() => {
NavigateToNewTab('/bili-user')
})
}
onMounted(() => {
// 检查邮箱验证状态
if (accountInfo.value?.isEmailVerified === false) {
if ((accountInfo.value?.nextSendEmailTime ?? -1) <= 0) {
canResendEmail.value = true
}
}
})
</script>
<template>
<NLayout
v-if="accountInfo.id"
style="height: 100vh"
>
<!-- 顶部导航栏 -->
<NLayoutHeader
bordered
style="height: var(--vtsuru-header-height); padding: 10px 15px 5px 15px"
>
<NPageHeader>
<template #title>
<NText
strong
style="font-size: 1.4rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)"
>
VTSURU CENTER
</NText>
</template>
<template #extra>
<NSpace
align="center"
justify="center"
>
<!-- 主题切换开关 -->
<NSwitch
:default-value="!isDarkMode"
@update:value="(value) => (themeType = value ? ThemeType.Light : ThemeType.Dark)"
>
<template #checked>
<NIcon :component="Sunny" />
</template>
<template #unchecked>
<NIcon :component="Moon" />
</template>
</NSwitch>
<NButton
size="small"
type="primary"
@click="$router.push({ name: 'user-index', params: { id: accountInfo?.name } })"
>
回到展示页
</NButton>
</NSpace>
</template>
</NPageHeader>
</NLayoutHeader>
<!-- 主布局部分 -->
<NLayout
has-sider
style="height: calc(100vh - 50px)"
>
<!-- 侧边导航栏 -->
<NLayoutSider
v-if="accountInfo?.isEmailVerified"
ref="sider"
bordered
show-trigger
collapse-mode="width"
:default-collapsed="windowWidth < 750"
:collapsed-width="64"
:width="180"
:native-scrollbar="false"
:scrollbar-props="{ trigger: 'none', style: {} }"
>
<!-- 顶部功能按钮区 -->
<NSpace
vertical
style="margin-top: 16px"
align="center"
>
<NSpace justify="center">
<NButton
type="info"
style="width: 100%"
@click="$router.push({ name: 'manage-index' })"
>
<template #icon>
<NIcon :component="BrowsersOutline" />
</template>
<template v-if="width >= 180">
面板
</template>
</NButton>
<NTooltip v-if="width >= 180">
<template #trigger>
<NButton @click="$router.push({ name: 'manage-feedback' })">
<template #icon>
<NIcon :component="PersonFeedback24Filled" />
</template>
</NButton>
</template>
反馈
</NTooltip>
</NSpace>
<!-- B站认证用户入口 -->
<NButton
v-if="accountInfo.biliUserAuthInfo"
type="info"
secondary
@click="gotoAuthPage()"
>
<template #icon>
<NIcon :component="Person48Filled" />
</template>
<template v-if="width >= 180">
认证用户主页
</template>
</NButton>
</NSpace>
<!-- 主导航菜单 -->
<NMenu
style="margin-top: 12px"
:disabled="accountInfo?.isEmailVerified !== true"
:default-value="($route.meta.parent as string) ?? $route.name?.toString()"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
/>
<!-- 底部信息区 -->
<NSpace
v-if="width > 150"
justify="center"
align="center"
vertical
>
<NText depth="3">
有更多功能建议请
<NButton
text
type="info"
@click="$router.push({ name: 'manage-feedback' })"
>
反馈
</NButton>
</NText>
<NText depth="3">
<NButton
text
type="info"
@click="$router.push({ name: 'about' })"
>
关于本站
</NButton>
</NText>
</NSpace>
<NDivider style="margin-bottom: 8px;" />
<NFlex
justify="center"
align="center"
>
<NText
:style="`font-size: 12px; text-align: center;color: ${isDarkMode ? '#555' : '#c0c0c0'};visibility: ${width < 180 ? 'hidden' : 'visible'}`"
>
By Megghy
</NText>
</NFlex>
</NLayoutSider>
<!-- 内容区域 -->
<NLayout>
<!-- 主内容区域 -->
<NScrollbar :style="`height: calc(100vh - var(--vtsuru-header-height) - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px)">
<NElement>
<!-- 已验证邮箱的用户显示内容 -->
<RouterView
v-if="accountInfo?.isEmailVerified"
v-slot="{ Component }"
>
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>
<NSpin show />
</template>
</Suspense>
</KeepAlive>
</RouterView>
<!-- 未验证邮箱的提示 -->
<template v-else>
<NCard>
<NSpace
vertical
size="large"
align="center"
>
<NFlex
justify="center"
align="center"
vertical
>
<NIcon
size="48"
color="#2080f0"
>
<Mail24Filled />
</NIcon>
<NText style="font-size: 20px; margin-top: 16px; font-weight: 500;">
请验证您的邮箱
</NText>
<NText
depth="3"
style="text-align: center; margin-top: 8px;"
>
我们已向您的邮箱发送了验证链接请查收并点击链接完成验证
</NText>
</NFlex>
<NAlert
type="warning"
style="max-width: 450px;"
>
<template #icon>
<NIcon>
<Info24Filled />
</NIcon>
</template>
如果长时间未收到邮件请检查垃圾邮件文件夹或点击下方按钮重新发送
</NAlert>
<NSpace>
<NButton
type="primary"
:disabled="!canResendEmail"
style="min-width: 140px;"
@click="resendEmail"
>
<template #icon>
<NIcon>
<Mail24Filled />
</NIcon>
</template>
重新发送验证邮件
</NButton>
<NTag
v-if="!canResendEmail"
type="warning"
round
>
<NCountdown
:duration="(accountInfo?.nextSendEmailTime ?? 0) - Date.now()"
@finish="canResendEmail = true"
/>
后可重新发送
</NTag>
</NSpace>
<NDivider style="width: 80%; min-width: 250px;" />
<NPopconfirm @positive-click="logout">
<template #trigger>
<NButton secondary>
<template #icon>
<NIcon>
<PersonFeedback24Filled />
</NIcon>
</template>
切换账号
</NButton>
</template>
确定要登出当前账号吗
</NPopconfirm>
</NSpace>
</NCard>
</template>
<NBackTop />
</NElement>
</NLayoutContent>
</NScrollbar>
<!-- 音乐播放器区域 -->
<NLayoutFooter :style="`height: ${aplayerHeight}px;overflow: auto`">
<div style="display: flex; align-items: center; margin: 0 10px">
<APlayer
v-if="musicRquestStore.aplayerMusics.length > 0"
ref="aplayer"
v-model:music="musicRquestStore.currentMusic"
v-model:volume="musicRquestStore.settings.volume"
v-model:shuffle="musicRquestStore.settings.shuffle"
v-model:repeat="musicRquestStore.settings.repeat"
:list="musicRquestStore.aplayerMusics"
:list-max-height="'200'"
mutex
list-folded
style="flex: 1; min-width: 400px"
@ended="musicRquestStore.onMusicEnd"
@play="musicRquestStore.onMusicPlay"
/>
<NSpace vertical>
<NTag
:bordered="false"
type="info"
size="small"
>
队列: {{ musicRquestStore.waitingMusics.length }}
</NTag>
<NButton
size="small"
type="info"
@click="onNextMusic"
>
下一首
</NButton>
</NSpace>
</div>
</NLayoutFooter>
</NLayout>
</NLayout>
</NLayout>
<!-- 未登录时显示的登录/注册界面 -->
<template v-else>
<NLayoutContent
style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
background: linear-gradient(135deg, rgba(250,250,250,0.8) 0%, rgba(240,240,245,0.9) 100%);
padding: 0;
margin: 0;
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
overflow: auto;
"
:class="isDarkMode ? 'login-dark-bg' : ''"
>
<template v-if="!isLoadingAccount">
<NCard
class="login-card"
:bordered="false"
>
<template #header>
<NFlex
justify="center"
align="center"
style="padding: 12px 0;"
>
<NText
strong
style="font-size: 1.8rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); background-image: linear-gradient(to right, #36d1dc, #5b86e5); -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>
VTSURU CENTER
</NText>
</NFlex>
</template>
<NSpace
vertical
size="large"
style="padding: 8px 0;"
>
<NFlex
justify="center"
align="center"
>
<NText style="font-size: 16px; text-align: center;">
请登录或注册后使用
</NText>
</NFlex>
<NAlert
type="info"
style="border-radius: 8px;"
>
<NFlex
vertical
align="center"
size="small"
>
<div style="text-align: center;">
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录, 直接访问认证完成后给出的链接即可
</div>
<NFlex
justify="center"
style="width: 100%; margin-top: 8px;"
>
<NButton
type="primary"
size="small"
@click="$router.push({ name: 'bili-user' })"
>
<template #icon>
<NIcon :component="BrowsersOutline" />
</template>
前往 Bilibili 认证用户主页
</NButton>
</NFlex>
</NFlex>
</NAlert>
<NDivider style="margin: 8px 0;" />
<RegisterAndLogin />
<NFlex justify="center">
<NButton
secondary
tag="a"
href="/"
style="min-width: 100px;"
>
回到主页
</NButton>
</NFlex>
</NSpace>
</NCard>
</template>
<template v-else>
<NCard
class="loading-card"
:bordered="false"
>
<NFlex
vertical
justify="center"
align="center"
style="padding: 20px 10px;"
>
<NSpin
:loading="isLoadingAccount"
size="large"
>
<NText>正在请求账户数据...</NText>
</NSpin>
</NFlex>
</NCard>
</template>
</NLayoutContent>
</template>
</template>
<style scoped>
.login-dark-bg {
background: linear-gradient(135deg, rgba(30, 30, 35, 0.9) 0%, rgba(20, 20, 25, 0.95) 100%) !important;
}
.login-card {
max-width: 520px;
width: 90%;
min-width: 300px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
.loading-card {
min-width: 280px;
width: 90%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
@media (max-width: 480px) {
.login-card, .loading-card {
width: 95%;
margin: 8px;
}
}
</style>