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) {
const notification = window.$notification
if (!notification) {
console.warn('[OBS] notification instance missing')
const message = window.$message
if (!message) {
console.warn('[OBS] message instance missing')
return
}
console.log('[OBS] 收到通知', payload)
const method = payload.Type === 'success' ? 'success' : 'error'
const title = resolveTitle(payload)
const description = payload.Message || '未知通知'
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') {
notification[method]({
title: payload.Type === 'success' ? '成功' : `失败`,
description,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
if (typeof message[method] === 'function') {
message[method](finalContent, {
duration: method === 'error' ? 6000 : 4000,
closable: true,
})
} else {
notification.create({
title: payload.Type === 'success' ? '成功' : `失败`,
content: description,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
message.create(finalContent, {
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 PointSettings from './PointSettings.vue'
import PointUserManage from './PointUserManage.vue'
import PointTestPanel from './PointTestPanel.vue'
const message = useMessage()
const accountInfo = useAccount()
@@ -734,6 +735,15 @@ onMounted(() => { })
>
<PointSettings />
</NTabPane>
<!-- 测试标签页 -->
<NTabPane
name="test"
tab="测试"
display-directive="show:lazy"
>
<PointTestPanel />
</NTabPane>
</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="暂无人点歌"
/>
</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>
</template>
@@ -370,4 +403,50 @@ onUnmounted(() => {
.minimal-list-inner.animating:hover {
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>

View File

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

View File

@@ -385,288 +385,287 @@ onUnmounted(() => {
</script>
<template>
<NSpace>
<NAlert type="info">
搜索时会优先选择非VIP歌曲, 所以点到付费曲目时可能会是猴版或者各种奇怪的歌
</NAlert>
</NSpace>
<NDivider />
<NSpace align="center">
<NButton
:type="listening ? 'error' : 'primary'"
:style="{ animation: listening ? 'animated-border 2.5s infinite' : '' }"
data-umami-event="Use Music Request"
:data-umami-event-uid="accountInfo?.biliId"
size="large"
@click="listening ? stopListen() : startListen()"
>
{{ listening ? '停止监听' : '开始监听' }}
</NButton>
<NButton
type="info"
size="small"
@click="showOBSModal = true"
>
OBS组件
</NButton>
<NButton
size="small"
@click="showNeteaseModal = true"
>
从网易云歌单导入空闲歌单
</NButton>
<NButton
type="primary"
secondary
:disabled="!accountInfo"
size="small"
@click="uploadConfig"
>
保存配置到服务器
</NButton>
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton
type="primary"
secondary
:disabled="!accountInfo"
size="small"
>
从服务器获取配置
</NButton>
</template>
这将覆盖当前设置, 确定?
</NPopconfirm>
</NSpace>
<NDivider />
<NCollapse :default-expanded-names="['1']">
<NCollapseItem
title="队列"
name="1"
>
<NEmpty v-if="musicRquestStore.waitingMusics.length == 0">
暂无
</NEmpty>
<NList
v-else
size="small"
bordered
>
<NListItem
v-for="item in musicRquestStore.waitingMusics"
:key="item.music.name"
>
<NSpace align="center">
<NButton
type="primary"
secondary
size="small"
@click="musicRquestStore.playMusic(item.music)"
>
播放
</NButton>
<NButton
type="error"
secondary
size="small"
@click="musicRquestStore.waitingMusics.splice(musicRquestStore.waitingMusics.indexOf(item), 1)"
>
取消
</NButton>
<NButton
type="warning"
secondary
size="small"
@click="blockMusic(item.music)"
>
拉黑
</NButton>
<span>
<NTag
v-if="item.music.from == SongFrom.Netease"
type="success"
size="small"
> 网易</NTag>
<NTag
v-else-if="item.music.from == SongFrom.Kugou"
type="success"
size="small"
> 酷狗</NTag>
</span>
<NText>
{{ item.from.name }}
</NText>
<NText depth="3">
{{ item.music.name }} - {{ item.music.author?.join('/') }}
</NText>
</NSpace>
</NListItem>
</NList>
</NCollapseItem>
</NCollapse>
<NDivider />
<NTabs>
<NTabPane
name="settings"
tab="设置"
>
<NSpace vertical>
<NCard size="small">
<template #header>
<NSpace align="center" justify="space-between">
<NSpace align="center">
<NRadioGroup v-model:value="settings.platform">
<NRadioButton value="netease">
网易云
</NRadioButton>
<NRadioButton value="kugou">
酷狗
</NRadioButton>
</NRadioGroup>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 点歌弹幕前缀 </NInputGroupLabel>
<NInput v-model:value="settings.orderPrefix" />
</NInputGroup>
<NCheckbox
:checked="settings.orderCooldown != undefined"
@update:checked="(checked: boolean) => {
settings.orderCooldown = checked ? 300 : undefined
}
"
<NButton
:type="listening ? 'error' : 'primary'"
:style="{ animation: listening ? 'animated-border 2.5s infinite' : '' }"
data-umami-event="Use Music Request"
:data-umami-event-uid="accountInfo?.biliId"
size="small"
@click="listening ? stopListen() : startListen()"
>
是否启用点歌冷却
</NCheckbox>
<NInputGroup
v-if="settings.orderCooldown"
style="width: 200px"
{{ listening ? '停止监听' : '开始监听' }}
</NButton>
<NButton
type="info"
size="small"
@click="showOBSModal = true"
>
<NInputGroupLabel> 冷却时间 () </NInputGroupLabel>
<NInputNumber
v-model:value="settings.orderCooldown"
@update:value="(value) => {
if (!value || value <= 0) settings.orderCooldown = undefined
}
"
/>
</NInputGroup>
OBS组件
</NButton>
</NSpace>
<NSpace>
<NCheckbox v-model:checked="settings.playMusicWhenFree">
空闲时播放空闲歌单
</NCheckbox>
<NCheckbox v-model:checked="settings.orderMusicFirst">
优先播放点歌
</NCheckbox>
</NSpace>
<NSpace>
<NTooltip>
<NSpace align="center">
<NButton
type="primary"
secondary
:disabled="!accountInfo"
size="small"
@click="uploadConfig"
>
保存配置到服务器
</NButton>
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton
type="info"
@click="getOutputDevice"
type="primary"
secondary
:disabled="!accountInfo"
size="small"
>
获取输出设备
从服务器获取配置
</NButton>
</template>
获取和修改输出设备需要打开麦克风权限
</NTooltip>
<NSelect
v-model:value="settings.deviceId"
:options="deviceList"
:fallback-option="() => ({ label: '未选择', value: '' })"
style="min-width: 200px"
@update:value="musicRquestStore.setSinkId"
/>
这将覆盖当前设置, 确定?
</NPopconfirm>
</NSpace>
</NSpace>
</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>
<NAlert type="info" closable style="margin-top: 10px">
搜索时会优先选择非VIP歌曲, 所以点到付费曲目时可能会是猴版或者各种奇怪的歌
</NAlert>
</NCard>
<NCard style="margin-top: 12px">
<NTabs type="line" animated>
<NTabPane
name="queue"
tab="当前点歌"
>
<template #default="{ item }">
<p :style="`min-height: ${30}px;width:97%;display:flex;align-items:center;`">
<NEmpty v-if="musicRquestStore.waitingMusics.length == 0" description="暂无点歌">
</NEmpty>
<NList
v-else
size="small"
bordered
>
<NListItem
v-for="item in musicRquestStore.waitingMusics"
:key="item.music.name"
>
<NSpace align="center">
<NButton
type="primary"
secondary
size="small"
@click="musicRquestStore.playMusic(item.music)"
>
播放
</NButton>
<NButton
type="error"
secondary
size="small"
@click="musicRquestStore.waitingMusics.splice(musicRquestStore.waitingMusics.indexOf(item), 1)"
>
取消
</NButton>
<NButton
type="warning"
secondary
size="small"
@click="blockMusic(item.music)"
>
拉黑
</NButton>
<span>
<NTag
v-if="item.music.from == SongFrom.Netease"
type="success"
size="small"
> 网易</NTag>
<NTag
v-else-if="item.music.from == SongFrom.Kugou"
type="success"
size="small"
> 酷狗</NTag>
</span>
<NText>
{{ item.from.name }}
</NText>
<NText depth="3">
{{ item.music.name }} - {{ item.music.author?.join('/') }}
</NText>
</NSpace>
</NListItem>
</NList>
</NTabPane>
<NTabPane
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%"
>
<NPopconfirm @positive-click="delMusic(item)">
<template #trigger>
<NButton
type="error"
secondary
size="small"
>
删除
</NButton>
</template>
确定删除?
</NPopconfirm>
<NButton
type="info"
type="error"
secondary
size="small"
@click="musicRquestStore.playMusic(item)"
@click="settings.blacklist.splice(settings.blacklist.indexOf(item), 1)"
>
播放
删除
</NButton>
<NText> {{ item.name }} - {{ item.author?.join('/') }} </NText>
<NText> {{ item }} </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)"
</NListItem>
</NList>
</NTabPane>
<NTabPane
name="settings"
tab="设置"
>
<NSpace vertical>
<NSpace align="center">
<NRadioGroup v-model:value="settings.platform">
<NRadioButton value="netease">
网易云
</NRadioButton>
<NRadioButton value="kugou">
酷狗
</NRadioButton>
</NRadioGroup>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 点歌弹幕前缀 </NInputGroupLabel>
<NInput v-model:value="settings.orderPrefix" />
</NInputGroup>
<NCheckbox
:checked="settings.orderCooldown != undefined"
@update:checked="(checked: boolean) => {
settings.orderCooldown = checked ? 300 : undefined
}
"
>
删除
</NButton>
<NText> {{ item }} </NText>
是否启用点歌冷却
</NCheckbox>
<NInputGroup
v-if="settings.orderCooldown"
style="width: 200px"
>
<NInputGroupLabel> 冷却时间 () </NInputGroupLabel>
<NInputNumber
v-model:value="settings.orderCooldown"
@update:value="(value) => {
if (!value || value <= 0) settings.orderCooldown = undefined
}
"
/>
</NInputGroup>
</NSpace>
</NListItem>
</NList>
</NTabPane>
</NTabs>
<NDivider style="height: 100px" />
<NSpace>
<NCheckbox v-model:checked="settings.playMusicWhenFree">
空闲时播放空闲歌单
</NCheckbox>
<NCheckbox v-model:checked="settings.orderMusicFirst">
优先播放点歌
</NCheckbox>
</NSpace>
<NSpace>
<NTooltip>
<template #trigger>
<NButton
type="info"
@click="getOutputDevice"
>
获取输出设备
</NButton>
</template>
获取和修改输出设备需要打开麦克风权限
</NTooltip>
<NSelect
v-model:value="settings.deviceId"
:options="deviceList"
:fallback-option="() => ({ label: '未选择', value: '' })"
style="min-width: 200px"
@update:value="musicRquestStore.setSinkId"
/>
</NSpace>
</NSpace>
</NTabPane>
</NTabs>
</NCard>
<NModal
v-model:show="showNeteaseModal"
preset="card"

View File

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

View File

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

View File

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

View File

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