Files
vtsuru.live/src/views/ManageLayout.vue

1499 lines
47 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 {
BookCoins20Filled,
CalendarClock24Filled,
Chat24Filled,
Info24Filled,
Live24Filled,
Lottery24Filled,
Mail24Filled,
PeopleQueue24Filled,
Person48Filled,
PersonFeedback24Filled,
TabletSpeaker24Filled,
VehicleShip24Filled,
VideoAdd20Filled,
} from '@vicons/fluent'
import { AnalyticsSharp, Bookmark, BookmarkOutline, BrowsersOutline, Chatbox, ChevronDown, ChevronUp, Eye, Moon, MusicalNote, Pause, Play, PlayBack, PlayForward, Sunny, TrashBin, VolumeHigh } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
NAlert,
NBackTop,
NButton,
NCard,
NCountdown,
NDivider,
NElement,
NFlex,
NIcon,
NLayout,
NLayoutContent,
NLayoutFooter,
NLayoutHeader,
NLayoutSider,
NMenu,
NPageHeader,
NPopconfirm,
NScrollbar,
NSlider,
NSpace,
NSpin,
NSwitch,
NTag,
NText,
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, h, onMounted, ref, watch } from 'vue'
// @ts-ignore
import APlayer from 'vue3-aplayer'
import { RouterLink, useRoute } from 'vue-router'
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 { ACCOUNT_API_URL } from '@/data/constants'
import { useBiliAuth } from '@/store/useBiliAuth'
import { useMusicRequestProvider } from '@/store/useMusicRequest'
import { isDarkMode, NavigateToNewTab } from '@/Utils'
// 全局状态和工具
const accountInfo = useAccount()
const message = useMessage()
const route = useRoute()
const windowWidth = window.innerWidth
const themeType = useStorage('Settings.Theme', ThemeType.Auto)
// 收藏功能相关
const favoriteMenuItems = useStorage<string[]>('Settings.FavoriteMenuItems', [])
const isFavorite = (key: string) => favoriteMenuItems.value?.includes(key)
function toggleFavorite(key: string) {
const list = favoriteMenuItems.value ?? []
const idx = list.indexOf(key)
if (idx === -1) list.unshift(key)
else list.splice(idx, 1)
favoriteMenuItems.value = [...list]
}
function renderFavoriteExtra(key: string) {
return () =>
h(
'span',
{ class: ['menu-fav', isFavorite(key) ? 'active' : ''] },
[
h(
NTooltip,
{ placement: 'right' },
{
trigger: () =>
h(
NButton,
{
text: true,
size: 'tiny',
circle: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
toggleFavorite(key)
},
style: 'padding: 0; height: 18px; width: 18px;',
},
{
icon: () =>
h(NIcon, {
component: isFavorite(key) ? Bookmark : BookmarkOutline,
size: 16,
color: isFavorite(key) ? '#f5c451' : undefined,
}),
},
),
default: () => (isFavorite(key) ? '取消收藏' : '收藏'),
},
),
],
)
}
// 侧边栏和布局相关
const sider = ref()
const { width } = useElementSize(sider)
const musicPlayerCardRef = ref(null)
const { height: musicPlayerCardHeight } = useElementSize(musicPlayerCardRef)
// 页面类型计算
const type = computed(() => route.meta.danmaku ? 'danmaku' : '')
// 音乐请求服务相关
const musicRquestStore = useMusicRequestProvider()
// 优化音乐播放器高度计算逻辑
const aplayerHeight = computed(() => {
if (!isPlayerVisible.value) {
return '0'
}
// Add 16px for NCard's top/bottom margin.
return `${musicPlayerCardHeight.value + 16}`
})
// 播放器是否可见
const isPlayerVisible = computed(
() => musicRquestStore.originMusics.length > 0 || musicRquestStore.waitingMusics.length > 0,
)
// 音乐播放器相关状态
const isPlayerMinimized = useStorage('Settings.MusicPlayer.Minimized', false)
const playerVolume = computed({
get: () => musicRquestStore.settings.volume,
set: value => musicRquestStore.settings.volume = value,
})
const aplayer = ref()
watch(aplayer, () => {
musicRquestStore.aplayerRef = aplayer.value
})
// 当前播放信息
const currentPlayingInfo = computed(() => {
if (musicRquestStore.currentOriginMusic && musicRquestStore.isPlayingOrderMusic) {
return {
type: 'request',
info: `正在播放 ${musicRquestStore.currentOriginMusic.from.name} 点的歌`,
}
} else if (musicRquestStore.currentMusic && musicRquestStore.currentMusic.title) {
return {
type: 'normal',
info: '正在播放背景音乐',
}
}
return null
})
// 邮箱验证相关
const canResendEmail = ref(false)
const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
// 图标渲染函数 - 用于菜单项
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
// 菜单配置(支持分组与收藏置顶)
const menuOptions = computed(() => {
// 通用的菜单项工厂,自动挂载收藏按钮到叶子节点
const withFavoriteExtra = (item: any): any => {
if (item?.children?.length) {
return {
...item,
children: item.children.map(withFavoriteExtra),
}
}
return {
...item,
extra: width.value >= 180 ? renderFavoriteExtra(item.key) : undefined,
}
}
const commonItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(AnalyticsSharp),
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-live' } }, { default: () => '直播记录' }),
key: 'manage-live',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Live24Filled),
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-analyze' } }, { default: () => '直播数据' }),
key: 'manage-analyze',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Eye),
}),
]
const dataItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-event' } }, { default: () => '舰长和SC' }),
key: 'manage-event',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(VehicleShip24Filled),
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-point' } }, { default: () => '积分和礼物' }),
key: 'manage-point',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(BookCoins20Filled),
}),
]
const toolsItems = [
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-schedule' } }, { default: () => '日程' }),
key: 'manage-schedule',
icon: renderIcon(CalendarClock24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-songList' } }, { default: () => '歌单' }),
key: 'manage-songList',
icon: renderIcon(MusicalNote),
disabled: accountInfo.value?.isEmailVerified === false,
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-questionBox' } }, { default: () => '棉花糖 (提问箱' }),
key: 'manage-questionBox',
icon: renderIcon(Chatbox),
disabled: accountInfo.value?.isEmailVerified === false,
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-videoCollect' } }, { default: () => '视频征集' }),
key: 'manage-videoCollect',
icon: renderIcon(VideoAdd20Filled),
disabled: accountInfo.value?.isEmailVerified === false,
}),
withFavoriteExtra({
label: () => h(RouterLink, { to: { name: 'manage-lottery' } }, { default: () => '动态抽奖' }),
key: 'manage-lottery',
icon: renderIcon(Lottery24Filled),
}),
]
const danmakuItem = {
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: [
withFavoriteExtra({
label: () => !isBiliVerified.value
? '弹幕机'
: h(NTooltip, {}, {
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
),
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '抽奖'
: h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '点播'
: h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '点歌'
: h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '排队'
: h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '读弹幕'
: h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
}),
/* withFavoriteExtra({
label: () => !isBiliVerified.value ? '弹幕投票' : h(
RouterLink,
{ to: { name: 'manage-danmakuVote' } },
{ default: () => '弹幕投票' },
),
key: 'manage-danmakuVote',
icon: renderIcon(Chat24Filled),
disabled: !isBiliVerified.value,
}), */
],
}
// 扁平化叶子项用于收藏置顶
const flattenLeaf = (items: any[]): any[] => {
const result: any[] = []
for (const it of items) {
if (it.children?.length) {
result.push(...flattenLeaf(it.children))
} else {
result.push(it)
}
}
return result
}
const allLeaf = [
...flattenLeaf(commonItems),
...flattenLeaf(dataItems),
...flattenLeaf(toolsItems),
...flattenLeaf(danmakuItem.children ?? []),
]
const leafMap = new Map(allLeaf.map(i => [i.key, i]))
const favorites = (favoriteMenuItems.value ?? [])
.map(k => leafMap.get(k))
.filter(Boolean) as any[]
const notFav = (i: any) => !isFavorite(i.key)
const danmakuChildren = (danmakuItem.children ?? []).filter(notFav)
const danmakuForGroup = danmakuChildren.length > 0 ? { ...danmakuItem, children: danmakuChildren } : null
const groups: any[] = []
if (favorites.length > 0) {
groups.push({ type: 'group', key: 'group-favorites', label: '我的收藏', children: favorites })
}
if (commonItems.filter(notFav).length > 0) {
groups.push({ type: 'group', key: 'group-common', label: '常用', children: commonItems.filter(notFav) })
}
if (dataItems.filter(notFav).length > 0) {
groups.push({ type: 'group', key: 'group-data', label: '数据', children: dataItems.filter(notFav) })
}
const toolsGroupChildren = [
...(danmakuForGroup ? [danmakuForGroup] : []),
...toolsItems.filter(notFav),
]
if (toolsGroupChildren.length > 0) {
groups.push({ type: 'group', key: 'group-tools', label: '互动与工具', children: toolsGroupChildren })
}
return groups
})
// 重发验证邮件
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 togglePlay() {
if (aplayer.value) {
const audio = aplayer.value.audio
if (audio.paused) {
aplayer.value.play()
} else {
aplayer.value.pause()
}
}
}
function onPreviousMusic() {
if (aplayer.value) {
// 如果当前播放时间大于3秒则重新开始播放当前歌曲
if (aplayer.value.audio.currentTime > 3) {
aplayer.value.audio.currentTime = 0
} else {
// 否则播放上一首
const currentIndex = musicRquestStore.aplayerMusics.findIndex(
music => music.id === musicRquestStore.currentMusic.id,
)
if (currentIndex > 0) {
musicRquestStore.currentMusic = musicRquestStore.aplayerMusics[currentIndex - 1]
aplayer.value.thenPlay()
}
}
}
}
function clearWaitingQueue() {
musicRquestStore.waitingMusics.splice(0)
message.success('已清空等待队列')
}
function togglePlayerMinimize() {
isPlayerMinimized.value = !isPlayerMinimized.value
}
// 跳转到认证页面
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
class="manage-sider-menu"
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"
:icon-size="16"
:root-indent="10"
:indent="12"
: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
type="primary"
strong
>
{{ accountInfo?.bindEmail }}
</NText> 发送了验证链接请查收并点击链接完成验证
</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
v-if="isPlayerVisible"
:style="`height: ${aplayerHeight}px; overflow: hidden; transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);`"
class="music-player-footer"
>
<NCard
ref="musicPlayerCardRef"
:bordered="false"
embedded
:content-style="isPlayerMinimized ? 'padding: 0' : undefined"
size="small"
class="music-player-card"
style="
margin: 8px;
border-radius: 12px;
backdrop-filter: blur(10px);
"
>
<!-- 播放器头部控制栏 -->
<template #header>
<NFlex
justify="space-between"
align="center"
style="padding: 0;"
>
<NFlex
align="center"
size="small"
>
<NIcon
:component="MusicalNote"
size="16"
:style="`color: ${isDarkMode ? '#a8dadc' : '#457b9d'}`"
/>
<NText
:depth="2"
style="font-size: 13px; font-weight: 500;"
>
音乐播放器
</NText>
<NTag
v-if="currentPlayingInfo && !isPlayerMinimized"
:type="currentPlayingInfo.type === 'request' ? 'success' : 'info'"
size="small"
round
:bordered="false"
style="font-size: 11px; padding: 2px 8px;"
>
{{ currentPlayingInfo.info }}
</NTag>
<template v-if="isPlayerMinimized">
<NText
v-if="musicRquestStore.currentMusic.title"
style="font-size: 13px; max-width: 250px; margin-left: 12px"
:ellipsis="{ tooltip: true }"
>
{{ musicRquestStore.currentMusic.title }} - {{ musicRquestStore.currentMusic.artist }}
</NText>
<NText
v-else
depth="3"
style="font-size: 13px; margin-left: 12px"
>
暂无播放
</NText>
</template>
</NFlex>
<NFlex
align="center"
size="small"
>
<template v-if="isPlayerMinimized">
<NTag
v-if="musicRquestStore.waitingMusics.length > 0"
type="warning"
size="small"
round
:bordered="false"
>
{{ musicRquestStore.waitingMusics.length }}
</NTag>
<NButton
circle
size="tiny"
tertiary
:disabled="musicRquestStore.aplayerMusics.length === 0"
@click.stop="togglePlay"
>
<template #icon>
<NIcon
:component="aplayer?.audio?.paused !== false ? Play : Pause"
size="14"
/>
</template>
</NButton>
<NButton
circle
size="tiny"
tertiary
:disabled="musicRquestStore.waitingMusics.length === 0 && musicRquestStore.aplayerMusics.length <= 1"
@click.stop="onNextMusic"
>
<template #icon>
<NIcon
:component="PlayForward"
size="14"
/>
</template>
</NButton>
</template>
<NTooltip>
<template #trigger>
<NButton
:type="isPlayerMinimized ? 'primary' : 'default'"
tertiary
size="small"
circle
@click="togglePlayerMinimize"
>
<template #icon>
<NIcon :component="isPlayerMinimized ? ChevronUp : ChevronDown" />
</template>
</NButton>
</template>
{{ isPlayerMinimized ? '展开播放器' : '收起播放器' }}
</NTooltip>
</NFlex>
</NFlex>
</template>
<!-- 主播放器内容 -->
<div v-show="!isPlayerMinimized">
<NFlex
align="center"
:wrap="false"
style="gap: 12px;"
>
<!-- APlayer组件 -->
<div style="flex: 1; min-width: 280px;">
<APlayer
ref="aplayer"
v-model:music="musicRquestStore.currentMusic"
v-model:volume="playerVolume"
v-model:shuffle="musicRquestStore.settings.shuffle"
v-model:repeat="musicRquestStore.settings.repeat"
:list="musicRquestStore.aplayerMusics"
list-max-height="200"
mutex
list-folded
style="border-radius: 8px;"
@ended="musicRquestStore.onMusicEnd"
@play="musicRquestStore.onMusicPlay"
/>
</div>
<!-- 右侧控制面板 -->
<div class="music-control-panel">
<!-- 播放控制按钮 -->
<NFlex
vertical
size="small"
align="center"
style="min-width: 100px;"
>
<NText
depth="3"
style="font-size: 12px; margin-bottom: 4px;"
>
播放控制
</NText>
<NFlex
size="small"
justify="center"
>
<NTooltip>
<template #trigger>
<NButton
circle
secondary
size="small"
:disabled="musicRquestStore.aplayerMusics.length === 0"
@click="onPreviousMusic"
>
<template #icon>
<NIcon :component="PlayBack" />
</template>
</NButton>
</template>
上一首 / 重播
</NTooltip>
<NTooltip>
<template #trigger>
<NButton
circle
type="primary"
size="small"
:disabled="musicRquestStore.aplayerMusics.length === 0"
@click="togglePlay"
>
<template #icon>
<NIcon :component="aplayer?.audio?.paused !== false ? Play : Pause" />
</template>
</NButton>
</template>
{{ aplayer?.audio?.paused !== false ? '播放' : '暂停' }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton
circle
secondary
size="small"
:disabled="musicRquestStore.waitingMusics.length === 0 && musicRquestStore.aplayerMusics.length <= 1"
@click="onNextMusic"
>
<template #icon>
<NIcon :component="PlayForward" />
</template>
</NButton>
</template>
下一首
</NTooltip>
</NFlex>
</NFlex>
<!-- 队列信息和管理 -->
<NFlex
vertical
size="small"
align="center"
style="min-width: 100px;"
>
<NText
depth="3"
style="font-size: 12px; margin-bottom: 4px;"
>
队列管理
</NText>
<NFlex
vertical
size="small"
align="center"
>
<NTag
:bordered="false"
:type="musicRquestStore.waitingMusics.length > 0 ? 'warning' : 'info'"
size="small"
round
style="min-width: 80px; text-align: center;"
>
等待: {{ musicRquestStore.waitingMusics.length }}
</NTag>
<NTag
:bordered="false"
type="success"
size="small"
round
style="min-width: 80px; text-align: center;"
>
歌单: {{ musicRquestStore.originMusics.length }}
</NTag>
<NTooltip v-if="musicRquestStore.waitingMusics.length > 0">
<template #trigger>
<NButton
size="tiny"
type="error"
secondary
@click="clearWaitingQueue"
>
<template #icon>
<NIcon
:component="TrashBin"
size="12"
/>
</template>
清空队列
</NButton>
</template>
清空所有等待中的点歌
</NTooltip>
</NFlex>
</NFlex>
<!-- 音量控制 -->
<NFlex
vertical
size="small"
align="center"
style="min-width: 100px;"
>
<NFlex
align="center"
size="small"
>
<NIcon
:component="VolumeHigh"
size="14"
:depth="3"
/>
<NText
depth="3"
style="font-size: 12px;"
>
音量
</NText>
</NFlex>
<NSlider
v-model:value="playerVolume"
:min="0"
:max="1"
:step="0.01"
style="width: 80px;"
:tooltip="false"
size="small"
/>
<NText
depth="3"
style="font-size: 11px;"
>
{{ Math.round(playerVolume * 100) }}%
</NText>
</NFlex>
</div>
</NFlex>
</div>
<!-- 最小化状态显示 -->
<template v-if="isPlayerMinimized">
<!-- Content is moved to the header for minimized state -->
</template>
</NCard>
</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;
}
/* 音乐播放器样式 */
.music-player-footer {
background: var(--body-color);
}
.music-player-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.music-player-card:hover {
transform: translateY(-1px);
box-shadow: 0 12px 40px rgba(0,0,0,0.15) !important;
}
.music-control-panel {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
min-width: 300px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.music-control-panel {
min-width: auto;
flex-direction: row;
justify-content: space-around;
align-items: center;
gap: 8px;
width: 100%;
}
.music-control-panel > div {
min-width: auto !important;
flex: 1;
}
}
@media (max-width: 480px) {
.login-card, .loading-card {
width: 95%;
margin: 8px;
}
.music-player-card {
margin: 4px !important;
}
.music-control-panel {
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
}
.music-control-panel > div {
width: 100% !important;
min-width: auto !important;
}
}
/* 播放器按钮悬停效果 */
.music-player-card .n-button {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.music-player-card .n-button:hover {
transform: translateY(-1px);
}
/* 音量滑块样式 */
.music-player-card .n-slider {
transition: all 0.2s ease;
}
.music-player-card .n-slider:hover {
transform: scale(1.02);
}
/* 标签动画 */
.music-player-card .n-tag {
transition: all 0.2s ease;
}
.music-player-card .n-tag:hover {
transform: scale(1.05);
}
/* 侧边栏菜单收藏按钮与紧凑样式 */
:deep(.manage-sider-menu .menu-fav) {
opacity: 0;
width: 0;
margin-left: 0;
overflow: hidden;
transition: opacity 0.15s ease, width 0.15s ease, margin-left 0.15s ease;
pointer-events: none; /* 不阻挡文字区域点击 */
display: inline-flex;
align-items: center;
justify-content: center;
}
:deep(.manage-sider-menu .n-menu-item:hover .menu-fav),
:deep(.manage-sider-menu .menu-fav.active) {
opacity: 1;
width: 18px;
margin-left: 6px;
pointer-events: auto;
}
:deep(.manage-sider-menu .menu-fav .n-button) {
padding: 0;
height: 18px;
width: 18px;
}
/* 略微收紧图标与文本的间距,提升有效可读宽度 */
:deep(.manage-sider-menu .n-menu-item .n-menu-item-content .n-menu-item-content__icon) {
margin-right: 6px;
}
:deep(.manage-sider-menu .n-menu-item .n-menu-item-content) {
padding-right: 6px;
}
</style>