add song request

This commit is contained in:
2023-11-19 18:36:21 +08:00
14 changed files with 1734 additions and 417 deletions

View File

@@ -1,5 +1,5 @@
import { ACCOUNT_API_URL, BASE_API } from '@/data/constants'
import { APIRoot, AccountInfo } from './api-models'
import { APIRoot, AccountInfo, FunctionTypes } from './api-models'
import { QueryPostAPI } from '@/api/query'
import { ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
@@ -47,6 +47,12 @@ function refreshCookie() {
}
})
}
export async function SaveAccountSettings() {
return await QueryPostAPI(ACCOUNT_API_URL + 'update-setting', ACCOUNT.value?.settings)
}
export async function SaveEnableFunctions(functions: FunctionTypes[]) {
return await QueryPostAPI(ACCOUNT_API_URL + 'update-enable-functions', functions)
}
export function useAccount() {
return ACCOUNT
}

View File

@@ -60,16 +60,39 @@ export interface Setting_QuestionBox {
export interface UserSetting {
sendEmail: Setting_SendEmail
questionBox: Setting_QuestionBox
songRequest: Setting_SongRequest
enableFunctions: FunctionTypes[]
indexTemplate: string | null,
indexTemplate: string | null
songListTemplate: string | null
scheduleTemplate: string | null
}
export interface Setting_SongRequest {
orderPrefix: string
onlyAllowSongList: boolean
queueMaxSize: number
allowAllDanmaku: boolean
allowFromWeb: boolean
needWearFanMedal: boolean
needJianzhang: boolean
needTidu: boolean
needZongdu: boolean
allowSC: boolean
scIgnoreLimit: boolean
scMinPrice: number
fanMedalMinLevel: number
allowReorderSong: boolean
enableCooldown: boolean
cooldownSecond: number
zongduCooldownSecond: number
tiduCooldownSecond: number
jianzhangCooldownSecond: number
}
export enum FunctionTypes {
SongList,
QuestionBox,
Schedule,
SongRequest,
}
export interface SongAuthorInfo {
name: string
@@ -167,7 +190,7 @@ export interface VideoCollectCreateModel {
endAt: number
maxVideoCount: number
}
export interface VideoCollectTable{
export interface VideoCollectTable {
id: string
shortId: string
name: string
@@ -190,17 +213,16 @@ export interface VideoCollectVideo {
length: number
watched?: boolean
}
export enum VideoFrom{
export enum VideoFrom {
Collect,
Spam
Spam,
}
export enum VideoStatus
{
Pending,
Accepted,
Rejected,
export enum VideoStatus {
Pending,
Accepted,
Rejected,
}
export interface VideoSender{
export interface VideoSender {
sendAt: number
sender?: string
senderId?: number
@@ -247,9 +269,9 @@ export interface OpenLiveLotteryUserInfo {
fans_medal_wearing_status: boolean //该房间粉丝勋章佩戴情况
guard_level: number
}
export enum OpenLiveLotteryType{
export enum OpenLiveLotteryType {
Waiting,
Result
Result,
}
export interface UpdateLiveLotteryUsersModel {
users: OpenLiveLotteryUserInfo[]
@@ -257,6 +279,7 @@ export interface UpdateLiveLotteryUsersModel {
type: OpenLiveLotteryType
}
export interface SongRequestInfo {
id: number
songName: string
song?: SongsInfo
status: SongRequestStatus
@@ -264,14 +287,16 @@ export interface SongRequestInfo {
scPrice?: number
user?: SongRequestUserInfo
createAt: number
finishAt?:number
isInLocal?: boolean
}
export interface SongRequestUserInfo {
name: string
uId: number
guardLevel: number
fansMedalLevel: number
fansMedalName: string
fansMedalWearingStatus: boolean
uid: number
guard_level: number
fans_medal_level: number
fans_medal_name: string
fans_medal_wearing_status: boolean
}
export enum SongRequestFrom {
@@ -286,4 +311,24 @@ export enum SongRequestStatus {
Singing,
Finish,
Cancel,
}
}
export interface EventModel {
type: EventDataTypes
name: string
avatar: string
uid: number
msg: string
time: number
num: number
price: number
guard_level: number
fans_medal_level: number
fans_medal_name: string
fans_medal_wearing_status: boolean
}
export enum EventDataTypes {
Guard,
SC,
Gift,
Message,
}

View File

@@ -245,6 +245,7 @@ export default class DanmakuClient {
chatClient.start()
console.log('[OPEN-LIVE] 已连接房间: ' + auth.anchor_info.room_id)
this.roomAuthInfo.value = auth
this.client = chatClient
return true
} else {
console.log('[OPEN-LIVE] 无法开启场次')

View File

@@ -23,6 +23,8 @@ export const HISTORY_API_URL = `${BASE_API}history/`
export const SCHEDULE_API_URL = `${BASE_API}schedule/`
export const VIDEO_COLLECT_API_URL = `${BASE_API}video-collect/`
export const OPEN_LIVE_API_URL = `${BASE_API}open-live/`
export const SONG_REQUEST_API_URL = `${BASE_API}song-request/`
export const EVENT_API_URL = `${BASE_API}event/`
export const ScheduleTemplateMap = {
'': { name: '默认', compoent: defineAsyncComponent(() => import('@/views/view/scheduleTemplate/DefaultScheduleTemplate.vue')) },

View File

@@ -1,6 +1,6 @@
import { QueryGetAPI } from '@/api/query'
import { useRequest } from 'vue-request'
import { NOTIFACTION_API_URL, isBackendUsable } from './constants'
import { NOTIFACTION_API_URL, SONG_REQUEST_API_URL, isBackendUsable } from './constants'
import { NotifactionInfo } from '@/api/api-models'
import { useAccount } from '@/api/account'
import { ref } from 'vue'
@@ -10,7 +10,7 @@ const n = ref<NotifactionInfo>()
let isLoading = false
function get() {
if (isLoading) return
QueryGetAPI<NotifactionInfo>(NOTIFACTION_API_URL + 'get')
QueryGetAPI<NotifactionInfo>(SONG_REQUEST_API_URL + 'get-active')
.then((data) => {
if (data.code == 200) {
n.value = data.data

View File

@@ -233,6 +233,14 @@ const routes: Array<RouteRecordRaw> = [
title: '直播抽奖',
},
},
{
path: 'song-request',
name: 'obs-song-request',
component: () => import('@/views/obs/SongRequestOBS.vue'),
meta: {
title: '弹幕点歌',
},
},
],
},
{

View File

@@ -72,7 +72,6 @@ const iconColor = 'white'
<div class="index-background">
<NSpace vertical justify="center" align="center" style="padding-top: 30px">
<NSpace justify="center" align="center" :size="width > 700 ? 50 : 0">
<vtb />
<NSpace vertical justify="center">
<NGradientText
size="3rem"

View File

@@ -29,8 +29,9 @@ import {
useMessage,
NDivider,
NTag,
NAlert,
} from 'naive-ui'
import { ref, onMounted, h } from 'vue'
import { ref, onMounted, h, onUnmounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
@@ -89,6 +90,9 @@ onMounted(async () => {
message.error('你不是从幻星平台访问此页面, 或未提供对应参数, 无法使用此功能')
}
})
onUnmounted(() => {
client.value?.Stop()
})
</script>
<template>
@@ -108,7 +112,7 @@ onMounted(async () => {
<NPageHeader :subtitle="($route.meta.title as string) ?? ''">
<template #extra>
<NSpace align="center">
<NTag :type="client ? 'success' : 'warning'"> {{ client ? `已连接 | ${client.roomAuthInfo.value?.anchor_info.uname}` : '未连接' }} </NTag>
<NTag :type="client?.roomAuthInfo.value ? 'success' : 'warning'"> {{ client?.roomAuthInfo.value ? `已连接 | ${client.roomAuthInfo.value?.anchor_info.uname}` : '未连接' }} </NTag>
<NSwitch :default-value="!isDarkMode()" @update:value="(value: string & number & boolean) => themeType = value ? ThemeType.Light : ThemeType.Dark">
<template #checked>
<NIcon :component="Sunny" />
@@ -120,7 +124,9 @@ onMounted(async () => {
</NSpace>
</template>
<template #title>
<NText strong style="font-size: 1.4rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-justify: auto"> VTSURU | 开放平台 </NText>
<NButton text tag="a" @click="$router.push({ name: 'open-live-index', query: $route.query })">
<NText strong style="font-size: 1.4rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-justify: auto"> VTSURU | 开放平台 </NText>
</NButton>
</template>
</NPageHeader>
</NLayoutHeader>
@@ -152,21 +158,23 @@ onMounted(async () => {
</NText>
</NSpace>
</NLayoutSider>
<NLayoutContent yle="height: 100%" :native-scrollbar="false">
<RouterView v-if="client?.roomAuthInfo" v-slot="{ Component }">
<NLayoutContent style="height: 100%; padding: 10px" :native-scrollbar="false">
<RouterView v-if="client?.roomAuthInfo.value" v-slot="{ Component }">
<KeepAlive>
<component :is="Component" :room-info="client?.roomAuthInfo" :client="client" :code="authInfo.Code" />
</KeepAlive>
</RouterView>
<template v-else>
<NSpin show />
<NAlert type="info" title="正在请求弹幕客户端认证信息...">
<NSpin show />
</NAlert>
</template>
<NBackTop />
</NLayoutContent>
</NLayout>
<NLayoutFooter style="height: 30px" bordered>
<NSpace justify="center" align="center" style="height: 100%">
<NButton text tag="a" href="/" target="_blank" type="info" secondary> vtsuru.live </NButton>
<NButton text tag="a" href="/" target="_blank" type="info"> vtsuru.live </NButton>
</NSpace>
</NLayoutFooter>
</NLayout>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { SaveAccountSettings, SaveEnableFunctions, useAccount } from '@/api/account'
import { NButton, NCard, NCheckbox, NCheckboxGroup, NDivider, NForm, NModal, NSelect, NSpace, NSpin, NSwitch, NTabPane, NTabs, SelectOption, useMessage } from 'naive-ui'
import { Ref, computed, h, onMounted, ref, defineAsyncComponent, onActivated } from 'vue'
import { FunctionTypes, ScheduleWeekInfo, SongFrom, SongLanguage, SongsInfo } from '@/api/api-models'
@@ -195,7 +195,7 @@ async function SaveComboGroupSetting(value: (string | number)[], meta: { actionT
if (accountInfo.value) {
isSaving.value = true
//UpdateEnableFunction(meta.value as FunctionTypes, meta.actionType == 'check')
await QueryPostAPI(ACCOUNT_API_URL + 'update-setting', accountInfo.value?.settings)
await SaveEnableFunctions(accountInfo.value.settings.enableFunctions)
.then((data) => {
if (data.code == 200) {
//message.success('保存成功')
@@ -216,10 +216,10 @@ async function SaveComboGroupSetting(value: (string | number)[], meta: { actionT
}
}
async function SaveComboSetting() {
isSaving.value = true
if (accountInfo.value) {
isSaving.value = true
//UpdateEnableFunction(meta.value as FunctionTypes, meta.actionType == 'check')
await QueryPostAPI(ACCOUNT_API_URL + 'update-setting', accountInfo.value?.settings)
await SaveAccountSettings()
.then((data) => {
if (data.code == 200) {
message.success('已保存')

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { Setting_SongRequest, SongRequestFrom, SongRequestInfo, SongRequestStatus } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { AVATAR_URL, SONG_REQUEST_API_URL } from '@/data/constants'
import { useElementSize } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Vue3Marquee } from 'vue3-marquee'
import { NCard, NDivider, NEmpty, NSpace, NText, useMessage } from 'naive-ui'
const props = defineProps<{
id?: number
}>()
const message = useMessage()
const route = useRoute()
const currentId = computed(() => {
return props.id ?? route.query.id
})
const listContainerRef = ref()
const footerRef = ref()
const footerListRef = ref()
const { height, width } = useElementSize(listContainerRef)
const footerSize = useElementSize(footerRef)
const footerListSize = useElementSize(footerListRef)
const itemHeight = 40
const key = ref(Date.now())
const songs = ref<SongRequestInfo[]>([])
const settings = ref<Setting_SongRequest>({} as Setting_SongRequest)
const singing = computed(() => {
return songs.value.find((s) => s.status == SongRequestStatus.Singing)
})
const activeSongs = computed(() => {
return songs.value.filter((s) => s.status == SongRequestStatus.Waiting)
})
async function get() {
try {
const data = await QueryGetAPI<{ songs: SongRequestInfo[]; setting: Setting_SongRequest }>(SONG_REQUEST_API_URL + 'get-active-and-settings', {
id: currentId.value,
})
if (data.code == 200) {
return data.data
}
} catch (err) {
console.error(err)
}
return {} as { songs: SongRequestInfo[]; setting: Setting_SongRequest }
}
const isMoreThanContainer = computed(() => {
return songs.value.length * itemHeight > height.value
})
const allowGuardTypes = computed(() => {
const types = []
if (settings.value.needTidu) {
types.push('提督')
}
if (settings.value.needZongdu) {
types.push('总督')
}
if (settings.value.needJianzhang) {
types.push('舰长')
}
return types
})
async function update() {
const r = await get()
if (r) {
songs.value = r.songs
settings.value = r.setting
}
}
let timer: any
onMounted(() => {
update()
timer = setInterval(update, 2000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<template>
<div class="song-request-background" v-bind="$attrs">
<p class="song-request-header">点歌</p>
<NDivider class="song-request-divider">
<p class="song-request-header-count">已有 {{ activeSongs.length ?? 0 }} </p>
</NDivider>
<div class="song-request-singing-container" :singing="songs.findIndex((s) => s.status == SongRequestStatus.Singing) > -1">
<div class="song-request-singing-prefix"></div>
<template v-if="singing">
<img class="song-request-singing-avatar" :src="AVATAR_URL + singing?.user?.uid" />
<p class="song-request-singing-song-name">{{ singing?.songName }}</p>
<p class="song-request-singing-name">{{ singing?.user?.name }}</p>
</template>
<div v-else class="song-request-singing-empty">暂未演唱</div>
<div class="song-request-singing-suffix"></div>
</div>
<div class="song-request-content" ref="listContainerRef">
<template v-if="activeSongs.length > 0">
<Vue3Marquee class="song-request-list" :key="key" vertical :pause="!isMoreThanContainer" :duration="20" :style="`height: ${height}px;width: ${width}px;`">
<span class="song-request-list-item" :from="(song.from as number)" :status="(song.status as number)" v-for="song in activeSongs" :key="song.id" :style="`height: ${itemHeight}px`">
<div class="song-request-list-item-song-name">
{{ song.songName }}
</div>
<div class="song-request-list-item-level" :has-level="(song.user?.fans_medal_level ?? 0) > 0">
{{ song.user?.fans_medal_level }}
</div>
<p class="song-request-list-item-name">{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name }}</p>
</span>
</Vue3Marquee>
</template>
<div v-else style="position: relative; top: 20%">
<NEmpty class="song-request-empty" description="暂无人点歌" />
</div>
</div>
<div class="song-request-footer" ref="footerRef">
<Vue3Marquee :key="key" ref="footerListRef" class="song-request-footer-marquee" :pause="footerSize.width < footerListSize.width" :duration="20">
<span class="song-request-tag" type="prefix">
<div class="song-request-tag-key">前缀</div>
<div class="song-request-tag-value">
{{ settings.orderPrefix }}
</div>
</span>
<span class="song-request-tag" type="prefix">
<div class="song-request-tag-key">允许</div>
<div class="song-request-tag-value">
{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join(',') : '无' }}
</div>
</span>
<span class="song-request-tag" type="sc">
<div class="song-request-tag-key">SC点歌</div>
<div class="song-request-tag-value">
{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}
</div>
</span>
<span class="song-request-tag" type="fan-madel">
<div class="song-request-tag-key">粉丝牌</div>
<div class="song-request-tag-value">
{{ settings.needWearFanMedal ? (settings.fanMedalMinLevel > 0 ? '> ' + settings.fanMedalMinLevel : '佩戴') : '无需' }}
</div>
</span></Vue3Marquee
>
</div>
</div>
</template>
<style scoped>
.song-request-background {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 100px;
min-width: 100px;
background-color: #0f0f0f48;
border-radius: 10px;
color: white;
}
.song-request-header {
margin: 0;
color: #fff;
text-align: center;
font-size: 20px;
font-weight: bold;
text-shadow: 0 0 10px #ca7b7b6e, 0 0 20px #ffffff8e, 0 0 30px #61606086, 0 0 40px rgba(64, 156, 179, 0.555);
}
.song-request-header-count {
color: #ffffffbd;
text-align: center;
font-size: 14px;
}
.song-request-divider {
margin: 0 auto;
margin-top: -15px;
margin-bottom: -15px;
width: 90%;
}
.song-request-singing-container {
height: 35px;
margin: 0 10px 0 10px;
display: flex;
align-items: center;
gap: 10px;
}
.song-request-singing-empty {
font-weight: bold;
font-style: italic;
color: #ffffffbe;
}
.song-request-singing-prefix {
border: 2px solid rgb(231, 231, 231);
height: 30px;
width: 10px;
border-radius: 10px;
}
.song-request-singing-container[singing='true'] .song-request-singing-prefix {
background-color: #75c37f;
animation: animated-border 3s linear infinite;
}
.song-request-singing-container[singing='false'] .song-request-singing-prefix {
background-color: #c37575;
}
.song-request-singing-avatar {
height: 30px;
border-radius: 50%;
/* 添加无限旋转动画 */
animation: rotate 20s linear infinite;
}
.song-request-singing-song-name {
font-size: large;
font-weight: bold;
}
.song-request-singing-name {
font-size: 12px;
font-style: italic;
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
.n-divider__line {
background-color: #ffffffd5;
}
.song-request-content {
background-color: #0f0f0f4f;
margin: 10px;
padding: 10px;
height: 100%;
border-radius: 10px;
}
.marquee {
justify-items: left;
}
.song-request-list-item {
display: flex;
align-items: center;
justify-content: left;
gap: 10px;
}
.song-request-list-item-song-name {
font-size: 18px;
font-weight: bold;
}
/* 手动添加 */
.song-request-list-item[from='0'] .song-request-list-item-name {
font-style: italic;
font-weight: bold;
color: #c6e4d9;
font-size: 12px;
}
.song-request-list-item[from='0'] .song-request-list-item-avatar {
display: none;
}
/* 弹幕点歌 */
.song-request-list-item[from='1'] {
}
.song-request-list-item-name {
font-style: italic;
font-size: 12px;
color: rgba(204, 204, 204, 0.993);
}
.song-request-list-item-level {
text-align: center;
height: 18px;
padding: 2px;
min-width: 15px;
border-radius: 5px;
background-color: #0f0f0f48;
color: rgba(204, 204, 204, 0.993);
font-size: 12px;
}
.song-request-list-item-level[has-level='false'] {
display: none;
}
.song-request-footer {
margin: 0 5px 5px 5px;
height: 60px;
border-radius: 5px;
background-color: #0f0f0f4f;
display: flex;
align-items: center;
}
.song-request-tag {
display: flex;
margin: 5px 0 5px 5px;
height: 40px;
border-radius: 3px;
background-color: #0f0f0f4f;
padding: 4px;
padding-right: 6px;
display: flex;
flex-direction: column;
justify-content: left;
}
.song-request-tag-key {
font-style: italic;
color: rgb(211, 211, 211);
font-size: 12px;
}
.song-request-tag-value {
font-size: 14px;
}
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 6px rgba(255, 255, 255, 0);
}
}
</style>

View File

@@ -1,46 +1,970 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { SongRequestInfo } from '@/api/api-models'
import { SaveAccountSettings, SaveEnableFunctions, useAccount } from '@/api/account'
import { EventDataTypes, EventModel, FunctionTypes, OpenLiveInfo, Setting_SongRequest, SongRequestFrom, SongRequestInfo, SongRequestStatus, SongRequestUserInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query'
import DanmakuClient, { AuthInfo, DanmakuInfo, RoomAuthInfo, SCInfo } from '@/data/DanmakuClient'
import { NList, NTabPane, NTabs, useMessage } from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { OPEN_LIVE_API_URL, SONG_REQUEST_API_URL } from '@/data/constants'
import { Check24Filled, Checkmark12Regular, Delete24Filled, Dismiss12Filled, Dismiss16Filled, Info24Filled, Mic24Filled, PeopleQueue24Filled } from '@vicons/fluent'
import { ReloadCircleSharp } from '@vicons/ionicons5'
import { useStorage } from '@vueuse/core'
import { format, isSameDay } from 'date-fns'
import { ca } from 'date-fns/locale'
import { number } from 'echarts'
import { List } from 'linqts'
import {
DataTableColumns,
NAlert,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDataTable,
NDivider,
NEllipsis,
NEmpty,
NIcon,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NLi,
NList,
NListItem,
NModal,
NPopconfirm,
NSpace,
NSpin,
NSwitch,
NTabPane,
NTabs,
NTag,
NText,
NTime,
NTooltip,
NUl,
useMessage,
useNotification,
} from 'naive-ui'
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import LiveLotteryOBS from '../obs/LiveLotteryOBS.vue'
import SongRequestOBS from '../obs/SongRequestOBS.vue'
const defaultSettings = {
orderPrefix: '点歌',
onlyAllowSongList: false,
queueMaxSize: 10,
allowAllDanmaku: true,
allowFromWeb: true,
needWearFanMedal: false,
needJianzhang: false,
needTidu: false,
needZongdu: false,
allowSC: true,
scIgnoreLimit: true,
scMinPrice: 30,
fanMedalMinLevel: 0,
allowReorderSong: false,
enableCooldown: false,
cooldownSecond: 1200,
zongduCooldownSecond: 300,
tiduCooldownSecond: 600,
jianzhangCooldownSecond: 900,
} as Setting_SongRequest
const STATUS_MAP = {
[SongRequestStatus.Waiting]: '等待中',
[SongRequestStatus.Singing]: '演唱中',
[SongRequestStatus.Finish]: '已演唱',
[SongRequestStatus.Cancel]: '已取消',
}
const route = useRoute()
const accountInfo = useAccount()
const message = useMessage()
const notice = useNotification()
const isWarnMessageAutoClose = useStorage('SongRequest.Settings.WarnMessageAutoClose', false)
const isLoading = ref(false)
const showOBSModal = ref(false)
const settings = computed({
get: () => {
if (accountInfo.value) {
return accountInfo.value.settings.songRequest
}
return defaultSettings
},
set: (value) => {
if (accountInfo.value) {
accountInfo.value.settings.songRequest = value
}
},
})
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
isOpenLive?: boolean
}>()
const activeSongs = ref<SongRequestInfo[]>([])
const localActiveSongs = useStorage('SongRequest.ActiveSongs', [] as SongRequestInfo[])
const songs = ref<SongRequestInfo[]>(await getAllSong())
const activeSongs = computed(() => {
return (accountInfo ? songs.value : localActiveSongs.value)
.sort((a, b) => b.status - a.status)
.filter((song) => {
return song.status == SongRequestStatus.Waiting || song.status == SongRequestStatus.Singing
})
})
const historySongs = computed(() => {
return (accountInfo ? songs.value : localActiveSongs.value)
.sort((a, b) => a.status - b.status)
.filter((song) => {
return song.status == SongRequestStatus.Finish || song.status == SongRequestStatus.Cancel
})
})
const newSongName = ref('')
const defaultPrefix = useStorage('Settings.SongRequest.DefaultPrefix', '点歌')
const configCanEdit = computed(() => {
return accountInfo.value != null && accountInfo.value != undefined
})
const table = ref()
async function getAllSong() {
if (accountInfo.value) {
try {
const data = await QueryGetAPI<SongRequestInfo[]>(SONG_REQUEST_API_URL + 'get-all', {
id: accountInfo.value.id,
})
if (data.code == 200) {
console.log('[OPEN-LIVE-Song-Request] 已获取所有数据')
return new List(data.data).OrderByDescending((s) => s.createAt).ToArray()
} else {
message.error('无法获取数据: ' + data.message)
return []
}
} catch (err) {
console.error(err)
message.error('无法获取数据')
}
return []
} else {
return localActiveSongs.value
}
}
async function getActiveSong() {
if (accountInfo.value) {
try {
const data = await QueryGetAPI<SongRequestInfo[]>(SONG_REQUEST_API_URL + 'get-active', {
id: accountInfo.value.id,
})
if (data.code == 200) {
console.log('[OPEN-LIVE-Song-Request] 已获取点歌队列')
return data.data
} else {
message.error('无法获取点歌队列: ' + data.message)
return []
}
} catch (err) {
console.error(err)
}
return []
} else {
return localActiveSongs.value
}
}
async function addSong(danmaku: EventModel) {
console.log(`[OPEN-LIVE-Song-Request] 收到 [${danmaku.name}] 的点歌${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`)
if (accountInfo.value) {
await QueryPostAPI<SongRequestInfo>(SONG_REQUEST_API_URL + 'try-add', danmaku)
.then((data) => {
if (data.code == 200) {
message.success(`[${danmaku.name}] 添加曲目: ${data.data.songName}`)
songs.value.unshift(data.data)
} else {
//message.error(`[${danmaku.name}] 添加曲目失败: ${data.message}`)
const time = Date.now()
notice.warning({
title: danmaku.name + ' 点歌失败',
description: data.message,
duration: isWarnMessageAutoClose.value ? 3000 : 0,
meta: () => h(NTime, { type: 'relative', time: time, key: updateKey.value }),
})
console.log(`[OPEN-LIVE-Song-Request] [${danmaku.name}] 添加曲目失败: ${data.message}`)
}
})
.catch((err) => {
console.error(err)
})
} else {
const songData = {
songName: danmaku.msg.trim().substring(settings.value.orderPrefix.length),
song: undefined,
status: SongRequestStatus.Waiting,
from: danmaku.type == EventDataTypes.Message ? SongRequestFrom.Danmaku : SongRequestFrom.SC,
scPrice: danmaku.type == EventDataTypes.SC ? danmaku.price : 0,
user: {
name: danmaku.name,
uid: danmaku.uid,
fans_medal_level: danmaku.fans_medal_level,
fans_medal_name: danmaku.fans_medal_name,
fans_medal_wearing_status: danmaku.fans_medal_wearing_status,
guard_level: danmaku.guard_level,
} as SongRequestUserInfo,
createAt: Date.now(),
isInLocal: true,
id: songs.value.length == 0 ? 1 : new List(songs.value).Max((s) => s.id) + 1,
} as SongRequestInfo
localActiveSongs.value.unshift(songData)
message.success(`[${danmaku.name}] 添加曲目: ${songData.songName}`)
}
}
async function addSongManual() {
if (!newSongName.value) {
message.error('请输入曲目名')
return
}
if (accountInfo.value) {
await QueryPostAPIWithParams<SongRequestInfo>(SONG_REQUEST_API_URL + 'add', {
name: newSongName.value,
})
.then((data) => {
if (data.code == 200) {
message.success(`已手动添加曲目: ${data.data.songName}`)
activeSongs.value.unshift(data.data)
newSongName.value = ''
console.log(`[OPEN-LIVE-Song-Request] 已手动添加曲目: ${data.data.songName}`)
} else {
message.error(`手动添加曲目失败: ${data.message}`)
}
})
.catch((err) => {
console.error(err)
})
} else {
const songData = {
songName: newSongName.value,
song: undefined,
status: SongRequestStatus.Waiting,
from: SongRequestFrom.Manual,
scPrice: undefined,
user: undefined,
createAt: Date.now(),
isInLocal: true,
id: songs.value.length == 0 ? 1 : new List(songs.value).Max((s) => s.id) + 1,
} as SongRequestInfo
localActiveSongs.value.unshift(songData)
message.success(`已手动添加曲目: ${songData.songName}`)
}
}
async function updateSongStatus(song: SongRequestInfo, status: SongRequestStatus) {
if (!configCanEdit.value) {
song.status = status
return
}
isLoading.value = true
let statusString = ''
let statusString2 = ''
switch (status) {
case SongRequestStatus.Waiting:
statusString = 'active'
statusString2 = '等待中'
break
case SongRequestStatus.Cancel:
statusString = 'cancel'
statusString2 = '已取消'
break
case SongRequestStatus.Finish:
statusString = 'finish'
statusString2 = '已完成'
break
case SongRequestStatus.Singing:
statusString = 'singing'
statusString2 = '演唱中'
break
}
await QueryGetAPI(SONG_REQUEST_API_URL + statusString, {
id: song.id,
})
.then((data) => {
if (data.code == 200) {
console.log(`[OPEN-LIVE-Song-Request] 更新曲目状态: ${song.songName} -> ${statusString}`)
song.status = status
if (status > SongRequestStatus.Singing) {
song.finishAt = Date.now()
}
message.success(`已更新曲目状态为: ${statusString2}`)
} else {
console.log(`[OPEN-LIVE-Song-Request] 更新曲目状态失败: ${data.message}`)
message.error(`更新曲目状态失败: ${data.message}`)
}
})
.catch((err) => {
console.error(err)
message.error(`更新曲目状态失败`)
})
.finally(() => {
isLoading.value = false
})
}
function onGetDanmaku(danmaku: DanmakuInfo) {
console.log(danmaku)
if (checkMessage(danmaku.msg)) {
addSong({
msg: danmaku.msg,
type: EventDataTypes.Message,
time: danmaku.timestamp,
uid: danmaku.uid,
name: danmaku.uname,
avatar: danmaku.uface,
fans_medal_level: danmaku.fans_medal_level,
fans_medal_name: danmaku.fans_medal_name,
fans_medal_wearing_status: danmaku.fans_medal_wearing_status,
guard_level: danmaku.guard_level,
num: 1,
price: 0,
} as EventModel)
}
}
function onGetSC(danmaku: SCInfo) {
console.log(danmaku)
if (settings.value.allowSC && checkMessage(danmaku.message)) {
addSong({
msg: danmaku.message,
type: EventDataTypes.SC,
time: danmaku.timestamp,
uid: danmaku.uid,
name: danmaku.uname,
fans_medal_level: danmaku.fans_medal_level,
fans_medal_name: danmaku.fans_medal_name,
fans_medal_wearing_status: danmaku.fans_medal_wearing_status,
guard_level: danmaku.guard_level,
avatar: danmaku.uface,
num: 1,
price: danmaku.rmb,
} as EventModel)
}
}
function checkMessage(msg: string) {
return msg
.trim()
.toLowerCase()
.startsWith(accountInfo.value ? settings.value.orderPrefix : defaultPrefix.value)
}
async function onUpdateFunctionEnable() {
if (accountInfo.value) {
const oldValue = JSON.parse(JSON.stringify(accountInfo.value.settings.enableFunctions))
if (accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest)) {
accountInfo.value.settings.enableFunctions = accountInfo.value.settings.enableFunctions.filter((f) => f != FunctionTypes.SongRequest)
} else {
accountInfo.value.settings.enableFunctions.push(FunctionTypes.SongRequest)
}
if (!accountInfo.value.settings.songRequest.orderPrefix) {
accountInfo.value.settings.songRequest.orderPrefix = defaultPrefix.value
}
await SaveEnableFunctions(accountInfo.value?.settings.enableFunctions)
.then((data) => {
if (data.code == 200) {
message.success(`${accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest) ? '启用' : '禁用'}点歌功能`)
} else {
if (accountInfo.value) {
accountInfo.value.settings.enableFunctions = oldValue
}
message.error(`点歌功能${accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest) ? '启用' : '禁用'}失败: ${data.message}`)
}
})
.catch((err) => {
console.error(err)
message.error(`点歌功能${accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest) ? '启用' : '禁用'}失败: ${err}`)
})
}
}
async function updateSettings() {
if (accountInfo.value) {
isLoading.value = true
await QueryPostAPI(SONG_REQUEST_API_URL + 'update-setting', settings.value)
.then((data) => {
if (data.code == 200) {
message.success('已保存')
} else {
message.error('保存失败: ' + data.message)
}
})
.catch((err) => {
console.error(err)
message.error('保存失败')
})
.finally(() => {
isLoading.value = false
})
} else {
message.success('完成')
}
}
async function deleteSongs(values: SongRequestInfo[]) {
await QueryPostAPI(
SONG_REQUEST_API_URL + 'del',
values.map((s) => s.id)
)
.then((data) => {
if (data.code == 200) {
message.success('删除成功')
songs.value = songs.value.filter((s) => !values.includes(s))
} else {
message.error('删除失败: ' + data.message)
console.error('删除失败: ' + data.message)
}
})
.catch((err) => {
console.error(err)
message.error('删除失败')
})
}
async function deactiveAllSongs() {
await QueryGetAPI(SONG_REQUEST_API_URL + 'deactive')
.then((data) => {
if (data.code == 200) {
message.success('已全部取消')
songs.value.forEach((s) => {
if (s.status <= SongRequestStatus.Singing) {
s.status = SongRequestStatus.Cancel
}
})
} else {
message.error('取消失败: ' + data.message)
}
})
.catch((err) => {
console.error(err)
message.error('取消失败')
})
}
const statusFilterOptions = computed(() => {
return Object.values(SongRequestStatus)
.filter((t) => /^\d+$/.test(t.toString()))
.map((t) => {
return {
label: STATUS_MAP[t as SongRequestStatus],
value: t,
}
})
})
const columns = [
{
title: '曲名',
key: 'songName',
},
{
title: '用户名',
key: 'user.name',
render: (data) => {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h(NTag, { bordered: false, size: 'small' }, data.from == SongRequestFrom.Manual ? () => h(NText, { italic: true }, () => '手动添加') : () => data.user?.name),
default: () => (data.from == SongRequestFrom.Manual ? '就是主播自己' : data.user?.uid),
}
)
},
},
{
title: '来自',
key: 'from',
render(data) {
let fromType: 'info' | 'success' | 'default' | 'error'
switch (data.from) {
case SongRequestFrom.Danmaku: {
fromType = 'info'
break
}
case SongRequestFrom.SC: {
fromType = 'error'
break
}
case SongRequestFrom.Web: {
fromType = 'success'
break
}
case SongRequestFrom.Manual: {
fromType = 'default'
break
}
}
return h(NTag, { size: 'small', type: fromType }, () => {
switch (data.from) {
case SongRequestFrom.Danmaku: {
return '弹幕'
}
case SongRequestFrom.SC: {
return 'SuperChat | ' + data.scPrice
}
case SongRequestFrom.Manual: {
return '手动添加'
}
case SongRequestFrom.Web: {
return '网页添加'
}
}
})
},
},
{
title: '状态',
key: 'status',
filter(value, row) {
return ~row.status == value
},
filterOptions: statusFilterOptions.value,
render(data) {
let statusType: 'info' | 'success' | 'warning' | 'error'
switch (data.status) {
case SongRequestStatus.Singing: {
statusType = 'success'
break
}
case SongRequestStatus.Waiting: {
statusType = 'warning'
break
}
case SongRequestStatus.Finish: {
statusType = 'info'
break
}
case SongRequestStatus.Cancel: {
statusType = 'error'
break
}
}
return h(NTag, { type: statusType, size: 'small' }, () => STATUS_MAP[data.status])
},
},
{
title: '时间',
key: 'time',
sorter: (a, b) => a.createAt - b.createAt,
render: (data) => {
return h(NTime, { time: data.createAt })
},
},
{
title: '操作',
key: 'manage',
width: 100,
render(data) {
return h(
NSpace,
{
justify: 'center',
size: 10,
},
() => [
data.status == SongRequestStatus.Finish || data.status == SongRequestStatus.Cancel
? h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'small',
type: 'info',
circle: true,
loading: isLoading.value,
onClick: () => {
updateSongStatus(data, SongRequestStatus.Waiting)
},
},
{
icon: () => h(NIcon, { component: ReloadCircleSharp }),
}
),
default: () => '重新放回等待列表',
})
: undefined,
h(
NPopconfirm,
{ onPositiveClick: () => deleteSongs([data]) },
{
trigger: () =>
h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'small',
type: 'error',
circle: true,
loading: isLoading.value,
},
{
icon: () => h(NIcon, { component: Delete24Filled }),
}
),
default: () => '删除记录',
}),
default: () => '确定删除?',
}
),
]
)
},
},
] as DataTableColumns<SongRequestInfo>
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)'
}
}
}
return ''
}
let timer: any
const updateKey = ref(0)
onMounted(() => {
if (accountInfo.value) {
settings.value = accountInfo.value.settings.songRequest
}
props.client.on('danmaku', onGetDanmaku)
props.client.on('sc', onGetSC)
timer = setInterval(() => {
updateKey.value++
}, 1000)
})
onUnmounted(() => {
props.client.off('danmaku', onGetDanmaku)
props.client.off('sc', onGetSC)
clearInterval(timer)
})
</script>
<template>
开发中...
<NTabs animated>
<NTabPane name="list" tab="列表">
<NList> </NList>
</NTabPane>
<NTabPane name="history" tab="历史"> </NTabPane>
</NTabs>
<NAlert type="info" v-if="accountInfo"> 启用点歌功能 <NSwitch :value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.SongRequest)" @update:value="onUpdateFunctionEnable" /> </NAlert>
<NAlert type="warning" v-else title="你尚未注册并登录 VTsuru.live, 大部分规则设置将不可用 (因为我懒得在前段重写一遍逻辑">
<NButton @click="$router.push({ name: 'manage-index' })" type="primary"> 前往登录或注册 </NButton>
</NAlert>
<br />
<NCard size="small">
<NTooltip>
<template #trigger>
<NButton @click="showOBSModal = true" type="primary" :disabled="!accountInfo"> OBS 组件 </NButton>
</template>
{{ configCanEdit ? '' : '登陆后才可以使用此功能' }}
</NTooltip>
</NCard>
<br />
<NCard>
<NTabs v-if="!accountInfo || accountInfo.settings.enableFunctions.includes(FunctionTypes.SongRequest)" animated>
<NTabPane name="list" tab="列表">
<NCard size="small">
<NSpace align="center">
<NTag type="success" :bordered="false">
<template #icon>
<NIcon :component="PeopleQueue24Filled" />
</template>
队列 | {{ activeSongs.filter((s) => s.status == SongRequestStatus.Waiting).length }}
</NTag>
<NTag type="success" :bordered="false">
<template #icon>
<NIcon :component="PeopleQueue24Filled" />
</template>
今日已演唱 | {{ songs.filter((s) => s.status != SongRequestStatus.Cancel && isSameDay(s.finishAt ?? 0, Date.now())).length }}
</NTag>
<NInputGroup>
<NInput placeholder="手动添加" v-model:value="newSongName" />
<NButton type="primary" @click="addSongManual"> 添加 </NButton>
</NInputGroup>
<NPopconfirm @positive-click="deactiveAllSongs">
<template #trigger>
<NButton type="error"> 全部取消 </NButton>
</template>
确定全部取消吗?
</NPopconfirm>
</NSpace>
</NCard>
<NDivider> {{ activeSongs.length }} </NDivider>
<NList v-if="activeSongs.length > 0" :show-divider="false" hoverable>
<NListItem v-for="song in activeSongs" :key="song.id" style="padding: 5px">
<NCard embedded size="small" content-style="padding: 5px;" :style="`${song.status == SongRequestStatus.Singing ? 'animation: animated-border 2.5s infinite;' : ''};height: 100%;`">
<NSpace justify="space-between" align="center" style="height: 100%; margin: 0 5px 0 5px">
<NSpace align="center">
<div :style="`border-radius: 4px; background-color: ${song.status == SongRequestStatus.Singing ? '#75c37f' : '#577fb8'}; width: 10px; height: 20px`"></div>
<NText strong style="font-size: 18px">
{{ song.songName }}
</NText>
<template v-if="song.from == SongRequestFrom.Manual">
<NTag size="small" :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>
</template>
{{ song.user?.uid }}
</NTooltip>
</template>
<NSpace v-if="(song.from == SongRequestFrom.Danmaku || song.from == SongRequestFrom.SC) && song.user?.fans_medal_wearing_status">
<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>
</NSpace>
<NTag v-if="(song.user?.guard_level ?? 0) > 0" size="small" :bordered="false" :color="{ textColor: 'white', color: GetGuardColor(song.user?.guard_level) }">
{{ song.user?.guard_level == 1 ? '总督' : song.user?.guard_level == 2 ? '提督' : '舰长' }}
</NTag>
<NTag v-if="song.from == SongRequestFrom.SC" size="small" :color="{ textColor: 'white', color: GetSCColor(song.scPrice ?? 0) }"> SC | {{ song.scPrice }} </NTag>
<NTooltip>
<template #trigger>
<NText style="font-size: small">
<NTime :time="song.createAt" type="relative" :key="updateKey" />
</NText>
</template>
<NTime :time="song.createAt" />
</NTooltip>
</NSpace>
<NSpace justify="end" align="center">
<audio v-if="song.song" :src="song.song?.url" controls style="width: 300px; height: 30px; margin-bottom: -5px"></audio>
<NTooltip>
<template #trigger>
<NButton
circle
type="primary"
style="height: 30px; width: 30px"
:disabled="songs.findIndex((s) => s.id != song.id && s.status == SongRequestStatus.Singing) > -1"
@click="updateSongStatus(song, song.status == SongRequestStatus.Singing ? SongRequestStatus.Waiting : SongRequestStatus.Singing)"
:style="`animation: ${song.status == SongRequestStatus.Waiting ? '' : 'loading 5s linear infinite'}`"
:secondary="song.status == SongRequestStatus.Singing"
:loading="isLoading"
>
<template #icon>
<NIcon :component="Mic24Filled" />
</template>
</NButton>
</template>
{{
songs.findIndex((s) => s.id != song.id && s.status == SongRequestStatus.Singing) > -1
? '还有其他正在演唱的歌曲'
: song.status == SongRequestStatus.Waiting && song.id
? '开始演唱'
: '停止演唱'
}}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton circle type="success" style="height: 30px; width: 30px" :loading="isLoading" @click="updateSongStatus(song, SongRequestStatus.Finish)">
<template #icon>
<NIcon :component="Checkmark12Regular" />
</template>
</NButton>
</template>
已完成演唱
</NTooltip>
<NTooltip>
<template #trigger>
<NButton circle type="error" style="height: 30px; width: 30px" :loading="isLoading" @click="updateSongStatus(song, SongRequestStatus.Cancel)">
<template #icon>
<NIcon :component="Dismiss16Filled" />
</template>
</NButton>
</template>
移出队列
</NTooltip>
</NSpace>
</NSpace>
</NCard>
</NListItem>
</NList>
<NEmpty v-else description="暂无曲目" />
</NTabPane>
<NTabPane name="history" tab="历史">
<NDataTable
size="small"
ref="table"
:columns="columns"
:data="songs"
:pagination="{
itemCount: songs.length,
pageSizes: [20, 50, 100],
showSizePicker: true,
prefix({ itemCount }) {
return `共 ${itemCount} 条记录`
},
}"
/>
</NTabPane>
<NTabPane name="setting" tab="设置">
<NSpin :show="isLoading">
<NDivider> 规则 </NDivider>
<NSpace vertical>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 点歌弹幕前缀 </NInputGroupLabel>
<template v-if="configCanEdit">
<NInput v-model:value="settings.orderPrefix" />
<NButton @click="updateSettings" type="primary">确定</NButton>
</template>
<NInput v-else v-model:value="defaultPrefix" />
</NInputGroup>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 最大队列长度 </NInputGroupLabel>
<NInputNumber v-model:value="settings.queueMaxSize" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
<NSpace align="center">
<NCheckbox v-model:checked="settings.allowAllDanmaku" @update:checked="updateSettings" :disabled="!configCanEdit"> 允许所有弹幕点歌 </NCheckbox>
<template v-if="!settings.allowAllDanmaku">
<NCheckbox v-model:checked="settings.needWearFanMedal" @update:checked="updateSettings" :disabled="!configCanEdit"> 需要拥有粉丝牌 </NCheckbox>
<NInputGroup v-if="settings.needWearFanMedal" style="width: 250px">
<NInputGroupLabel> 最低粉丝牌等级 </NInputGroupLabel>
<NInputNumber v-model:value="settings.fanMedalMinLevel" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
<NCheckbox v-if="!settings.allowAllDanmaku" v-model:checked="settings.needJianzhang" @update:checked="updateSettings" :disabled="!configCanEdit"> 只允许舰长 </NCheckbox>
<NCheckbox v-if="!settings.allowAllDanmaku" v-model:checked="settings.needTidu" @update:checked="updateSettings" :disabled="!configCanEdit"> 只允许提督 </NCheckbox>
<NCheckbox v-if="!settings.allowAllDanmaku" v-model:checked="settings.needZongdu" @update:checked="updateSettings" :disabled="!configCanEdit"> 只允许总督 </NCheckbox>
</template>
</NSpace>
<NSpace align="center">
<NCheckbox v-model:checked="settings.allowSC" @update:checked="updateSettings" :disabled="!configCanEdit"> 允许通过 SuperChat 点歌 </NCheckbox>
<span v-if="settings.allowSC">
<NCheckbox v-model:checked="settings.allowSC" @update:checked="updateSettings" :disabled="!configCanEdit"> SC点歌无视限制 </NCheckbox>
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
包含冷却时间, 队列长度, 重复点歌等
</NTooltip>
</span>
<NInputGroup v-if="settings.allowSC" style="width: 250px">
<NInputGroupLabel> 最低SC价格 </NInputGroupLabel>
<NInputNumber v-model:value="settings.scMinPrice" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
</NSpace>
<NSpace>
<NCheckbox v-model:checked="settings.onlyAllowSongList" @update:checked="updateSettings" :disabled="!configCanEdit">
仅允许点
<NButton text tag="a" href="/manage/song-list" target="_blank" type="info"> 歌单 </NButton>
内的歌曲
</NCheckbox>
<NCheckbox v-model:checked="settings.allowFromWeb" @update:checked="updateSettings" :disabled="!configCanEdit"> 允许通过网页点歌 </NCheckbox>
</NSpace>
<NDivider> 冷却 (单位: ) </NDivider>
<NCheckbox v-model:checked="settings.enableCooldown" @update:checked="updateSettings" :disabled="!configCanEdit"> 启用点歌冷却 </NCheckbox>
<NSpace v-if="settings.enableCooldown">
<NInputGroup style="width: 250px">
<NInputGroupLabel> 普通弹幕 </NInputGroupLabel>
<NInputNumber v-model:value="settings.cooldownSecond" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
<NInputGroup style="width: 220px">
<NInputGroupLabel> 舰长 </NInputGroupLabel>
<NInputNumber v-model:value="settings.jianzhangCooldownSecond" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
<NInputGroup style="width: 220px">
<NInputGroupLabel> 提督 </NInputGroupLabel>
<NInputNumber v-model:value="settings.tiduCooldownSecond" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
<NInputGroup style="width: 220px">
<NInputGroupLabel> 总督 </NInputGroupLabel>
<NInputNumber v-model:value="settings.zongduCooldownSecond" :disabled="!configCanEdit" />
<NButton @click="updateSettings" type="info" :disabled="!configCanEdit">确定</NButton>
</NInputGroup>
</NSpace>
<NDivider> 其他 </NDivider>
<NCheckbox v-model:checked="isWarnMessageAutoClose"> 自动关闭点歌失败时的提示消息 </NCheckbox>
</NSpace>
</NSpin>
</NTabPane>
</NTabs>
</NCard>
<NModal v-model:show="showOBSModal" title="OBS组件" preset="card" style="width: 800px">
<NAlert title="这是什么? " type="info"> 将等待队列以及结果显示在OBS中 </NAlert>
<NDivider> 浏览 </NDivider>
<div style="height: 500px; width: 280px; position: relative; margin: 0 auto">
<SongRequestOBS :id="accountInfo?.id" />
</div>
<br />
<NInput :value="'https://vtsuru.live/obs/song-request?id=' + accountInfo?.id" />
<NDivider />
<NCollapse>
<NCollapseItem title="使用说明">
<NUl>
<NLi> OBS 来源中添加源, 选择 浏览器</NLi>
<NLi> URL 栏填入上方链接</NLi>
<NLi>根据自己的需要调整宽度和高度 (这里是宽 280px 500px)</NLi>
<NLi>完事</NLi>
</NUl>
</NCollapseItem>
</NCollapse>
</NModal>
</template>
<style>
@keyframes loading {
/*以百分比来规定改变发生的时间 也可以通过"from"和"to",等价于0% 和 100%*/
0% {
/*rotate(2D旋转) scale(放大或者缩小) translate(移动) skew(翻转)*/
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0);
}
}
@keyframes animated-border-round {
0% {
box-shadow: 0 0 0px #589580;
border-radius: 50%;
}
100% {
box-shadow: 0 0 0 5px rgba(255, 255, 255, 0);
border-radius: 50%;
}
}
</style>

View File

@@ -333,193 +333,200 @@ onUnmounted(() => {
</script>
<template>
<NLayoutContent style="height: 100vh; padding: 20px">
<NResult v-if="!code && !accountInfo" status="403" title="403" description="该页面只能从幻星平台访问或者注册用户使用" />
<template v-else>
<NResult v-if="!code && !accountInfo" status="403" title="403" description="该页面只能从幻星平台访问或者注册用户使用" />
<template v-else>
<NCard>
<template #header>
直播抽奖
<NDivider vertical />
<NButton text type="primary" tag="a" href="https://vtsuru.live" target="_blank"> 前往 VTsuru.live 主站 </NButton>
</template>
<NAlert v-if="!code && accountInfo && !accountInfo.isBiliVerified" type="error"> 请先绑定B站账号 </NAlert>
<NAlert v-else-if="!code && accountInfo && accountInfo.biliAuthCodeStatus != 1" type="error"> 身份码状态异常, 请重新绑定 </NAlert>
<NCard>
<template #header>
直播抽奖
<NDivider vertical />
<NButton text type="primary" tag="a" href="https://vtsuru.live" target="_blank"> 前往 VTsuru.live 主站 </NButton>
<NSpace align="center">
<NButton type="info" @click="showModal = true" size="small"> 抽奖历史</NButton>
<NButton type="success" @click="showOBSModal = true" size="small"> OBS组件</NButton>
</NSpace>
</NCard>
<NCard size="small" embedded title="抽奖选项">
<template #header-extra>
<NButton size="small" secondary @click="lotteryOption = defaultOption" :disabled="isStartLottery"> 恢复默认 </NButton>
</template>
<NAlert v-if="!code && accountInfo && !accountInfo.isBiliVerified" type="error"> 请先绑定B站账号 </NAlert>
<NAlert v-else-if="!code && accountInfo && accountInfo.biliAuthCodeStatus != 1" type="error"> 身份码状态异常, 请重新绑定 </NAlert>
<NCard>
<NSpace align="center">
<NButton type="info" @click="showModal = true" size="small"> 抽奖历史</NButton>
<NButton type="success" @click="showOBSModal = true" size="small"> OBS组件</NButton>
</NSpace>
</NCard>
<NCard size="small" embedded title="抽奖选项">
<template #header-extra>
<NButton size="small" secondary @click="lotteryOption = defaultOption" :disabled="isStartLottery"> 恢复默认 </NButton>
</template>
<NSpace justify="center" align="center">
<NTag :bordered="false"> 抽奖类型 </NTag>
<NRadioGroup v-model:value="lotteryOption.type" :disabled="isLottering" size="small">
<NRadioButton value="danmaku" :disabled="isStartLottery"> 弹幕 </NRadioButton>
<NRadioButton value="gift" :disabled="isStartLottery"> 礼物 </NRadioButton>
</NRadioGroup>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"></NDivider>
<NSpace align="center">
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 抽选人数 </NInputGroupLabel>
<NInputNumber :disabled="isStartLottery" v-model:value="lotteryOption.resultCount" placeholder="" min="1" />
<NSpace justify="center" align="center">
<NTag :bordered="false"> 抽奖类型 </NTag>
<NRadioGroup v-model:value="lotteryOption.type" :disabled="isLottering" size="small">
<NRadioButton value="danmaku" :disabled="isStartLottery"> 弹幕 </NRadioButton>
<NRadioButton value="gift" :disabled="isStartLottery"> 礼物 </NRadioButton>
</NRadioGroup>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"></NDivider>
<NSpace align="center">
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 抽选人数 </NInputGroupLabel>
<NInputNumber :disabled="isStartLottery" v-model:value="lotteryOption.resultCount" placeholder="" min="1" />
</NInputGroup>
<NCheckbox :disabled="isStartLottery" v-model:checked="lotteryOption.needGuard"> 需要上舰 </NCheckbox>
<NCheckbox :disabled="isStartLottery" v-model:checked="lotteryOption.needFanMedal"> 需要粉丝牌 </NCheckbox>
<NCollapseTransition>
<NInputGroup v-if="lotteryOption.needFanMedal" style="max-width: 200px">
<NInputGroupLabel> 最低粉丝牌等级 </NInputGroupLabel>
<NInputNumber v-model:value="lotteryOption.fanCardLevel" min="1" max="50" :default-value="1" :disabled="isLottering || isStartLottery" />
</NInputGroup>
<NCheckbox :disabled="isStartLottery" v-model:checked="lotteryOption.needGuard"> 需要上舰 </NCheckbox>
<NCheckbox :disabled="isStartLottery" v-model:checked="lotteryOption.needFanMedal"> 需要粉丝牌 </NCheckbox>
<NCollapseTransition>
<NInputGroup v-if="lotteryOption.needFanMedal" style="max-width: 200px">
<NInputGroupLabel> 最低粉丝牌等级 </NInputGroupLabel>
<NInputNumber v-model:value="lotteryOption.fanCardLevel" min="1" max="50" :default-value="1" :disabled="isLottering || isStartLottery" />
</NInputGroup>
</NCollapseTransition>
<template v-if="lotteryOption.type == 'danmaku'">
</NCollapseTransition>
<template v-if="lotteryOption.type == 'danmaku'">
<NTooltip>
<template #trigger>
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 弹幕内容 </NInputGroupLabel>
<NInput :disabled="isStartLottery" v-model:value="lotteryOption.danmakuKeyword" placeholder="留空则任何弹幕都可以" />
</NInputGroup>
</template>
符合规则的弹幕才会被添加到抽奖队列中
</NTooltip>
<NRadioGroup v-model:value="lotteryOption.danmakuFilterType" name="判定类型" :disabled="isLottering" size="small">
<NRadioButton :disabled="isStartLottery" value="all"> 完全一致 </NRadioButton>
<NRadioButton :disabled="isStartLottery" value="contains"> 包含 </NRadioButton>
<NRadioButton :disabled="isStartLottery" value="regex"> 正则 </NRadioButton>
</NRadioGroup>
</template>
<template v-else-if="lotteryOption.type == 'gift'">
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 最低价格 </NInputGroupLabel>
<NInputNumber :disabled="isStartLottery" v-model:value="lotteryOption.giftMinPrice" placeholder="留空则不限制" />
</NInputGroup>
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 礼物名称 </NInputGroupLabel>
<NInput :disabled="isStartLottery" v-model:value="lotteryOption.giftName" placeholder="留空则不限制" />
</NInputGroup>
</template>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"></NDivider>
<NSpace justify="center" align="center">
<NTag :bordered="false"> 抽取方式 </NTag>
<NRadioGroup v-model:value="lotteryOption.lotteryType" name="抽取类型" size="small" :disabled="isLottering">
<NRadioButton value="single">
单个
<NTooltip>
<template #trigger>
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 弹幕内容 </NInputGroupLabel>
<NInput :disabled="isStartLottery" v-model:value="lotteryOption.danmakuKeyword" placeholder="留空则任何弹幕都可以" />
</NInputGroup>
<NIcon :component="Info24Filled" />
</template>
符合规则的弹幕才会被添加到抽奖队列中
一个一个减少
</NTooltip>
<NRadioGroup v-model:value="lotteryOption.danmakuFilterType" name="判定类型" :disabled="isLottering" size="small">
<NRadioButton :disabled="isStartLottery" value="all"> 完全一致 </NRadioButton>
<NRadioButton :disabled="isStartLottery" value="contains"> 包含 </NRadioButton>
<NRadioButton :disabled="isStartLottery" value="regex"> 正则 </NRadioButton>
</NRadioGroup>
</template>
<template v-else-if="lotteryOption.type == 'gift'">
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 最低价格 </NInputGroupLabel>
<NInputNumber :disabled="isStartLottery" v-model:value="lotteryOption.giftMinPrice" placeholder="留空则不限制" />
</NInputGroup>
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 礼物名称 </NInputGroupLabel>
<NInput :disabled="isStartLottery" v-model:value="lotteryOption.giftName" placeholder="留空则不限制" />
</NInputGroup>
</template>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"></NDivider>
<NSpace justify="center" align="center">
<NTag :bordered="false"> 抽取方式 </NTag>
<NRadioGroup v-model:value="lotteryOption.lotteryType" name="抽取类型" size="small" :disabled="isLottering">
<NRadioButton value="single">
单个
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
一个一个减少
</NTooltip>
</NRadioButton>
<NRadioButton value="half">
减半
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
点一次减少一半
</NTooltip>
</NRadioButton>
</NRadioGroup>
</NSpace>
</NCard>
<NCard v-if="originUsers" size="small">
<NSpace justify="center" align="center">
<NButton type="primary" @click="continueLottery" :loading="isStartLottery" :disabled="isStartLottery || isLotteried || !client"> 开始 </NButton>
<NButton type="warning" :disabled="!isStartLottery" @click="pause"> 停止 </NButton>
<NButton type="error" :disabled="isLottering || originUsers.length == 0" @click="clear"> 清空 </NButton>
</NSpace>
<NDivider style="margin: 20px 0 20px 0"> <template v-if="isStartLottery"> 进行抽取前需要先停止 </template> </NDivider>
<NSpace justify="center">
<NButton type="primary" secondary @click="startLottery" :loading="isLottering" :disabled="isStartLottery || isLotteried"> 进行抽取 </NButton>
<NButton type="info" secondary :disabled="isStartLottery || isLottering || !isLotteried" @click="reset"> 重置 </NButton>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"> {{ currentUsers?.length }} </NDivider>
<NGrid v-if="currentUsers.length > 0" cols="1 500:2 800:3 1000:4" :x-gap="12" :y-gap="8">
<NGridItem v-for="item in currentUsers" v-bind:key="item.uId">
<NCard size="small" :title="item.name" style="height: 155px" embedded>
<template #header>
<NSpace align="center" vertical :size="5">
<NAvatar round lazy borderd :size="64" :src="item.avatar + '@64w_64h'" :img-props="{ referrerpolicy: 'no-referrer' }" style="box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)" />
<NSpace v-if="item.fans_medal_wearing_status">
<NTag size="tiny" round>
<NTag size="tiny" round :bordered="false">
{{ item.fans_medal_level }}
</NTag>
<span style="color: #577fb8">
{{ item.fans_medal_name }}
</span>
</NTag>
</NSpace>
<NTag v-else size="tiny" round :bordered="false"> 无粉丝牌 </NTag>
{{ item.name }}
</NSpace>
<NButton style="position: absolute; right: 5px; top: 5px; color: #753e3e" @click="removeUser(item)" size="small" circle>
<template #icon>
<NIcon :component="Delete24Filled" />
</template>
</NButton>
</NRadioButton>
<NRadioButton value="half">
减半
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
</NCard>
</NGridItem>
</NGrid>
<NEmpty v-else description="暂无用户" />
</NCard>
点一次减少一半
</NTooltip>
</NRadioButton>
</NRadioGroup>
</NSpace>
</NCard>
</template>
<NModal v-model:show="showModal" preset="card" title="抽奖结果" style="max-width: 90%; width: 800px" closable>
<template #header-extra>
<NButton type="error" size="small" @click="lotteryHistory = []"> 清空 </NButton>
</template>
<NScrollbar v-if="lotteryHistory.length > 0" style="max-height: 80vh">
<NList>
<NListItem v-for="item in lotteryHistory" :key="item.time">
<NCard size="small">
<NCard v-if="originUsers" size="small">
<NSpace justify="center" align="center">
<NButton type="primary" @click="continueLottery" :loading="isStartLottery" :disabled="isStartLottery || isLotteried || !client"> 开始 </NButton>
<NButton type="warning" :disabled="!isStartLottery" @click="pause"> 停止 </NButton>
<NButton type="error" :disabled="isLottering || originUsers.length == 0" @click="clear"> 清空 </NButton>
</NSpace>
<NDivider style="margin: 20px 0 20px 0"> <template v-if="isStartLottery"> 进行抽取前需要先停止 </template> </NDivider>
<NSpace justify="center">
<NButton
type="primary"
secondary
@click="startLottery"
:loading="isLottering"
:disabled="isStartLottery || isLotteried"
data-umami-event="Open-Live Use Lottery"
:data-umami-event-uid="client.roomAuthInfo.value?.anchor_info.uid"
>
进行抽取
</NButton>
<NButton type="info" secondary :disabled="isStartLottery || isLottering || !isLotteried" @click="reset"> 重置 </NButton>
</NSpace>
<NDivider style="margin: 10px 0 10px 0"> {{ currentUsers?.length }} </NDivider>
<NGrid v-if="currentUsers.length > 0" cols="1 500:2 800:3 1000:4" :x-gap="12" :y-gap="8">
<NGridItem v-for="item in currentUsers" v-bind:key="item.uId">
<NCard size="small" :title="item.name" style="height: 155px" embedded>
<template #header>
<NTime :time="item.time" />
</template>
<template #header-extra>
<NButton type="error" size="small" @click="lotteryHistory.splice(lotteryHistory.indexOf(item), 1)"> 删除 </NButton>
</template>
<NSpace vertical>
<NSpace v-for="user in item.users" :key="user.uId">
<NAvatar round lazy :src="user.avatar + '@64w_64h'" :img-props="{ referrerpolicy: 'no-referrer' }" />
{{ user.name }}
<NSpace align="center" vertical :size="5">
<NAvatar round lazy borderd :size="64" :src="item.avatar + '@64w_64h'" :img-props="{ referrerpolicy: 'no-referrer' }" style="box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)" />
<NSpace v-if="item.fans_medal_wearing_status">
<NTag size="tiny" round>
<NTag size="tiny" round :bordered="false">
{{ item.fans_medal_level }}
</NTag>
<span style="color: #577fb8">
{{ item.fans_medal_name }}
</span>
</NTag>
</NSpace>
<NTag v-else size="tiny" round :bordered="false"> 无粉丝牌 </NTag>
{{ item.name }}
</NSpace>
</NSpace>
</NCard>
</NListItem>
</NList>
</NScrollbar>
<NEmpty v-else description="暂无记录" />
</NModal>
<NModal v-model:show="showOBSModal" preset="card" title="OBS 组件" style="max-width: 90%; width: 800px; max-height: 90vh" closable content-style="overflow: auto">
<NAlert title="这是什么? " type="info"> 将等待队列以及结果显示在OBS中 </NAlert>
<NDivider> 浏览 </NDivider>
<div style="height: 400px; width: 250px; position: relative; margin: 0 auto">
<LiveLotteryOBS :code="code" />
</div>
<br />
<NInput :value="'https://vtsuru.live/obs/live-lottery?code=' + code" />
<NDivider />
<NCollapse>
<NCollapseItem title="使用说明">
<NUl>
<NLi> OBS 来源中添加源, 选择 浏览器</NLi>
<NLi> URL 栏填入上方链接</NLi>
<NLi>根据自己的需要调整宽度和高度</NLi>
<NLi>完事</NLi>
</NUl>
</NCollapseItem>
</NCollapse>
<NDivider />
</NModal>
</NLayoutContent>
<NButton style="position: absolute; right: 5px; top: 5px; color: #753e3e" @click="removeUser(item)" size="small" circle>
<template #icon>
<NIcon :component="Delete24Filled" />
</template>
</NButton>
</template>
</NCard>
</NGridItem>
</NGrid>
<NEmpty v-else description="暂无用户" />
</NCard>
</NCard>
</template>
<NModal v-model:show="showModal" preset="card" title="抽奖结果" style="max-width: 90%; width: 800px" closable>
<template #header-extra>
<NButton type="error" size="small" @click="lotteryHistory = []"> 清空 </NButton>
</template>
<NScrollbar v-if="lotteryHistory.length > 0" style="max-height: 80vh">
<NList>
<NListItem v-for="item in lotteryHistory" :key="item.time">
<NCard size="small">
<template #header>
<NTime :time="item.time" />
</template>
<template #header-extra>
<NButton type="error" size="small" @click="lotteryHistory.splice(lotteryHistory.indexOf(item), 1)"> 删除 </NButton>
</template>
<NSpace vertical>
<NSpace v-for="user in item.users" :key="user.uId">
<NAvatar round lazy :src="user.avatar + '@64w_64h'" :img-props="{ referrerpolicy: 'no-referrer' }" />
{{ user.name }}
</NSpace>
</NSpace>
</NCard>
</NListItem>
</NList>
</NScrollbar>
<NEmpty v-else description="暂无记录" />
</NModal>
<NModal v-model:show="showOBSModal" preset="card" title="OBS 组件" style="max-width: 90%; width: 800px; max-height: 90vh" closable content-style="overflow: auto">
<NAlert title="这是什么? " type="info"> 将等待队列以及结果显示在OBS中 </NAlert>
<NDivider> 浏览 </NDivider>
<div style="height: 400px; width: 250px; position: relative; margin: 0 auto">
<LiveLotteryOBS :code="code" />
</div>
<br />
<NInput :value="'https://vtsuru.live/obs/live-lottery?code=' + code" />
<NDivider />
<NCollapse>
<NCollapseItem title="使用说明">
<NUl>
<NLi> OBS 来源中添加源, 选择 浏览器</NLi>
<NLi> URL 栏填入上方链接</NLi>
<NLi>根据自己的需要调整宽度和高度 (这里是宽 250px 400px)</NLi>
<NLi>完事</NLi>
</NUl>
</NCollapseItem>
</NCollapse>
<NDivider />
</NModal>
</template>
@/data/DanmakuClient