songlist add import from file, partically complete point system

This commit is contained in:
2024-02-10 13:05:18 +08:00
parent a69fd44706
commit ae576ed20c
39 changed files with 3629 additions and 420 deletions

View File

@@ -1,26 +1,49 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { BiliAuthCodeStatusType } from '@/api/api-models'
import { BiliAuthCodeStatusType, BiliAuthModel } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import { ACCOUNT_API_URL, TURNSTILE_KEY } from '@/data/constants'
import { Mic24Filled, Question24Regular } from '@vicons/fluent'
import { Info24Filled, Mic24Filled, Question24Regular } from '@vicons/fluent'
import { useLocalStorage } from '@vueuse/core'
import { NAlert, NButton, NCard, NCountdown, NDivider, NEllipsis, NIcon, NInput, NInputGroup, NModal, NPopconfirm, NSpace, NTag, NText, NTime, NTooltip, useLoadingBar, useMessage } from 'naive-ui'
import {
NAlert,
NButton,
NCard,
NCode,
NCountdown,
NDivider,
NEllipsis,
NIcon,
NInput,
NInputGroup,
NModal,
NPopconfirm,
NSpace,
NTag,
NText,
NTime,
NTooltip,
useLoadingBar,
useMessage,
} from 'naive-ui'
import { onUnmounted, ref } from 'vue'
import VueTurnstile from 'vue-turnstile'
import SettingsManageView from './SettingsManageView.vue'
import { useAuthStore } from '@/store/useAuthStore'
const token = ref('')
const turnstile = ref()
const accountInfo = useAccount()
const useAuth = useAuthStore()
const cookie = useLocalStorage('JWT_Token', '')
const message = useMessage()
const resetEmailModalVisiable = ref(false)
const resetPasswordModalVisiable = ref(false)
const bindBiliCodeModalVisiable = ref(false)
const bindBiliAuthModalVisiable = ref(false)
const resetNameModalVisiable = ref(false)
const newEmailAddress = ref('')
@@ -32,6 +55,7 @@ const newName = ref('')
const newPassword = ref('')
const newPassword2 = ref('')
const biliCode = ref('')
const biliAuthText = ref('')
const isLoading = ref(false)
function logout() {
@@ -43,7 +67,24 @@ function resetBili() {
QueryGetAPI(ACCOUNT_API_URL + 'reset-bili')
.then((data) => {
if (data.code == 200) {
message.success('已解绑 Bilibili 账号')
message.success('已解绑 Bilibili 主播账号')
setTimeout(() => {
location.reload()
}, 1000)
} else {
message.error(data.message)
}
})
.catch((err) => {
message.error('发生错误')
})
}
function resetBiliAuthBind() {
isLoading.value = true
QueryGetAPI(ACCOUNT_API_URL + 'reset-bili-auth')
.then((data) => {
if (data.code == 200) {
message.success('已解绑 Bilibili 用户账号')
setTimeout(() => {
location.reload()
}, 1000)
@@ -163,6 +204,30 @@ async function BindBili() {
isLoading.value = false
})
}
async function BindBiliAuth() {
if (!biliAuthText.value) {
message.error('认证链接不能为空')
return
}
isLoading.value = true
await QueryGetAPI<BiliAuthModel>(ACCOUNT_API_URL + 'bind-bili-auth', { token: biliAuthText.value })
.then(async (data) => {
if (data.code == 200) {
message.success('已绑定用户: ' + data.data.userId)
setTimeout(() => {
location.reload()
}, 1000)
} else {
message.error(data.message)
}
})
.catch((err) => {
message.error('发生错误')
})
.finally(() => {
isLoading.value = false
})
}
async function ChangeBili() {
if (!biliCode.value) {
message.error('身份码不能为空')
@@ -235,13 +300,17 @@ onUnmounted(() => {
</NSpace>
</NCard>
<NCard size="small">
Bilibili 账户:
主播 Bilibili 账户:
<NEllipsis v-if="accountInfo?.isBiliVerified" style="max-width: 100%">
<NText style="color: var(--primary-color)">
<NSpace :size="5" align="center">
已认证 | {{ accountInfo?.biliId }}
<NTag v-if="accountInfo.biliAuthCodeStatus == BiliAuthCodeStatusType.Active" type="success" size="small" :bordered="false"> 身份码: 有效 </NTag>
<NTag v-else-if="accountInfo.biliAuthCodeStatus == BiliAuthCodeStatusType.Inactive" type="error" size="small" :bordered="false"> 身份码: 需更新 </NTag>
<NTag v-if="accountInfo.biliAuthCodeStatus == BiliAuthCodeStatusType.Active" type="success" size="small" :bordered="false">
身份码: 有效
</NTag>
<NTag v-else-if="accountInfo.biliAuthCodeStatus == BiliAuthCodeStatusType.Inactive" type="error" size="small" :bordered="false">
身份码: 需更新
</NTag>
<NTag v-else-if="accountInfo.biliAuthCodeStatus == BiliAuthCodeStatusType.Notfound" type="warning" size="small" :bordered="false">
身份码: 需绑定
<NTooltip>
@@ -254,19 +323,56 @@ onUnmounted(() => {
<NButton size="tiny" type="info" @click="bindBiliCodeModalVisiable = true"> 更新身份码 </NButton>
<NPopconfirm @positive-click="resetBili">
<template #trigger>
<NButton size="tiny" type="error"> 解除绑定 </NButton>
<NButton size="tiny" type="error"> 解除认证 </NButton>
</template>
确定解除绑定? 后现有的数据跟踪数据将被删除并且无法恢复
确定解除认证? 后现有的数据跟踪数据将被删除并且无法恢复
</NPopconfirm>
</NSpace>
</NText>
</NEllipsis>
<template v-else>
<NTag type="error" size="small"> 未认证 </NTag>
<NTag type="error" size="small">
未认证
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
如果你不是主播的话则不需要在意这个
</NTooltip>
</NTag>
<NDivider vertical />
<NButton size="small" @click="bindBiliCodeModalVisiable = true" type="info"> 进行绑定 </NButton>
</template>
</NCard>
<NCard size="small" v-if="false">
用户 Bilibili 账户:
<NEllipsis v-if="accountInfo?.biliUserAuthInfo" style="max-width: 100%">
<NText style="color: var(--primary-color)">
<NSpace :size="5" align="center">
已绑定 | {{ accountInfo?.biliUserAuthInfo?.name }} [{{ accountInfo?.biliUserAuthInfo?.userId }}]
<NPopconfirm @positive-click="resetBiliAuthBind">
<template #trigger>
<NButton size="tiny" type="error"> 解除绑定 </NButton>
</template>
确定解除绑定吗?
</NPopconfirm>
</NSpace>
</NText>
</NEllipsis>
<template v-else>
<NTag type="error" size="small">
未认证
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
用于进行积分兑换等操作
</NTooltip>
</NTag>
<NDivider vertical />
<NButton size="small" @click="bindBiliAuthModalVisiable = true" type="info"> 进行绑定 </NButton>
</template>
</NCard>
<EventFetcherStatusCard />
<NAlert title="Token" type="info">
请注意保管, 这个东西可以完全操作你的账号
@@ -345,5 +451,33 @@ onUnmounted(() => {
<NButton @click="accountInfo?.isBiliVerified ? ChangeBili() : BindBili()" type="success" :loading="!token || isLoading"> 确定 </NButton>
</template>
</NModal>
<NModal v-model:show="bindBiliAuthModalVisiable" preset="card" title="绑定用户账户" style="width: 700px; max-width: 90%">
<NSpace vertical>
<NAlert title="获取认证链接" type="info">
因为部分功能如积分兑换等也需要对没有注册本站账户的用户开放, 所以需要现在另一个页面获取认证链接, 然后再回到这里绑定
</NAlert>
<NInputGroup>
<NInput v-model:value="biliAuthText" placeholder="认证链接, 或者 Token" />
<NTooltip>
<template #trigger>
<NButton type="primary" tag="a" href="/bili-auth" target="_blank">
<template #icon>
<NIcon>
<Question24Regular />
</NIcon>
</template>
前往获取
</NButton>
</template>
直接粘贴认证完成后给出的类似
<NCode> https://vtsuru.live/bili-user?auth=abcdefghijklmnopqrstuvwxyz== </NCode>
的链接即可
</NTooltip>
</NInputGroup>
</NSpace>
<template #footer>
<NButton @click="BindBiliAuth()" type="success" :loading="isLoading" :disabled="!biliAuthText"> 确定 </NButton>
</template>
</NModal>
<VueTurnstile ref="turnstile" :site-key="TURNSTILE_KEY" v-model="token" theme="auto" style="text-align: center" />
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import { PointOrderModel } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { POINT_API_URL } from '@/data/constants'
import { NCard, NList, NListItem, useMessage } from 'naive-ui'
import { ref } from 'vue'
const message = useMessage()
const orders = ref<PointOrderModel[]>(await getOrders())
async function getOrders() {
try {
const data = await QueryGetAPI<PointOrderModel[]>(POINT_API_URL + 'get-orders')
if (data.code == 200) {
return data.data
} else {
message.error('获取订单失败: ' + data.message)
}
} catch (err) {
console.log(err)
message.error('获取订单失败: ' + err)
}
return []
}
</script>
<template>
<NList bordered hoverable clickable>
<NListItem v-for="order in orders" v-bind:key="order.id">
<NCard :bordered="false">
</NCard>
</NListItem>
</NList>
</template>

View File

@@ -293,7 +293,7 @@ onMounted(() => {
<template #trigger>
<NTime :time="item.sendAt" :to="Date.now()" type="relative" />
</template>
<NTime />
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
</NSpace>

View File

@@ -6,12 +6,14 @@ import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import SongList from '@/components/SongList.vue'
import { FETCH_API, SONG_API_URL } from '@/data/constants'
import { Info24Filled } from '@vicons/fluent'
import { ArchiveOutline } from '@vicons/ionicons5'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
import { List } from 'linqts'
import {
FormInst,
FormRules,
NAlert,
NButton,
NCheckbox,
NDivider,
@@ -23,7 +25,9 @@ import {
NInputGroupLabel,
NInputNumber,
NModal,
NP,
NPagination,
NScrollbar,
NSelect,
NSpace,
NSpin,
@@ -31,12 +35,17 @@ import {
NTable,
NTabs,
NTag,
NText,
NTooltip,
NTransfer,
NUpload,
NUploadDragger,
UploadFileInfo,
useMessage,
} from 'naive-ui'
import { Option } from 'naive-ui/es/transfer/src/interface'
import { computed, onMounted, ref } from 'vue'
import * as XLSX from 'xlsx'
const message = useMessage()
const accountInfo = useAccount()
@@ -143,6 +152,17 @@ const songSelectOption = [
},
]
const uploadFiles = ref<UploadFileInfo[]>([])
const uploadSongsFromFile = ref<SongsInfo[]>([])
const uploadSongsOptions = computed(() => {
return uploadSongsFromFile.value.map((s) => ({
label: `${s.name} - ${!s.author ? '未知' : s.author.join('/')}`,
value: s.name,
disabled: songs.value.findIndex((exist) => exist.name == s.name) > -1,
}))
})
const selecteduploadSongs = ref<string[]>([])
async function addCustomSong() {
isModalLoading.value = true
formRef.value
@@ -181,7 +201,9 @@ async function addNeteaseSongs() {
neteaseSongsOptions.value = neteaseSongs.value.map((s) => ({
label: `${s.name} - ${s.author.join('/')}`,
value: s.key,
disabled: songs.value.findIndex((exist) => exist.id == s.id) > -1 || data.data.findIndex((add) => add.id == s.id) > -1,
disabled:
songs.value.findIndex((exist) => exist.id == s.id) > -1 ||
data.data.findIndex((add) => add.id == s.id) > -1,
}))
} else {
message.error('添加失败: ' + data.message)
@@ -218,6 +240,33 @@ async function addFingsingSongs(song: SongsInfo) {
})
.catch((err) => {
message.error('添加失败')
console.error(err)
})
.finally(() => {
isModalLoading.value = false
})
}
async function addUploadFileSong() {
if (selecteduploadSongs.value.length == 0) {
message.error('请选择歌曲')
return
}
isModalLoading.value = true
await addSongs(
uploadSongsFromFile.value.filter((s) => selecteduploadSongs.value.find((select) => s.name == select)),
SongFrom.Custom,
)
.then((data) => {
if (data.code == 200) {
message.success(`已添加 ${data.data.length} 首歌曲`)
songs.value.push(...data.data)
} else {
message.error('添加失败: ' + data.message)
}
})
.catch((err) => {
message.error('添加失败: ' + err)
console.error(err)
})
.finally(() => {
isModalLoading.value = false
@@ -234,6 +283,7 @@ async function addSongs(songsShoudAdd: SongsInfo[], from: SongFrom) {
Url: s.url,
Description: s.description,
Cover: s.cover,
Tags: s.tags,
})),
)
}
@@ -251,7 +301,9 @@ async function getNeteaseSongList() {
value: s.key,
disabled: songs.value.findIndex((exist) => exist.id == s.id) > -1,
}))
message.success(`成功获取歌曲信息, 共 ${data.data.length} 条, 歌单中已存在 ${neteaseSongsOptions.value.filter((s) => s.disabled).length}`)
message.success(
`成功获取歌曲信息, 共 ${data.data.length} 条, 歌单中已存在 ${neteaseSongsOptions.value.filter((s) => s.disabled).length}`,
)
} else {
message.error('获取歌单失败: ' + data.message)
}
@@ -265,7 +317,10 @@ async function getNeteaseSongList() {
}
async function getFivesingSearchList(isRestart = false) {
isModalLoading.value = true
await fetch(FETCH_API + `http://search.5sing.kugou.com/home/json?keyword=${fivesingSearchInput.value}&sort=1&page=${fivesingCurrentPage.value}&filter=3`)
await fetch(
FETCH_API +
`http://search.5sing.kugou.com/home/json?keyword=${fivesingSearchInput.value}&sort=1&page=${fivesingCurrentPage.value}&filter=3`,
)
.then(async (data) => {
const json = await data.json()
if (json.list.length == 0) {
@@ -318,7 +373,9 @@ async function playFivesingSong(song: SongsInfo) {
})
}
async function getFivesingSongUrl(song: SongsInfo): Promise<string> {
const data = await fetch(FETCH_API + `http://service.5sing.kugou.com/song/getsongurl?songid=${song.id}&songtype=bz&from=web&version=6.6.72`)
const data = await fetch(
FETCH_API + `http://service.5sing.kugou.com/song/getsongurl?songid=${song.id}&songtype=bz&from=web&version=6.6.72`,
)
const result = await data.text()
//忽略掉result的第一个字符和最后一个字符, 并反序列化
const json = JSON.parse(result.substring(1, result.length - 1))
@@ -374,7 +431,119 @@ function exportData() {
const BOM = new Uint8Array([0xef, 0xbb, 0xbf])
const utf8encoder = new TextEncoder()
const utf8array = utf8encoder.encode(text)
saveAs(new Blob([BOM, utf8array], { type: 'text/csv;charset=utf-8;' }), `歌单_${format(Date.now(), 'yyyy-MM-dd HH:mm:ss')}_${accountInfo.value?.name}_.csv`)
saveAs(
new Blob([BOM, utf8array], { type: 'text/csv;charset=utf-8;' }),
`歌单_${format(Date.now(), 'yyyy-MM-dd HH:mm:ss')}_${accountInfo.value?.name}_.csv`,
)
}
function parseExcelFile() {
if (uploadFiles.value.length == 0) {
message.error('请选择文件')
return
}
const file = uploadFiles.value[0]
if (!file.file) {
message.error('无效的文件')
return
}
const reader = new FileReader()
reader.readAsArrayBuffer(file.file)
reader.onload = (e) => {
const data = new Uint8Array(e?.target?.result as ArrayBuffer)
const workbook = XLSX.read(data, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: null })
if (json.length == 0) {
message.error('文件为空')
}
const headers = json[0] as any
const rows = json.slice(1) as any[]
const songs = rows.map((row) => {
const song = {} as SongsInfo
for (let i = 0; i < headers.length; i++) {
const key = headers[i] as string
const value = row[i] as string
switch (key.toLowerCase().trim()) {
case 'id':
case 'name':
case '名称':
case '曲名':
case '歌名':
if (!value) {
console.log('忽略空歌名: ' + row)
continue
}
song.name = value
break
case 'author':
case 'singer':
case '作者':
case '歌手':
song.author = new List(value?.includes('/') ? value.split('/') : value.split(','))
.Select((a) => a.trim())
.Distinct()
.ToArray()
break
case 'description':
case 'desc':
case '说明':
case '描述':
song.description = value
break
case 'url':
case '链接':
song.url = value
break
case 'language':
case '语言':
switch (value) {
case '中文':
case '汉语':
song.language = [SongLanguage.Chinese]
break
case '英文':
case '英语':
song.language = [SongLanguage.English]
break
case '日文':
case '日语':
song.language = [SongLanguage.Japanese]
break
case '法语':
song.language = [SongLanguage.French]
break
case '西语':
song.language = [SongLanguage.Spanish]
break
default:
song.language = [SongLanguage.Other]
}
break
case 'tags':
case 'tag':
case '标签':
song.tags = new List(value?.split(','))
.Select((t) => t.trim())
.Distinct()
.ToArray()
break
}
}
return song
})
uploadSongsFromFile.value = songs.filter((s) => s.name)
console.log(uploadSongsFromFile.value)
message.success('解析完成, 共获取 ' + uploadSongsFromFile.value.length + ' 首曲目')
}
}
function beforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
//只能选择xlsx和xls和csv
if (data.file.name.endsWith('.xlsx') || data.file.name.endsWith('.xls') || data.file.name.endsWith('.csv')) {
return true
}
message.error('只能选择xlsx和xls和csv')
return false
}
onMounted(async () => {
@@ -397,174 +566,299 @@ onMounted(async () => {
>
刷新
</NButton>
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'template', template: 'songlist' } })"> 修改模板 </NButton>
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'template', template: 'songlist' } })">
修改模板
</NButton>
</NSpace>
<NDivider style="margin: 16px 0 16px 0" />
<NModal v-model:show="showModal" style="max-width: 1000px" preset="card">
<template #header> 添加歌曲 </template>
<NSpin :show="isModalLoading">
<NTabs default-value="custom" animated>
<NTabPane name="custom" tab="手动录入">
<NForm ref="formRef" :rules="addSongRules" :model="addSongModel">
<NFormItem path="name" label="名称">
<NInput v-model:value="addSongModel.name" autosize style="min-width: 200px" placeholder="就是歌曲名称" />
</NFormItem>
<NFormItem path="author" label="作者">
<NSelect v-model:value="addSongModel.author" :options="authors" filterable multiple tag placeholder="输入后按回车新增" />
</NFormItem>
<NFormItem path="description" label="备注">
<NInput v-model:value="addSongModel.description" placeholder="可选" :maxlength="250" show-count autosize style="min-width: 300px" clearable />
</NFormItem>
<NFormItem path="language" label="语言">
<NSelect v-model:value="addSongModel.language" multiple :options="songSelectOption" placeholder="可选" />
</NFormItem>
<NFormItem path="tags" label="标签">
<NSelect v-model:value="addSongModel.tags" filterable multiple clearable tag placeholder="可选,输入后按回车新增" :options="tags" />
</NFormItem>
<NFormItem path="url" label="链接">
<NInput v-model:value="addSongModel.url" placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开" />
</NFormItem>
<NFormItem path="options">
<template #label>
点歌设置
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
这个不是控制是否允许点歌的! 启用后将会覆盖点歌功能中的设置, 用于单独设置歌曲要求
</NTooltip>
</template>
<NSpace vertical>
<NCheckbox
:checked="addSongModel.options != undefined"
@update:checked="
(checked: boolean) => {
addSongModel.options = checked
? ({
needJianzhang: false,
needTidu: false,
needZongdu: false,
} as SongRequestOption)
: undefined
}
"
>
是否启用
</NCheckbox>
<template v-if="addSongModel.options != undefined">
<NSpace>
<NCheckbox v-model:checked="addSongModel.options.needJianzhang"> 需要舰长 </NCheckbox>
<NCheckbox v-model:checked="addSongModel.options.needTidu"> 需要提督 </NCheckbox>
<NCheckbox v-model:checked="addSongModel.options.needZongdu"> 需要总督 </NCheckbox>
</NSpace>
<NSpace align="center">
<NCheckbox
:checked="addSongModel.options.scMinPrice != undefined"
@update:checked="
(checked: boolean) => {
if (addSongModel.options) addSongModel.options.scMinPrice = checked ? 30 : undefined
}
"
>
需要SC
</NCheckbox>
<NInputGroup v-if="addSongModel.options?.scMinPrice" style="width: 200px">
<NInputGroupLabel> SC最低价格 </NInputGroupLabel>
<NInputNumber v-model:value="addSongModel.options.scMinPrice" min="30" />
</NInputGroup>
</NSpace>
<NSpace align="center">
<NCheckbox
:checked="addSongModel.options.fanMedalMinLevel != undefined"
@update:checked="
(checked: boolean) => {
if (addSongModel.options) addSongModel.options.fanMedalMinLevel = checked ? 5 : undefined
}
"
>
需要粉丝牌
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
这个即使不开也会遵循全局点歌设置的粉丝牌等级
</NTooltip>
</NCheckbox>
<NInputGroup v-if="addSongModel.options?.fanMedalMinLevel" style="width: 200px">
<NInputGroupLabel> 最低等级 </NInputGroupLabel>
<NInputNumber v-model:value="addSongModel.options.fanMedalMinLevel" min="0" />
</NInputGroup>
</NSpace>
<NScrollbar style="max-height: 80vh">
<NSpin :show="isModalLoading">
<NTabs default-value="custom" animated>
<NTabPane name="custom" tab="手动录入">
<NForm ref="formRef" :rules="addSongRules" :model="addSongModel">
<NFormItem path="name" label="名称">
<NInput
v-model:value="addSongModel.name"
autosize
style="min-width: 200px"
placeholder="就是歌曲名称"
/>
</NFormItem>
<NFormItem path="author" label="作者">
<NSelect
v-model:value="addSongModel.author"
:options="authors"
filterable
multiple
tag
placeholder="输入后按回车新增"
/>
</NFormItem>
<NFormItem path="description" label="备注">
<NInput
v-model:value="addSongModel.description"
placeholder="可选"
:maxlength="250"
show-count
autosize
style="min-width: 300px"
clearable
/>
</NFormItem>
<NFormItem path="language" label="语言">
<NSelect
v-model:value="addSongModel.language"
multiple
:options="songSelectOption"
placeholder="可选"
/>
</NFormItem>
<NFormItem path="tags" label="标签">
<NSelect
v-model:value="addSongModel.tags"
filterable
multiple
clearable
tag
placeholder="可选,输入后按回车新增"
:options="tags"
/>
</NFormItem>
<NFormItem path="url" label="链接">
<NInput
v-model:value="addSongModel.url"
placeholder="可选, 后缀为mp3、wav、ogg时将会尝试播放, 否则会在新页面打开"
/>
</NFormItem>
<NFormItem path="options">
<template #label>
点歌设置
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
这个不是控制是否允许点歌的! 启用后将会覆盖点歌功能中的设置, 用于单独设置歌曲要求
</NTooltip>
</template>
</NSpace>
</NFormItem>
</NForm>
<NButton type="primary" @click="addCustomSong"> 添加 </NButton>
</NTabPane>
<NTabPane name="netease" tab="从网易云歌单导入">
<NInput clearable style="width: 100%" autosize :status="neteaseSongListId ? 'success' : 'error'" v-model:value="neteaseIdInput" placeholder="直接输入歌单Id或者网页链接">
<template #suffix>
<NTag v-if="neteaseSongListId" type="success" size="small"> 歌单Id: {{ neteaseSongListId }} </NTag>
<NSpace vertical>
<NCheckbox
:checked="addSongModel.options != undefined"
@update:checked="
(checked: boolean) => {
addSongModel.options = checked
? ({
needJianzhang: false,
needTidu: false,
needZongdu: false,
} as SongRequestOption)
: undefined
}
"
>
是否启用
</NCheckbox>
<template v-if="addSongModel.options != undefined">
<NSpace>
<NCheckbox v-model:checked="addSongModel.options.needJianzhang"> 需要舰长 </NCheckbox>
<NCheckbox v-model:checked="addSongModel.options.needTidu"> 需要提督 </NCheckbox>
<NCheckbox v-model:checked="addSongModel.options.needZongdu"> 需要总督 </NCheckbox>
</NSpace>
<NSpace align="center">
<NCheckbox
:checked="addSongModel.options.scMinPrice != undefined"
@update:checked="
(checked: boolean) => {
if (addSongModel.options) addSongModel.options.scMinPrice = checked ? 30 : undefined
}
"
>
需要SC
</NCheckbox>
<NInputGroup v-if="addSongModel.options?.scMinPrice" style="width: 200px">
<NInputGroupLabel> SC最低价格 </NInputGroupLabel>
<NInputNumber v-model:value="addSongModel.options.scMinPrice" min="30" />
</NInputGroup>
</NSpace>
<NSpace align="center">
<NCheckbox
:checked="addSongModel.options.fanMedalMinLevel != undefined"
@update:checked="
(checked: boolean) => {
if (addSongModel.options) addSongModel.options.fanMedalMinLevel = checked ? 5 : undefined
}
"
>
需要粉丝牌
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
这个即使不开也会遵循全局点歌设置的粉丝牌等级
</NTooltip>
</NCheckbox>
<NInputGroup v-if="addSongModel.options?.fanMedalMinLevel" style="width: 200px">
<NInputGroupLabel> 最低等级 </NInputGroupLabel>
<NInputNumber v-model:value="addSongModel.options.fanMedalMinLevel" min="0" />
</NInputGroup>
</NSpace>
</template>
</NSpace>
</NFormItem>
</NForm>
<NButton type="primary" @click="addCustomSong"> 添加 </NButton>
</NTabPane>
<NTabPane name="netease" tab="从网易云歌单导入">
<NInput
clearable
style="width: 100%"
autosize
:status="neteaseSongListId ? 'success' : 'error'"
v-model:value="neteaseIdInput"
placeholder="直接输入歌单Id或者网页链接"
>
<template #suffix>
<NTag v-if="neteaseSongListId" type="success" size="small"> 歌单Id: {{ neteaseSongListId }} </NTag>
</template>
</NInput>
<NDivider style="margin: 10px" />
<NButton type="primary" @click="getNeteaseSongList" :disabled="!neteaseSongListId"> 获取 </NButton>
<template v-if="neteaseSongsOptions.length > 0">
<NDivider style="margin: 10px" />
<NTransfer
style="height: 500px"
ref="transfer"
v-model:value="selectedNeteaseSongs"
:options="neteaseSongsOptions"
source-filterable
/>
<NDivider style="margin: 10px" />
<NButton type="primary" @click="addNeteaseSongs">
添加到歌单 | {{ selectedNeteaseSongs.length }}
</NButton>
</template>
</NInput>
<NDivider style="margin: 10px" />
<NButton type="primary" @click="getNeteaseSongList" :disabled="!neteaseSongListId"> 获取 </NButton>
<template v-if="neteaseSongsOptions.length > 0">
</NTabPane>
<NTabPane name="5sing" tab="从5sing搜索">
<NInput
clearable
style="width: 100%"
autosize
v-model:value="fivesingSearchInput"
placeholder="输入要搜索的歌名"
maxlength="15"
/>
<NDivider style="margin: 10px" />
<NTransfer style="height: 500px" ref="transfer" v-model:value="selectedNeteaseSongs" :options="neteaseSongsOptions" source-filterable />
<NDivider style="margin: 10px" />
<NButton type="primary" @click="addNeteaseSongs"> 添加到歌单 | {{ selectedNeteaseSongs.length }} </NButton>
</template>
</NTabPane>
<NTabPane name="5sing" tab="从5sing搜索">
<NInput clearable style="width: 100%" autosize v-model:value="fivesingSearchInput" placeholder="输入要搜索的歌名" maxlength="15" />
<NDivider style="margin: 10px" />
<NButton type="primary" @click="getFivesingSearchList(true)" :disabled="!fivesingSearchInput"> 搜索 </NButton>
<template v-if="fivesingResults.length > 0">
<NDivider style="margin: 10px" />
<div style="overflow-x: auto">
<NTable size="small" style="overflow-x: auto">
<thead>
<tr>
<th>名称</th>
<th>作者</th>
<th>试听</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="song in fivesingResults" v-bind:key="song.id">
<td>{{ song.name }}</td>
<td>
<NSpace>
<NTag size="small" v-for="author in song.author" :key="author">
{{ author }}
</NTag>
</NSpace>
</td>
<td style="display: flex; justify-content: flex-end">
<!-- 在这里播放song.url链接中的音频 -->
<NButton size="small" v-if="!song.url" @click="playFivesingSong(song)" :loading="isGettingFivesingSongPlayUrl == song.id"> 试听 </NButton>
<audio v-else controls style="max-height: 30px">
<source :src="song.url" />
</audio>
</td>
<td>
<NButton size="small" color="green" @click="addFingsingSongs(song)" :disabled="songs.findIndex((s) => s.from == SongFrom.FiveSing && s.id == song.id) > -1"> 添加 </NButton>
</td>
</tr>
</tbody>
</NTable>
</div>
<br />
<NPagination v-model:page="fivesingCurrentPage" :page-count="fivesingTotalPageCount" simple @update-page="getFivesingSearchList(false)" />
</template>
</NTabPane>
<NTabPane name="file" tab="从文件导入">
开发中...
</NTabPane>
</NTabs>
</NSpin>
<NButton type="primary" @click="getFivesingSearchList(true)" :disabled="!fivesingSearchInput">
搜索
</NButton>
<template v-if="fivesingResults.length > 0">
<NDivider style="margin: 10px" />
<div style="overflow-x: auto">
<NTable size="small" style="overflow-x: auto">
<thead>
<tr>
<th>名称</th>
<th>作者</th>
<th>试听</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="song in fivesingResults" v-bind:key="song.id">
<td>{{ song.name }}</td>
<td>
<NSpace>
<NTag size="small" v-for="author in song.author" :key="author">
{{ author }}
</NTag>
</NSpace>
</td>
<td style="display: flex; justify-content: flex-end">
<!-- 在这里播放song.url链接中的音频 -->
<NButton
size="small"
v-if="!song.url"
@click="playFivesingSong(song)"
:loading="isGettingFivesingSongPlayUrl == song.id"
>
试听
</NButton>
<audio v-else controls style="max-height: 30px">
<source :src="song.url" />
</audio>
</td>
<td>
<NButton
size="small"
color="green"
@click="addFingsingSongs(song)"
:disabled="songs.findIndex((s) => s.from == SongFrom.FiveSing && s.id == song.id) > -1"
>
添加
</NButton>
</td>
</tr>
</tbody>
</NTable>
</div>
<br />
<NPagination
v-model:page="fivesingCurrentPage"
:page-count="fivesingTotalPageCount"
simple
@update-page="getFivesingSearchList(false)"
/>
</template>
</NTabPane>
<NTabPane name="file" tab="从文件导入">
<NAlert type="info">
Excel 文件格式详见:
<NButton
type="info"
tag="a"
href="https://www.yuque.com/megghy/dez70g/ngrqwkiegrh593w5"
target="_blank"
size="tiny"
>
此页面
</NButton>
</NAlert>
<NUpload
v-model:file-list="uploadFiles"
:default-upload="false"
:max="1"
directory-dnd
@before-upload="beforeUpload"
>
<NUploadDragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<ArchiveOutline />
</n-icon>
</div>
<NText style="font-size: 16px"> 点击或者拖动文件到该区域来上传 </NText>
<NP depth="3" style="margin: 8px 0 0 0"> 仅限 Excel 文件(.xlsx和.xls) 以及 csv 文件 </NP>
</NUploadDragger>
</NUpload>
<NButton type="primary" @click="parseExcelFile"> 解析 </NButton>
<template v-if="uploadSongsOptions.length > 0">
<NDivider style="margin: 10px" />
<NButton type="primary" @click="addUploadFileSong">
添加到歌单 | {{ selecteduploadSongs.length }}
</NButton>
<NDivider style="margin: 10px" />
<NTransfer
style="height: 400px"
v-model:value="selecteduploadSongs"
:options="uploadSongsOptions"
source-filterable
/>
</template>
</NTabPane>
</NTabs>
</NSpin>
</NScrollbar>
</NModal>
<NSpin v-if="isLoading" show />
<SongList v-else :songs="songs" is-self />

View File

@@ -40,22 +40,44 @@ import {
NImage,
useDialog,
NPopconfirm,
NEmpty,
} from 'naive-ui'
import { computed, ref } from 'vue'
import PointOrderManage from '../PointOrderManage.vue'
import { computed, onMounted, ref } from 'vue'
import PointOrderManage from './PointOrderManage.vue'
import PointUserManage from './PointUserManage.vue'
import { cloneFnJSON } from '@vueuse/core'
import { useAuthStore } from '@/store/useAuthStore'
import PointSettings from './PointSettings.vue'
import { useRouteHash } from '@vueuse/router'
const message = useMessage()
const accountInfo = useAccount()
const dialog = useDialog()
const useBiliAuth = useAuthStore()
const goods = ref<ResponsePointGoodModel[]>(await getGoods())
const currentGoodsModel = ref<{ goods: PointGoodsModel; fileList: UploadFileInfo[] }>({
const realHash = useRouteHash('goods', {
mode: 'replace',
})
const hash = computed({
get() {
return realHash.value?.slice(1) ?? ''
},
set(val) {
realHash.value = '#' + val
},
})
const goods = ref<ResponsePointGoodModel[]>(await useBiliAuth.GetGoods(accountInfo.value?.id, message))
const defaultGoodsModel = {
goods: {
type: GoodsTypes.Virtual,
status: GoodsStatus.Normal,
maxBuyCount: 1,
isAllowRebuy: false,
} as PointGoodsModel,
fileList: [],
})
} as { goods: PointGoodsModel; fileList: UploadFileInfo[] }
const currentGoodsModel = ref<{ goods: PointGoodsModel; fileList: UploadFileInfo[] }>(JSON.parse(JSON.stringify(defaultGoodsModel)))
const showAddGoodsModal = ref(false)
@@ -82,7 +104,17 @@ const rules = {
required: true,
message: '需要阅读并同意本站隐私政策',
validator: (rule: FormItemRule, value: boolean) => {
return (currentGoodsModel.value.goods.type != GoodsTypes.Physical && currentGoodsModel.value.goods.collectUrl != undefined) || isAllowedPrivacyPolicy.value
return (
(currentGoodsModel.value.goods.type != GoodsTypes.Physical && currentGoodsModel.value.goods.collectUrl != undefined) ||
isAllowedPrivacyPolicy.value
)
},
},
maxBuyCount: {
required: true,
message: '需要输入最大购买数量',
validator: (rule: FormItemRule, value: number) => {
return currentGoodsModel.value.goods.type != GoodsTypes.Physical || (currentGoodsModel.value.goods.maxBuyCount ?? 0) > 0
},
},
}
@@ -124,21 +156,6 @@ const dropDownOptions = computed(() => {
return Object.values(dropDownActions)
})
async function getGoods() {
try {
var resp = await QueryGetAPI<ResponsePointGoodModel[]>(POINT_API_URL + 'get-goods', {
id: accountInfo.value?.id,
})
if (resp.code == 200) {
return resp.data
} else {
message.error('无法获取数据: ' + resp.message)
}
} catch (err) {
message.error('无法获取数据: ' + err)
}
return []
}
async function setFunctionEnable(enable: boolean) {
let success = false
if (enable) {
@@ -217,23 +234,24 @@ function onUpdateClick(item: ResponsePointGoodModel) {
]
: [],
}
isAllowedPrivacyPolicy.value = true
showAddGoodsModal.value = true
}
//下架
function onSetShelfClick(item: ResponsePointGoodModel, status: GoodsStatus) {
const d = dialog.warning({
title: '警告',
content: '你确定要下架这个礼物吗?',
content: `你确定要${status == GoodsStatus.Normal ? '重新上架' : '下架'}这个礼物吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
d.loading = true
const originStatus = item.status
item.status = status
//item.status = status
try {
const data = await QueryGetAPI(POINT_API_URL + 'update-goods-status', {
id: item.id,
status: item.status,
const data = await QueryPostAPI(POINT_API_URL + 'update-goods-status', {
ids: [item.id],
status: status,
})
if (data.code == 200) {
message.success('成功')
@@ -284,7 +302,22 @@ function onDeleteClick(item: ResponsePointGoodModel) {
},
})
}
function onModalOpen() {
if (currentGoodsModel.value.goods.id) {
resetGoods()
}
showAddGoodsModal.value = true
}
function resetGoods() {
currentGoodsModel.value = JSON.parse(JSON.stringify(defaultGoodsModel))
}
function responseGoodsToModel(goods: ResponsePointGoodModel) {}
onMounted(() => {
if (!hash.value) {
hash.value = 'goods'
}
})
</script>
<template>
@@ -298,20 +331,34 @@ function responseGoodsToModel(goods: ResponsePointGoodModel) {}
</NText>
</NAlert>
<NDivider />
<NTabs animated>
<NTabs animated v-model:value="hash">
<NTabPane name="goods" tab="礼物">
<NFlex>
<NButton type="primary" @click="showAddGoodsModal = true"> 添加礼物 </NButton>
<NButton type="primary" @click="onModalOpen"> 添加礼物 </NButton>
</NFlex>
<NDivider />
<NGrid cols="1 500:2 700:3 1000:4 1200:5" :x-gap="12" :y-gap="8">
<NGridItem v-for="item in goods" :key="item.id">
<NGridItem v-for="item in goods.filter((g) => g.status != GoodsStatus.Discontinued)" :key="item.id">
<PointGoodsItem :goods="item">
<template #footer>
<NFlex>
<NButton type="info" size="small" @click="onUpdateClick(item)"> 修改 </NButton>
<NButton v-if="item.status != GoodsStatus.Discontinued" type="warning" size="small" @click="onSetShelfClick(item, GoodsStatus.Discontinued)"> 下架 </NButton>
<NButton v-else type="success" size="small" @click="onSetShelfClick(item, GoodsStatus.Normal)"> 上架 </NButton>
<NButton type="warning" size="small" @click="onSetShelfClick(item, GoodsStatus.Discontinued)"> 下架 </NButton>
<NButton type="error" size="small" @click="onDeleteClick(item)"> 删除 </NButton>
</NFlex>
</template>
</PointGoodsItem>
</NGridItem>
</NGrid>
<NDivider>已下架</NDivider>
<NEmpty v-if="goods.filter((g) => g.status == GoodsStatus.Discontinued).length == 0" description="暂无已下架的物品" />
<NGrid v-else cols="1 500:2 700:3 1000:4 1200:5" :x-gap="12" :y-gap="8">
<NGridItem v-for="item in goods.filter((g) => g.status == GoodsStatus.Discontinued)" :key="item.id">
<PointGoodsItem :goods="item">
<template #footer>
<NFlex>
<NButton type="info" size="small" @click="onUpdateClick(item)"> 修改 </NButton>
<NButton type="success" size="small" @click="onSetShelfClick(item, GoodsStatus.Normal)"> 上架 </NButton>
<NButton type="error" size="small" @click="onDeleteClick(item)"> 删除 </NButton>
</NFlex>
</template>
@@ -320,32 +367,62 @@ function responseGoodsToModel(goods: ResponsePointGoodModel) {}
</NGrid>
</NTabPane>
<NTabPane name="orders" tab="订单" display-directive="show:lazy">
<PointOrderManage />
<PointOrderManage :goods="goods" />
</NTabPane>
<NTabPane name="users" tab="用户" display-directive="show:lazy">
<PointUserManage />
</NTabPane>
<NTabPane name="settings" tab="设置" display-directive="show:lazy">
<PointSettings />
</NTabPane>
<NTabPane name="users" tab="用户" display-directive="show:lazy"> </NTabPane>
<NTabPane name="settings" tab="设置" display-directive="show:lazy"> </NTabPane>
</NTabs>
<NDivider />
<NModal v-model:show="showAddGoodsModal" preset="card" style="width: 600px; max-width: 90%" title="添加/修改礼物信息">
<template #header-extra>
<NPopconfirm v-if="!currentGoodsModel.goods.id" @positive-click="resetGoods">
<template #trigger>
<NButton type="warning" size="small"> 重置 </NButton>
</template>
确定要重置此页面内容?
</NPopconfirm>
</template>
<NScrollbar style="max-height: 80vh">
<NForm ref="formRef" :model="currentGoodsModel" :rules="rules" style="width: 95%">
<NFormItem path="name" label="名称" required>
<NFormItem path="goods.name" label="名称" required>
<NInput v-model:value="currentGoodsModel.goods.name" placeholder="必填, 礼物名称" />
</NFormItem>
<NFormItem path="price" label="所需积分" required>
<NFormItem path="goods.price" label="所需积分" required>
<NInputNumber v-model:value="currentGoodsModel.goods.price" placeholder="必填, 兑换所需要的积分" min="0" />
</NFormItem>
<NFormItem path="count" label="库存">
<NCheckbox :checked="currentGoodsModel.goods.count && currentGoodsModel.goods.count < 0" @update:checked="(v) => (currentGoodsModel.goods.count = v ? -1 : 100)"> 不限 </NCheckbox>
<NInputNumber v-if="currentGoodsModel.goods.count > -1" v-model:value="currentGoodsModel.goods.count" placeholder="可选, 礼物库存" style="max-width: 120px" />
<NFormItem path="goods.count" label="库存">
<NCheckbox
:checked="currentGoodsModel.goods.count && currentGoodsModel.goods.count < 0"
@update:checked="(v) => (currentGoodsModel.goods.count = v ? -1 : 100)"
>
不限
</NCheckbox>
<NInputNumber
v-if="currentGoodsModel.goods.count > -1"
v-model:value="currentGoodsModel.goods.count"
placeholder="可选, 礼物库存"
style="max-width: 120px"
/>
</NFormItem>
<NFormItem path="description" label="描述">
<NInput v-model:value="currentGoodsModel.goods.description" placeholder="可选, 礼物描述" maxlength="500" />
<NFormItem path="goods.description" label="描述">
<NInput v-model:value="currentGoodsModel.goods.description" placeholder="可选, 礼物描述" maxlength="500" type="textarea" />
</NFormItem>
<NFormItem path="tags" label="标签">
<NSelect v-model:value="currentGoodsModel.goods.tags" filterable multiple clearable tag placeholder="可选,输入后按回车添加" :options="existTags" />
<NFormItem path="goods.tags" label="标签">
<NSelect
v-model:value="currentGoodsModel.goods.tags"
filterable
multiple
clearable
tag
placeholder="可选,输入后按回车添加"
:options="existTags"
/>
</NFormItem>
<NFormItem path="cover" label="封面">
<NFormItem path="goods.cover" label="封面">
<NFlex v-if="currentGoodsModel.goods.cover">
<NText>当前封面: </NText>
<NImage :src="FILE_BASE_URL + currentGoodsModel.goods.cover" height="50" object-fit="cover" />
@@ -361,16 +438,25 @@ function responseGoodsToModel(goods: ResponsePointGoodModel) {}
+ {{ currentGoodsModel.goods.cover ? '更换' : '上传' }}封面
</NUpload>
</NFormItem>
<NFormItem path="type" label="类型">
<NFormItem path="goods.type" label="类型">
<NRadioGroup v-model:value="currentGoodsModel.goods.type">
<NRadioButton :value="GoodsTypes.Virtual">虚拟礼物</NRadioButton>
<NRadioButton :value="GoodsTypes.Physical">实体礼物</NRadioButton>
</NRadioGroup>
</NFormItem>
<template v-if="currentGoodsModel.goods.type == GoodsTypes.Physical">
<NFormItem path="collectUrl" label="收货地址">
<NFormItem path="settings" label="选项">
<NCheckbox v-model:checked="currentGoodsModel.goods.isAllowRebuy">允许重复购买</NCheckbox>
</NFormItem>
<NFormItem path="goods.maxBuyCount" label="最大购买数量">
<NInputNumber v-model:value="currentGoodsModel.goods.maxBuyCount" placeholder="必填, 最大购买数量" min="1" />
</NFormItem>
<NFormItem path="goods.collectUrl" label="收货地址">
<NFlex vertical>
<NRadioGroup :value="currentGoodsModel.goods.collectUrl == undefined ? 0 : 1" @update:value="(v) => (currentGoodsModel.goods.collectUrl = v == 1 ? '' : undefined)">
<NRadioGroup
:value="currentGoodsModel.goods.collectUrl == undefined ? 0 : 1"
@update:value="(v) => (currentGoodsModel.goods.collectUrl = v == 1 ? '' : undefined)"
>
<NRadioButton :value="0">通过本站收集收货地址</NRadioButton>
<NRadioButton :value="1">
使用站外链接收集地址
@@ -385,21 +471,21 @@ function responseGoodsToModel(goods: ResponsePointGoodModel) {}
</NFlex>
</NFormItem>
<template v-if="currentGoodsModel.goods.collectUrl != undefined">
<NFormItem path="url" label="收集链接">
<NInput v-model:value="currentGoodsModel.goods.collectUrl" placeholder="用于给用户填写自己收货地址的表格的分享链接" maxlength="300" />
</NFormItem>
<NFormItem label="内嵌收集链接">
<NCheckbox v-model:checked="currentGoodsModel.goods.embedCollectUrl"> 尝试将收集链接嵌入到网页中 </NCheckbox>
<NFormItem path="goods.url" label="收集链接">
<NFlex vertical style="width: 100%">
<NInput v-model:value="currentGoodsModel.goods.collectUrl" placeholder="用于给用户填写自己收货地址的表格的分享链接" maxlength="300" />
<NCheckbox v-model:checked="currentGoodsModel.goods.embedCollectUrl"> 尝试将收集链接嵌入到网页中 </NCheckbox>
</NFlex>
</NFormItem>
</template>
<template v-else>
<NFormItem path="privacy" label="隐私策略" required>
<NCheckbox v-model:checked="isAllowedPrivacyPolicy"> 同意本站隐私策略 </NCheckbox>
<NCheckbox v-model:checked="isAllowedPrivacyPolicy"> 同意本站隐私协议 </NCheckbox>
</NFormItem>
</template>
</template>
<template v-else>
<NFormItem path="content" required>
<NFormItem path="goods.content" required>
<template #label>
礼物内容
<NTooltip>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ResponsePointGoodModel, ResponsePointOrder2OwnerModel } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import PointOrderCard from '@/components/manage/PointOrderCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { NButton, NCard, NEmpty, NList, NListItem, useMessage } from 'naive-ui'
import { h, onMounted, ref } from 'vue'
const props = defineProps<{
goods: ResponsePointGoodModel[]
}>()
const message = useMessage()
const orders = ref<ResponsePointOrder2OwnerModel[]>([])
async function getOrders() {
try {
const data = await QueryGetAPI<ResponsePointOrder2OwnerModel[]>(POINT_API_URL + 'get-orders')
if (data.code == 200) {
return data.data
} else {
message.error('获取订单失败: ' + data.message)
}
} catch (err) {
console.log(err)
message.error('获取订单失败: ' + err)
}
return []
}
onMounted(async () => {
orders.value = await getOrders()
})
</script>
<template>
<NEmpty v-if="orders.length == 0" description="暂无订单"></NEmpty>
<PointOrderCard v-else :order="orders" :goods="goods" type="owner" />
</template>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { EventDataTypes, SettingPointGiftAllowType, Setting_Point } from '@/api/api-models'
import { QueryPostAPI } from '@/api/query'
import { POINT_API_URL } from '@/data/constants'
import { Delete24Regular, Info24Filled } from '@vicons/fluent'
import {
NAlert,
NButton,
NCard,
NCheckbox,
NCheckboxGroup,
NDivider,
NFlex,
NIcon,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NList,
NListItem,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
NSpin,
NTag,
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, ref } from 'vue'
const accountInfo = useAccount()
const message = useMessage()
const defaultSettingPoint: Setting_Point = {
allowType: [EventDataTypes.Guard],
jianzhangPoint: 10,
tiduPoint: 100,
zongduPoint: 1000,
giftPercentMap: {}, // Empty object for an empty map
scPointPercent: 0.1,
giftPointPercent: 0.1,
giftAllowType: SettingPointGiftAllowType.All,
}
const setting = computed({
get: () => {
if (accountInfo.value) {
return accountInfo.value.settings.point
}
return defaultSettingPoint
},
set: (value) => {
if (accountInfo.value) {
accountInfo.value.settings.point = value
}
},
})
const addGiftModel = ref<{ name: string; point: number }>({ name: '', point: 1 })
const canEdit = computed(() => {
return accountInfo.value && accountInfo.value.settings
})
const isLoading = ref(false)
const showAddGiftModal = ref(false)
async function updateSettings() {
if (accountInfo.value) {
isLoading.value = true
setting.value.giftPercentMap ??= {}
try {
const data = await QueryPostAPI(POINT_API_URL + 'update-setting', setting.value)
if (data.code == 200) {
message.success('已保存')
return true
} else {
message.error('保存失败: ' + data.message)
}
} catch (err) {
message.error('保存失败: ' + err)
console.error(err)
} finally {
isLoading.value = false
}
} else {
message.success('完成')
}
return false
}
async function addGift() {
if (!addGiftModel.value.name) {
message.error('请输入礼物名称')
return
}
if (addGiftModel.value.point > 2147483647) {
//不能超过int
message.error('积分不能超过2147483647')
}
setting.value.giftPercentMap[addGiftModel.value.name] = addGiftModel.value.point
updateGift()
}
async function deleteGift(name: string) {
const oldValue = setting.value.giftPercentMap[name]
delete setting.value.giftPercentMap[name]
if (!(await updateGift())) {
setting.value.giftPercentMap[name] = oldValue
}
}
async function updateGift() {
return await updateSettings()
}
</script>
<template>
<NAlert type="info"> 积分总是最多保留两位小数, 四舍五入 </NAlert>
<NDivider> 常用 </NDivider>
<NSpin :show="isLoading">
<NFlex vertical>
<NFlex>
允许的积分来源
<NCheckboxGroup v-model:value="setting.allowType" @update:value="updateSettings" :disabled="!canEdit">
<NCheckbox :value="EventDataTypes.Guard"> 上舰 </NCheckbox>
<NCheckbox :value="EventDataTypes.SC"> Superchat </NCheckbox>
<NCheckbox :value="EventDataTypes.Gift"> 礼物 </NCheckbox>
</NCheckboxGroup>
</NFlex>
<template v-if="setting.allowType.includes(EventDataTypes.Guard)">
<NDivider>上舰设置</NDivider>
<NFlex align="center">
上舰所给予的积分
<NFlex>
<NInputGroup style="width: 230px" :disabled="!canEdit">
<NInputGroupLabel> 舰长 </NInputGroupLabel>
<NInputNumber v-model:value="setting.jianzhangPoint" :disabled="!canEdit" min="0" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit">确定</NButton>
</NInputGroup>
<NInputGroup style="width: 230px" :disabled="!canEdit">
<NInputGroupLabel> 提督 </NInputGroupLabel>
<NInputNumber v-model:value="setting.tiduPoint" :disabled="!canEdit" min="0" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit">确定</NButton>
</NInputGroup>
<NInputGroup style="width: 230px" :disabled="!canEdit">
<NInputGroupLabel> 总督 </NInputGroupLabel>
<NInputNumber v-model:value="setting.zongduPoint" :disabled="!canEdit" min="0" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit">确定</NButton>
</NInputGroup>
</NFlex>
</NFlex>
</template>
<template v-if="setting.allowType.includes(EventDataTypes.SC)">
<NDivider>SC设置</NDivider>
<NFlex>
<NInputGroup style="width: 280px" :disabled="!canEdit">
<NInputGroupLabel> SC转换倍率 </NInputGroupLabel>
<NInputNumber v-model:value="setting.scPointPercent" :disabled="!canEdit" min="0" step="0.01" max="1" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit"
>确定
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
将SC的价格以指定比例转换为积分, 如这里是0.5, 则一个30块的sc获得的积分为 30 * 0.5 = 15
</NTooltip>
</NButton>
</NInputGroup>
</NFlex>
</template>
<template v-if="setting.allowType.includes(EventDataTypes.Gift)">
<NDivider>礼物设置</NDivider>
<NFlex vertical>
<NRadioGroup v-model:value="setting.giftAllowType" @update:value="updateSettings">
<NRadioButton :value="SettingPointGiftAllowType.WhiteList"> 只包含下方的礼物 </NRadioButton>
<NRadioButton :value="SettingPointGiftAllowType.All"> 包含所有礼物 </NRadioButton>
</NRadioGroup>
<template v-if="setting.giftAllowType === SettingPointGiftAllowType.All">
<NInputGroup style="width: 280px" :disabled="!canEdit">
<NInputGroupLabel> 礼物转换倍率 </NInputGroupLabel>
<NInputNumber v-model:value="setting.giftPointPercent" :disabled="!canEdit" min="0" step="0.01" max="1" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit">
确定
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
将礼物的价格以指定比例转换为积分, 如这里是0.5, 则一个10块的礼物获得的积分为 10 * 0.5 = 5
</NTooltip>
</NButton>
</NInputGroup>
</template>
<NCard>
<NFlex vertical>
<NButton @click="showAddGiftModal = true" type="primary" :disabled="!canEdit" style="max-width: 200px"> 添加礼物 </NButton>
<NList bordered>
<NListItem v-for="item in Object.entries(setting.giftPercentMap)" :key="item[0]">
<NFlex align="center">
<NTag :bordered="false" size="small" type="success"> {{ item[0] }} </NTag>
<NInputGroup style="width: 200px" :disabled="!canEdit">
<NInputNumber :value="setting.giftPercentMap[item[0]]" @update:value="(v) => (setting.giftPercentMap[item[0]] = v ?? 0)" :disabled="!canEdit" min="0" />
<NButton @click="updateSettings" type="info" :disabled="!canEdit">确定</NButton>
</NInputGroup>
<NPopconfirm @positive-click="deleteGift(item[0])">
<template #trigger>
<NButton type="error" text :disabled="!canEdit">
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
</NButton>
</template>
确定要删除这个礼物吗?
</NPopconfirm>
</NFlex>
</NListItem>
</NList>
</NFlex>
</NCard>
</NFlex>
<NModal v-model:show="showAddGiftModal" preset="card" title="添加礼物" style="max-width: 400px">
<NFlex align="center" vertical>
<NAlert title="注意" type="warning"> 这里填写的积分是指这个礼物直接对应多少积分, 而不是兑换比例 </NAlert>
<NInputGroup>
<NInputGroupLabel> 礼物名称 </NInputGroupLabel>
<NInput v-model:value="addGiftModel.name" placeholder="礼物名称" />
</NInputGroup>
<NInputGroup>
<NInputGroupLabel> 给予积分 </NInputGroupLabel>
<NInputNumber v-model:value="addGiftModel.point" placeholder="积分数量" min="0" />
</NInputGroup>
<NButton @click="addGift" type="info" :loading="isLoading">确定</NButton>
</NFlex>
</NModal>
</template>
</NFlex>
</NSpin>
</template>

View File

@@ -1,25 +1,56 @@
<script setup lang="ts">
import { ResponsePointOrder2StreamerModel, ResponsePointUserModel } from '@/api/api-models'
import {
ResponsePointHisrotyModel,
ResponsePointOrder2OwnerModel,
ResponsePointUserModel,
} from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import PointHistoryCard from '@/components/manage/PointHistoryCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { NCard, NDataTable, NDivider, NFlex, NList, NListItem, NModal, NSpin, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { useAuthStore } from '@/store/useAuthStore'
import {
DataTableColumns,
NButton,
NCard,
NDataTable,
NDescriptions,
NDescriptionsItem,
NDivider,
NEmpty,
NFlex,
NInput,
NInputNumber,
NList,
NListItem,
NModal,
NSpin,
NTag,
NText,
NTime,
NTooltip,
useMessage,
} from 'naive-ui'
import { h, onMounted, ref } from 'vue'
const props = defineProps<{
user: ResponsePointUserModel
}>()
const orders = ref<ResponsePointOrder2StreamerModel[]>(await getOrders())
const pointHistory = ref([])
const isLoading = ref(false)
const message = useMessage()
const isLoading = ref(false)
const orders = ref<ResponsePointOrder2OwnerModel[]>([])
const pointHistory = ref<ResponsePointHisrotyModel[]>([])
const showAddPointModal = ref(false)
const addPointCount = ref(0)
const addPointReason = ref<string>()
async function getOrders() {
try {
isLoading.value = true
const data = await QueryGetAPI<ResponsePointOrder2StreamerModel[]>(POINT_API_URL + 'get-user-orders', {
id: props.user.info?.id,
const data = await QueryGetAPI<ResponsePointOrder2OwnerModel[]>(POINT_API_URL + 'get-user-orders', {
authId: props.user.info?.id,
})
if (data.code == 200) {
return data.data
@@ -35,22 +66,129 @@ async function getOrders() {
}
return []
}
async function getPointHistory() {
try {
isLoading.value = true
const data = await QueryGetAPI<ResponsePointHisrotyModel[]>(
POINT_API_URL + 'get-user-histories',
props.user.info.id > 0
? {
authId: props.user.info.id,
}
: {
id: props.user.info.userId ?? props.user.info.openId,
},
)
if (data.code == 200) {
return data.data
} else {
message.error('获取积分历史失败: ' + data.message)
console.error(data)
}
} catch (err) {
message.error('获取积分历史失败: ' + err)
console.error(err)
} finally {
isLoading.value = false
}
return []
}
async function givePoint() {
if (addPointCount.value <= 0) {
message.error('积分数量必须大于0')
return
}
isLoading.value = true
try {
const data = await QueryGetAPI(POINT_API_URL + 'give-point', {
authId: props.user.info?.id,
count: addPointCount.value,
reason: addPointReason.value,
})
if (data.code == 200) {
message.success('添加成功')
showAddPointModal.value = false
props.user.point += addPointCount.value
setTimeout(async () => {
pointHistory.value = await getPointHistory()
}, 1500)
} else {
message.error('添加积分失败: ' + data.message)
console.error(data)
}
} catch (err) {
message.error('添加积分失败: ' + err)
console.error(err)
} finally {
isLoading.value = false
}
}
onMounted(async () => {
pointHistory.value = await getPointHistory()
orders.value = await getOrders()
})
</script>
<template>
<NCard :bordered="false">
<NCard title="用户信息">
<NFlex>
</NFlex>
<NCard :bordered="false" content-style="padding-top: 0">
<NCard :title="`用户信息 | ${user.isAuthed ? '已认证' : '未认证'}`">
<template #header>
<NFlex align="center">
<NTag :bordered="false" :type="user.isAuthed ? 'success' : 'error'" size="small">
{{ user.isAuthed ? '已认证' : '未认证' }}
</NTag>
关于
</NFlex>
</template>
<NDescriptions label-placement="left" bordered size="small">
<NDescriptionsItem label="用户名">
{{ user.info.name }}
</NDescriptionsItem>
<NDescriptionsItem v-if="user.info.userId > 0" label="UId">
{{ user.info.userId }}
</NDescriptionsItem>
<NDescriptionsItem v-else label="OpenId">
{{ user.info.openId }}
</NDescriptionsItem>
<NDescriptionsItem label="积分">
{{ user.point }}
</NDescriptionsItem>
<NDescriptionsItem v-if="user.isAuthed" label="认证时间">
<NTime :time="user.info.createAt" />
</NDescriptionsItem>
</NDescriptions>
<template #footer>
<NFlex>
<NTooltip :disabled="user.isAuthed">
<template #trigger>
<NButton type="primary" @click="showAddPointModal = true" :disabled="!user.isAuthed" size="small"> 给予积分 </NButton>
</template>
<NText> 未认证用户无法给予积分 </NText>
</NTooltip>
</NFlex>
</template>
</NCard>
<NDivider>
订单
</NDivider>
<NDivider> 订单 </NDivider>
<NSpin :show="isLoading">
<NList>
<template v-if="orders.length == 0">
<NEmpty description="暂无订单" />
</template>
<NList v-else>
<NListItem v-for="order in orders" v-bind:key="order.id"> </NListItem>
</NList>
</NSpin>
<NDivider> 积分历史 </NDivider>
<NSpin :show="isLoading">
<PointHistoryCard :histories="pointHistory" />
</NSpin>
<NModal v-model:show="showAddPointModal" preset="card" style="width: 500px; max-width: 90vw; height: auto">
<template #header> 给予积分 </template>
<NFlex vertical>
<NInputNumber v-model:value="addPointCount" type="number" placeholder="请输入积分数量" min="0" style="max-width: 120px" />
<NInput placeholder="请输入备注" v-model:value="addPointReason" :maxlength="100" show-count clearable />
<NButton type="primary" @click="givePoint" :loading="isLoading"> 给予 </NButton>
</NFlex>
</NModal>
</NCard>
</template>

View File

@@ -2,25 +2,65 @@
import { ResponsePointUserModel } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { POINT_API_URL } from '@/data/constants'
import { NButton, NCard, NDataTable, NList, NListItem, NModal, useMessage } from 'naive-ui'
import { h, ref } from 'vue'
import {
DataTableColumns,
NButton,
NCard,
NCheckbox,
NDataTable,
NDivider,
NEmpty,
NFlex,
NList,
NListItem,
NModal,
NPopconfirm,
NScrollbar,
NSpin,
NTag,
NTime,
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue'
import PointUserDetailCard from './PointUserDetailCard.vue'
import { useStorage } from '@vueuse/core'
type PointUserSettings = {
onlyAuthed: boolean
}
const message = useMessage()
const defaultSettings: PointUserSettings = {
onlyAuthed: false,
}
const settings = useStorage<PointUserSettings>('Settings.Point.Users', JSON.parse(JSON.stringify(defaultSettings)))
const pn = ref(1)
const ps = ref(25)
const showModal = ref(false)
const isLoading = ref(true)
const users = ref<ResponsePointUserModel[]>(await getUsers())
const users = ref<ResponsePointUserModel[]>([])
const filteredUsers = computed(() => {
return users.value
.filter((user) => {
if (settings.value.onlyAuthed) {
return user.isAuthed
}
return true
})
.sort((a, b) => b.updateAt - a.updateAt)
})
const currentUser = ref<ResponsePointUserModel>()
const column = [
const column: DataTableColumns<ResponsePointUserModel> = [
{
title: '认证',
key: 'auth',
render: (row: ResponsePointUserModel) => {
return row.isAuthed ? '已认证' : '未认证'
return h(NTag, { type: row.isAuthed ? 'success' : 'error' }, () => (row.isAuthed ? '已认证' : '未认证'))
},
},
{
@@ -32,7 +72,8 @@ const column = [
},
{
title: '积分',
key: 'points',
key: 'point',
sorter: 'default',
render: (row: ResponsePointUserModel) => {
return row.point
},
@@ -41,29 +82,45 @@ const column = [
title: '订单数量',
key: 'orders',
render: (row: ResponsePointUserModel) => {
return row.orderCount
return row.isAuthed ? row.orderCount : '无'
},
},
{
title: '最后更新于',
key: 'updateAt',
sorter: 'default',
render: (row: ResponsePointUserModel) => {
return h(NTooltip, null, {
trigger: () => h(NTime, { time: row.updateAt, type: 'relative' }),
default: () => h(NTime, { time: row.updateAt }),
})
},
},
{
title: '操作',
key: 'action',
render: (row: ResponsePointUserModel) => {
return h(
NButton,
{
onClick: () => {
currentUser.value = row
showModal.value = true
return h(NFlex, { justify: 'center' }, () => [
h(
NButton,
{
onClick: () => {
currentUser.value = row
showModal.value = true
},
type: 'info',
size: 'small',
},
},
{ default: () => '详情' },
)
{ default: () => '详情' },
),
])
},
},
]
async function getUsers() {
try {
isLoading.value = true
const data = await QueryGetAPI<ResponsePointUserModel[]>(POINT_API_URL + 'get-all-users')
if (data.code == 200) {
return data.data
@@ -73,14 +130,53 @@ async function getUsers() {
} catch (err) {
console.log(err)
message.error('获取订单失败: ' + err)
} finally {
isLoading.value = false
}
return []
}
onMounted(async () => {
users.value = await getUsers()
})
</script>
<template>
<NDataTable :columns="column" :data="users" :pagination="{ pageSize: ps, page: pn, showSizePicker: true, pageSizes: [10, 25, 50, 100] }" />
<NModal v-model:show="showModal" style="max-width: 600px" title="用户详情">
<PointUserDetailCard v-if="currentUser" :user="currentUser" />
<NSpin :show="isLoading" style="min-height: 200px; min-width: 200px">
<NCard title="设置">
<template #header-extra>
<NPopconfirm @positive-click="settings = JSON.parse(JSON.stringify(defaultSettings))">
<template #trigger>
<NButton size="small" type="warning">恢复默认</NButton>
</template>
<span>确定要恢复默认设置吗?</span>
</NPopconfirm>
</template>
<NFlex>
<NCheckbox v-model:checked="settings.onlyAuthed"> 只显示已认证用户 </NCheckbox>
</NFlex>
</NCard>
<template v-if="filteredUsers.length == 0">
<NDivider />
<NEmpty description="暂无用户" />
</template>
<NDataTable
v-else
scroll-x="600"
:columns="column"
:data="filteredUsers"
:pagination="{ defaultPageSize: ps, showSizePicker: true, pageSizes: [10, 25, 50, 100] }"
/>
</NSpin>
<NModal
v-model:show="showModal"
preset="card"
style="max-width: 600px; min-width: 400px"
title="用户详情"
content-style="padding: 0"
>
<NScrollbar style="max-height: 80vh">
<PointUserDetailCard v-if="currentUser" :user="currentUser" :authInfo="currentUser.info" />
</NScrollbar>
</NModal>
</template>