add song request from web

This commit is contained in:
2023-11-20 14:18:43 +08:00
parent 6b087030f5
commit 7dcbc87436
9 changed files with 232 additions and 49 deletions

View File

@@ -41,6 +41,7 @@ export interface AccountInfo extends UserInfo {
eventFetcherOnline: boolean eventFetcherOnline: boolean
eventFetcherStatus: string eventFetcherStatus: string
canRequestSong: boolean
nextSendEmailTime?: number nextSendEmailTime?: number
} }
@@ -116,6 +117,7 @@ export interface SongsInfo {
tags?: string[] tags?: string[]
createTime: number createTime: number
updateTime: number updateTime: number
paidSong: boolean
} }
export enum SongLanguage { export enum SongLanguage {
Chinese, // 中文 Chinese, // 中文

View File

@@ -12,6 +12,7 @@ import {
NAvatar, NAvatar,
NButton, NButton,
NCard, NCard,
NCheckbox,
NCollapseTransition, NCollapseTransition,
NDataTable, NDataTable,
NDivider, NDivider,
@@ -31,9 +32,9 @@ import {
NTooltip, NTooltip,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { onMounted, h, ref, watch, computed, reactive } from 'vue' import { onMounted, h, ref, watch, computed, reactive, VNodeChild } from 'vue'
import APlayer from 'vue3-aplayer' import APlayer from 'vue3-aplayer'
import { NotepadEdit20Filled, Delete24Filled, Play24Filled, SquareArrowForward24Filled } from '@vicons/fluent' import { NotepadEdit20Filled, Delete24Filled, Play24Filled, SquareArrowForward24Filled, Info24Filled } from '@vicons/fluent'
import NeteaseIcon from '@/svgs/netease.svg' import NeteaseIcon from '@/svgs/netease.svg'
import FiveSingIcon from '@/svgs/fivesing.svg' import FiveSingIcon from '@/svgs/fivesing.svg'
@@ -41,6 +42,7 @@ const props = defineProps<{
songs: SongsInfo[] songs: SongsInfo[]
canEdit?: boolean canEdit?: boolean
isSelf: boolean isSelf: boolean
extraButtom?: (song: SongsInfo) => VNodeChild[]
}>() }>()
watch( watch(
() => props.songs, () => props.songs,
@@ -212,7 +214,7 @@ function createColumns(): DataTableColumns<SongsInfo> {
title: '操作', title: '操作',
key: 'manage', key: 'manage',
disabled: () => !props.canEdit, disabled: () => !props.canEdit,
width: props.isSelf ? 170 : 100, width: 170,
render(data) { render(data) {
return h( return h(
NSpace, NSpace,
@@ -289,6 +291,7 @@ function createColumns(): DataTableColumns<SongsInfo> {
}), }),
] ]
: null, : null,
props.extraButtom?.(data),
] ]
) )
}, },
@@ -528,6 +531,17 @@ onMounted(() => {
<NFormItem path="tags" label="标签"> <NFormItem path="tags" label="标签">
<NSelect v-model:value="updateSongModel.tags" filterable multiple clearable tag placeholder="可选,按回车确认" :options="tagsSelectOption" /> <NSelect v-model:value="updateSongModel.tags" filterable multiple clearable tag placeholder="可选,按回车确认" :options="tagsSelectOption" />
</NFormItem> </NFormItem>
<NFormItem path="paidSong" label="付费歌曲">
<NCheckbox v-model:checked="updateSongModel.paidSong">
是否付费歌曲
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
用于区分是否可以从网页进行点歌
</NTooltip>
</NCheckbox>
</NFormItem>
<NFormItem path="url" label="链接"> <NFormItem path="url" label="链接">
<NInput v-model:value="updateSongModel.url" placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开" :disabled="updateSongModel.from != SongFrom.Custom" /> <NInput v-model:value="updateSongModel.url" placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开" :disabled="updateSongModel.from != SongFrom.Custom" />
</NFormItem> </NFormItem>

View File

@@ -219,7 +219,9 @@ onMounted(async () => {
</NLayout> </NLayout>
</NLayout> </NLayout>
<NModal v-model:show="registerAndLoginModalVisiable" style="width: 500px; max-width: 90vw"> <NModal v-model:show="registerAndLoginModalVisiable" style="width: 500px; max-width: 90vw">
<div>
<RegisterAndLogin /> <RegisterAndLogin />
</div>
</NModal> </NModal>
</template> </template>

View File

@@ -4,13 +4,16 @@ import { SongFrom, SongLanguage, SongsInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query' import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import SongList from '@/components/SongList.vue' import SongList from '@/components/SongList.vue'
import { FETCH_API, SONG_API_URL } from '@/data/constants' import { FETCH_API, SONG_API_URL } from '@/data/constants'
import { Info24Filled } from '@vicons/fluent'
import { import {
FormInst, FormInst,
FormRules, FormRules,
NButton, NButton,
NCheckbox,
NDivider, NDivider,
NForm, NForm,
NFormItem, NFormItem,
NIcon,
NInput, NInput,
NModal, NModal,
NPagination, NPagination,
@@ -21,6 +24,7 @@ import {
NTable, NTable,
NTabs, NTabs,
NTag, NTag,
NTooltip,
NTransfer, NTransfer,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
@@ -218,7 +222,7 @@ async function getNeteaseSongList() {
if (data.code == 200) { if (data.code == 200) {
neteaseSongs.value = data.data neteaseSongs.value = data.data
neteaseSongsOptions.value = data.data.map((s) => ({ neteaseSongsOptions.value = data.data.map((s) => ({
label: `${s.name} - ${s.author.join('/')}`, label: `${s.name} - ${!s.author ? '未知' : s.author.join('/')}`,
value: s.key, value: s.key,
disabled: songs.value.findIndex((exist) => exist.id == s.id) > -1, disabled: songs.value.findIndex((exist) => exist.id == s.id) > -1,
})) }))
@@ -363,6 +367,17 @@ onMounted(async () => {
<NFormItem path="url" label="链接"> <NFormItem path="url" label="链接">
<NInput v-model:value="addSongModel.url" placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开" /> <NInput v-model:value="addSongModel.url" placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开" />
</NFormItem> </NFormItem>
<NFormItem path="paidSong" label="付费歌曲">
<NCheckbox v-model:checked="addSongModel.paidSong">
是否付费歌曲
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
用于区分是否可以从网页进行点歌
</NTooltip>
</NCheckbox>
</NFormItem>
</NForm> </NForm>
<NButton type="primary" @click="addCustomSong"> 添加 </NButton> <NButton type="primary" @click="addCustomSong"> 添加 </NButton>
</NTabPane> </NTabPane>

View File

@@ -69,7 +69,9 @@ const allowGuardTypes = computed(() => {
async function update() { async function update() {
const r = await get() const r = await get()
if (r) { if (r) {
songs.value = r.songs songs.value = r.songs.sort((a, b) => {
return b.createAt - a.createAt
})
settings.value = r.setting settings.value = r.setting
} }
} }
@@ -90,7 +92,7 @@ onUnmounted(() => {
<NDivider class="song-request-divider"> <NDivider class="song-request-divider">
<p class="song-request-header-count">已有 {{ activeSongs.length ?? 0 }} </p> <p class="song-request-header-count">已有 {{ activeSongs.length ?? 0 }} </p>
</NDivider> </NDivider>
<div class="song-request-singing-container" :singing="songs.findIndex((s) => s.status == SongRequestStatus.Singing) > -1"> <div class="song-request-singing-container" :singing="songs.findIndex((s) => s.status == SongRequestStatus.Singing) > -1" :from="(singing?.from as number)" :status="(singing?.status as number)">
<div class="song-request-singing-prefix"></div> <div class="song-request-singing-prefix"></div>
<template v-if="singing"> <template v-if="singing">
<img class="song-request-singing-avatar" :src="AVATAR_URL + singing?.user?.uid" referrerpolicy="no-referrer" /> <img class="song-request-singing-avatar" :src="AVATAR_URL + singing?.user?.uid" referrerpolicy="no-referrer" />
@@ -107,10 +109,10 @@ onUnmounted(() => {
<div class="song-request-list-item-song-name"> <div class="song-request-list-item-song-name">
{{ song.songName }} {{ song.songName }}
</div> </div>
<p class="song-request-list-item-name">{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name }}</p>
<div class="song-request-list-item-level" :has-level="(song.user?.fans_medal_level ?? 0) > 0"> <div class="song-request-list-item-level" :has-level="(song.user?.fans_medal_level ?? 0) > 0">
{{ song.user?.fans_medal_level }} {{ song.user?.fans_medal_level }}
</div> </div>
<p class="song-request-list-item-name">{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name }}</p>
</span> </span>
</Vue3Marquee> </Vue3Marquee>
</template> </template>
@@ -211,9 +213,16 @@ onUnmounted(() => {
/* 添加无限旋转动画 */ /* 添加无限旋转动画 */
animation: rotate 20s linear infinite; animation: rotate 20s linear infinite;
} }
/* 网页点歌 */
.song-request-singing-container[from='3'] .song-request-singing-avatar {
display: none;
}
.song-request-singing-song-name { .song-request-singing-song-name {
font-size: large; font-size: large;
font-weight: bold; font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
} }
.song-request-singing-name { .song-request-singing-name {
font-size: 12px; font-size: 12px;
@@ -236,12 +245,16 @@ onUnmounted(() => {
padding: 10px; padding: 10px;
height: 100%; height: 100%;
border-radius: 10px; border-radius: 10px;
overflow-x: hidden;
} }
.marquee { .marquee {
justify-items: left; justify-items: left;
} }
.song-request-list-item { .song-request-list-item {
display: flex; display: flex;
width: 100%;
align-self: flex-start;
position: relative;
align-items: center; align-items: center;
justify-content: left; justify-content: left;
gap: 10px; gap: 10px;
@@ -249,13 +262,17 @@ onUnmounted(() => {
.song-request-list-item-song-name { .song-request-list-item-song-name {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%;
} }
/* 手动添加 */ /* 手动添加 */
.song-request-list-item[from='0'] .song-request-list-item-name { .song-request-list-item[from='0'] .song-request-list-item-name {
font-style: italic; font-style: italic;
font-weight: bold; font-weight: bold;
color: #c6e4d9; color: #d2d8d6;
font-size: 12px; font-size: 12px;
} }
.song-request-list-item[from='0'] .song-request-list-item-avatar { .song-request-list-item[from='0'] .song-request-list-item-avatar {
@@ -265,10 +282,15 @@ onUnmounted(() => {
/* 弹幕点歌 */ /* 弹幕点歌 */
.song-request-list-item[from='1'] { .song-request-list-item[from='1'] {
} }
.song-request-list-item-name { .song-request-list-item-name {
font-style: italic; font-style: italic;
font-size: 12px; font-size: 12px;
color: rgba(204, 204, 204, 0.993); color: rgba(204, 204, 204, 0.993);
text-overflow: ellipsis;
white-space: nowrap;
margin-left: auto;
} }
.song-request-list-item-level { .song-request-list-item-level {
text-align: center; text-align: center;

View File

@@ -85,6 +85,7 @@ const message = useMessage()
const notice = useNotification() const notice = useNotification()
const isWarnMessageAutoClose = useStorage('SongRequest.Settings.WarnMessageAutoClose', false) const isWarnMessageAutoClose = useStorage('SongRequest.Settings.WarnMessageAutoClose', false)
const volumn = useStorage('Settings.Volumn', 0.5)
const isLoading = ref(false) const isLoading = ref(false)
const showOBSModal = ref(false) const showOBSModal = ref(false)
@@ -114,12 +115,24 @@ const localActiveSongs = useStorage('SongRequest.ActiveSongs', [] as SongRequest
const originSongs = ref<SongRequestInfo[]>(await getAllSong()) const originSongs = ref<SongRequestInfo[]>(await getAllSong())
const songs = computed(() => { const songs = computed(() => {
return originSongs.value.filter((s) => { return originSongs.value.filter((s) => {
return ( if (filterName.value) {
(filterSongName.value == '' || filterSongNameContains.value if (filterNameContains.value) {
? s.songName.toLowerCase().includes(filterSongName.value.toLowerCase()) if (!s.user?.name.toLowerCase().includes(filterName.value.toLowerCase())) {
: s.songName.toLowerCase() == filterSongName.value.toLowerCase()) && return false
(filterName.value == '' || filterNameContains.value ? s.user?.name.toLowerCase().includes(filterName.value.toLowerCase()) : s.user?.name.toLowerCase() == filterName.value.toLowerCase()) }
) } else if (s.user?.name.toLowerCase() !== filterName.value.toLowerCase()) {
return false
}
} else if (filterSongName.value) {
if (filterSongNameContains.value) {
if (!s.songName.toLowerCase().includes(filterSongName.value.toLowerCase())) {
return false
}
} else if (s.songName.toLowerCase() !== filterSongName.value.toLowerCase()) {
return false
}
}
return true
}) })
}) })
const activeSongs = computed(() => { const activeSongs = computed(() => {
@@ -172,27 +185,6 @@ async function getAllSong() {
return localActiveSongs.value 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) { async function addSong(danmaku: EventModel) {
console.log(`[OPEN-LIVE-Song-Request] 收到 [${danmaku.name}] 的点歌${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`) console.log(`[OPEN-LIVE-Song-Request] 收到 [${danmaku.name}] 的点歌${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`)
if (accountInfo.value) { if (accountInfo.value) {
@@ -656,8 +648,32 @@ function GetGuardColor(level: number | null | undefined): string {
} }
return '' return ''
} }
async function updateActive() {
if (!accountInfo.value) return
try {
const data = await QueryGetAPI<SongRequestInfo[]>(SONG_REQUEST_API_URL + 'get-active', {
id: accountInfo.value?.id,
})
if (data.code == 200) {
data.data.forEach((item) => {
const song = originSongs.value.find((s) => s.id == item.id)
if (song) {
if (song.status != item.status) song.status = item.status
} else {
originSongs.value.unshift(item)
}
})
} else {
message.error('无法获取点歌队列: ' + data.message)
return []
}
} catch (err) {
console.error(err)
}
}
let timer: any let timer: any
let updateActiveTimer: any
const updateKey = ref(0) const updateKey = ref(0)
onMounted(() => { onMounted(() => {
if (accountInfo.value) { if (accountInfo.value) {
@@ -668,11 +684,15 @@ onMounted(() => {
timer = setInterval(() => { timer = setInterval(() => {
updateKey.value++ updateKey.value++
}, 1000) }, 1000)
updateActiveTimer = setInterval(() => {
updateActive()
}, 3000)
}) })
onUnmounted(() => { onUnmounted(() => {
props.client.off('danmaku', onGetDanmaku) props.client.off('danmaku', onGetDanmaku)
props.client.off('sc', onGetSC) props.client.off('sc', onGetSC)
clearInterval(timer) clearInterval(timer)
clearInterval(updateActiveTimer)
}) })
</script> </script>
@@ -771,7 +791,7 @@ onUnmounted(() => {
</NTooltip> </NTooltip>
</NSpace> </NSpace>
<NSpace justify="end" align="center"> <NSpace justify="end" align="center">
<audio v-if="song.song" :src="song.song?.url" controls style="width: 300px; height: 30px; margin-bottom: -5px"></audio> <audio v-if="song.song" :volumn="volumn" :src="song.song?.url" controls style="width: 300px; height: 30px; margin-bottom: -5px"></audio>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton <NButton

View File

@@ -1,16 +1,28 @@
<template> <template>
<NSpin v-if="isLoading" show /> <NSpin v-if="isLoading" show />
<component v-else :is="componentType" :user-info="userInfo" :bili-info="biliInfo" :currentData="currentData" /> <component
v-else
:is="componentType"
:user-info="userInfo"
:bili-info="biliInfo"
:currentData="currentData"
:song-request-settings="settings"
:song-request-active="songs"
@request-song="requestSong"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { SongsInfo } from '@/api/api-models' import { Setting_SongRequest, SongRequestInfo, SongsInfo } from '@/api/api-models'
import DefaultSongListTemplate from '@/views/view/songListTemplate/DefaultSongListTemplate.vue' import DefaultSongListTemplate from '@/views/view/songListTemplate/DefaultSongListTemplate.vue'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { UserInfo } from '@/api/api-models' import { UserInfo } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI, QueryPostAPIWithParams } from '@/api/query'
import { SONG_API_URL } from '@/data/constants' import { SONG_API_URL, SONG_REQUEST_API_URL } from '@/data/constants'
import { NSpin, useMessage } from 'naive-ui' import { NSpin, useMessage } from 'naive-ui'
import { useAccount } from '@/api/account'
const accountInfo = useAccount()
const props = defineProps<{ const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -39,7 +51,22 @@ const isLoading = ref(true)
const message = useMessage() const message = useMessage()
const errMessage = ref('') const errMessage = ref('')
const songs = ref<SongRequestInfo[]>([])
const settings = ref<Setting_SongRequest>({} as Setting_SongRequest)
async function getSongRequestInfo() {
try {
const data = await QueryGetAPI<{ songs: SongRequestInfo[]; setting: Setting_SongRequest }>(SONG_REQUEST_API_URL + 'get-active-and-settings', {
id: props.userInfo?.id,
})
if (data.code == 200) {
return data.data
}
} catch (err) {
console.error(err)
}
return {} as { songs: SongRequestInfo[]; setting: Setting_SongRequest }
}
async function getSongs() { async function getSongs() {
isLoading.value = true isLoading.value = true
await QueryGetAPI<SongsInfo[]>(SONG_API_URL + 'get', { await QueryGetAPI<SongsInfo[]>(SONG_API_URL + 'get', {
@@ -61,10 +88,33 @@ async function getSongs() {
isLoading.value = false isLoading.value = false
}) })
} }
async function requestSong(song: SongsInfo) {
if (props.userInfo && accountInfo.value?.id != props.userInfo?.id) {
try {
const data = await QueryPostAPIWithParams(SONG_REQUEST_API_URL + 'add-from-web', {
target: props.userInfo?.id,
song: song.key,
})
if (data.code == 200) {
message.success('点歌成功')
} else {
message.error('点歌失败: ' + data.message)
}
} catch (err) {
message.error('点歌失败: ' + err)
}
}
}
onMounted(async () => { onMounted(async () => {
if (!props.fakeData) { if (!props.fakeData) {
await getSongs() await getSongs()
const r = await getSongRequestInfo()
if (r) {
songs.value = r.songs
settings.value = r.setting
}
} else { } else {
currentData.value = props.fakeData currentData.value = props.fakeData
isLoading.value = false isLoading.value = false

View File

@@ -2,19 +2,71 @@
import { useAccount } from '@/api/account' import { useAccount } from '@/api/account'
import { SongsInfo, UserInfo } from '@/api/api-models' import { SongsInfo, UserInfo } from '@/api/api-models'
import SongList from '@/components/SongList.vue' import SongList from '@/components/SongList.vue'
import { NDivider } from 'naive-ui' import { CloudAdd20Filled } from '@vicons/fluent'
import { NButton, NDivider, NIcon, NTooltip, useMessage } from 'naive-ui'
import { h, ref } from 'vue'
import { Setting_SongRequest, SongRequestInfo } from '@/api/api-models'
const accountInfo = useAccount() const accountInfo = useAccount()
defineProps<{ const props = defineProps<{
userInfo: UserInfo | undefined userInfo: UserInfo | undefined
biliInfo: any | undefined biliInfo: any | undefined
songRequestSettings: Setting_SongRequest
songRequestActive: SongRequestInfo[]
currentData: SongsInfo[] | undefined currentData: SongsInfo[] | undefined
}>() }>()
const emits = defineEmits(['requestSong'])
const isLoading = ref('')
const message = useMessage()
const buttoms = (song: SongsInfo) => [
accountInfo.value?.id != props.userInfo?.id
? h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () =>
h(
NButton,
{
type: 'warning',
size: 'small',
circle: true,
loading: isLoading.value == song.key,
disabled: !accountInfo,
onClick: () => {
if (song.paidSong || !props.songRequestSettings.allowFromWeb) {
navigator.clipboard.writeText(`${props.songRequestSettings.orderPrefix} ${song.name}`)
message.success('复制成功')
} else {
isLoading.value = song.key
emits('requestSong', song)
isLoading.value = ''
}
},
},
{
icon: () => h(NIcon, { component: CloudAdd20Filled }),
}
),
default: () =>
!props.songRequestSettings.allowFromWeb
? '点歌 | 用户不允许从网页点歌, 点击后将复制点歌内容到剪切板'
: song.paidSong
? '点歌 | 这是付费SC歌曲, 点击后将复制点歌内容到剪切板'
: !accountInfo
? '点歌 | 你需要登录后才能点歌'
: '点歌',
}
)
: undefined,
]
</script> </script>
<template> <template>
<NDivider style="margin-top: 10px" /> <NDivider style="margin-top: 10px" />
<SongList v-if="currentData" :songs="currentData ?? []" :is-self="accountInfo?.id == userInfo?.id" v-bind="$attrs" /> <SongList v-if="currentData" :songs="currentData ?? []" :is-self="accountInfo?.id == userInfo?.id" :extra-buttom="buttoms" v-bind="$attrs" />
<NDivider /> <NDivider />
</template> </template>

View File

@@ -1,11 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { Setting_SongRequest, SongRequestInfo, SongsInfo, UserInfo } from '@/api/api-models';
import { NGridItem, NGrid } from 'naive-ui'
import { NGridItem,NGrid } from 'naive-ui'; const props = defineProps<{
userInfo: UserInfo | undefined
biliInfo: any | undefined
songRequestSettings: Setting_SongRequest
songRequretActive: SongRequestInfo[]
currentData: SongsInfo[] | undefined
}>()
</script> </script>
<template> <template>
<NGrid> <NGrid>
<NGridItem> <NGridItem> </NGridItem>
</NGridItem>
</NGrid> </NGrid>
</template> </template>