refactor: 将 OBS 通知从 notification 改为 message 组件并优化点歌页面布局

- 将 OBS 通知组件从 n-notification 改为 n-message,简化显示逻辑
- 优化通知内容格式,将标题和元信息作为前缀显示
- 调整通知持续时间:成功 4 秒,错误 6 秒
- 重构点歌页面布局,将功能开关和 OBS 组件按钮移至顶部卡片
- 在 MinimalRequestOBS 组件中添加点歌要求信息显示(前缀、允许类型、SC、粉丝牌等)
- 优化点歌队
This commit is contained in:
2025-11-21 23:25:29 +08:00
parent 9691704da5
commit c79656bc77
10 changed files with 1016 additions and 612 deletions

View File

@@ -44,32 +44,30 @@ export const useOBSNotification = defineStore('obs-notification', () => {
} }
function showNotification(payload: ObsNotificationPayload) { function showNotification(payload: ObsNotificationPayload) {
const notification = window.$notification const message = window.$message
if (!notification) { if (!message) {
console.warn('[OBS] notification instance missing') console.warn('[OBS] message instance missing')
return return
} }
console.log('[OBS] 收到通知', payload) console.log('[OBS] 收到通知', payload)
const method = payload.Type === 'success' ? 'success' : 'error' const method = payload.Type === 'success' ? 'success' : 'error'
const title = resolveTitle(payload) const title = resolveTitle(payload)
const description = payload.Message || '未知通知'
const meta = resolveMeta(payload) const meta = resolveMeta(payload)
const prefix = [title, meta].filter(Boolean).join(' · ')
const description = payload.Message || '未知通知'
const finalContent = prefix ? `${prefix}\n${description}` : description
if (typeof notification[method] === 'function') { if (typeof message[method] === 'function') {
notification[method]({ message[method](finalContent, {
title: payload.Type === 'success' ? '成功' : `失败`, duration: method === 'error' ? 6000 : 4000,
description, closable: true,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
}) })
} else { } else {
notification.create({ message.create(finalContent, {
title: payload.Type === 'success' ? '成功' : `失败`,
content: description,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
type: method, type: method,
duration: method === 'error' ? 6000 : 4000,
closable: true,
}) })
} }
} }

View File

@@ -68,6 +68,7 @@ import { copyToClipboard } from '@/Utils'
import PointOrderManage from './PointOrderManage.vue' import PointOrderManage from './PointOrderManage.vue'
import PointSettings from './PointSettings.vue' import PointSettings from './PointSettings.vue'
import PointUserManage from './PointUserManage.vue' import PointUserManage from './PointUserManage.vue'
import PointTestPanel from './PointTestPanel.vue'
const message = useMessage() const message = useMessage()
const accountInfo = useAccount() const accountInfo = useAccount()
@@ -734,6 +735,15 @@ onMounted(() => { })
> >
<PointSettings /> <PointSettings />
</NTabPane> </NTabPane>
<!-- 测试标签页 -->
<NTabPane
name="test"
tab="测试"
display-directive="show:lazy"
>
<PointTestPanel />
</NTabPane>
</NTabs> </NTabs>
<!-- 添加/修改礼物模态框 --> <!-- 添加/修改礼物模态框 -->

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
NCard,
NButton,
NForm,
NFormItem,
NInputNumber,
NInput,
NSelect,
NFlex,
NAlert,
NStatistic,
NPopconfirm,
useMessage,
NSpin,
NTag,
NDivider,
} from 'naive-ui'
import { EventDataTypes } from '@/api/api-models'
import { useAccount } from '@/api/account'
import { QueryPostAPI as Post, QueryGetAPI as Get } from '@/api/query'
import { POINT_API_URL } from '@/data/constants'
const message = useMessage()
const accountInfo = useAccount()
// 测试表单数据
const testForm = ref({
type: EventDataTypes.Message,
giftName: '',
giftPrice: 0,
giftCount: 1,
guardLevel: '舰长',
})
// 测试账户积分
const testAccountPoint = ref<number>(0)
const isLoading = ref(false)
const isTesting = ref(false)
// 事件类型选项
const eventTypeOptions = [
{ label: '弹幕', value: EventDataTypes.Message },
{ label: '礼物', value: EventDataTypes.Gift },
{ label: '上舰', value: EventDataTypes.Guard },
{ label: 'SC', value: EventDataTypes.SC },
]
// 舰长等级选项
const guardLevelOptions = [
{ label: '舰长', value: '舰长' },
{ label: '提督', value: '提督' },
{ label: '总督', value: '总督' },
]
// 是否显示礼物相关字段
const showGiftFields = computed(() => testForm.value.type === EventDataTypes.Gift)
const showGuardFields = computed(() => testForm.value.type === EventDataTypes.Guard)
const showPriceField = computed(
() => testForm.value.type === EventDataTypes.SC || testForm.value.type === EventDataTypes.Gift
)
// 获取测试账户积分
async function fetchTestAccountPoint() {
isLoading.value = true
try {
const res = await Get<number>(POINT_API_URL + 'get-test-account-point')
if (res.code === 200) {
testAccountPoint.value = res.data ?? 0
}
} catch (err) {
console.error('获取测试账户积分失败:', err)
} finally {
isLoading.value = false
}
}
// 执行测试
async function runTest() {
isTesting.value = true
try {
const payload: any = {
type: testForm.value.type,
}
// 根据类型添加参数
if (testForm.value.type === EventDataTypes.Gift) {
if (!testForm.value.giftName.trim()) {
message.error('请输入礼物名称')
return
}
if (testForm.value.giftPrice < 0) {
message.error('礼物价格不能为负数')
return
}
payload.giftName = testForm.value.giftName
payload.giftPrice = testForm.value.giftPrice
payload.giftCount = testForm.value.giftCount
} else if (testForm.value.type === EventDataTypes.Guard) {
payload.guardLevel = testForm.value.guardLevel
} else if (testForm.value.type === EventDataTypes.SC) {
if (testForm.value.giftPrice <= 0) {
message.error('SC价格必须大于0')
return
}
payload.giftPrice = testForm.value.giftPrice
}
const res = await Post<{ success: boolean; message: string; pointsAwarded?: number }>(POINT_API_URL + 'test-point',
payload
)
if (res.code === 200 && res.data) {
if (res.data.success) {
message.success(res.data.message)
// 刷新测试账户积分
await fetchTestAccountPoint()
} else {
message.warning(res.data.message)
}
} else {
message.error(res.message || '测试失败')
}
} catch (err: any) {
message.error(`测试失败: ${err.message || err}`)
console.error('测试失败:', err)
} finally {
isTesting.value = false
}
}
// 重置测试账户
async function resetTestAccount() {
isLoading.value = true
try {
const res = await Post(POINT_API_URL + 'reset-test-account', {})
if (res.code === 200) {
message.success('测试账户已重置')
testAccountPoint.value = 0
} else {
message.error(res.message || '重置失败')
}
} catch (err: any) {
message.error(`重置失败: ${err.message || err}`)
console.error('重置失败:', err)
} finally {
isLoading.value = false
}
}
// 初始化时获取积分
fetchTestAccountPoint()
</script>
<template>
<NCard title="积分测试系统">
<template #header-extra>
<NTag type="info">
测试账户 OUId: 00000000-0000-0000-0000-000000000000
</NTag>
</template>
<NSpin :show="isLoading">
<NFlex
vertical
:gap="16"
>
<NAlert
type="info"
closable
>
此功能用于测试积分系统的配置所有测试事件将记录到一个专用的 mock 账户OUId=0你可以在这里测试不同类型事件的积分获取情况
</NAlert>
<!-- 测试账户积分显示 -->
<NCard
size="small"
:bordered="false"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
>
<NFlex
justify="space-between"
align="center"
>
<div>
<NStatistic
label="测试账户当前积分"
:value="testAccountPoint"
style="color: white"
>
<template #suffix>
<span style="color: white; font-size: 16px"></span>
</template>
</NStatistic>
</div>
<NPopconfirm
@positive-click="resetTestAccount"
>
<template #trigger>
<NButton
type="error"
:loading="isLoading"
secondary
>
重置积分
</NButton>
</template>
确定要重置测试账户的积分吗
</NPopconfirm>
</NFlex>
</NCard>
<NDivider>测试表单</NDivider>
<!-- 测试表单 -->
<NForm
label-placement="left"
label-width="120"
>
<NFormItem
label="事件类型"
required
>
<NSelect
v-model:value="testForm.type"
:options="eventTypeOptions"
placeholder="选择事件类型"
/>
</NFormItem>
<!-- 礼物相关字段 -->
<template v-if="showGiftFields">
<NFormItem
label="礼物名称"
required
>
<NInput
v-model:value="testForm.giftName"
placeholder="例如: 小心心、辣条"
clearable
/>
</NFormItem>
<NFormItem
label="礼物价格"
required
>
<NInputNumber
v-model:value="testForm.giftPrice"
placeholder="礼物价格(元)"
:min="0"
:precision="2"
style="width: 100%"
/>
</NFormItem>
<NFormItem label="礼物数量">
<NInputNumber
v-model:value="testForm.giftCount"
placeholder="礼物数量"
:min="1"
style="width: 100%"
/>
</NFormItem>
</template>
<!-- 上舰相关字段 -->
<NFormItem
v-if="showGuardFields"
label="舰长等级"
required
>
<NSelect
v-model:value="testForm.guardLevel"
:options="guardLevelOptions"
placeholder="选择舰长等级"
/>
</NFormItem>
<!-- SC 价格字段 -->
<NFormItem
v-if="testForm.type === EventDataTypes.SC"
label="SC价格"
required
>
<NInputNumber
v-model:value="testForm.giftPrice"
placeholder="SC价格"
:min="0.01"
:precision="2"
style="width: 100%"
/>
</NFormItem>
<NFormItem>
<NFlex
justify="end"
style="width: 100%"
>
<NButton
type="primary"
:loading="isTesting"
@click="runTest"
>
执行测试
</NButton>
</NFlex>
</NFormItem>
</NForm>
<NAlert
v-if="!accountInfo?.settings?.point"
type="warning"
>
请先配置积分设置
</NAlert>
</NFlex>
</NSpin>
</NCard>
</template>
<style scoped>
:deep(.n-statistic .n-statistic-value__content) {
color: white !important;
}
:deep(.n-statistic .n-statistic__label) {
color: rgba(255, 255, 255, 0.9) !important;
}
</style>

View File

@@ -172,6 +172,39 @@ onUnmounted(() => {
description="暂无人点歌" description="暂无人点歌"
/> />
</div> </div>
<div
v-if="settings.showRequireInfo"
class="minimal-requirements"
>
<div class="minimal-requirements-row">
<div class="minimal-requirements-tag">
<span class="tag-label">前缀</span>
<span class="tag-value">{{ settings.orderPrefix || '-' }}</span>
</div>
<div class="minimal-requirements-tag">
<span class="tag-label">允许</span>
<span class="tag-value">{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join('/') : '无' }}</span>
</div>
</div>
<div class="minimal-requirements-row">
<div class="minimal-requirements-tag">
<span class="tag-label">SC</span>
<span class="tag-value">{{ settings.allowSC ? `≥ ¥${settings.scMinPrice}` : '不允许' }}</span>
</div>
<div class="minimal-requirements-tag">
<span class="tag-label">粉丝牌</span>
<span class="tag-value">
{{
settings.needWearFanMedal
? settings.fanMedalMinLevel > 0
? `${settings.fanMedalMinLevel}`
: '需佩戴'
: '无需'
}}
</span>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -370,4 +403,50 @@ onUnmounted(() => {
.minimal-list-inner.animating:hover { .minimal-list-inner.animating:hover {
animation-play-state: paused; animation-play-state: paused;
} }
.minimal-requirements {
margin-top: 6px;
padding: 4px 4px 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: #e2e8f0;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.85);
}
.minimal-requirements-row {
display: flex;
gap: 8px;
}
.minimal-requirements-tag {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
}
.tag-label {
font-size: 10px;
letter-spacing: 0.08em;
color: rgba(226, 232, 240, 0.75);
text-transform: uppercase;
opacity: 0.8;
}
.tag-value {
margin-top: 0;
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>

View File

@@ -151,18 +151,42 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<NAlert <!-- 顶部功能开关与全局操作 -->
v-if="accountInfo.id" <NCard v-if="accountInfo.id" size="small">
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.LiveRequest) ? 'success' : 'warning'" <template #header>
> <NSpace align="center" justify="space-between">
启用弹幕点播功能 <NSpace align="center">
<NText>启用弹幕点播功能</NText>
<NSwitch <NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.LiveRequest)" :value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.LiveRequest)"
@update:value="onUpdateFunctionEnable" @update:value="onUpdateFunctionEnable"
/> />
</NSpace>
<br> <!-- OBS 组件按钮 -->
<NText depth="3"> <NTooltip>
<template #trigger>
<NButton
type="primary"
size="small"
:disabled="!accountInfo"
@click="showOBSModal = true"
>
OBS 组件
</NButton>
</template>
{{ liveRequest.configCanEdit ? '配置 OBS 样式与参数' : '登陆后才可以使用此功能' }}
</NTooltip>
</NSpace>
</template>
<!-- 提示信息 -->
<NAlert
v-if="accountInfo.settings.enableFunctions.includes(FunctionTypes.LiveRequest)"
type="info"
closable
style="margin-top: 10px"
>
如果没有部署 如果没有部署
<NButton <NButton
text text
@@ -173,13 +197,14 @@ onUnmounted(() => {
> >
VtsuruEventFetcher VtsuruEventFetcher
</NButton> </NButton>
则其需要保持此页面开启才能点播, 也不要同时开多个页面, 会导致点播重复 !(部署了则不影响) 则其需要保持此页面开启才能点播, 也不要同时开多个页面, 会导致点播重复 (部署了则不影响)
</NText>
</NAlert> </NAlert>
</NCard>
<NAlert <NAlert
v-else v-else
type="warning" type="warning"
title="你尚未注册并登录 VTsuru.live, 大部分规则设置将不可用 (因为我懒得在前重写一遍逻辑" title="你尚未注册并登录 VTsuru.live, 大部分规则设置将不可用 (因为我懒得在前重写一遍逻辑)"
> >
<NButton <NButton
tag="a" tag="a"
@@ -190,27 +215,12 @@ onUnmounted(() => {
前往登录或注册 前往登录或注册
</NButton> </NButton>
</NAlert> </NAlert>
<br>
<NCard size="small"> <!-- 主体内容 -->
<NSpace align="center"> <NCard style="margin-top: 12px">
<NTooltip>
<template #trigger>
<NButton
type="primary"
:disabled="!accountInfo"
@click="showOBSModal = true"
>
OBS 组件
</NButton>
</template>
{{ liveRequest.configCanEdit ? '' : '登陆后才可以使用此功能' }}
</NTooltip>
</NSpace>
</NCard>
<br>
<NCard>
<NTabs <NTabs
v-if="!accountInfo || accountInfo.settings.enableFunctions.includes(FunctionTypes.LiveRequest)" v-if="!accountInfo || accountInfo.settings.enableFunctions.includes(FunctionTypes.LiveRequest)"
type="line"
animated animated
display-directive="show:lazy" display-directive="show:lazy"
> >
@@ -223,12 +233,13 @@ onUnmounted(() => {
<div <div
v-if="liveRequest.selectedSong" v-if="liveRequest.selectedSong"
class="song-list" class="song-list"
style="margin-bottom: 15px"
> >
<SongPlayer <SongPlayer
v-model:is-lrc-loading="liveRequest.isLrcLoading" v-model:is-lrc-loading="liveRequest.isLrcLoading"
:song="liveRequest.selectedSong" :song="liveRequest.selectedSong"
/> />
<NDivider style="margin: 15px 0 15px 0" /> <NDivider style="margin: 15px 0" />
</div> </div>
</Transition> </Transition>

View File

@@ -385,19 +385,16 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<NSpace> <NCard size="small">
<NAlert type="info"> <template #header>
搜索时会优先选择非VIP歌曲, 所以点到付费曲目时可能会是猴版或者各种奇怪的歌 <NSpace align="center" justify="space-between">
</NAlert>
</NSpace>
<NDivider />
<NSpace align="center"> <NSpace align="center">
<NButton <NButton
:type="listening ? 'error' : 'primary'" :type="listening ? 'error' : 'primary'"
:style="{ animation: listening ? 'animated-border 2.5s infinite' : '' }" :style="{ animation: listening ? 'animated-border 2.5s infinite' : '' }"
data-umami-event="Use Music Request" data-umami-event="Use Music Request"
:data-umami-event-uid="accountInfo?.biliId" :data-umami-event-uid="accountInfo?.biliId"
size="large" size="small"
@click="listening ? stopListen() : startListen()" @click="listening ? stopListen() : startListen()"
> >
{{ listening ? '停止监听' : '开始监听' }} {{ listening ? '停止监听' : '开始监听' }}
@@ -409,13 +406,9 @@ onUnmounted(() => {
> >
OBS组件 OBS组件
</NButton> </NButton>
<NButton </NSpace>
size="small"
@click="showNeteaseModal = true"
>
从网易云歌单导入空闲歌单
</NButton>
<NSpace align="center">
<NButton <NButton
type="primary" type="primary"
secondary secondary
@@ -439,14 +432,20 @@ onUnmounted(() => {
这将覆盖当前设置, 确定? 这将覆盖当前设置, 确定?
</NPopconfirm> </NPopconfirm>
</NSpace> </NSpace>
<NDivider /> </NSpace>
<NCollapse :default-expanded-names="['1']"> </template>
<NCollapseItem <NAlert type="info" closable style="margin-top: 10px">
title="队列" 搜索时会优先选择非VIP歌曲, 所以点到付费曲目时可能会是猴版或者各种奇怪的歌
name="1" </NAlert>
</NCard>
<NCard style="margin-top: 12px">
<NTabs type="line" animated>
<NTabPane
name="queue"
tab="当前点歌"
> >
<NEmpty v-if="musicRquestStore.waitingMusics.length == 0"> <NEmpty v-if="musicRquestStore.waitingMusics.length == 0" description="暂无点歌">
暂无
</NEmpty> </NEmpty>
<NList <NList
v-else v-else
@@ -503,10 +502,97 @@ onUnmounted(() => {
</NSpace> </NSpace>
</NListItem> </NListItem>
</NList> </NList>
</NCollapseItem> </NTabPane>
</NCollapse>
<NDivider /> <NTabPane
<NTabs> name="list"
tab="闲置歌单"
>
<NSpace style="margin-bottom: 10px">
<NPopconfirm @positive-click="clearMusic">
<template #trigger>
<NButton type="error" size="small">
清空
</NButton>
</template>
确定清空吗?
</NPopconfirm>
<NButton size="small" @click="showNeteaseModal = true">
从网易云歌单导入
</NButton>
</NSpace>
<NEmpty v-if="musicRquestStore.originMusics.length == 0">
暂无
</NEmpty>
<NVirtualList
v-else
style="max-height: 600px"
:item-size="36"
:items="originMusics"
item-resizable
>
<template #default="{ item }">
<div :style="`height: ${36}px; display:flex; align-items:center; padding: 0 5px;`">
<NSpace
align="center"
style="width: 100%"
>
<NPopconfirm @positive-click="delMusic(item)">
<template #trigger>
<NButton
type="error"
secondary
size="tiny"
>
删除
</NButton>
</template>
确定删除?
</NPopconfirm>
<NButton
type="info"
secondary
size="tiny"
@click="musicRquestStore.playMusic(item)"
>
播放
</NButton>
<NText> {{ item.name }} - {{ item.author?.join('/') }} </NText>
</NSpace>
</div>
</template>
</NVirtualList>
</NTabPane>
<NTabPane
name="blacklist"
tab="黑名单"
>
<NList bordered>
<NListItem
v-for="item in settings.blacklist"
:key="item"
>
<NSpace
align="center"
style="width: 100%"
>
<NButton
type="error"
secondary
size="small"
@click="settings.blacklist.splice(settings.blacklist.indexOf(item), 1)"
>
删除
</NButton>
<NText> {{ item }} </NText>
</NSpace>
</NListItem>
</NList>
</NTabPane>
<NTabPane <NTabPane
name="settings" name="settings"
tab="设置" tab="设置"
@@ -578,95 +664,8 @@ onUnmounted(() => {
</NSpace> </NSpace>
</NSpace> </NSpace>
</NTabPane> </NTabPane>
<NTabPane
name="list"
tab="闲置歌单"
>
<NSpace>
<NPopconfirm @positive-click="clearMusic">
<template #trigger>
<NButton type="error">
清空
</NButton>
</template>
确定清空吗?
</NPopconfirm>
<NButton @click="showNeteaseModal = true">
从网易云歌单导入
</NButton>
</NSpace>
<NDivider style="margin: 15px 0 10px 0" />
<NEmpty v-if="musicRquestStore.originMusics.length == 0">
暂无
</NEmpty>
<NVirtualList
v-else
style="max-height: 1000px"
:item-size="30"
:items="originMusics"
item-resizable
>
<template #default="{ item }">
<p :style="`min-height: ${30}px;width:97%;display:flex;align-items:center;`">
<NSpace
align="center"
style="width: 100%"
>
<NPopconfirm @positive-click="delMusic(item)">
<template #trigger>
<NButton
type="error"
secondary
size="small"
>
删除
</NButton>
</template>
确定删除?
</NPopconfirm>
<NButton
type="info"
secondary
size="small"
@click="musicRquestStore.playMusic(item)"
>
播放
</NButton>
<NText> {{ item.name }} - {{ item.author?.join('/') }} </NText>
</NSpace>
</p>
</template>
</NVirtualList>
</NTabPane>
<NTabPane
name="blacklist"
tab="黑名单"
>
<NList>
<NListItem
v-for="item in settings.blacklist"
:key="item"
>
<NSpace
align="center"
style="width: 100%"
>
<NButton
type="error"
secondary
size="small"
@click="settings.blacklist.splice(settings.blacklist.indexOf(item), 1)"
>
删除
</NButton>
<NText> {{ item }} </NText>
</NSpace>
</NListItem>
</NList>
</NTabPane>
</NTabs> </NTabs>
<NDivider style="height: 100px" /> </NCard>
<NModal <NModal
v-model:show="showNeteaseModal" v-model:show="showNeteaseModal"
preset="card" preset="card"

View File

@@ -1017,14 +1017,10 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
</script> </script>
<template> <template>
<!-- 功能启用开关 --> <!-- 顶部功能开关与全局操作 -->
<NAlert <NCard v-if="accountInfo?.id" size="small">
v-if="accountInfo?.id"
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue) ? 'success' : 'warning'"
title="弹幕队列功能"
closable
>
<template #header> <template #header>
<NSpace align="center" justify="space-between">
<NSpace align="center"> <NSpace align="center">
<NText>启用弹幕队列功能</NText> <NText>启用弹幕队列功能</NText>
<NSwitch <NSwitch
@@ -1033,8 +1029,27 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
@update:value="onUpdateFunctionEnable" @update:value="onUpdateFunctionEnable"
/> />
</NSpace> </NSpace>
<NTooltip :disabled="configCanEdit">
<template #trigger>
<NButton
type="primary"
size="small"
:disabled="!configCanEdit"
@click="showOBSModal = true"
>
OBS 组件
</NButton>
</template> </template>
<NText depth="3"> 登录后可使用 OBS 组件功能
</NTooltip>
</NSpace>
</template>
<NAlert
v-if="accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue)"
type="info"
closable
style="margin-top: 10px"
>
如果没有部署 如果没有部署
<NButton <NButton
text text
@@ -1046,8 +1061,9 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
VtsuruEventFetcher VtsuruEventFetcher
</NButton> </NButton>
则其需要保持此页面开启才能点播, 也不要同时开多个页面, 会导致点播重复 (部署了则不影响) 则其需要保持此页面开启才能点播, 也不要同时开多个页面, 会导致点播重复 (部署了则不影响)
</NText>
</NAlert> </NAlert>
</NCard>
<!-- 未登录提示 --> <!-- 未登录提示 -->
<NAlert <NAlert
v-else v-else
@@ -1068,29 +1084,7 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
</NButton> </NButton>
</NAlert> </NAlert>
<NCard <NCard style="margin-top: 12px">
size="small"
style="margin-top: 10px;"
>
<NSpace align="center">
<!-- OBS 组件按钮 -->
<NTooltip :disabled="configCanEdit">
<template #trigger>
<NButton
type="primary"
:disabled="!configCanEdit"
@click="showOBSModal = true"
>
OBS 组件
</NButton>
</template>
登录后可使用 OBS 组件功能
</NTooltip>
<!-- 其他全局操作按钮可以在这里添加 -->
</NSpace>
</NCard>
<NCard style="margin-top: 10px;">
<!-- 主内容区域 --> <!-- 主内容区域 -->
<NTabs <NTabs
v-if="!accountInfo.id || accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue)" v-if="!accountInfo.id || accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue)"
@@ -1214,16 +1208,15 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
<!-- 队列列表 --> <!-- 队列列表 -->
<NSpin :show="isLoading && originQueue.length === 0"> <NSpin :show="isLoading && originQueue.length === 0">
<NList <div
v-if="queue.length > 0" v-if="queue.length > 0"
hoverable class="queue-list-container"
clickable
style="max-height: 60vh; overflow-y: auto;"
> >
<NListItem <TransitionGroup name="list">
<div
v-for="(queueData, index) in queue" v-for="(queueData, index) in queue"
:key="queueData.id" :key="queueData.id"
style="padding: 5px 0;" class="queue-item-wrapper"
> >
<NCard <NCard
embedded embedded
@@ -1444,8 +1437,10 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
</NSpace> </NSpace>
</NSpace> </NSpace>
</NCard> </NCard>
</NListItem> <NDivider style="margin: 0" />
</NList> </div>
</TransitionGroup>
</div>
<NEmpty <NEmpty
v-else v-else
description="当前队列为空" description="当前队列为空"

View File

@@ -206,13 +206,14 @@ const columns: DataTableColumns<SongRequestInfo> = [
</script> </script>
<template> <template>
<NCard size="small"> <NSpace vertical :size="12">
<NSpace> <NSpace>
<NInputGroup style="width: 300px"> <NInputGroup style="width: 250px">
<NInputGroupLabel> 筛选曲名 </NInputGroupLabel> <NInputGroupLabel> 筛选曲名 </NInputGroupLabel>
<NInput <NInput
:value="songRequest.filterSongName" :value="songRequest.filterSongName"
clearable clearable
placeholder="搜索歌曲..."
@update:value="songRequest.filterSongName = $event" @update:value="songRequest.filterSongName = $event"
> >
<template #suffix> <template #suffix>
@@ -225,11 +226,12 @@ const columns: DataTableColumns<SongRequestInfo> = [
</template> </template>
</NInput> </NInput>
</NInputGroup> </NInputGroup>
<NInputGroup style="width: 300px"> <NInputGroup style="width: 250px">
<NInputGroupLabel> 筛选用户 </NInputGroupLabel> <NInputGroupLabel> 筛选用户 </NInputGroupLabel>
<NInput <NInput
:value="songRequest.filterName" :value="songRequest.filterName"
clearable clearable
placeholder="搜索用户..."
@update:value="songRequest.filterName = $event" @update:value="songRequest.filterName = $event"
> >
<template #suffix> <template #suffix>
@@ -243,8 +245,6 @@ const columns: DataTableColumns<SongRequestInfo> = [
</NInput> </NInput>
</NInputGroup> </NInputGroup>
</NSpace> </NSpace>
</NCard>
<br>
<NDataTable <NDataTable
ref="table" ref="table"
size="small" size="small"
@@ -252,8 +252,10 @@ const columns: DataTableColumns<SongRequestInfo> = [
:data="songRequest.songs" :data="songRequest.songs"
:bordered="false" :bordered="false"
:loading="songRequest.isLoading" :loading="songRequest.isLoading"
:pagination="{ pageSize: 10 }"
:row-class-name="(row, index) => (row.status == SongRequestStatus.Singing || row.status == SongRequestStatus.Waiting ? 'song-active' : '')" :row-class-name="(row, index) => (row.status == SongRequestStatus.Singing || row.status == SongRequestStatus.Waiting ? 'song-active' : '')"
/> />
</NSpace>
</template> </template>
<style> <style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { SongRequestInfo } from '@/api/api-models' import type { SongRequestInfo } from '@/api/api-models'
import { import {
Checkmark12Regular, Checkmark12Regular,
@@ -24,6 +25,7 @@ import { useLiveRequest } from '@/composables/useLiveRequest'
const props = defineProps<{ const props = defineProps<{
song: SongRequestInfo song: SongRequestInfo
index: number
isLoading: boolean isLoading: boolean
isLrcLoading: string isLrcLoading: string
updateKey: number updateKey: number
@@ -55,28 +57,35 @@ function onBlockUser() {
songRequest.blockUser(props.song) songRequest.blockUser(props.song)
} }
function getSCColor(price: number): string { function getIndexStyle(status: SongRequestStatus): CSSProperties {
if (price === 0) return `#2a60b2` let backgroundColor
if (price > 0 && price < 30) return `#2a60b2` switch (status) {
if (price >= 30 && price < 50) return `#2a60b2` case SongRequestStatus.Singing:
if (price >= 50 && price < 100) return `#427d9e` backgroundColor = '#18a058'
if (price >= 100 && price < 500) return `#c99801` break
if (price >= 500 && price < 1000) return `#e09443` case SongRequestStatus.Waiting:
if (price >= 1000 && price < 2000) return `#e54d4d` backgroundColor = '#2080f0'
if (price >= 2000) return `#ab1a32` break
return '' default:
backgroundColor = '#86909c'
} }
function getGuardColor(level: number | null | undefined): string { return {
if (level) { display: 'inline-flex',
switch (level) { alignItems: 'center',
case 1: return 'rgb(122, 4, 35)' justifyContent: 'center',
case 2: return 'rgb(157, 155, 255)' fontWeight: 'bold',
case 3: return 'rgb(104, 136, 241)' width: '24px',
minWidth: '24px', // 防止压缩
height: '24px',
borderRadius: '50%',
color: 'white',
fontSize: '13px',
backgroundColor,
marginRight: '8px',
flexShrink: 0, // 防止压缩
} }
} }
return ''
}
// 获取父组件中的活跃歌曲 // 获取父组件中的活跃歌曲
const activeSongs = inject<SongRequestInfo[]>('activeSongs', []) const activeSongs = inject<SongRequestInfo[]>('activeSongs', [])
@@ -91,121 +100,100 @@ const hasOtherSingSong = computed(() => {
<NCard <NCard
embedded embedded
size="small" size="small"
content-style="padding: 5px;" content-style="padding: 8px 12px;"
:style="`${isSingingStatus ? 'animation: animated-border 2.5s infinite;' : ''};height: 100%;`" :bordered="isSingingStatus"
> :style="isSingingStatus ? 'border-left: 4px solid #18a058;' : 'border-left: 4px solid transparent;'"
<NSpace
justify="space-between"
align="center"
style="height: 100%; margin: 0 5px 0 5px"
>
<NSpace align="center">
<div
:style="`border-radius: 4px; background-color: ${isSingingStatus ? '#75c37f' : '#577fb8'}; width: 10px; height: 20px`"
/>
<NText
strong
style="font-size: 18px"
> >
<NSpace justify="space-between" align="center" :wrap="false">
<!-- 左侧信息 -->
<NSpace align="center" :size="8" :wrap="false">
<!-- 序号 -->
<span :style="getIndexStyle(song.status)">
{{ index }}
</span>
<!-- 歌曲名称 -->
<NText strong style="font-size: 16px">
{{ song.songName }} {{ song.songName }}
</NText> </NText>
<!-- 用户信息 -->
<template v-if="song.from == SongRequestFrom.Manual"> <template v-if="song.from == SongRequestFrom.Manual">
<!-- Manual --> <NTag size="tiny" :bordered="false">
<NTag
size="small"
:bordered="false"
>
手动添加 手动添加
</NTag> </NTag>
</template> </template>
<template v-else> <template v-else>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NTag <NTag size="tiny" :bordered="false" type="info" round>
size="small"
:bordered="false"
type="info"
>
<NText
italic
depth="3"
>
{{ song.user?.name || '未知用户' }} {{ song.user?.name || '未知用户' }}
</NText>
</NTag> </NTag>
</template> </template>
{{ song.user?.uid || '未知ID' }} UID: {{ song.user?.uid || '未知' }}
</NTooltip> </NTooltip>
</template> </template>
<NSpace
v-if=" <!-- 粉丝牌 -->
(song.from == SongRequestFrom.Danmaku || song.from == SongRequestFrom.SC)
&& song.user?.fans_medal_wearing_status
"
>
<NTag
size="tiny"
round
>
<NTag <NTag
v-if="(song.from == SongRequestFrom.Danmaku || song.from == SongRequestFrom.SC) && song.user?.fans_medal_wearing_status"
size="tiny" size="tiny"
round round
:bordered="false" :bordered="false"
style="padding: 0 6px 0 0;"
> >
<NText depth="3"> <NTag size="tiny" round :bordered="false" type="info" style="margin-right: 4px;">
{{ song.user?.fans_medal_level }} {{ song.user?.fans_medal_level }}
</NText>
</NTag> </NTag>
<span style="color: #577fb8"> <span style="color: #577fb8">{{ song.user?.fans_medal_name }}</span>
{{ song.user?.fans_medal_name }}
</span>
</NTag> </NTag>
</NSpace>
<!-- 舰长 -->
<NTag <NTag
v-if="(song.user?.guard_level ?? 0) > 0" v-if="(song.user?.guard_level ?? 0) > 0"
size="small" size="tiny"
:bordered="false" :bordered="false"
:color="{ textColor: 'white', color: songRequest.getGuardColor(song.user?.guard_level) }" :color="{ textColor: 'white', color: songRequest.getGuardColor(song.user?.guard_level) }"
> >
{{ song.user?.guard_level == 1 ? '总督' : song.user?.guard_level == 2 ? '提督' : '舰长' }} {{ song.user?.guard_level == 1 ? '总督' : song.user?.guard_level == 2 ? '提督' : '舰长' }}
</NTag> </NTag>
<!-- SC/礼物 -->
<NTag <NTag
v-if="song.from == SongRequestFrom.SC" v-if="song.from == SongRequestFrom.SC"
size="small" size="tiny"
:color="{ textColor: 'white', color: songRequest.getSCColor(song.price ?? 0) }" :color="{ textColor: 'white', color: songRequest.getSCColor(song.price ?? 0) }"
> >
SC{{ song.price ? ` | ${song.price}` : '' }} SC{{ song.price ? ` | ${song.price}` : '' }}
</NTag> </NTag>
<NTag <NTag
v-if="song.from == SongRequestFrom.Gift" v-if="song.from == SongRequestFrom.Gift"
size="small" size="tiny"
:color="{ textColor: 'white', color: songRequest.getSCColor(song.price ?? 0) }" :color="{ textColor: 'white', color: songRequest.getSCColor(song.price ?? 0) }"
> >
礼物{{ song.price ? ` | ${song.price}` : '' }} 礼物{{ song.price ? ` | ${song.price}` : '' }}
</NTag> </NTag>
<!-- 时间 -->
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NText style="font-size: small"> <NText depth="3" style="font-size: 12px">
<NTime <NTime :key="updateKey" :time="song.createAt" type="relative" />
:key="updateKey"
:time="song.createAt"
type="relative"
/>
</NText> </NText>
</template> </template>
<NTime :time="song.createAt" /> <NTime :time="song.createAt" />
</NTooltip> </NTooltip>
</NSpace> </NSpace>
<NSpace
justify="end" <!-- 右侧操作按钮 -->
align="center" <NSpace justify="end" align="center" :size="6" :wrap="false">
>
<NTooltip v-if="hasSong"> <NTooltip v-if="hasSong">
<template #trigger> <template #trigger>
<NButton <NButton
circle circle
size="small"
type="success" type="success"
style="height: 30px; width: 30px" ghost
:loading="isLrcLoading == song?.song?.key" :loading="isLrcLoading == song?.song?.key"
@click="onSelectSong" @click="onSelectSong"
> >
@@ -216,43 +204,32 @@ const hasOtherSingSong = computed(() => {
</template> </template>
试听 试听
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton
circle circle
type="primary" size="small"
style="height: 30px; width: 30px" :type="song.status == SongRequestStatus.Singing ? 'warning' : 'primary'"
:ghost="song.status == SongRequestStatus.Singing"
:disabled="hasOtherSingSong" :disabled="hasOtherSingSong"
:style="`animation: ${song.status == SongRequestStatus.Waiting ? '' : 'loading 5s linear infinite'}`"
:secondary="song.status == SongRequestStatus.Singing"
:loading="isLoading" :loading="isLoading"
@click=" @click="onUpdateStatus(song.status == SongRequestStatus.Singing ? SongRequestStatus.Waiting : SongRequestStatus.Singing)"
onUpdateStatus(
song.status == SongRequestStatus.Singing
? SongRequestStatus.Waiting
: SongRequestStatus.Singing,
)
"
> >
<template #icon> <template #icon>
<NIcon :component="Mic24Filled" /> <NIcon :component="Mic24Filled" />
</template> </template>
</NButton> </NButton>
</template> </template>
{{ {{ hasOtherSingSong ? '还有其他正在演唱' : (song.status == SongRequestStatus.Waiting ? '开始演唱' : '暂停演唱') }}
hasOtherSingSong
? '还有其他正在进行的点播'
: song.status == SongRequestStatus.Waiting && song.id
? '开始处理'
: '停止处理'
}}
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton
circle circle
type="primary" size="small"
style="height: 30px; width: 30px" type="success"
:loading="isLoading" :loading="isLoading"
@click="onUpdateStatus(SongRequestStatus.Finish)" @click="onUpdateStatus(SongRequestStatus.Finish)"
> >
@@ -263,55 +240,37 @@ const hasOtherSingSong = computed(() => {
</template> </template>
完成 完成
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NPopconfirm <NPopconfirm @positive-click="onUpdateStatus(SongRequestStatus.Cancel)">
@positive-click="onUpdateStatus(SongRequestStatus.Cancel)"
>
<template #trigger> <template #trigger>
<NButton <NButton circle size="small" type="error" :loading="isLoading">
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
>
<template #icon> <template #icon>
<NIcon :component="Dismiss16Filled" /> <NIcon :component="Dismiss16Filled" />
</template> </template>
</NButton> </NButton>
</template> </template>
是否取消处理? 确定取消?
</NPopconfirm> </NPopconfirm>
</template> </template>
取消 取消
</NTooltip> </NTooltip>
<NTooltip
v-if=" <NTooltip v-if="song.from == SongRequestFrom.Danmaku && song.user?.uid">
song.from == SongRequestFrom.Danmaku
&& song.user?.uid
&& song.status !== SongRequestStatus.Cancel
"
>
<template #trigger> <template #trigger>
<NPopconfirm <NPopconfirm @positive-click="onBlockUser">
@positive-click="onBlockUser"
>
<template #trigger> <template #trigger>
<NButton <NButton circle size="small" type="error" ghost :loading="isLoading">
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
>
<template #icon> <template #icon>
<NIcon :component="PresenceBlocked16Regular" /> <NIcon :component="PresenceBlocked16Regular" />
</template> </template>
</NButton> </NButton>
</template> </template>
是否拉黑此用户? 确定拉黑此用户?
</NPopconfirm> </NPopconfirm>
</template> </template>
拉黑用户 拉黑
</NTooltip> </NTooltip>
</NSpace> </NSpace>
</NSpace> </NSpace>

View File

@@ -52,105 +52,125 @@ async function updateSettings() {
</script> </script>
<template> <template>
<NCard size="small"> <NSpace vertical :size="12">
<NSpace align="center"> <NCard size="small" :bordered="false" content-style="padding: 0;">
<NTag <NSpace justify="space-between" align="center">
type="success" <!-- 左侧统计 -->
:bordered="false" <NSpace align="center" :size="16">
> <NTag type="success" round :bordered="false">
<template #icon> <template #icon>
<NIcon :component="PeopleQueue24Filled" /> <NIcon :component="PeopleQueue24Filled" />
</template> </template>
队列 | {{ waitingCount }} 队列: {{ waitingCount }}
</NTag> </NTag>
<NTag <NTag type="info" round :bordered="false">
type="success"
:bordered="false"
>
<template #icon> <template #icon>
<NIcon :component="Checkmark12Regular" /> <NIcon :component="Checkmark12Regular" />
</template> </template>
今日已处理 | {{ todayFinishedCount }} 今日已点: {{ todayFinishedCount }}
</NTag> </NTag>
<NInputGroup> <NText depth="3" style="font-size: 12px">
{{ songRequest.activeSongs.length }}
</NText>
</NSpace>
<!-- 右侧操作 -->
<NSpace align="center">
<NInputGroup size="small">
<NInput <NInput
:value="songRequest.newSongName" :value="songRequest.newSongName"
placeholder="手动添加" placeholder="手动添加歌曲"
@update:value="songRequest.newSongName = $event" @update:value="songRequest.newSongName = $event"
style="width: 150px"
/> />
<NButton <NButton type="primary" ghost @click="songRequest.addSongManual()">
type="primary"
@click="songRequest.addSongManual()"
>
添加 添加
</NButton> </NButton>
</NInputGroup> </NInputGroup>
<NRadioGroup <NRadioGroup
v-model:value="accountInfo.settings.songRequest.sortType" v-model:value="accountInfo.settings.songRequest.sortType"
:disabled="!songRequest.configCanEdit" :disabled="!songRequest.configCanEdit"
type="button" size="small"
@update:value="value => { @update:value="updateSettings"
updateSettings()
}"
> >
<NRadioButton :value="QueueSortType.TimeFirst"> <NRadioButton :value="QueueSortType.TimeFirst">时间</NRadioButton>
加入时间优先 <NRadioButton :value="QueueSortType.PaymentFist">付费</NRadioButton>
</NRadioButton> <NRadioButton :value="QueueSortType.GuardFirst">舰长</NRadioButton>
<NRadioButton :value="QueueSortType.PaymentFist"> <NRadioButton :value="QueueSortType.FansMedalFirst">粉丝牌</NRadioButton>
付费价格优先
</NRadioButton>
<NRadioButton :value="QueueSortType.GuardFirst">
舰长优先 (按等级)
</NRadioButton>
<NRadioButton :value="QueueSortType.FansMedalFirst">
粉丝牌等级优先
</NRadioButton>
</NRadioGroup> </NRadioGroup>
<NCheckbox <NCheckbox
:checked="currentIsReverse" :checked="currentIsReverse"
size="small"
@update:checked="value => { @update:checked="value => {
if (songRequest.configCanEdit) { if (songRequest.configCanEdit) {
accountInfo.settings.songRequest.isReverse = value accountInfo.settings.songRequest.isReverse = value
updateSettings() updateSettings()
} } else {
else {
songRequest.isReverse = value songRequest.isReverse = value
} }
}" }"
> >
倒序 倒序
</NCheckbox> </NCheckbox>
<NPopconfirm @positive-click="songRequest.deactiveAllSongs()"> <NPopconfirm @positive-click="songRequest.deactiveAllSongs()">
<template #trigger> <template #trigger>
<NButton type="error"> <NButton type="error" size="small" ghost>
全部取消 全部取消
</NButton> </NButton>
</template> </template>
确定全部取消吗? 确定全部取消吗?
</NPopconfirm> </NPopconfirm>
</NSpace> </NSpace>
</NSpace>
</NCard> </NCard>
<NDivider> {{ songRequest.activeSongs.length }} </NDivider>
<NList <div v-if="songRequest.activeSongs.length > 0" class="song-list-container">
v-if="songRequest.activeSongs.length > 0" <TransitionGroup name="list">
:show-divider="false" <div
hoverable v-for="(song, index) in songRequest.activeSongs"
>
<NListItem
v-for="song in songRequest.activeSongs"
:key="song.id" :key="song.id"
style="padding: 5px" class="song-item-wrapper"
> >
<SongRequestItem <SongRequestItem
:song="song" :song="song"
:index="index + 1"
:is-loading="songRequest.isLoading" :is-loading="songRequest.isLoading"
:is-lrc-loading="songRequest.isLrcLoading" :is-lrc-loading="songRequest.isLrcLoading"
:update-key="songRequest.updateKey" :update-key="songRequest.updateKey"
/> />
</NListItem> <NDivider style="margin: 0" />
</NList> </div>
</TransitionGroup>
</div>
<NEmpty <NEmpty
v-else v-else
description="暂无曲目" description="暂无点播内容"
style="margin-top: 40px"
/> />
</NSpace>
</template> </template>
<style scoped>
.song-list-container {
margin-top: 10px;
position: relative;
}
.song-item-wrapper {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.list-leave-active {
position: absolute;
width: 100%;
}
</style>