feat: enhance song management and notification features

- Updated SongList.vue to enable default sorting with a boolean flag.
- Modified TempComponent.vue to include useNotification for better user feedback.
- Refined useLiveRequest.ts to enforce type safety on song request settings.
- Adjusted RTCClient.ts to improve event handling and connection logic.
- Enhanced useVTsuruHub.ts to ensure SignalR connection is awaited during initialization.
- Improved ManageLayout.vue by reorganizing menu options for better user experience.
- Added folder scanning functionality in SongListManageView.vue for importing songs from local directories.
- Implemented OBS notification system in various OBS-related components for real-time updates.
- Removed outdated documentation files related to local question saving functionality.
- Introduced a new store useOBSNotification.ts to manage OBS notifications effectively.
This commit is contained in:
Megghy
2025-10-13 04:12:42 +08:00
parent 4ad9766043
commit 902926f28f
19 changed files with 585 additions and 410 deletions

12
src/components.d.ts vendored
View File

@@ -18,18 +18,6 @@ declare module 'vue' {
LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NBadge: typeof import('naive-ui')['NBadge']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']

View File

@@ -294,7 +294,7 @@ function createColumns(): DataTableColumns<SongsInfo> {
resizable: true,
minWidth: 150, // 增加最小宽度
width: 300,
sorter: 'default', // 启用默认排序
sorter: true, // 启用默认排序
render(data) {
// 同时显示原名和翻译名 (如果存在)
return h(NSpace, { vertical: true, size: 0, wrap: false }, () => [

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NSpin, useLoadingBar, useMessage, useModal } from 'naive-ui'
import { NSpin, useDialog, useLoadingBar, useMessage, useModal, useNotification } from 'naive-ui'
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { cookie, useAccount } from '@/api/account'

View File

@@ -1,6 +1,7 @@
import type {
DanmakuUserInfo,
EventModel,
Setting_LiveRequest,
SongRequestInfo,
SongsInfo,
} from '@/api/api-models'
@@ -69,7 +70,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
return true
})
const settings = accountInfo.value?.settings?.songRequest || {}
const settings: Setting_LiveRequest = accountInfo.value?.settings?.songRequest
switch (settings.sortType) {
case QueueSortType.TimeFirst: {
@@ -154,7 +155,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
`[SONG-REQUEST] 收到 [${danmaku.uname}] 的点播${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`,
)
const settings = accountInfo.value?.settings?.songRequest || {}
const settings: Setting_LiveRequest = accountInfo.value?.settings?.songRequest
if (settings.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
window.$notification.info({
@@ -436,7 +437,7 @@ export const useLiveRequest = defineStore('songRequest', () => {
}
function onGetSC(danmaku: EventModel) {
const settings = accountInfo.value?.settings?.songRequest || {}
const settings = accountInfo.value?.settings?.songRequest
if (settings.allowSC && checkMessage(danmaku.msg)) {
addSong(danmaku)

View File

@@ -46,6 +46,7 @@ export abstract class BaseRTCClient {
this.send('VTsuru.RTCEvent.On', eventName)
}
public off(eventName: string, listener: (args: any) => void) {
if (this.events[eventName]) {
const index = this.events[eventName].indexOf(listener)
@@ -99,14 +100,14 @@ export class SlaveRTCClient extends BaseRTCClient {
type: 'slave' = 'slave' as const
protected async getAllRTC(): Promise<ComponentsEventHubModel[]> {
return await this.vhub.invoke<ComponentsEventHubModel[]>('GetAllRTC') || []
return await this.vhub.invoke<ComponentsEventHubModel[]>('GetOnlineRTC') || []
}
public async connectToAllMaster() {
const masters = (await this.getAllRTC()).filter(
(item: ComponentsEventHubModel) =>
item.IsMaster
&& item.Token != this.peer!.id
&& item.Token != this.peer.id
&& !this.connections.some(conn => conn.peer == item.Token),
)
masters.forEach((item: ComponentsEventHubModel) => {

View File

@@ -0,0 +1,122 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { ref } from 'vue'
import { useVTsuruHub } from './useVTsuruHub'
interface ObsNotificationPayload {
Type: 'success' | 'failed'
Title?: string
Message: string
Source?: string | null
UserName?: string | null
Timestamp?: number
}
const sourceLabelMap: Record<string, string> = {
'live-request': '点歌',
'queue': '排队',
}
export const useOBSNotification = defineStore('obs-notification', () => {
const isInited = ref(false)
const hub = useVTsuruHub()
const listeners = new Map<string, (payload: ObsNotificationPayload) => void>()
function resolveMeta(payload: ObsNotificationPayload): string | undefined {
const userName = payload.UserName?.trim()
const sourceLabel = payload.Source ? sourceLabelMap[payload.Source] ?? payload.Source : undefined
if (!userName && !sourceLabel) {
return undefined
}
const text = [userName, sourceLabel].filter(Boolean).join(' · ')
return text || undefined
}
function resolveTitle(payload: ObsNotificationPayload): string {
if (payload.Title?.trim()) {
return payload.Title
}
if (payload.Source && sourceLabelMap[payload.Source]) {
return sourceLabelMap[payload.Source]
}
return payload.Type === 'success' ? '提示' : '警告'
}
function showNotification(payload: ObsNotificationPayload) {
const notification = window.$notification
if (!notification) {
console.warn('[OBS] notification 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)
if (typeof notification[method] === 'function') {
notification[method]({
title: payload.Type === 'success' ? '成功' : `失败`,
description,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
})
} else {
notification.create({
title: payload.Type === 'success' ? '成功' : `失败`,
content: description,
duration: method === 'error' ? 8000 : 5000,
keepAliveOnHover: true,
type: method,
})
}
}
/**
* 初始化 OBS 通知系统
* @param sources 可选的source过滤列表如果提供则只显示这些类型的通知
*/
async function init(sources?: string[]) {
const listenerId = sources ? sources.sort().join(',') : 'all'
// 如果已经为这个过滤器初始化过,直接返回
if (listeners.has(listenerId)) {
return
}
const listener = (payload: ObsNotificationPayload) => {
// 如果指定了sources过滤器检查通知是否匹配
if (sources && sources.length > 0) {
if (!payload.Source || !sources.includes(payload.Source)) {
return // 不匹配,忽略此通知
}
}
showNotification(payload)
}
listeners.set(listenerId, listener)
if (!isInited.value) {
isInited.value = true
await hub.Init()
}
await hub.on('ObsNotification', listener)
const filterInfo = sources && sources.length > 0
? `(过滤: ${sources.join(', ')})`
: '(所有类型)'
console.log(`[OBS] OBS 通知模块已初始化 ${filterInfo}`)
}
return {
init,
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useOBSNotification, import.meta.hot))
}

View File

@@ -98,9 +98,9 @@ export const useVTsuruHub = defineStore('VTsuruHub', () => {
signalRClient.value?.onreconnected(listener)
}
function Init() {
async function Init() {
if (!isInited.value && !isIniting.value) {
connectSignalR()
await connectSignalR()
}
return useVTsuruHub()
}

View File

@@ -366,18 +366,6 @@ const menuOptions = computed(() => {
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
? '点播'
@@ -397,6 +385,18 @@ const menuOptions = computed(() => {
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '抽奖'
: h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
}),
withFavoriteExtra({
label: () => !isBiliVerified.value
? '点歌'
@@ -407,7 +407,7 @@ const menuOptions = computed(() => {
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},

View File

@@ -3,12 +3,14 @@ import { NSpin } from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher'
import { useOBSNotification } from '@/store/useOBSNotification'
const timer = ref<any>()
const visible = ref(true)
const active = ref(true)
const webfetcher = useWebFetcher()
const accountInfo = useAccount()
const obsNotification = useOBSNotification()
const code = accountInfo.value.id ? accountInfo.value.biliAuthCode : window.$route.query.code?.toString()

View File

@@ -105,6 +105,12 @@ const isGettingFivesingSongPlayUrl = ref(0)
const uploadFiles = ref<UploadFileInfo[]>([])
const uploadSongsFromFile = ref<SongsInfo[]>([])
// 文件夹读取相关
const folderSongs = ref<SongsInfo[]>([])
const folderSongsOptions = ref<Option[]>([])
const selectedFolderSongs = ref<string[]>([])
const isScanningFolder = ref(false)
// 模态框加载状态
const isModalLoading = ref(false)
@@ -181,6 +187,265 @@ const uploadSongsOptions = computed(() => {
const selecteduploadSongs = ref<string[]>([])
// 支持的音频文件扩展名
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac', '.wma', '.ape']
/**
* 选择文件夹并扫描音频文件
*/
async function selectFolder() {
try {
// 检查浏览器是否支持 File System Access API
if (!('showDirectoryPicker' in window)) {
message.error('您的浏览器不支持文件夹选择功能,请使用最新版本的 Chrome、Edge 或其他现代浏览器')
return
}
isScanningFolder.value = true
folderSongs.value = []
// @ts-ignore
const directoryHandle = await window.showDirectoryPicker({
mode: 'read'
})
message.info('正在扫描文件夹...')
const audioFiles: { name: string, file: File, path: string }[] = []
// 递归扫描文件夹
await scanDirectory(directoryHandle, audioFiles, '')
if (audioFiles.length === 0) {
message.warning('未在文件夹中找到音频文件')
isScanningFolder.value = false
return
}
message.info(`找到 ${audioFiles.length} 个音频文件,正在解析...`)
// 解析音频文件信息
for (const audioFile of audioFiles) {
const songInfo = parseAudioFileName(audioFile.name, audioFile.file, audioFile.path)
if (songInfo) {
folderSongs.value.push(songInfo)
}
}
// 更新选项
updateFolderSongsOptions()
message.success(`成功解析 ${folderSongs.value.length} 首歌曲`)
} catch (err: any) {
if (err.name === 'AbortError') {
message.info('已取消选择')
} else {
console.error(err)
message.error(`扫描文件夹失败: ${err.message}`)
}
} finally {
isScanningFolder.value = false
}
}
/**
* 递归扫描目录
*/
async function scanDirectory(
directoryHandle: any,
audioFiles: { name: string, file: File, path: string }[],
currentPath: string
) {
for await (const entry of directoryHandle.values()) {
const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
if (entry.kind === 'file') {
// 检查是否为音频文件
const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase()
if (AUDIO_EXTENSIONS.includes(ext)) {
const file = await entry.getFile()
audioFiles.push({
name: entry.name,
file: file,
path: entryPath
})
}
} else if (entry.kind === 'directory') {
// 递归扫描子目录
await scanDirectory(entry, audioFiles, entryPath)
}
}
}
/**
* 解析音频文件名,提取歌曲信息
* 支持的格式:
* - "歌名.mp3"
* - "歌手 - 歌名.mp3"
* - "歌名 - 歌手.mp3"
* - "歌手-歌名.mp3"
* - "[歌手] 歌名.mp3"
* - "歌手 《歌名》.mp3"
*/
function parseAudioFileName(fileName: string, file: File, filePath: string): SongsInfo | null {
// 移除文件扩展名
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'))
let name = ''
let author: string[] = []
// 尝试各种格式
// 格式: "歌手 - 歌名" 或 "歌名 - 歌手"
if (nameWithoutExt.includes(' - ')) {
const parts = nameWithoutExt.split(' - ').map(p => p.trim())
if (parts.length >= 2) {
// 假设第一部分是歌手,第二部分是歌名
author = parts[0].split(/[/、&]/).map(a => a.trim()).filter(a => a)
name = parts.slice(1).join(' - ')
// 如果第二部分更像歌手名(包含多个分隔符),交换
if (parts[1].match(/[/、&]/)) {
name = parts[0]
author = parts.slice(1).join(' - ').split(/[/、&]/).map(a => a.trim()).filter(a => a)
}
}
}
// 格式: "歌手-歌名" (无空格)
else if (nameWithoutExt.includes('-') && !nameWithoutExt.startsWith('-')) {
const parts = nameWithoutExt.split('-').map(p => p.trim())
if (parts.length >= 2) {
author = parts[0].split(/[/、&]/).map(a => a.trim()).filter(a => a)
name = parts.slice(1).join('-')
}
}
// 格式: "[歌手] 歌名" 或 "【歌手】歌名"
else if (nameWithoutExt.match(/^[[【](.+?)[\]】]\s*(.+)$/)) {
const match = nameWithoutExt.match(/^[[【](.+?)[\]】]\s*(.+)$/)
if (match) {
author = match[1].split(/[/、&]/).map(a => a.trim()).filter(a => a)
name = match[2].trim()
}
}
// 格式: "歌手 《歌名》" 或 "歌手《歌名》"
else if (nameWithoutExt.match(/^(.+?)\s*[《<](.+?)[》>]$/)) {
const match = nameWithoutExt.match(/^(.+?)\s*[《<](.+?)[》>]$/)
if (match) {
author = match[1].split(/[/、&]/).map(a => a.trim()).filter(a => a)
name = match[2].trim()
}
}
// 默认: 整个文件名作为歌名
else {
name = nameWithoutExt.trim()
}
if (!name) {
return null
}
// 创建一个本地URL用于后续可能的播放预览
const url = URL.createObjectURL(file)
return {
id: 0,
key: '',
name,
author: author.length > 0 ? author : ['未知'],
url,
description: `从文件导入: ${filePath}`,
language: [],
tags: [],
from: SongFrom.Custom,
createTime: Date.now(),
updateTime: Date.now(),
// 存储原始文件信息,以便后续可能需要上传
// @ts-ignore
_originalFile: file,
// @ts-ignore
_filePath: filePath
} as SongsInfo
}
/**
* 更新文件夹歌曲选项
*/
function updateFolderSongsOptions(newlyAddedSongs: SongsInfo[] = []) {
folderSongsOptions.value = folderSongs.value.map(s => ({
label: `${s.name} - ${s.author?.join('/') || '未知'}`,
value: s.name + '_' + (s as any)._filePath, // 使用组合键避免重名
disabled:
songs.value.findIndex(exist => exist.name === s.name) > -1
|| newlyAddedSongs.findIndex(add => add.name === s.name) > -1,
}))
}
/**
* 添加从文件夹选择的歌曲
*/
async function addFolderSongs() {
if (selectedFolderSongs.value.length === 0) {
message.error('请选择要添加的歌曲')
return
}
isModalLoading.value = true
try {
const songsToAdd = folderSongs.value.filter(s =>
selectedFolderSongs.value.find(select => select === (s.name + '_' + (s as any)._filePath))
)
// 注意: 由于歌曲URL是本地Blob URL需要根据实际需求处理
// 选项1: 直接使用本地URL仅在当前会话有效
// 选项2: 上传文件到服务器(需要实现文件上传接口)
// 这里使用选项1但添加提示
const result = await addSongs(songsToAdd.map(s => ({
...s,
description: (s.description || '') + ' [注意: 链接为本地文件,刷新页面后可能失效]'
})), SongFrom.Custom)
if (result.code === 200) {
message.success(`已添加 ${result.data.length} 首歌曲`)
songs.value.push(...result.data)
// 更新选项禁用状态
updateFolderSongsOptions(result.data)
} else {
message.error(`添加失败: ${result.message}`)
}
} catch (err: any) {
message.error(`添加失败: ${err.message}`)
console.error(err)
} finally {
isModalLoading.value = false
}
}
/**
* 批量编辑文件夹歌曲信息
*/
function batchEditFolderSongs(field: 'author' | 'language' | 'tags', value: string[]) {
const selectedSongs = folderSongs.value.filter(s =>
selectedFolderSongs.value.find(select => select === (s.name + '_' + (s as any)._filePath))
)
selectedSongs.forEach(song => {
if (field === 'author') {
song.author = value
} else if (field === 'language') {
song.language = value
} else if (field === 'tags') {
song.tags = value
}
})
// 更新选项显示
updateFolderSongsOptions()
message.success(`已更新 ${selectedSongs.length} 首歌曲的${field === 'author' ? '作者' : field === 'language' ? '语言' : '标签'}信息`)
}
/**
* 添加自定义歌曲
*/
@@ -1351,7 +1616,114 @@ onMounted(async () => {
name="directory"
tab="从文件夹读取"
>
开发中...
<NAlert
type="info"
style="margin-bottom: 16px"
>
<template #header>
功能说明
</template>
<NSpace vertical>
<div>选择本地文件夹自动扫描其中的音频文件支持 MP3WAVOGGFLACM4A 等格式</div>
<div>支持的文件名格式</div>
<ul style="margin: 8px 0; padding-left: 20px">
<li>歌名.mp3</li>
<li>歌手 - 歌名.mp3</li>
<li>歌手-歌名.mp3</li>
<li>[歌手] 歌名.mp3</li>
<li>歌手 歌名.mp3</li>
</ul>
<div style="color: #ff6b6b">
<strong>注意</strong>导入的歌曲链接为本地文件地址仅在当前浏览器会话有效刷新页面后可能需要重新导入
</div>
</NSpace>
</NAlert>
<NButton
type="primary"
:loading="isScanningFolder"
@click="selectFolder"
>
<template #icon>
<NIcon :component="ArchiveOutline" />
</template>
选择文件夹
</NButton>
<template v-if="folderSongsOptions.length > 0">
<NDivider style="margin: 16px 0" />
<!-- 批量编辑工具 -->
<NCollapse>
<NCollapseItem
title="批量编辑工具"
name="batch-edit"
>
<NSpace
vertical
style="width: 100%"
>
<NAlert type="info">
选中歌曲后可以批量设置作者语言或标签信息
</NAlert>
<NSpace align="center">
<span>批量设置作者</span>
<NSelect
style="width: 300px"
:options="authors"
filterable
multiple
tag
placeholder="选择或输入作者"
@update:value="(value) => batchEditFolderSongs('author', value)"
/>
</NSpace>
<NSpace align="center">
<span>批量设置语言</span>
<NSelect
style="width: 300px"
:options="languageSelectOption"
filterable
multiple
tag
placeholder="选择或输入语言"
@update:value="(value) => batchEditFolderSongs('language', value)"
/>
</NSpace>
<NSpace align="center">
<span>批量设置标签</span>
<NSelect
style="width: 300px"
:options="tags"
filterable
multiple
tag
placeholder="选择或输入标签"
@update:value="(value) => batchEditFolderSongs('tags', value)"
/>
</NSpace>
</NSpace>
</NCollapseItem>
</NCollapse>
<NDivider style="margin: 16px 0" />
<NButton
type="primary"
@click="addFolderSongs"
>
添加到歌单 | {{ selectedFolderSongs.length }}
</NButton>
<NDivider style="margin: 16px 0" />
<NTransfer
v-model:value="selectedFolderSongs"
style="height: 500px"
:options="folderSongsOptions"
source-filterable
/>
</template>
</NTabPane>
</NTabs>
</NSpin>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import ClassicRequestOBS from './live-request/ClassicRequestOBS.vue'
import FreshRequestOBS from './live-request/FreshRequestOBS.vue'
import { useOBSNotification } from '@/store/useOBSNotification'
const props = defineProps<{
id?: number
@@ -22,6 +23,12 @@ const styleType = computed(() => {
const queryStyle = route.query.style
return props.style || (typeof queryStyle === 'string' ? queryStyle : 'classic')
})
const obsNotification = useOBSNotification()
onMounted(() => {
// 只接收 live-request 类型的通知
void obsNotification.init(['live-request'])
})
</script>
<template>

View File

@@ -8,6 +8,7 @@ import { useRoute } from 'vue-router'
import { QueryGetAPI } from '@/api/query'
import { AVATAR_URL, MUSIC_REQUEST_API_URL } from '@/data/constants'
import { useWebRTC } from '@/store/useRTC'
import { useOBSNotification } from '@/store/useOBSNotification'
interface WaitMusicInfo {
from: DanmakuUserInfo
@@ -64,7 +65,11 @@ async function update() {
originSongs.value = r
}
}
const obsNotification = useOBSNotification()
onMounted(() => {
// 只接收 live-request 类型的通知
void obsNotification.init(['live-request'])
update()
window.$mitt.on('onOBSComponentUpdate', () => {
update()
@@ -313,9 +318,6 @@ onUnmounted(() => {
display: none;
}
/* 弹幕点歌 */
.music-request-list-item[from='1'] {}
.music-request-list-item-name {
font-style: italic;
font-size: 12px;

View File

@@ -16,6 +16,7 @@ import {
import { QueryGetAPI } from '@/api/query'
import { QUEUE_API_URL } from '@/data/constants'
import { useWebRTC } from '@/store/useRTC'
import { useOBSNotification } from '@/store/useOBSNotification'
const props = defineProps<{
id?: number
@@ -159,7 +160,10 @@ async function update() {
}
}
const obsNotification = useOBSNotification()
onMounted(() => {
// 只接收 queue 类型的通知
void obsNotification.init(['queue'])
update()
window.$mitt.on('onOBSComponentUpdate', () => {
update()

View File

@@ -241,10 +241,10 @@ const hasOtherSingSong = computed(() => {
</template>
{{
hasOtherSingSong
? '还有其他正在演唱的歌曲'
? '还有其他正在进行的点播'
: song.status == SongRequestStatus.Waiting && song.id
? '开始演唱'
: '停止演唱'
? '开始处理'
: '停止处理'
}}
</NTooltip>
<NTooltip>
@@ -263,45 +263,56 @@ const hasOtherSingSong = computed(() => {
</template>
完成
</NTooltip>
<NPopconfirm
@positive-click="onUpdateStatus(SongRequestStatus.Cancel)"
>
<NTooltip>
<template #trigger>
<NButton
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
<NPopconfirm
@positive-click="onUpdateStatus(SongRequestStatus.Cancel)"
>
<template #icon>
<NIcon :component="Dismiss16Filled" />
<template #trigger>
<NButton
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
>
<template #icon>
<NIcon :component="Dismiss16Filled" />
</template>
</NButton>
</template>
</NButton>
是否取消处理?
</NPopconfirm>
</template>
是否取消处理?
</NPopconfirm>
<NPopconfirm
取消
</NTooltip>
<NTooltip
v-if="
song.from == SongRequestFrom.Danmaku
&& song.user?.uid
&& song.status !== SongRequestStatus.Cancel
"
@positive-click="onBlockUser"
>
<template #trigger>
<NButton
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
<NPopconfirm
@positive-click="onBlockUser"
>
<template #icon>
<NIcon :component="PresenceBlocked16Regular" />
<template #trigger>
<NButton
circle
type="error"
style="height: 30px; width: 30px"
:loading="isLoading"
>
<template #icon>
<NIcon :component="PresenceBlocked16Regular" />
</template>
</NButton>
</template>
</NButton>
是否拉黑此用户?
</NPopconfirm>
</template>
是否拉黑用户?
</NPopconfirm>
拉黑用户
</NTooltip>
</NSpace>
</NSpace>
</NCard>

View File

@@ -292,6 +292,13 @@ async function updateSettings() {
</NButton>
内的歌曲
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.songRequest.allowReorderSong"
:disabled="!liveRequest.configCanEdit"
@update:checked="updateSettings"
>
允许重复点歌
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.songRequest.allowFromWeb"
:disabled="!liveRequest.configCanEdit"