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

@@ -4,4 +4,4 @@ indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 200
max_line_length = 120

View File

@@ -31,6 +31,7 @@
"prettier": "^3.2.4",
"qrcode.vue": "^3.4.1",
"queue-typescript": "^1.0.1",
"unplugin-vue-markdown": "^0.26.0",
"uuid": "^9.0.1",
"vite": "^5.0.12",
"vite-svg-loader": "^5.1.0",
@@ -42,7 +43,8 @@
"vue3-aplayer": "^1.7.3",
"vue3-marquee": "^4.2.0-beta.1",
"vueuc": "^0.4.58",
"worker-timers": "^7.1.1"
"worker-timers": "^7.1.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/eslint": "^8.56.2",

View File

@@ -30,7 +30,7 @@
</template>
<script setup lang="ts">
import { useProviderStore } from '@/store/useProviderStore'
import { useLoadingBarStore } from '@/store/useLoadingBarStore'
import ManageLayout from '@/views/ManageLayout.vue'
import ViewerLayout from '@/views/ViewerLayout.vue'
import { useStorage } from '@vueuse/core'

View File

@@ -103,3 +103,54 @@ export async function getImageUploadModel(files: UploadFileInfo[] | undefined |
}
return result
}
export class GuidUtils {
// 将数字转换为GUID
public static numToGuid(value: number): string {
const buffer = new ArrayBuffer(16)
const view = new DataView(buffer)
view.setBigUint64(8, BigInt(value)) // 将数字写入后8个字节
return GuidUtils.bufferToGuid(buffer)
}
// 检查GUID是否由数字生成
public static isGuidFromUserId(guid: string): boolean {
const buffer = GuidUtils.guidToBuffer(guid)
const view = new DataView(buffer)
for (let i = 0; i < 8; i++) {
if (view.getUint8(i) !== 0) return false // 检查前8个字节是否为0
}
return true
}
// 将GUID转换为数字
public static guidToLong(guid: string): number {
if (!GuidUtils.isGuidFromUserId(guid)) {
throw new Error('The provided GUID was not generated from a long value.')
}
const buffer = GuidUtils.guidToBuffer(guid)
const view = new DataView(buffer)
return Number(view.getBigUint64(8)) // 读取后8个字节的long值
}
// 辅助方法将ArrayBuffer转换为GUID字符串
private static bufferToGuid(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const guid = bytes.reduce((str, byte, idx) => {
const pair = byte.toString(16).padStart(2, '0')
return str + (idx === 4 || idx === 6 || idx === 8 || idx === 10 ? '-' : '') + pair
}, '')
return guid
}
// 辅助方法将GUID字符串转换为ArrayBuffer
private static guidToBuffer(guid: string): ArrayBuffer {
const hex = guid.replace(/-/g, '')
if (hex.length !== 32) throw new Error('Invalid GUID format.')
const buffer = new ArrayBuffer(16)
const view = new DataView(buffer)
for (let i = 0; i < 16; i++) {
view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16))
}
return buffer
}
}

View File

@@ -27,6 +27,7 @@ export interface UserInfo {
enableFunctions: FunctionTypes[]
isInBlackList: boolean
templateTypes: { [key: string]: string }
streamerInfo?: StreamerModel
}
}
export interface AccountInfo extends UserInfo {
@@ -51,6 +52,7 @@ export interface AccountInfo extends UserInfo {
blackList: number[]
biliBlackList: { [key: number]: string }
streamerInfo?: StreamerModel
biliUserAuthInfo?: BiliAuthModel
}
export interface StreamerModel {
name: string
@@ -88,6 +90,8 @@ export interface UserSetting {
questionBox: Setting_QuestionBox
songRequest: Setting_SongRequest
queue: Setting_Queue
point: Setting_Point
enableFunctions: FunctionTypes[]
indexTemplate: string | null
@@ -155,7 +159,20 @@ export interface Setting_Queue {
isReverse: boolean
}
export interface Setting_Point {
allowType: EventDataTypes[]
jianzhangPoint: number // decimal maps to number in TypeScript
tiduPoint: number // decimal maps to number in TypeScript
zongduPoint: number // decimal maps to number in TypeScript
giftPercentMap: { [key: string]: number } // Dictionary<string, double> maps to an index signature in TypeScript
scPointPercent: number // double maps to number in TypeScript
giftPointPercent: number // double maps to number in TypeScript
giftAllowType: SettingPointGiftAllowType
}
export enum SettingPointGiftAllowType {
All,
WhiteList,
}
export enum KeywordMatchType {
Full,
Contains,
@@ -427,6 +444,7 @@ export interface EventModel {
name: string
uface: string
uid: number
open_id: string
msg: string
time: number
num: number
@@ -436,6 +454,7 @@ export interface EventModel {
fans_medal_name: string
fans_medal_wearing_status: boolean
emoji?: string
ouid: string
}
export enum EventDataTypes {
Guard,
@@ -530,6 +549,8 @@ export interface ResponsePointGoodModel {
images: string[]
status: GoodsStatus
type: GoodsTypes
isAllowRebuy: boolean
maxBuyCount?: number
}
export interface ImageUploadModel {
existImages: string[]
@@ -548,11 +569,15 @@ export interface PointGoodsModel {
embedCollectUrl?: boolean
description: string
content?: string
isAllowRebuy: boolean
maxBuyCount?: number
}
export interface AddressInfo {
id?: string
province: string
city: string
district: string
street: string
address: string
phone: number
name: string
@@ -563,41 +588,66 @@ export interface BiliAuthBaseModel {
openId: string
avatar: string
name: string
createAt: number
}
export interface BiliAuthModel extends BiliAuthBaseModel {
address?: AddressInfo
address?: AddressInfo[]
token: string
}
export interface PointOrderModel{
id: number
}
export interface ResponsePointUserModel{
export interface ResponsePointUserModel {
point: number
orderCount: number
isAuthed: boolean
info?: BiliAuthBaseModel
info: BiliAuthBaseModel
updateAt: number
createAt: number
trackingNumber?: string
expressCompany?: string
}
export interface ResponsePointOrder2StreamerModel {
export interface ResponsePointOrder2OwnerModel {
instanceOf: 'owner'
id: number
point: number
type: GoodsTypes
customer: BiliAuthModel
address?: AddressInfo
goodsId: number
createAt: number
status: PointOrderStatus
trackingNumber?: string
expressCompany?: string
}
export interface ResponsePointOrder2UserModel {
instanceOf: 'user'
id: number
point: number
type: GoodsTypes
address?: AddressInfo
goodsId: PointGoodsModel
goods: ResponsePointGoodModel
status: PointOrderStatus
createAt: number
}
export enum PointOrderStatus {
Pending, // 订单正在等待处理
Shipped, // 订单已发货
Canceled, // 订单已取消
Refunded, // 订单已退款
Failed, // 订单处理失败
Completed, // 订单已完成
}
export interface ResponsePointHisrotyModel {
point: number
ouId: string
type: EventDataTypes
from: PointFrom
createAt: number
extra?: any
}
export enum PointFrom {
Danmaku,
Manual,
Use,
}

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { useProviderStore } from '@/store/useProviderStore'
import { useLoadingBarStore } from '@/store/useLoadingBarStore'
import { useLoadingBar } from 'naive-ui'
import { onMounted } from 'vue'
// Setup code
onMounted(() => {
const providerStore = useProviderStore()
const providerStore = useLoadingBarStore()
const loadingBar = useLoadingBar()
providerStore.setLoadingBar(loadingBar)
})

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { AddressInfo } from '@/api/api-models'
import { useElementSize } from '@vueuse/core'
import { NButton, NFlex, NPopconfirm, NTag, NText } from 'naive-ui'
import { ref } from 'vue'
const { size = 'default' } = defineProps<{
address: AddressInfo | undefined
size?: 'small' | 'default'
}>()
const elementRef = ref()
const { height } = useElementSize(elementRef.value)
</script>
<template>
<NText v-if="!address" depth="3" italic> 未知 </NText>
<NFlex v-else ref="elementRef">
<NFlex vertical :size="5">
<NText v-if="size != 'small'">
{{ address.province }}
<NText depth="3"> </NText>
{{ address.city }}
<NText depth="3"> </NText>
{{ address.district }}
<NText depth="3"> </NText>
{{ address.street }}
</NText>
<NText depth="3">
<NFlex align="center">
<NTag size="tiny" type="info" :bordered="false"> 详细地址 </NTag>
{{ address.address }}
</NFlex>
</NText>
<NText v-if="size != 'small'" depth="3">
<NFlex align="center">
<NTag size="tiny" type="info" :bordered="false"> 收货人 </NTag>
<span> {{ address.phone }} {{ address.name }} </span>
</NFlex>
</NText>
</NFlex>
<NFlex style="flex: 1" justify="end" align="center">
<slot name="actions"></slot>
</NFlex>
</NFlex>
</template>

View File

@@ -1,45 +1,57 @@
<script setup lang="ts">
import { ResponsePointGoodModel } from '@/api/api-models'
import { NButton, NCard, NDropdown, NEllipsis, NFlex, NIcon, NImage, NPopselect, NTag, NText } from 'naive-ui'
import { GoodsTypes, ResponsePointGoodModel } from '@/api/api-models'
import { NButton, NCard, NDropdown, NEllipsis, NEmpty, NFlex, NIcon, NImage, NPopselect, NTag, NText } from 'naive-ui'
import { FILE_BASE_URL, IMGUR_URL } from '@/data/constants'
import { computed, ref } from 'vue'
import { MoreHorizontal16Filled, MoreVertical16Filled } from '@vicons/fluent'
const props = defineProps<{
goods: ResponsePointGoodModel
goods: ResponsePointGoodModel | undefined
}>()
const emptyCover = IMGUR_URL + 'None.png'
</script>
<template>
<NCard>
<NEmpty v-if="!goods" description="已失效" />
<NCard v-else embedded>
<template #cover>
<NImage :src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover" :fallback-src="emptyCover" height="150" object-fit="cover" :preview-disabled="!goods.cover" style="width: 100%" />
<NImage
:src="goods.cover ? FILE_BASE_URL + goods.cover : emptyCover"
:fallback-src="emptyCover"
height="150"
object-fit="cover"
:preview-disabled="!goods.cover"
style="width: 100%"
/>
</template>
<template #header-extra>
<slot name="header-extra"></slot>
</template>
<template #header>
<NEllipsis>
{{ goods.name }}
</NEllipsis>
</template>
<NFlex vertical>
<NText depth="3" :italic="!goods.description">
{{ goods.description }}
</NText>
<NFlex>
<NTag v-for="tag in goods.tags" :key="tag" :bordered="false">{{ tag }}</NTag>
</NFlex>
<NFlex justify="space-between">
<NFlex>
<NText> 库存: </NText>
<NText depth="3"> 库存: </NText>
<NText v-if="goods.count && goods.count > -1">
{{ goods.count }}
</NText>
<NText v-else> 不限 </NText>
<NText v-else> </NText>
</NFlex>
</NFlex>
</template>
<template #header>
<NFlex align="center">
<NTag size="small" :bordered="goods.type != GoodsTypes.Physical">
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
</NTag>
<NEllipsis>
{{ goods.name }}
</NEllipsis>
</NFlex>
</template>
<NFlex vertical>
<NText :depth="goods.description ? 1 : 3" :italic="!goods.description">
{{ goods.description ? goods.description : '暂无描述' }}
</NText>
<NFlex>
<NTag v-for="tag in goods.tags" :key="tag" :bordered="false" size="tiny">{{ tag }}</NTag>
</NFlex>
</NFlex>
<template #footer>
<slot name="footer"></slot>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { DataTableColumns, NDataTable, NDivider, NFlex, NTag, NText, NTime, NTooltip } from 'naive-ui'
import { h } from 'vue'
import { EventDataTypes, PointFrom, ResponsePointHisrotyModel } from '@/api/api-models'
const props = defineProps<{
histories: ResponsePointHisrotyModel[]
}>()
const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
{
title: '时间',
key: 'createAt',
sorter: 'default',
render: (row: ResponsePointHisrotyModel) => {
return h(NTooltip, null, {
trigger: () => h(NTime, { time: row.createAt, type: 'relative' }),
default: () => h(NTime, { time: row.createAt }),
})
},
},
{
title: '积分变动',
key: 'point',
render: (row: ResponsePointHisrotyModel) => {
return h(NText, { style: { color: row.from === PointFrom.Use ? 'red' : 'green' } }, () => (row.from === PointFrom.Use ? '' : '+') + row.point)
},
},
{
title: '来自',
key: 'from',
filter(value, row) {
return ~row.from == value
},
filterOptions: [
{
label: '直播间',
value: PointFrom.Danmaku,
},
{
label: '手动',
value: PointFrom.Manual,
},
{
label: '使用',
value: PointFrom.Use,
},
],
render: (row: ResponsePointHisrotyModel) => {
const get = () => {
switch (row.from) {
case PointFrom.Danmaku:
return h(NTag, { type: 'info', bordered: false, size: 'small' }, () => '直播间')
case PointFrom.Manual:
return h(NTag, { type: 'success', bordered: false, size: 'small' }, () => '手动')
case PointFrom.Use:
return h(NTag, { type: 'warning', bordered: false, size: 'small' }, () => '使用')
}
}
return h(NFlex, {}, () => get())
},
},
{
title: '详情',
key: 'action',
render: (row: ResponsePointHisrotyModel) => {
switch (row.from) {
case PointFrom.Danmaku:
switch (row.type) {
case EventDataTypes.Guard:
return h(NFlex, { justify: 'center', align: 'center' }, () => [
h(NTag, { type: 'info', size: 'small' }, () => '上舰'),
h(NDivider, { vertical: true, style: { margin: '0' } }),
row.extra?.msg,
])
case EventDataTypes.Gift:
return h(NFlex, { justify: 'center' }, () => [
h(NTag, { type: 'info', size: 'small', style: { margin: '0' } }, () => '礼物'),
h(NDivider, { vertical: true }),
row.extra?.msg,
])
case EventDataTypes.SC:
return h(NFlex, { justify: 'center' }, () => [
h(NTag, { type: 'info', size: 'small', style: { margin: '0' } }, () => 'SC'),
h(NDivider, { vertical: true }),
row.extra?.price,
])
}
case PointFrom.Manual:
return h(NFlex, { align: 'center' }, () => [
h(NTag, { type: 'info', size: 'small', style: { margin: '0' } }, () => '备注'),
h(NDivider, { vertical: true }),
h(NText, { depth: 3 }, () => row.extra ?? h(NText, { italic: true, depth: '3' }, () => '未提供')),
])
case PointFrom.Use:
return h(NFlex, { align: 'center' }, () => [
h(NTag, { type: 'success', size: 'small', style: { margin: '0' }, strong: true }, () => '购买'),
h(NDivider, { vertical: true }),
row.extra,
])
}
},
},
]
</script>
<template>
<NDataTable
:columns="historyColumn"
:data="histories"
:pagination="{ showSizePicker: true, pageSizes: [10, 25, 50, 100], defaultPageSize: 10, size: 'small' }"
>
</NDataTable>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import {
GoodsTypes,
PointOrderStatus,
ResponsePointGoodModel,
ResponsePointOrder2OwnerModel,
ResponsePointOrder2UserModel,
} from '@/api/api-models'
import {
DataTableColumns,
NButton,
NCard,
NDataTable,
NDivider,
NFlex,
NIcon,
NInput,
NModal,
NScrollbar,
NTag,
NText,
NTime,
NTooltip,
} from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import AddressDisplay from './AddressDisplay.vue'
import PointGoodsItem from './PointGoodsItem.vue'
import { Info24Filled } from '@vicons/fluent'
const props = defineProps<{
order: ResponsePointOrder2UserModel[] | ResponsePointOrder2OwnerModel[]
type: 'user' | 'owner'
goods?: ResponsePointGoodModel[]
loading?: boolean
}>()
const isLoading = ref(false)
watch(
() => props.loading,
() => {
isLoading.value = props.loading
},
)
const orderAsUser = computed(() => {
return props.order as ResponsePointOrder2UserModel[]
})
const orderAsOwner = computed(() => {
return props.order as ResponsePointOrder2OwnerModel[]
})
const showDetailModal = ref(false)
const orderDetail = ref<ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel>()
const currentGoods = computed(() => {
//@ts-ignore
if (props.type == 'user') return orderDetail.value.goods
//@ts-ignore
else return props.goods.find((g) => g.id == orderDetail.value.goodsId)
})
const orderColumn: DataTableColumns<ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel> = [
{
title: '订单号',
key: 'id',
},
{
title: '时间',
key: 'time',
sorter: 'default',
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return h(NTime, { time: row.createAt })
},
},
{
title: '使用积分',
key: 'point',
},
{
title: '订单状态',
key: 'status',
sorter: 'default',
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
switch (row.status) {
case PointOrderStatus.Pending:
return h(NTag, { size: 'small' }, () => '等待发货')
case PointOrderStatus.Shipped:
return h(NTag, { size: 'small', type: 'info' }, () => '已发货')
case PointOrderStatus.Completed:
return h(NTag, { size: 'small', type: 'success' }, () => '已完成')
}
},
},
{
title: '订单类型',
key: 'type',
filter: (filterOptionValue: unknown, row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.type == filterOptionValue
},
filterOptions: [
{
label: '实体礼物',
value: GoodsTypes.Physical,
},
{
label: '虚拟礼物',
value: GoodsTypes.Virtual,
},
],
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return h(NTag, { type: 'success', bordered: false, size: 'small' }, () =>
row.type == GoodsTypes.Physical ? '实体礼物' : '虚拟礼物',
)
},
},
{
title: '地址',
key: 'address',
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
if (row.type == GoodsTypes.Physical) {
return h(AddressDisplay, { address: row.address })
} else {
return h(NText, { depth: 3 }, () => '无需发货')
}
},
},
{
title: '操作',
key: 'action',
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return h(
NButton,
{
type: 'info',
size: 'small',
onClick: () => {
orderDetail.value = row
showDetailModal.value = true
},
},
{ default: () => '详情' },
)
},
},
]
</script>
<template>
<NDataTable
:loading="isLoading"
:columns="orderColumn"
:data="order"
:pagination="{ showSizePicker: true, pageSizes: [10, 25, 50, 100], defaultPageSize: 10, size: 'small' }"
>
</NDataTable>
<NModal
v-if="orderDetail"
v-model:show="showDetailModal"
preset="card"
title="订单详情"
style="max-width: 800px; max-height: 90vh"
>
<NScrollbar style="max-height: 80vh">
<div style="width: 97%">
<template v-if="type == 'user'">
<NDivider style="margin-top: 0">
商品快照
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
兑换成功时生成的礼物快照, 即使主播对礼物内容进行了修改这个地方也不会变化
</NTooltip>
</NDivider>
<NFlex justify="center">
<PointGoodsItem style="max-width: 300px" :goods="currentGoods" />
</NFlex>
<template v-if="orderDetail.type == GoodsTypes.Virtual">
<NDivider> 虚拟礼物内容 </NDivider>
<NInput :value="currentGoods?.content" type="textarea" readonly placeholder="无内容" />
</template>
</template>
<template v-else-if="type == 'owner'"> </template>
</div>
</NScrollbar>
</NModal>
</template>

View File

@@ -4,10 +4,12 @@ import ChatClientDirectOpenLive from '@/data/chat/ChatClientDirectOpenLive.js'
import { ref } from 'vue'
import { clearInterval, setInterval } from 'worker-timers'
import { OPEN_LIVE_API_URL } from './constants'
import { GuidUtils } from '@/Utils'
export interface DanmakuInfo {
room_id: number
uid: number
open_id: string
uname: string
msg: string
msg_id: string
@@ -23,6 +25,7 @@ export interface DanmakuInfo {
export interface GiftInfo {
room_id: number
uid: number
open_id: string
uname: string
uface: string
gift_id: number
@@ -53,6 +56,7 @@ export interface GiftInfo {
export interface SCInfo {
room_id: number // 直播间id
uid: number // 购买用户UID
open_id: string
uname: string // 购买的用户昵称
uface: string // 购买用户头像
message_id: number // 留言id(风控场景下撤回留言需要)
@@ -70,6 +74,7 @@ export interface SCInfo {
interface GuardInfo {
user_info: {
uid: number // 用户uid
open_id: string
uname: string // 用户昵称
uface: string // 用户头像
}
@@ -267,6 +272,8 @@ export default class DanmakuClient {
fans_medal_wearing_status: data.fans_medal_wearing_status,
emoji: data.dm_type == 1 ? data.emoji_img_url : undefined,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid),
},
command,
)
@@ -293,6 +300,8 @@ export default class DanmakuClient {
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid),
},
command,
)
@@ -318,6 +327,8 @@ export default class DanmakuClient {
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid),
},
command,
)
@@ -343,6 +354,8 @@ export default class DanmakuClient {
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.user_info.uface,
open_id: data.user_info.open_id,
ouid: data.user_info.open_id ?? GuidUtils.numToGuid(data.user_info.uid),
},
command,
)

View File

@@ -10,6 +10,7 @@ export const isBackendUsable = ref(true)
export const AVATAR_URL = 'https://workers.vrp.moe/api/bilibili/avatar/'
export const FILE_BASE_URL = 'https://files.vtsuru.live'
export const IMGUR_URL = FILE_BASE_URL + '/imgur/'
export const THINGS_URL = FILE_BASE_URL + '/things/'
export const apiFail = ref(false)
export const BASE_API = () => (process.env.NODE_ENV === 'development' ? debugAPI : apiFail.value ? failoverAPI : releseAPI)

View File

@@ -0,0 +1,75 @@
# 用户协议
## 第1章 总则
欢迎您使用我们的网站服务!为了维护您的合法权益,请您在使用 vtsuru.live以下简称“本网站”提供的服务之前详细阅读以下所有条款。本用户协议以下简称“本协议”是您以下也称“用户”与本网站之间关于使用本网站提供的服务所订立的权利义务规范。您使用本网站即表示您同意所有该等协议并同意遵循本协议的规定。
## 第2章 收货地址的收集与使用
### 第2.1条 收集目的
您理解并同意,在使用本网站服务进行商品订购及配送时,您需要填写实际的收货地址信息,包括但不限于姓名、联系方式、收货地址等,本网站对该等信息的收集仅为了能够顺利完成商品的配送服务。
### 第2.2条 信息的保护
1. 本网站承诺对您的收货地址信息进行严格的保密措施,未经用户同意,不会将用户的收货地址信息透露给第三方,除非:
- 根据法律法规的要求;
- 按照相关政府主管部门的要求;
- 为完成合并、分割、收购或资产转让而必须共享的;
- 为提供您所要求的服务所必需的。
2. 本网站将采用行业标准保护用户信息的安全,防止信息的丢失、被不法分子恶意篡改。
### 第2.3条 信息的更新
用户应保证提供的收货地址信息准确无误,并在信息变更时及时更新,以便本网站能提供更好的服务并及时准确地进行商品配送。
## 第3章 法律责任及免责
### 第3.1条 法律责任
若本网站发现或收到他人举报投诉用户提供的收货信息有误导致配送错误或其他问题时,本网站有权要求用户在规定时间内进行说明、改正,直至采取中止或终止向用户提供服务的措施。
### 第3.2条 免责事项
如因下述任一情况导致个人信息的泄漏,网站将不承担任何责任:
- 用户将个人账户信息告知他人或与他人共享注册账户导致的任何个人信息的泄露;
- 任何由于黑客攻击、计算机病毒的侵入及其他非因本网站故意或重大过失造成的信息泄露;
- 因不可抗力导致的信息泄漏。
## 第4章 协议修改
本网站有权根据国家法律法规变化及网络环境的变化修改本协议的条款。一旦协议的内容发生变动,本网站将会在网站上公布最新的用户协议,不再向用户个别通知。若用户继续使用本网站提供的服务,则视为用户接受修改后的协议。若用户不同意本网站的修改,可立即停止使用本网站提供的服务。
## 第5章 法律适用与管辖
本协议的订立、执行和解释及纠纷的解决均适用中华人民共和国法律。若用户和本网站之间发生任何纠纷或争议,首先应友好协商解决,协商不成时,任何一方均有权将争议提交本网站所在地人民法院诉讼解决。
## 第6章 其他规定
### 第6.1条 协议的独立性
如果本协议中任何一条之规定因任何原因被判定为无效或不可执行,该规定应视为可分割的且不影响任何其余规定之效力及可执行性。
### 第6.2条 协议的转让
未经本网站事先书面同意,用户不得将本协议项下的权利和义务转让给任何第三方。
### 第6.3条 通知和送达
本网站对于用户的通知均可通过本网站公告、站内信、电子邮箱、手机短信或常规的信件传送等方式进行;此类通知于发送之日视为已送达收件人。
### 第6.4条 标题仅为便于阅读
本协议中的标题仅为方便而设,不影响对于条款本身的解释。本协议的解释权属于本网站。
通过完成账户注册、使用本网站提供的服务、或以其他任何明示或暗示的方式接受本协议全部或部分条款,即表示您已经阅读、理解并同意本协议的全部内容。用户在使用本网站提供的服务之前,应确保自己完全理解本协议的全部内容,对于协议中以加粗、下划线等方式显著标识的内容,用户应特别注意阅读。若您对本协议的任何条款或其修订有异议,请停止使用本网站提供的所有服务。
---
最终解释权归 vtsuru.live 所有。
更新日期2024.2.8
生效日期2024.2.8

View File

@@ -8,6 +8,7 @@ import App from './App.vue'
import { GetSelfAccount, UpdateAccountLoop } from './api/account'
import { GetNotifactions } from './data/notifactions'
import router from './router'
import { useAuthStore } from './store/useAuthStore'
const pinia = createPinia()
@@ -45,6 +46,7 @@ QueryGetAPI<string>(BASE_API() + 'vtsuru/version')
//加载其他数据
GetSelfAccount()
GetNotifactions()
useAuthStore().getAuthInfo()
UpdateAccountLoop()
InitTTS()
})

View File

@@ -1,4 +1,4 @@
import { useProviderStore } from '@/store/useProviderStore'
import { useLoadingBarStore } from '@/store/useLoadingBarStore'
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import IndexView from '../views/IndexView.vue'
import manage from './manage'
@@ -75,6 +75,15 @@ const routes: Array<RouteRecordRaw> = [
keepAlive: true,
},
},
{
path: '/bili-user',
name: 'bili-user',
component: () => import('@/views/pointViews/PointUserLayout.vue'),
meta: {
title: 'Bilibili 账户',
keepAlive: true,
},
},
manage,
user,
obs,
@@ -94,11 +103,11 @@ const router = createRouter({
routes,
})
router.beforeEach((to, from, next) => {
useProviderStore().loadingBar?.start()
useLoadingBarStore().loadingBar?.start()
next()
})
router.afterEach((to, from) => {
const loadingBar = useProviderStore().loadingBar
const loadingBar = useLoadingBarStore().loadingBar
loadingBar?.finish()
})

View File

@@ -40,6 +40,8 @@ export default {
children: [
{
path: 'ics',
name: 'user-schedule-ics',
component: () => import('@/views/view/ScheduleView.vue'),
beforeEnter(to: any) {
// 直接重定向到外部 URL
window.location.href = 'https://vtsuru.live/api/schedule/get-ics?id=' + to.query.id
@@ -47,5 +49,14 @@ export default {
},
],
},
{
path: 'goods',
name: 'user-goods',
component: () => import('@/views/pointViews/PointGoodsView.vue'),
meta: {
title: '积分兑换',
keepAlive: true,
},
},
],
}

138
src/store/useAuthStore.ts Normal file
View File

@@ -0,0 +1,138 @@
import { defineStore } from 'pinia'
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { AddressInfo, BiliAuthModel, ResponsePointGoodModel } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import { useStorage } from '@vueuse/core'
import { BILI_AUTH_API_URL, POINT_API_URL } from '@/data/constants'
import { MessageApiInjection } from 'naive-ui/es/message/src/MessageProvider'
export const useAuthStore = defineStore('BiliAuth', () => {
const biliAuth = ref<BiliAuthModel>({} as BiliAuthModel)
const biliTokens = useStorage<
{
id: number
uId: number
name?: string
token: string
}[]
>('Bili.Auth.Tokens', [])
const biliToken = useStorage<string>('Bili.Auth.Selected', null)
const isLoading = ref(false)
const isAuthed = computed(() => biliToken.value != null && biliToken.value.length > 0)
async function setCurrentAuth(token: string) {
if (!token) {
console.warn('[bili-auth] 无效的token')
return
}
biliAuth.value = {} as BiliAuthModel
biliToken.value = token
await getAuthInfo()
}
async function getAuthInfo() {
try {
isLoading.value = true
await QueryBiliAuthGetAPI<BiliAuthModel>(BILI_AUTH_API_URL + 'info').then((data) => {
if (data.code == 200) {
biliAuth.value = data.data
console.log('[bili-auth] 已获取 Bilibili 认证信息')
// 将token加入到biliTokens
const index = biliTokens.value.findIndex((t) => t.id == biliAuth.value.id)
if (index >= 0) {
biliTokens.value[index] = {
id: biliAuth.value.id,
token: biliToken.value,
name: biliAuth.value.name,
uId: biliAuth.value.userId,
}
//console.log('更新已存在的认证账户: ' + biliAuth.value.userId)
} else {
biliTokens.value.push({
id: biliAuth.value.id,
token: biliToken.value,
name: biliAuth.value.name,
uId: biliAuth.value.userId,
})
console.log('添加新的认证账户: ' + biliAuth.value.userId)
}
return true
} else {
console.error('[bili-auth] 无法获取 Bilibili 认证信息: ' + data.message)
//message.error('无法获取 Bilibili 认证信息: ' + data.message)
}
})
} catch (err) {
console.error('[bili-auth] 无法获取 Bilibili 认证信息: ' + err)
//message.error('无法获取 Bilibili 认证信息: ' + err)
} finally {
isLoading.value = false
}
return false
}
function QueryBiliAuthGetAPI<T>(url: string, params?: any, headers?: [string, string][]) {
headers ??= []
if (headers.find((h) => h[0] == 'Bili-Auth') == null) {
headers.push(['Bili-Auth', biliToken.value ?? ''])
}
return QueryGetAPI<T>(url, params, headers)
}
function QueryBiliAuthPostAPI<T>(url: string, body?: unknown, headers?: [string, string][]) {
headers ??= []
if (headers.find((h) => h[0] == 'Bili-Auth') == null) {
headers.push(['Bili-Auth', biliToken.value ?? ''])
}
return QueryPostAPI<T>(url, body, headers)
}
async function GetSpecificPoint(id: number) {
try {
const data = await QueryBiliAuthGetAPI<number>(POINT_API_URL + 'user/get-point', { id: id })
if (data.code == 200) {
return data.data
} else {
console.error('[point] 无法获取在指定直播间拥有的积分: ' + data.message)
}
} catch (err) {
console.error('[point] 无法获取在指定直播间拥有的积分: ' + err)
}
return null
}
async function GetGoods(id: number | undefined = undefined, message?: MessageApiInjection) {
if (!id) {
return []
}
try {
var resp = await QueryGetAPI<ResponsePointGoodModel[]>(POINT_API_URL + 'get-goods', {
id: id,
})
if (resp.code == 200) {
return resp.data
} else {
message?.error('无法获取数据: ' + resp.message)
console.error('无法获取数据: ' + resp.message)
}
} catch (err) {
message?.error('无法获取数据: ' + err)
console.error('无法获取数据: ' + err)
}
return []
}
return {
biliAuth,
biliToken,
biliTokens,
isLoading,
isAuthed,
getAuthInfo,
QueryBiliAuthGetAPI,
QueryBiliAuthPostAPI,
GetSpecificPoint,
GetGoods,
setCurrentAuth,
}
})

View File

@@ -0,0 +1,13 @@
import { defineStore } from 'pinia'
import { LoadingBarApi } from 'naive-ui'
import { ref } from 'vue'
export const useLoadingBarStore = defineStore('provider', () => {
const loadingBar = ref<LoadingBarApi>()
function setLoadingBar(b: LoadingBarApi) {
loadingBar.value = b
}
return { loadingBar, setLoadingBar }
})

View File

@@ -1,13 +0,0 @@
import { defineStore } from 'pinia'
import { LoadingBarApi } from 'naive-ui'
import { ref } from 'vue'
export const useProviderStore = defineStore('provider', () => {
const loadingBar = ref<LoadingBarApi>()
function setLoadingBar(b: LoadingBarApi) {
loadingBar.value = b
}
return { loadingBar, setLoadingBar }
})

View File

@@ -4,7 +4,24 @@ import { QueryGetAPI } from '@/api/query'
import { BILI_API_URL, BILI_AUTH_API_URL } from '@/data/constants'
import { useStorage } from '@vueuse/core'
import { randomUUID } from 'crypto'
import { NFlex, NAlert, NButton, NCard, NCountdown, NInput, NInputGroup, NInputNumber, NSpace, NSpin, NText, useMessage, NTimeline, NTimelineItem, NSteps, NStep } from 'naive-ui'
import {
NFlex,
NAlert,
NButton,
NCard,
NCountdown,
NInput,
NInputGroup,
NInputNumber,
NSpace,
NSpin,
NText,
useMessage,
NTimeline,
NTimelineItem,
NSteps,
NStep,
} from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { v4 as uuidv4 } from 'uuid'
@@ -112,7 +129,7 @@ onMounted(async () => {
<NSpace vertical justify="center" align="center" style="width: 100%">
<template v-if="!timeOut">
<NSpin />
<span> 剩余 <NCountdown :duration="timeLeft - Date.now()" /> </span>
<span> 剩余 <NCountdown :duration="timeLeft" /> </span>
<NInputGroup>
<NInput :value="startModel?.code" :allow-input="() => false" />
<NButton @click="copyCode"> 复制认证码 </NButton>
@@ -141,7 +158,7 @@ onMounted(async () => {
<NText>
点击
<NText type="primary" strong> 开始认证 </NText>
后请在 2 分钟之内使用
后请在 5 分钟之内使用
<NText strong type="primary"> 需要认证的账户 </NText>
在指定的直播间直播间内发送给出的验证码
</NText>
@@ -155,10 +172,25 @@ onMounted(async () => {
<NAlert type="success"> 你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得 </NAlert>
<NText> 你的登陆链接为: </NText>
<NInputGroup>
<NInput :value="`https://vtsuru.live/point?auth=${biliToken}`" type="textarea" :allow-input="() => false" />
<NInput :value="`https://vtsuru.live/bili-user?auth=${biliToken}`" type="textarea" :allow-input="() => false" />
<NButton @click="copyCode" type="info" style="height: 100%"> 复制登陆链接 </NButton>
</NInputGroup>
<NButton @click="" type="primary"> 前往个人中心 </NButton>
<NFlex>
<NButton @click="$router.push({ name: 'bili-user' })" type="primary"> 前往个人中心 </NButton>
<NButton
@click="
() => {
currentStep = 0
//@ts-ignore
biliToken = null
guidKey = uuidv4()
}
"
type="warning"
>
重新认证
</NButton>
</NFlex>
</NFlex>
</template>
</NFlex>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { isDarkMode } from '@/Utils'
import { NavigateToNewTab, isDarkMode } from '@/Utils'
import { isLoadingAccount, useAccount } from '@/api/account'
import { ThemeType } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
@@ -48,6 +48,7 @@ import { computed, h, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import APlayer from 'vue3-aplayer'
import DanmakuLayout from './manage/DanmakuLayout.vue'
import { useAuthStore } from '@/store/useAuthStore'
const accountInfo = useAccount()
const message = useMessage()
@@ -289,7 +290,10 @@ const menuOptions = [
},
),
]),
default: () => (accountInfo.value?.isBiliVerified ? '需要使用直播弹幕的功能' : '你尚未进行 Bilibili 认证, 请前往面板进行绑定'),
default: () =>
accountInfo.value?.isBiliVerified
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
@@ -410,7 +414,18 @@ function logout() {
window.location.reload()
}
function onNextMusic() {
musicRquestStore.nextMusic();
musicRquestStore.nextMusic()
}
function gotoAuthPage() {
if (!accountInfo.value?.biliUserAuthInfo) {
message.error('你尚未进行 Bilibili 认证, 请前往面板进行认证和绑定')
return
}
useAuthStore()
.setCurrentAuth(accountInfo.value?.biliUserAuthInfo.token)
.then(() => {
NavigateToNewTab('/bili-user')
})
}
onMounted(() => {
@@ -431,7 +446,12 @@ onMounted(() => {
</template>
<template #extra>
<NSpace align="center" justify="center">
<NSwitch :default-value="!isDarkMode()" @update:value="(value: string & number & boolean) => (themeType = value ? ThemeType.Light : ThemeType.Dark)">
<NSwitch
:default-value="!isDarkMode()"
@update:value="
(value: string & number & boolean) => (themeType = value ? ThemeType.Light : ThemeType.Dark)
"
>
<template #checked>
<NIcon :component="Sunny" />
</template>
@@ -439,30 +459,54 @@ onMounted(() => {
<NIcon :component="Moon" />
</template>
</NSwitch>
<NButton size="small" style="right: 0px; position: relative" type="primary" @click="$router.push({ name: 'user-index', params: { id: accountInfo?.name } })"> 回到主页 </NButton>
<NButton
size="small"
style="right: 0px; position: relative"
type="primary"
@click="$router.push({ name: 'user-index', params: { id: accountInfo?.name } })"
>
回到主页
</NButton>
</NSpace>
</template>
</NPageHeader>
</NLayoutHeader>
<NLayout has-sider>
<NLayoutSider ref="sider" bordered show-trigger collapse-mode="width" :default-collapsed="windowWidth < 750" :collapsed-width="64" :width="180" :native-scrollbar="false">
<NSpace justify="center" style="margin-top: 16px">
<NButton @click="$router.push({ name: 'manage-index' })" type="info" style="width: 100%">
<NLayoutSider
ref="sider"
bordered
show-trigger
collapse-mode="width"
:default-collapsed="windowWidth < 750"
:collapsed-width="64"
:width="180"
:native-scrollbar="false"
>
<NSpace vertical style="margin-top: 16px" align="center">
<NSpace justify="center">
<NButton @click="$router.push({ name: 'manage-index' })" type="info" style="width: 100%">
<template #icon>
<NIcon :component="BrowsersOutline" />
</template>
<template v-if="width >= 180"> 面板 </template>
</NButton>
<NTooltip v-if="width >= 180">
<template #trigger>
<NButton @click="$router.push({ name: 'manage-feedback' })">
<template #icon>
<NIcon :component="PersonFeedback24Filled" />
</template>
</NButton>
</template>
反馈
</NTooltip>
</NSpace>
<NButton v-if="false" @click="gotoAuthPage()" type="info" secondary>
<template #icon>
<NIcon :component="BrowsersOutline" />
</template>
<template v-if="width >= 180"> 面板 </template>
<template v-if="width >= 180"> 认证用户主页 </template>
</NButton>
<NTooltip v-if="width >= 180">
<template #trigger>
<NButton @click="$router.push({ name: 'manage-feedback' })">
<template #icon>
<NIcon :component="PersonFeedback24Filled" />
</template>
</NButton>
</template>
反馈
</NTooltip>
</NSpace>
<NMenu
style="margin-top: 12px"
@@ -501,8 +545,14 @@ onMounted(() => {
请进行邮箱验证
<br /><br />
<NSpace>
<NButton size="small" type="info" :disabled="!canResendEmail" @click="resendEmail"> 重新发送验证邮件 </NButton>
<NCountdown v-if="!canResendEmail" :duration="(accountInfo?.nextSendEmailTime ?? 0) - Date.now()" @finish="canResendEmail = true" />
<NButton size="small" type="info" :disabled="!canResendEmail" @click="resendEmail">
重新发送验证邮件
</NButton>
<NCountdown
v-if="!canResendEmail"
:duration="(accountInfo?.nextSendEmailTime ?? 0) - Date.now()"
@finish="canResendEmail = true"
/>
<NPopconfirm @positive-click="logout" size="small">
<template #trigger>
@@ -534,7 +584,9 @@ onMounted(() => {
style="flex: 1; min-width: 400px"
/>
<NSpace vertical>
<NTag :bordered="false" type="info" size="small"> 队列: {{ musicRquestStore.waitingMusics.length }} </NTag>
<NTag :bordered="false" type="info" size="small">
队列: {{ musicRquestStore.waitingMusics.length }}
</NTag>
<NButton size="small" type="info" @click="onNextMusic"> 下一首 </NButton>
</NSpace>
</div>
@@ -543,7 +595,17 @@ onMounted(() => {
</NLayout>
</NLayout>
<template v-else>
<NLayoutContent style="display: flex; justify-content: center; align-items: center; flex-direction: column; padding: 50px; height: 100%; box-sizing: border-box">
<NLayoutContent
style="
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 50px;
height: 100%;
box-sizing: border-box;
"
>
<template v-if="!isLoadingAccount">
<NSpace vertical justify="center" align="center">
<NText> 请登录或注册后使用 </NText>

View File

@@ -6,7 +6,7 @@ import { FunctionTypes, ThemeType, UserInfo } from '@/api/api-models'
import { useUser } from '@/api/user'
import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
import { FETCH_API } from '@/data/constants'
import { CalendarClock24Filled } from '@vicons/fluent'
import { CalendarClock24Filled, Wallet24Filled } from '@vicons/fluent'
import { Chatbox, Home, Moon, MusicalNote, Sunny } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
@@ -129,6 +129,21 @@ onMounted(async () => {
key: 'user-questionBox',
icon: renderIcon(Chatbox),
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'user-goods',
},
},
{ default: () => '积分' },
),
show: (userInfo.value?.extra?.enableFunctions.indexOf(FunctionTypes.Point) ?? -1) > -1,
key: 'user-goods',
icon: renderIcon(Wallet24Filled),
},
]
await RequestBiliUserData()
})

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>

View File

@@ -60,6 +60,7 @@ type SpeechSettings = {
voiceAPISchemeType: 'http' | 'https'
voiceAPI?: string
splitText: boolean
useAPIDirectly: boolean
combineGiftDelay?: number
}
@@ -87,6 +88,7 @@ const settings = useStorage<SpeechSettings>('Setting.Speech', {
voiceType: 'local',
voiceAPISchemeType: 'https',
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
useAPIDirectly: false,
splitText: false,
combineGiftDelay: 2,
@@ -122,7 +124,9 @@ const isSpeaking = ref(false)
const speakingText = ref('')
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
const isVtsuruVoiceAPI = computed(() => {
return settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
return (
settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
)
})
const canSpeech = ref(false)
@@ -187,7 +191,10 @@ async function speak() {
return
}
const data = speakQueue.value[0]
if (data.data.type == EventDataTypes.Gift && data.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000) {
if (
data.data.type == EventDataTypes.Gift &&
data.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000
) {
return
}
let text = getTextFromDanmaku(speakQueue.value.shift()?.data)
@@ -271,7 +278,7 @@ function speakFromAPI(text: string) {
}
isSpeaking.value = true
isApiAudioLoading.value = true
let url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : FETCH_API + 'http'}://${settings.value.voiceAPI
let url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : (settings.value.useAPIDirectly ? '' : FETCH_API) + 'http'}://${settings.value.voiceAPI
.trim()
.replace(/^(?:https?:\/\/)/, '')
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}`
@@ -329,7 +336,11 @@ function onGetEvent(data: EventModel) {
}
if (data.type == EventDataTypes.Gift) {
const exist = speakQueue.value.find(
(v) => v.data.type == EventDataTypes.Gift && v.data.uid == data.uid && v.data.msg == data.msg && v.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000,
(v) =>
v.data.type == EventDataTypes.Gift &&
v.data.uid == data.uid &&
v.data.msg == data.msg &&
v.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000,
)
if (exist) {
exist.updateAt = Date.now()
@@ -337,7 +348,9 @@ function onGetEvent(data: EventModel) {
exist.data.price += data.price
exist.combineCount ??= 0
exist.combineCount += data.num
console.log(`[TTS] ${data.name} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`)
console.log(
`[TTS] ${data.name} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
)
return
}
}
@@ -378,11 +391,17 @@ function getTextFromDanmaku(data: EventModel | undefined) {
break
}
text = text
.replace(templateConstants.name.regex, settings.value.voiceType == 'api' && settings.value.splitText ? `\'${data.name}\'` : data.name)
.replace(
templateConstants.name.regex,
settings.value.voiceType == 'api' && settings.value.splitText ? `\'${data.name}\'` : data.name,
)
.replace(templateConstants.count.regex, data.num.toString())
.replace(templateConstants.price.regex, data.price.toString())
.replace(templateConstants.message.regex, data.msg)
.replace(templateConstants.guard_level.regex, data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '')
.replace(
templateConstants.guard_level.regex,
data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '',
)
.replace(templateConstants.fans_medal_level.regex, data.fans_medal_level.toString())
.trim()
@@ -468,6 +487,8 @@ function test(type: EventDataTypes) {
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
break
case EventDataTypes.SC:
@@ -485,6 +506,8 @@ function test(type: EventDataTypes) {
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
break
case EventDataTypes.Guard:
@@ -502,6 +525,8 @@ function test(type: EventDataTypes) {
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
break
case EventDataTypes.Gift:
@@ -519,6 +544,8 @@ function test(type: EventDataTypes) {
fans_medal_wearing_status: false,
emoji: undefined,
uface: '',
open_id: '00000000-0000-0000-0000-000000000000',
ouid: '00000000-0000-0000-0000-000000000000',
})
break
}
@@ -549,7 +576,9 @@ onUnmounted(() => {
</script>
<template>
<NAlert v-if="!speechSynthesisInfo || !speechSynthesisInfo.speechSynthesis" type="error"> 你的浏览器不支持语音功能 </NAlert>
<NAlert v-if="!speechSynthesisInfo || !speechSynthesisInfo.speechSynthesis" type="error">
你的浏览器不支持语音功能
</NAlert>
<template v-else>
<NSpace vertical>
<NAlert v-if="settings.voiceType == 'local'" type="info" closeable>
@@ -589,10 +618,18 @@ onUnmounted(() => {
</NSpace>
<br />
<NSpace align="center">
<NButton @click="canSpeech ? stopSpeech() : startSpeech()" :type="canSpeech ? 'error' : 'primary'" data-umami-event="Use TTS" :data-umami-event-uid="accountInfo?.id" size="large">
<NButton
@click="canSpeech ? stopSpeech() : startSpeech()"
:type="canSpeech ? 'error' : 'primary'"
data-umami-event="Use TTS"
:data-umami-event-uid="accountInfo?.id"
size="large"
>
{{ canSpeech ? '停止监听' : '开始监听' }}
</NButton>
<NButton @click="uploadConfig" type="primary" secondary :disabled="!accountInfo" size="small"> 保存配置到服务器 </NButton>
<NButton @click="uploadConfig" type="primary" secondary :disabled="!accountInfo" size="small">
保存配置到服务器
</NButton>
<NPopconfirm @positive-click="downloadConfig">
<template #trigger>
<NButton type="primary" secondary :disabled="!accountInfo" size="small"> 从服务器获取配置 </NButton>
@@ -615,7 +652,12 @@ onUnmounted(() => {
</NTooltip>
<NTooltip v-else>
<template #trigger>
<NButton circle :disabled="!isSpeaking" @click="cancelSpeech" :style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`">
<NButton
circle
:disabled="!isSpeaking"
@click="cancelSpeech"
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
>
<template #icon>
<NIcon :component="Mic24Filled" :color="isSpeaking ? 'green' : 'gray'" />
</template>
@@ -631,9 +673,24 @@ onUnmounted(() => {
<NListItem v-for="item in speakQueue">
<NSpace align="center">
<NButton @click="forceSpeak(item.data)" type="primary" secondary size="small"> </NButton>
<NButton @click="speakQueue.splice(speakQueue.indexOf(item), 1)" type="error" secondary size="small"> 取消 </NButton>
<NTag v-if="item.data.type == EventDataTypes.Gift && item.combineCount" type="info" size="small" style="animation: animated-border 2.5s infinite"> 连续赠送中</NTag>
<NTag v-else-if="item.data.type == EventDataTypes.Gift && settings.combineGiftDelay" type="success" size="small"> 等待连续赠送检查 </NTag>
<NButton @click="speakQueue.splice(speakQueue.indexOf(item), 1)" type="error" secondary size="small">
取消
</NButton>
<NTag
v-if="item.data.type == EventDataTypes.Gift && item.combineCount"
type="info"
size="small"
style="animation: animated-border 2.5s infinite"
>
连续赠送中</NTag
>
<NTag
v-else-if="item.data.type == EventDataTypes.Gift && settings.combineGiftDelay"
type="success"
size="small"
>
等待连续赠送检查
</NTag>
<span>
<NTag v-if="item.data.type == EventDataTypes.Message" type="success" size="small"> 弹幕</NTag>
<NTag v-else-if="item.data.type == EventDataTypes.Gift" type="success" size="small"> 礼物</NTag>
@@ -670,7 +727,11 @@ onUnmounted(() => {
</NDivider>
<Transition name="fade" mode="out-in">
<NSpace v-if="settings.voiceType == 'local'" vertical>
<NSelect v-model:value="settings.speechInfo.voice" :options="voiceOptions" :fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" />
<NSelect
v-model:value="settings.speechInfo.voice"
:options="voiceOptions"
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })"
/>
<span style="width: 100%">
<NText> 音量 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
@@ -687,7 +748,7 @@ onUnmounted(() => {
<template v-else>
<div>
<NCollapse>
<NCollapseItem title="要求" name="1">
<NCollapseItem title="要求 👀 " name="1">
<NUl>
<NLi> 直接返回音频数据 (wav, mp3, m4a etc.) </NLi>
<NLi>
@@ -699,10 +760,12 @@ onUnmounted(() => {
不使用https的话将会使用 cloudflare workers 进行代理, 会慢很多
</NTooltip>
</NLi>
<NLi> 指定API可以被外部访问 </NLi>
<NLi> 指定API可以被外部访问 (除非你本地部署并且启用了https) </NLi>
</NUl>
推荐项目:
<NButton text type="info" tag="a" href="https://github.com/Artrajz/vits-simple-api" target="_blank"> vits-simple-api </NButton>
推荐项目, 可以用于本地部署:
<NButton text type="info" tag="a" href="https://github.com/Artrajz/vits-simple-api" target="_blank">
vits-simple-api
</NButton>
</NCollapseItem>
</NCollapse>
<br />
@@ -716,8 +779,15 @@ onUnmounted(() => {
</NAlert>
<NAlert v-if="isVtsuruVoiceAPI" type="success" closable>
看起来你正在使用本站提供的测试API (voice.vtsuru.live), 这个接口将会返回
<NButton text type="info" tag="a" href="https://space.bilibili.com/5859321" target="_blank"> Xz乔希 </NButton>
训练的 Taffy 模型结果, 不支持部分英文, 仅用于测试, 用的人多的时候会比较慢, 不保证可用性. 侵删
<NButton text type="info" tag="a" href="https://space.bilibili.com/5859321" target="_blank">
Xz乔希
</NButton>
训练的
<NTooltip>
<template #trigger> Taffy </template>
链接里的 id 改成 0 会变成莲莲捏🥰
</NTooltip>
模型结果, 不支持部分英文, 仅用于测试, 用的人多的时候会比较慢, 不保证可用性. 侵删
</NAlert>
</NSpace>
<br />
@@ -739,13 +809,38 @@ onUnmounted(() => {
</NInputGroup>
<br /><br />
<NSpace vertical>
<NAlert v-if="settings.voiceAPISchemeType == 'http'" type="info"> 不使用https的话将会使用 cloudflare workers 进行代理, 会慢很多 </NAlert>
<NAlert v-if="settings.voiceAPISchemeType == 'http'" type="info">
不使用https的话默认将会使用 cloudflare workers 进行代理, 会慢很多
<br />
<NCheckbox v-model:checked="settings.useAPIDirectly">
不使用代理
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
希望你知道这样做会产生的影响, 无法使用不关我事
</NTooltip>
</NCheckbox>
</NAlert>
<span style="width: 100%">
<NText> 音量 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
<NSlider
style="min-width: 200px"
v-model:value="settings.speechInfo.volume"
:min="0"
:max="1"
:step="0.01"
/>
</span>
</NSpace>
<audio ref="apiAudio" :src="apiAudioSrc" :volume="settings.speechInfo.volume" @ended="cancelSpeech" @canplay="isApiAudioLoading = false" @error="onAPIError"></audio>
<audio
ref="apiAudio"
:src="apiAudioSrc"
:volume="settings.speechInfo.volume"
@ended="cancelSpeech"
@canplay="isApiAudioLoading = false"
@error="onAPIError"
></audio>
</div>
</template>
</Transition>
@@ -761,7 +856,15 @@ onUnmounted(() => {
<NSpace vertical>
<NSpace>
支持的变量:
<NButton size="tiny" secondary v-for="item in Object.values(templateConstants)" :key="item.name" @click="copyToClipboard(item.words)"> {{ item.words }} | {{ item.name }} </NButton>
<NButton
size="tiny"
secondary
v-for="item in Object.values(templateConstants)"
:key="item.name"
@click="copyToClipboard(item.words)"
>
{{ item.words }} | {{ item.name }}
</NButton>
</NSpace>
<NInputGroup>
<NInputGroupLabel> 弹幕模板 </NInputGroupLabel>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import {
AddressInfo,
GoodsTypes,
PointGoodsModel,
ResponsePointGoodModel,
ResponsePointOrder2UserModel,
UserInfo,
} from '@/api/api-models'
import { useUser } from '@/api/user'
import AddressDisplay from '@/components/manage/AddressDisplay.vue'
import PointGoodsItem from '@/components/manage/PointGoodsItem.vue'
import { POINT_API_URL } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import {
NAlert,
NButton,
NCard,
NDataTable,
NDivider,
NFlex,
NForm,
NFormItem,
NGrid,
NGridItem,
NIcon,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NLayoutContent,
NModal,
NSelect,
NSpace,
NSpin,
NTag,
NText,
NTimeline,
NTimelineItem,
NTooltip,
SelectOption,
useDialog,
useMessage,
} from 'naive-ui'
import { ref, computed, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
userInfo: UserInfo
biliInfo: any
}>()
const useAuth = useAuthStore()
const isLoading = ref(false)
const message = useMessage()
const dialog = useDialog()
const biliAuth = computed(() => useAuth.biliAuth)
const goods = ref<ResponsePointGoodModel[]>([])
const currentPoint = ref<number>(-1)
const showBuyModal = ref(false)
const showAddressSelect = ref(false)
const currentGoods = ref<ResponsePointGoodModel>()
const buyCount = ref(1)
const selectedAddress = ref<AddressInfo>()
const canDoBuy = computed(() => {
return currentGoods.value && currentGoods.value.price * buyCount.value < currentPoint.value
})
const addressOptions = computed(() => {
if (!biliAuth.value.id) return []
return (
biliAuth.value.address?.map((item) => {
return {
label: item.address,
value: item.id,
}
}) ?? []
)
})
const canBuy = computed(() => {
if (!biliAuth.value.id) return false
if (!currentPoint.value) return false
return true
})
function getTooltip(goods: ResponsePointGoodModel) {
if (!canBuy.value) return '请先进行账号认证'
if ((currentPoint.value ?? 0) < goods.price) {
return '当前积分不足'
} else {
return '开始兑换'
}
}
async function buyGoods() {
if (buyCount.value < 1) {
message.error('兑换数量不能小于1')
} else if (!selectedAddress.value && currentGoods.value?.type == GoodsTypes.Physical) {
message.error('请选择收货地址')
} else if (!Number.isInteger(buyCount.value)) {
message.error('兑换数量必须为整数')
} else {
try {
isLoading.value = true
const data = await useAuth.QueryBiliAuthPostAPI<ResponsePointOrder2UserModel>(POINT_API_URL + 'buy', {
vId: props.userInfo.id,
goodsId: currentGoods.value?.id,
count: buyCount.value,
addressId: selectedAddress.value ? selectedAddress.value.id : null,
})
if (data.code == 200) {
message.info('兑换成功')
dialog.success({
title: '成功',
content: `兑换成功,订单号:${data.data.id}`,
positiveText: '前往查看',
negativeText: '我知道了',
onPositiveClick: () => {
useRouter().push({ name: 'PointOrderView', params: { id: data.data.id } })
},
onNegativeClick: () => {
showBuyModal.value = false
showAddressSelect.value = false
selectedAddress.value = undefined
buyCount.value = 1
currentGoods.value = undefined
},
})
} else {
message.error('兑换失败: ' + data.message)
console.error(data)
}
} catch (err) {
console.error(err)
message.error('兑换失败: ' + err)
} finally {
isLoading.value = false
}
}
}
function onBuyClick(good: ResponsePointGoodModel) {
showBuyModal.value = true
currentGoods.value = good
}
const renderLabel = (option: SelectOption) => {
return h(AddressDisplay, { address: biliAuth.value.address?.find((a) => a.id == option.value), size: 'small' })
}
const renderOption = ({ node, option }: { node: any; option: SelectOption }) => {
return h(
NButton,
{
style: 'width: 100%;height: 100%;margin: 5px;padding: 12px;',
secondary: true,
type: selectedAddress.value?.id != option.value ? 'default' : 'info',
onClick: () => {
selectedAddress.value = biliAuth.value.address?.find((a) => a.id == option.value)
showAddressSelect.value = false
},
},
() => h(AddressDisplay, { address: biliAuth.value.address?.find((a) => a.id == option.value) }),
)
}
onMounted(async () => {
if (props.userInfo && useAuth.isAuthed) {
if (!biliAuth.value.id) {
isLoading.value = true
await useAuth.getAuthInfo()
}
if (biliAuth.value.id) {
currentPoint.value = (await useAuth.GetSpecificPoint(props.userInfo.id)) ?? -1
}
}
goods.value = await useAuth.GetGoods(props.userInfo.id, message)
isLoading.value = false
})
</script>
<template>
<NAlert v-if="!useAuth.isAuthed" type="warning">
你尚未进行 Bilibili 账号认证, 无法兑换积分
<br />
<NButton type="primary" @click="$router.push({ name: 'bili-auth' })" size="small" style="margin-top: 12px">
立即认证
</NButton>
</NAlert>
<NCard v-else>
<template #header> 你好, {{ useAuth.biliAuth.name }} </template>
<NText> 你在 {{ userInfo.extra?.streamerInfo?.name ?? userInfo.name }} 的直播间的积分为 {{ currentPoint }} </NText>
</NCard>
<NDivider />
<NSpin :show="isLoading">
<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">
<PointGoodsItem :goods="item">
<template #footer>
<NFlex justify="space-between" align="center">
<NTooltip>
<template #trigger>
<NButton :disabled="!canBuy" size="small" type="primary" @click="onBuyClick(item)">兑换</NButton>
</template>
{{ getTooltip(item) }}
</NTooltip>
<NFlex style="flex: 1" justify="end">
<NText style="size: 34px">
🪙
{{ item.price }}
</NText>
</NFlex>
</NFlex>
</template>
</PointGoodsItem>
</NGridItem>
</NGrid>
</NSpin>
<NModal
v-model:show="showBuyModal"
v-if="currentGoods"
preset="card"
title="确认兑换"
style="width: 400px; max-width: 90vw; height: auto"
>
<template #header>
<NFlex align="baseline">
<NTag :type="currentGoods.type == GoodsTypes.Physical ? 'info' : 'default'" :bordered="false">
{{ currentGoods.type == GoodsTypes.Physical ? '实体礼物' : '虚拟物品' }}
</NTag>
<NText> {{ currentGoods.name }} </NText>
</NFlex>
</template>
<PointGoodsItem v-if="currentGoods" :goods="currentGoods" />
<template v-if="currentGoods.type == GoodsTypes.Physical">
<NDivider> 选项 </NDivider>
<NForm>
<NFormItem label="兑换数量" required
><NInputNumber v-model:value="buyCount" :min="1" style="max-width: 120px" step="1" :precision="0" />
</NFormItem>
<NFormItem label="收货地址" required>
<NSelect
v-model:show="showAddressSelect"
:value="selectedAddress?.id"
:options="addressOptions"
:render-label="renderLabel"
:render-option="renderOption"
placeholder="请选择地址"
/>
</NFormItem>
</NForm>
</template>
<NDivider>
<NTag :type="currentGoods.price * buyCount > currentPoint ? 'error' : 'success'">
{{ currentGoods.price * buyCount > currentPoint ? '积分不足' : '可兑换' }}
</NTag>
</NDivider>
<NButton type="primary" :disabled="!canDoBuy" @click="buyGoods" :loading="isLoading"> 确认兑换 </NButton>
<NText>
所需积分: {{ currentGoods.price * buyCount }}
<NDivider vertical />
当前积分: {{ currentPoint }}
</NText>
</NModal>
</template>

View File

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

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { NDataTable, NLayoutContent, NSpace, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/store/useAuthStore'
import { POINT_API_URL } from '@/data/constants'
import { ResponsePointHisrotyModel } from '@/api/api-models'
import PointHistoryCard from '@/components/manage/PointHistoryCard.vue'
const message = useMessage()
const useAuth = useAuthStore()
const isLoading = ref(false)
const history = ref<ResponsePointHisrotyModel[]>([])
async function getHistories() {
try {
isLoading.value = true
const data = await useAuth.QueryBiliAuthGetAPI<ResponsePointHisrotyModel[]>(POINT_API_URL + 'user/get-histories')
if (data.code == 200) {
console.log('[point] 已获取积分历史')
return data.data
} else {
message.error('获取积分历史失败: ' + data.message)
console.error(data)
}
} catch (err) {
message.error('获取积分历史失败: ' + err)
console.error(err)
}
return []
}
onMounted(async () => {
history.value = await getHistories()
})
</script>
<template>
<PointHistoryCard :histories="history" />
</template>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
NButton,
NCard,
NDataTable,
NListItem,
NTabPane,
NTabs,
NLayout,
NLayoutContent,
NText,
useMessage,
NLayoutHeader,
NFlex,
NDescriptions,
NDescriptionsItem,
NResult,
NSpin,
NDivider,
NTag,
NList,
} from 'naive-ui'
import { computed, h, onMounted, ref } from 'vue'
import { useAuthStore } from '@/store/useAuthStore'
import { UserInfo } from '@/api/api-models'
import { POINT_API_URL } from '@/data/constants'
import PointUserHistoryView from './PointUserHistoryView.vue'
import PointUserSettings from './PointUserSettings.vue'
import { useRouteHash } from '@vueuse/router'
import PointOrderView from './PointOrderView.vue'
import { useRoute } from 'vue-router'
const useAuth = useAuthStore()
const message = useMessage()
const realHash = useRouteHash('points', {
mode: 'replace',
})
const hash = computed({
get() {
return realHash.value?.startsWith('#') ? realHash.value.slice(1) : realHash.value
},
set(val) {
realHash.value = '#' + val
},
})
const biliAuth = computed(() => useAuth.biliAuth)
const isLoading = ref(false)
const points = ref<{ owner: UserInfo; points: number }[]>([])
const pointColumn = [
{
title: '所属用户',
key: 'owner.name',
},
{
title: '积分',
key: 'points',
},
{
title: '详情',
key: 'action',
render: (row: { owner: UserInfo; points: number }) => {
return h(NButton, {
onClick: () => {},
})
},
},
]
async function getAllPoints() {
isLoading.value = true
try {
const data = await useAuth.QueryBiliAuthGetAPI<{ owner: UserInfo; points: number }[]>(
POINT_API_URL + 'user/get-all-point',
)
if (data.code == 200) {
console.log('[point] 已获取积分')
return data.data
}
} catch (err) {
console.error(err)
message.error('获取积分失败: ' + err)
} finally {
isLoading.value = false
}
return []
}
function switchAuth(token: string) {
if (token == useAuth.biliToken) {
message.info('当前正在使用该账号')
return
}
useAuth.setCurrentAuth(token)
message.success('已选择账号')
}
onMounted(async () => {
const route = useRoute()
if (route.query.auth) {
useAuth.biliToken = route.query.auth as string
}
if (biliAuth.value?.id < 0) {
isLoading.value = true
await useAuth.getAuthInfo()
isLoading.value = false
}
if (biliAuth.value.id >= 0) {
points.value = await getAllPoints()
}
})
</script>
<template>
<NLayout>
<NSpin v-if="!biliAuth.id && useAuth.isLoading" :show="useAuth.isLoading" />
<NLayoutContent v-else-if="!useAuth.biliToken && useAuth.biliTokens.length > 0" style="height: 100vh">
<NCard title="选择B站账号" embedded>
<NList clickable bordered>
<NListItem v-for="item in useAuth.biliTokens" :key="item.token" @click="switchAuth(item.token)">
<NFlex align="center"> {{ item.name }} - {{ item.uId }} </NFlex>
</NListItem>
</NList>
</NCard>
</NLayoutContent>
<NLayoutContent v-else-if="!biliAuth.id" style="height: 100vh">
<NResult status="error" title="你还未进行过B站账户验证" description="请先进行认证" style="padding-top: 64px">
<template #footer>
<NButton type="primary" @click="$router.push({ name: 'bili-auth' })">去认证</NButton>
</template>
</NResult>
</NLayoutContent>
<template v-else>
<NLayoutHeader style="padding: 10px" bordered>
<NFlex justify="center">
<NText style="font-size: 24px"> 认证用户个人中心 </NText>
</NFlex>
</NLayoutHeader>
<NLayoutContent content-style="padding: 24px;">
<NFlex align="center" justify="center">
<div style="max-width: 95vw; width: 900px">
<NCard title="我的信息">
<NDescriptions label-placement="left" bordered size="small">
<NDescriptionsItem label="OpenId">
{{ biliAuth.openId }}
</NDescriptionsItem>
<NDescriptionsItem label="UserId">
{{ biliAuth.userId }}
</NDescriptionsItem>
</NDescriptions>
</NCard>
<NDivider />
<NTabs v-if="hash" v-model:value="hash" default-value="points" animated>
<NTabPane name="points" tab="我的积分" display-directive="show:lazy">
<NDivider style="margin-top: 10px" />
<NFlex justify="center">
<NDataTable
:loading="isLoading"
:columns="pointColumn"
:data="points"
:pagination="{ defaultPageSize: 10, showSizePicker: true, pageSizes: [10, 25, 50, 100] }"
size="small"
style="max-width: 600px"
/>
</NFlex>
</NTabPane>
<NTabPane name="orders" tab="我的订单" display-directive="show:lazy">
<NDivider style="margin-top: 10px" />
<PointOrderView />
</NTabPane>
<NTabPane name="histories" tab="积分记录" display-directive="show:lazy">
<NDivider style="margin-top: 10px" />
<PointUserHistoryView />
</NTabPane>
<NTabPane name="settings" tab="设置" display-directive="show:lazy">
<NDivider style="margin-top: 10px" />
<PointUserSettings />
</NTabPane>
</NTabs>
</div>
</NFlex>
</NLayoutContent>
</template>
</NLayout>
</template>

View File

@@ -0,0 +1,367 @@
<script setup lang="ts">
import { AddressInfo } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import { POINT_API_URL, THINGS_URL } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import { useStorage } from '@vueuse/core'
import {
FormRules,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDivider,
NFlex,
NForm,
NFormItem,
NInput,
NInputNumber,
NLayoutContent,
NList,
NListItem,
NModal,
NPopconfirm,
NScrollbar,
NSelect,
NSpace,
NSpin,
NTag,
NText,
NTimeline,
NTimelineItem,
SelectOption,
useMessage,
} from 'naive-ui'
import { computed, h, ref } from 'vue'
//@ts-ignore
import UserAgreement from '@/document/UserAgreement.md'
import AddressDisplay from '@/components/manage/AddressDisplay.vue'
type AreaData = {
[province: string]: {
[city: string]: {
[district: string]: string[]
}
}
}
const useAuth = useAuthStore()
const message = useMessage()
const isLoading = ref(false)
const userAgree = ref(false)
const areas = useStorage<{
createAt: number
data: AreaData
}>('Data.Areas', {
createAt: 0,
data: {},
})
const provinceOptions = computed(() => {
return Object.keys(areas.value?.data ?? {}).map((p) => ({ label: p, value: p }))
})
const cityOptions = (province: string) => {
if (!areas.value?.data[province]) return []
return Object.keys(areas.value?.data[province] ?? {}).map((c) => ({ label: c, value: c }))
}
const districtOptions = (province: string, city: string) => {
if (!areas.value?.data[province]?.[city]) return []
return Object.keys(areas.value?.data[province][city] ?? {}).map((d) => ({ label: d, value: d }))
}
const streetOptions = (province: string, city: string, district: string) => {
if (!areas.value?.data[province]?.[city]?.[district]) return []
return areas.value?.data[province][city][district]?.map((s) => ({ label: s, value: s })) ?? []
}
const rules: FormRules = {
phone: {
required: true,
message: '请输入手机号',
},
address: {
required: true,
message: '请输入详细地址',
},
name: {
required: true,
message: '请输入收件人姓名',
},
area: {
required: true,
message: '请选择地区',
validator: () => {
if (currentAddress.value?.province && currentAddress.value?.city && currentAddress.value?.district) {
return true
}
return false
},
},
agreement: {
required: true,
message: '请阅读并同意用户协议',
validator: () => {
return userAgree.value
},
},
}
const formRef = ref()
const biliAuth = computed(() => useAuth.biliAuth)
const currentAddress = ref<AddressInfo>()
const showAddressModal = ref(false)
const showAgreementModal = ref(false)
async function updateAddress() {
formRef.value
?.validate()
.then(async () => {
isLoading.value = true
try {
const data = await useAuth.QueryBiliAuthPostAPI<AddressInfo>(
POINT_API_URL + 'user/update-address',
currentAddress.value,
)
if (data.code == 200) {
message.success('已保存')
showAddressModal.value = false
currentAddress.value = {} as AddressInfo
if (biliAuth.value.address) {
const index = biliAuth.value.address?.findIndex((a) => a.id == data.data.id) ?? -1
if (index >= 0) {
biliAuth.value.address[index] = data.data
} else {
biliAuth.value.address.push(data.data)
}
}
} else {
message.error('更新地址失败: ' + data.message)
console.error(data)
}
} catch (err) {
message.error('更新地址失败: ' + err)
console.error(err)
}
})
.catch(() => {
message.error('信息未填写完成')
})
.finally(() => {
isLoading.value = false
})
}
async function deleteAddress(id: string) {
isLoading.value = true
try {
const data = await useAuth.QueryBiliAuthGetAPI(POINT_API_URL + 'user/del-address', { id })
if (data.code == 200) {
message.success('已删除')
if (biliAuth.value.address) {
biliAuth.value.address = biliAuth.value.address?.filter((a) => a.id != id)
}
} else {
message.error('删除地址失败: ' + data.message)
console.error(data)
}
} catch (err) {
message.error('删除地址失败: ' + err)
console.error(err)
} finally {
isLoading.value = false
}
}
async function getArea() {
if (areas.value && Date.now() - areas.value?.createAt < 1000 * 60 * 60 * 24 * 7) {
return
}
try {
isLoading.value = true
const data = await fetch(THINGS_URL + 'area_data.json')
if (data.ok) {
const area = {
createAt: Date.now(),
data: await data.json(),
}
console.log(area)
areas.value = area
}
} catch (err) {
console.error(err)
message.error('获取区域数据失败')
}
isLoading.value = false
}
async function onOpenAddressModal() {
showAddressModal.value = true
currentAddress.value = {} as AddressInfo
await getArea()
}
function onAreaSelectChange(level: number) {
if (!currentAddress.value) return
const newValue = {} as AddressInfo
switch (level) {
case 0: {
// @ts-ignore
currentAddress.value.city = undefined
// @ts-ignore
currentAddress.value.district = undefined
// @ts-ignore
currentAddress.value.street = undefined
}
case 1: {
// @ts-ignore
currentAddress.value.district = undefined
// @ts-ignore
currentAddress.value.street = undefined
}
case 2: {
// @ts-ignore
currentAddress.value.street = undefined
}
}
}
function switchAuth(token: string) {
if (token == useAuth.biliToken) {
message.info('当前正在使用该账号')
return
}
useAuth.setCurrentAuth(token)
message.success('已切换账号')
}
</script>
<template>
<NSpin :show="useAuth.isLoading">
<NFlex justify="center" align="center">
<NCard title="更多" embedded>
<NCollapse>
<NCollapseItem title="收货地址" name="1">
<NFlex vertical>
<NButton @click="onOpenAddressModal" type="primary"> 添加地址 </NButton>
<NList size="small" bordered>
<NListItem v-for="address in biliAuth.address" :key="address.id">
<AddressDisplay :address="address">
<template #actions>
<NButton
size="small"
@click="
() => {
currentAddress = address
showAddressModal = true
}
"
type="info"
>
修改
</NButton>
<NPopconfirm @positive-click="() => deleteAddress(address?.id ?? '')">
<template #trigger>
<NButton size="small" type="error"> 删除 </NButton>
</template>
确定要删除这个收货信息吗?
</NPopconfirm>
</template>
</AddressDisplay>
</NListItem>
</NList>
</NFlex>
</NCollapseItem>
</NCollapse>
</NCard>
<NCard title="账号操作" embedded>
<NDivider> 切换账号 </NDivider>
<NList clickable bordered>
<NListItem v-for="item in useAuth.biliTokens" :key="item.token" @click="switchAuth(item.token)">
<NFlex align="center">
<NTag v-if="useAuth.biliToken == item.token" type="info"> 当前账号 </NTag>
{{ item.uId }}
</NFlex>
</NListItem>
</NList>
</NCard>
</NFlex>
</NSpin>
<NModal
v-model:show="showAddressModal"
preset="card"
style="width: 800px; max-width: 90vw; height: auto"
title="添加/更新地址"
>
<NSpin v-if="currentAddress" :show="isLoading">
<NForm ref="formRef" :model="currentAddress" :rules="rules">
<NFormItem label="地址" path="area" required>
<NFlex style="width: 100%">
<NSelect
v-model:value="currentAddress.province"
:options="provinceOptions"
@update:value="onAreaSelectChange(0)"
placeholder="请选择省"
style="width: 100px"
filterable
/>
<NSelect
v-model:value="currentAddress.city"
:key="currentAddress.province"
:options="cityOptions(currentAddress.province)"
:disabled="!currentAddress?.province"
@update:value="onAreaSelectChange(1)"
placeholder="请选择市"
style="width: 100px"
filterable
/>
<NSelect
v-model:value="currentAddress.district"
:key="currentAddress.city"
:options="districtOptions(currentAddress.province, currentAddress.city)"
:disabled="!currentAddress?.city"
@update:value="onAreaSelectChange(2)"
placeholder="请选择区"
style="width: 100px"
filterable
/>
<NSelect
v-model:value="currentAddress.street"
:key="currentAddress.district"
:options="streetOptions(currentAddress.province, currentAddress.city, currentAddress.district)"
:disabled="!currentAddress?.district"
placeholder="请选择街道"
style="width: 150px"
filterable
/>
</NFlex>
</NFormItem>
<NFormItem label="详细地址" path="address" required>
<NInput v-model:value="currentAddress.address" placeholder="详细地址" type="textarea" />
</NFormItem>
<NFormItem label="联系电话" path="phone" required>
<NInputNumber
v-model:value="currentAddress.phone"
placeholder="联系电话"
:show-button="false"
style="width: 200px"
/>
</NFormItem>
<NFormItem label="联系人" path="name" required>
<NInput v-model:value="currentAddress.name" placeholder="联系人" style="max-width: 150px" />
</NFormItem>
<NFormItem label="用户协议" required>
<NCheckbox v-model:checked="userAgree">
阅读并同意本站
<NButton text @click="showAgreementModal = true" type="info"> 用户协议 </NButton>
</NCheckbox>
</NFormItem>
<NButton @click="updateAddress" type="info" :loading="isLoading"> 保存 </NButton>
</NForm>
</NSpin>
</NModal>
<NModal
v-model:show="showAgreementModal"
title="用户协议"
preset="card"
style="width: 800px; max-width: 90vw; height: 90vh"
>
<NScrollbar style="height: 80vh"> <UserAgreement /></NScrollbar>
</NModal>
</template>

View File

@@ -4,6 +4,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'
import { defineConfig } from 'vite'
import svgLoader from 'vite-svg-loader'
import Markdown from 'unplugin-vue-markdown/vite'
export default defineConfig({
plugins: [
@@ -12,9 +13,13 @@ export default defineConfig({
propsDestructure: true,
defineModel: true,
},
include: [/\.vue$/, /\.md$/],
}),
svgLoader(),
vueJsx(),
Markdown({
/* options */
}),
],
resolve: {
alias: {

412
yarn.lock
View File

@@ -699,6 +699,35 @@ __metadata:
languageName: node
linkType: hard
"@mdit-vue/plugin-component@npm:^2.0.0":
version: 2.0.0
resolution: "@mdit-vue/plugin-component@npm:2.0.0"
dependencies:
"@types/markdown-it": "npm:^13.0.7"
markdown-it: "npm:^14.0.0"
checksum: 626552eecb4ef1b69a88023f049750b84e4e61b9aaa36f985c6972ca2500c433099320c0f6ba267b6d031cd9b726547d54f771e4b2cd319b03968b2fe1b1d724
languageName: node
linkType: hard
"@mdit-vue/plugin-frontmatter@npm:^2.0.0":
version: 2.0.0
resolution: "@mdit-vue/plugin-frontmatter@npm:2.0.0"
dependencies:
"@mdit-vue/types": "npm:2.0.0"
"@types/markdown-it": "npm:^13.0.7"
gray-matter: "npm:^4.0.3"
markdown-it: "npm:^14.0.0"
checksum: 43f40992b95046c311d97b4c8f26e562e9ad191930ad7a003041077c24bec328a74ae8eebb51c32ea8138315f275de989913193fc2610f85ae044ef2473bd1c6
languageName: node
linkType: hard
"@mdit-vue/types@npm:2.0.0, @mdit-vue/types@npm:^2.0.0":
version: 2.0.0
resolution: "@mdit-vue/types@npm:2.0.0"
checksum: 5bc5104c7f29e5a298ba3d6421ebb5f8b3948864fbd8b6982d2bdb1bf16d0f0b77cef4898efcdd2783a613f8b642ceedb335b73a9c0a139c051ea6e8bece04cc
languageName: node
linkType: hard
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -762,6 +791,22 @@ __metadata:
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^5.1.0":
version: 5.1.0
resolution: "@rollup/pluginutils@npm:5.1.0"
dependencies:
"@types/estree": "npm:^1.0.0"
estree-walker: "npm:^2.0.2"
picomatch: "npm:^2.3.1"
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
checksum: c7bed15711f942d6fdd3470fef4105b73991f99a478605e13d41888963330a6f9e32be37e6ddb13f012bc7673ff5e54f06f59fd47109436c1c513986a8a7612d
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.9.1":
version: 4.9.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.9.1"
@@ -870,7 +915,7 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:*":
"@types/estree@npm:*, @types/estree@npm:^1.0.0":
version: 1.0.5
resolution: "@types/estree@npm:1.0.5"
checksum: b3b0e334288ddb407c7b3357ca67dbee75ee22db242ca7c56fe27db4e1a31989cb8af48a84dd401deb787fe10cc6b2ab1ee82dc4783be87ededbe3d53c79c70d
@@ -898,6 +943,13 @@ __metadata:
languageName: node
linkType: hard
"@types/linkify-it@npm:*":
version: 3.0.5
resolution: "@types/linkify-it@npm:3.0.5"
checksum: 696e09975991c649ba37c5585714929fdebf5c64a8bfb99910613ef838337dbbba6c608fccdfa03d6347432586ef12e139bc0e947ae6fec569096fef5cc1c550
languageName: node
linkType: hard
"@types/lodash-es@npm:^4.17.9":
version: 4.17.12
resolution: "@types/lodash-es@npm:4.17.12"
@@ -914,6 +966,23 @@ __metadata:
languageName: node
linkType: hard
"@types/markdown-it@npm:^13.0.7":
version: 13.0.7
resolution: "@types/markdown-it@npm:13.0.7"
dependencies:
"@types/linkify-it": "npm:*"
"@types/mdurl": "npm:*"
checksum: 8a0fda0eb518ca2b25fcb5da32398930729270e9095cd4f7f3e379098b9d0f9e6336974becf2f36e69bbdbdc57818fef731149988c9e98e9f3f47501fefd9d39
languageName: node
linkType: hard
"@types/mdurl@npm:*":
version: 1.0.5
resolution: "@types/mdurl@npm:1.0.5"
checksum: 8991c781eb94fb3621e48e191251a94057908fc14be60f52bdd7c48684af923ffa77559ea979450a0475f85c08f8a472f99ff9c2ca4308961b9b9d35fd7584f7
languageName: node
linkType: hard
"@types/node@npm:^20.11.5":
version: 20.11.5
resolution: "@types/node@npm:20.11.5"
@@ -1440,6 +1509,15 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.11.3":
version: 8.11.3
resolution: "acorn@npm:8.11.3"
bin:
acorn: bin/acorn
checksum: 3ff155f8812e4a746fee8ecff1f227d527c4c45655bb1fad6347c3cb58e46190598217551b1500f18542d2bbe5c87120cb6927f5a074a59166fbdd9468f0a299
languageName: node
linkType: hard
"acorn@npm:^8.9.0":
version: 8.11.2
resolution: "acorn@npm:8.11.2"
@@ -1449,6 +1527,13 @@ __metadata:
languageName: node
linkType: hard
"adler-32@npm:~1.3.0":
version: 1.3.1
resolution: "adler-32@npm:1.3.1"
checksum: c1b7185526ee1bbe0eac8ed414d5226af4cd02a0540449a72ec1a75f198c5e93352ba4d7b9327231eea31fd83c2d080d13baf16d8ed5710fb183677beb85f612
languageName: node
linkType: hard
"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0":
version: 7.1.0
resolution: "agent-base@npm:7.1.0"
@@ -1519,6 +1604,25 @@ __metadata:
languageName: node
linkType: hard
"anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
dependencies:
normalize-path: "npm:^3.0.0"
picomatch: "npm:^2.0.4"
checksum: 57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac
languageName: node
linkType: hard
"argparse@npm:^1.0.7":
version: 1.0.10
resolution: "argparse@npm:1.0.10"
dependencies:
sprintf-js: "npm:~1.0.2"
checksum: b2972c5c23c63df66bca144dbc65d180efa74f25f8fd9b7d9a0a6c88ae839db32df3d54770dcb6460cf840d232b60695d1a6b1053f599d84e73f7437087712de
languageName: node
linkType: hard
"argparse@npm:^2.0.1":
version: 2.0.1
resolution: "argparse@npm:2.0.1"
@@ -1643,6 +1747,13 @@ __metadata:
languageName: node
linkType: hard
"binary-extensions@npm:^2.0.0":
version: 2.2.0
resolution: "binary-extensions@npm:2.2.0"
checksum: d73d8b897238a2d3ffa5f59c0241870043aa7471335e89ea5e1ff48edb7c2d0bb471517a3e4c5c3f4c043615caa2717b5f80a5e61e07503d51dc85cb848e665d
languageName: node
linkType: hard
"boolbase@npm:^1.0.0":
version: 1.0.0
resolution: "boolbase@npm:1.0.0"
@@ -1669,7 +1780,7 @@ __metadata:
languageName: node
linkType: hard
"braces@npm:^3.0.2":
"braces@npm:^3.0.2, braces@npm:~3.0.2":
version: 3.0.2
resolution: "braces@npm:3.0.2"
dependencies:
@@ -1744,6 +1855,16 @@ __metadata:
languageName: node
linkType: hard
"cfb@npm:~1.2.1":
version: 1.2.2
resolution: "cfb@npm:1.2.2"
dependencies:
adler-32: "npm:~1.3.0"
crc-32: "npm:~1.2.0"
checksum: 87f6d9c3878268896ed6ca29dfe32a2aa078b12d0f21d8405c95911b74ab6296823d7312bbf5e18326d00b16cc697f587e07a17018c5edf7a1ba31dd5bc6da36
languageName: node
linkType: hard
"chalk@npm:^2.4.2":
version: 2.4.2
resolution: "chalk@npm:2.4.2"
@@ -1765,6 +1886,25 @@ __metadata:
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
dependencies:
anymatch: "npm:~3.1.2"
braces: "npm:~3.0.2"
fsevents: "npm:~2.3.2"
glob-parent: "npm:~5.1.2"
is-binary-path: "npm:~2.1.0"
is-glob: "npm:~4.0.1"
normalize-path: "npm:~3.0.0"
readdirp: "npm:~3.6.0"
dependenciesMeta:
fsevents:
optional: true
checksum: 8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462
languageName: node
linkType: hard
"chownr@npm:^2.0.0":
version: 2.0.0
resolution: "chownr@npm:2.0.0"
@@ -1779,6 +1919,13 @@ __metadata:
languageName: node
linkType: hard
"codepage@npm:~1.15.0":
version: 1.15.0
resolution: "codepage@npm:1.15.0"
checksum: 2455b482302cb784b46dea60a8ee83f0c23e794bdd979556bdb107abe681bba722af62a37f5c955ff4efd68fdb9688c3986e719b4fd536c0e06bb25bc82abea3
languageName: node
linkType: hard
"color-convert@npm:^1.9.0":
version: 1.9.3
resolution: "color-convert@npm:1.9.3"
@@ -1832,6 +1979,15 @@ __metadata:
languageName: node
linkType: hard
"crc-32@npm:~1.2.0, crc-32@npm:~1.2.1":
version: 1.2.2
resolution: "crc-32@npm:1.2.2"
bin:
crc32: bin/crc32.njs
checksum: 11dcf4a2e77ee793835d49f2c028838eae58b44f50d1ff08394a610bfd817523f105d6ae4d9b5bef0aad45510f633eb23c903e9902e4409bed1ce70cb82b9bf0
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2":
version: 7.0.3
resolution: "cross-spawn@npm:7.0.3"
@@ -2135,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"entities@npm:^4.2.0, entities@npm:^4.5.0":
"entities@npm:^4.2.0, entities@npm:^4.4.0, entities@npm:^4.5.0":
version: 4.5.0
resolution: "entities@npm:4.5.0"
checksum: 5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250
@@ -2516,6 +2672,16 @@ __metadata:
languageName: node
linkType: hard
"esprima@npm:^4.0.0":
version: 4.0.1
resolution: "esprima@npm:4.0.1"
bin:
esparse: ./bin/esparse.js
esvalidate: ./bin/esvalidate.js
checksum: ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3
languageName: node
linkType: hard
"esquery@npm:^1.4.0, esquery@npm:^1.4.2":
version: 1.5.0
resolution: "esquery@npm:1.5.0"
@@ -2569,6 +2735,15 @@ __metadata:
languageName: node
linkType: hard
"extend-shallow@npm:^2.0.1":
version: 2.0.1
resolution: "extend-shallow@npm:2.0.1"
dependencies:
is-extendable: "npm:^0.1.0"
checksum: ee1cb0a18c9faddb42d791b2d64867bd6cfd0f3affb711782eb6e894dd193e2934a7f529426aac7c8ddb31ac5d38000a00aa2caf08aa3dfc3e1c8ff6ba340bd9
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -2712,6 +2887,13 @@ __metadata:
languageName: node
linkType: hard
"frac@npm:~1.1.2":
version: 1.1.2
resolution: "frac@npm:1.1.2"
checksum: 640740eb58b590eb38c78c676955bee91cd22d854f5876241a15c49d4495fa53a84898779dcf7eca30aabfe1c1a4a705752b5f224934257c5dda55c545413ba7
languageName: node
linkType: hard
"fs-minipass@npm:^2.0.0":
version: 2.1.0
resolution: "fs-minipass@npm:2.1.0"
@@ -2811,7 +2993,7 @@ __metadata:
languageName: node
linkType: hard
"glob-parent@npm:^5.1.2":
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
dependencies:
@@ -2927,6 +3109,18 @@ __metadata:
languageName: node
linkType: hard
"gray-matter@npm:^4.0.3":
version: 4.0.3
resolution: "gray-matter@npm:4.0.3"
dependencies:
js-yaml: "npm:^3.13.1"
kind-of: "npm:^6.0.2"
section-matter: "npm:^1.0.0"
strip-bom-string: "npm:^1.0.0"
checksum: e38489906dad4f162ca01e0dcbdbed96d1a53740cef446b9bf76d80bec66fa799af07776a18077aee642346c5e1365ed95e4c91854a12bf40ba0d4fb43a625a6
languageName: node
linkType: hard
"has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2":
version: 1.0.2
resolution: "has-bigints@npm:1.0.2"
@@ -3142,6 +3336,15 @@ __metadata:
languageName: node
linkType: hard
"is-binary-path@npm:~2.1.0":
version: 2.1.0
resolution: "is-binary-path@npm:2.1.0"
dependencies:
binary-extensions: "npm:^2.0.0"
checksum: a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38
languageName: node
linkType: hard
"is-boolean-object@npm:^1.1.0":
version: 1.1.2
resolution: "is-boolean-object@npm:1.1.2"
@@ -3177,6 +3380,13 @@ __metadata:
languageName: node
linkType: hard
"is-extendable@npm:^0.1.0":
version: 0.1.1
resolution: "is-extendable@npm:0.1.1"
checksum: dd5ca3994a28e1740d1e25192e66eed128e0b2ff161a7ea348e87ae4f616554b486854de423877a2a2c171d5f7cd6e8093b91f54533bc88a59ee1c9838c43879
languageName: node
linkType: hard
"is-extglob@npm:^2.1.1":
version: 2.1.1
resolution: "is-extglob@npm:2.1.1"
@@ -3191,7 +3401,7 @@ __metadata:
languageName: node
linkType: hard
"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3":
"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1":
version: 4.0.3
resolution: "is-glob@npm:4.0.3"
dependencies:
@@ -3333,6 +3543,18 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:^3.13.1":
version: 3.14.1
resolution: "js-yaml@npm:3.14.1"
dependencies:
argparse: "npm:^1.0.7"
esprima: "npm:^4.0.0"
bin:
js-yaml: bin/js-yaml.js
checksum: 6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b
languageName: node
linkType: hard
"js-yaml@npm:^4.1.0":
version: 4.1.0
resolution: "js-yaml@npm:4.1.0"
@@ -3403,6 +3625,13 @@ __metadata:
languageName: node
linkType: hard
"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2":
version: 6.0.3
resolution: "kind-of@npm:6.0.3"
checksum: 61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4
languageName: node
linkType: hard
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -3420,6 +3649,15 @@ __metadata:
languageName: node
linkType: hard
"linkify-it@npm:^5.0.0":
version: 5.0.0
resolution: "linkify-it@npm:5.0.0"
dependencies:
uc.micro: "npm:^2.0.0"
checksum: ff4abbcdfa2003472fc3eb4b8e60905ec97718e11e33cca52059919a4c80cc0e0c2a14d23e23d8c00e5402bc5a885cdba8ca053a11483ab3cc8b3c7a52f88e2d
languageName: node
linkType: hard
"linqts@npm:^1.15.0":
version: 1.15.0
resolution: "linqts@npm:1.15.0"
@@ -3521,6 +3759,22 @@ __metadata:
languageName: node
linkType: hard
"markdown-it@npm:^14.0.0":
version: 14.0.0
resolution: "markdown-it@npm:14.0.0"
dependencies:
argparse: "npm:^2.0.1"
entities: "npm:^4.4.0"
linkify-it: "npm:^5.0.0"
mdurl: "npm:^2.0.0"
punycode.js: "npm:^2.3.1"
uc.micro: "npm:^2.0.0"
bin:
markdown-it: bin/markdown-it.mjs
checksum: aabea498a1395776b5ca2b83ce7942d75608595b09215213edf224d5f09c31dfc7bb5a4c73ed2ead9a0a38da3e5e6e2c37daae71afd227c2acb6905ae5d6b498
languageName: node
linkType: hard
"mdn-data@npm:2.0.28":
version: 2.0.28
resolution: "mdn-data@npm:2.0.28"
@@ -3535,6 +3789,13 @@ __metadata:
languageName: node
linkType: hard
"mdurl@npm:^2.0.0":
version: 2.0.0
resolution: "mdurl@npm:2.0.0"
checksum: 633db522272f75ce4788440669137c77540d74a83e9015666a9557a152c02e245b192edc20bc90ae953bbab727503994a53b236b4d9c99bdaee594d0e7dd2ce0
languageName: node
linkType: hard
"merge2@npm:^1.3.0, merge2@npm:^1.4.1":
version: 1.4.1
resolution: "merge2@npm:1.4.1"
@@ -3781,6 +4042,13 @@ __metadata:
languageName: node
linkType: hard
"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0":
version: 3.0.0
resolution: "normalize-path@npm:3.0.0"
checksum: e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046
languageName: node
linkType: hard
"nth-check@npm:^2.0.1, nth-check@npm:^2.1.1":
version: 2.1.1
resolution: "nth-check@npm:2.1.1"
@@ -3961,7 +4229,7 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^2.3.1":
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1":
version: 2.3.1
resolution: "picomatch@npm:2.3.1"
checksum: 26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be
@@ -4061,6 +4329,13 @@ __metadata:
languageName: node
linkType: hard
"punycode.js@npm:^2.3.1":
version: 2.3.1
resolution: "punycode.js@npm:2.3.1"
checksum: 1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb
languageName: node
linkType: hard
"punycode@npm:^2.1.0":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -4093,6 +4368,15 @@ __metadata:
languageName: node
linkType: hard
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
dependencies:
picomatch: "npm:^2.2.1"
checksum: 6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b
languageName: node
linkType: hard
"regenerator-runtime@npm:^0.14.0":
version: 0.14.1
resolution: "regenerator-runtime@npm:0.14.1"
@@ -4275,6 +4559,16 @@ __metadata:
languageName: node
linkType: hard
"section-matter@npm:^1.0.0":
version: 1.0.0
resolution: "section-matter@npm:1.0.0"
dependencies:
extend-shallow: "npm:^2.0.1"
kind-of: "npm:^6.0.0"
checksum: 8007f91780adc5aaa781a848eaae50b0f680bbf4043b90cf8a96778195b8fab690c87fe7a989e02394ce69890e330811ec8dab22397d384673ce59f7d750641d
languageName: node
linkType: hard
"seemly@npm:^0.3.6, seemly@npm:^0.3.8":
version: 0.3.8
resolution: "seemly@npm:0.3.8"
@@ -4408,6 +4702,22 @@ __metadata:
languageName: node
linkType: hard
"sprintf-js@npm:~1.0.2":
version: 1.0.3
resolution: "sprintf-js@npm:1.0.3"
checksum: ecadcfe4c771890140da5023d43e190b7566d9cf8b2d238600f31bec0fc653f328da4450eb04bd59a431771a8e9cc0e118f0aa3974b683a4981b4e07abc2a5bb
languageName: node
linkType: hard
"ssf@npm:~0.11.2":
version: 0.11.2
resolution: "ssf@npm:0.11.2"
dependencies:
frac: "npm:~1.1.2"
checksum: c3fd24a90dc37a9dc5c4154cb4121e27507c33ebfeee3532aaf03625756b2c006cf79c0a23db0ba16c4a6e88e1349455327867e03453fc9d54b32c546bc18ca6
languageName: node
linkType: hard
"ssri@npm:^10.0.0":
version: 10.0.5
resolution: "ssri@npm:10.0.5"
@@ -4490,6 +4800,13 @@ __metadata:
languageName: node
linkType: hard
"strip-bom-string@npm:^1.0.0":
version: 1.0.0
resolution: "strip-bom-string@npm:1.0.0"
checksum: 5c5717e2643225aa6a6d659d34176ab2657037f1fe2423ac6fcdb488f135e14fef1022030e426d8b4d0989e09adbd5c3288d5d3b9c632abeefd2358dfc512bca
languageName: node
linkType: hard
"strip-bom@npm:^3.0.0":
version: 3.0.0
resolution: "strip-bom@npm:3.0.0"
@@ -4756,6 +5073,13 @@ __metadata:
languageName: node
linkType: hard
"uc.micro@npm:^2.0.0":
version: 2.0.0
resolution: "uc.micro@npm:2.0.0"
checksum: eb3699e35120ee5764b4f1e8ee426117ac97f5474abf312fdd356213bcbeab9ef5a205805dab56afa53f5b9472b452b6ba8de305270d1eeb6730c831f922c8a4
languageName: node
linkType: hard
"unbox-primitive@npm:^1.0.2":
version: 1.0.2
resolution: "unbox-primitive@npm:1.0.2"
@@ -4793,6 +5117,35 @@ __metadata:
languageName: node
linkType: hard
"unplugin-vue-markdown@npm:^0.26.0":
version: 0.26.0
resolution: "unplugin-vue-markdown@npm:0.26.0"
dependencies:
"@mdit-vue/plugin-component": "npm:^2.0.0"
"@mdit-vue/plugin-frontmatter": "npm:^2.0.0"
"@mdit-vue/types": "npm:^2.0.0"
"@rollup/pluginutils": "npm:^5.1.0"
"@types/markdown-it": "npm:^13.0.7"
markdown-it: "npm:^14.0.0"
unplugin: "npm:^1.6.0"
peerDependencies:
vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0
checksum: 6a5290af99063b6fe7abce3adeaa704dbd7718e29fed58878288152fd094b6a3b91f76478a114e2cb933055feb03f1a6d0268bef935dddc3dcd90cfbdeac6dc2
languageName: node
linkType: hard
"unplugin@npm:^1.6.0":
version: 1.7.1
resolution: "unplugin@npm:1.7.1"
dependencies:
acorn: "npm:^8.11.3"
chokidar: "npm:^3.5.3"
webpack-sources: "npm:^3.2.3"
webpack-virtual-modules: "npm:^0.6.1"
checksum: 4e358b4d45aeab6c654943edf63c0f4ad22831386eba414065c4b535c84ec4e295cca145f263f878059ea96e19c904835af25dd5f7f46f3c4a49302e621d3cab
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.0.13":
version: 1.0.13
resolution: "update-browserslist-db@npm:1.0.13"
@@ -4952,6 +5305,7 @@ __metadata:
queue-typescript: "npm:^1.0.1"
stylus: "npm:^0.62.0"
typescript: "npm:^5.3.3"
unplugin-vue-markdown: "npm:^0.26.0"
uuid: "npm:^9.0.1"
vite: "npm:^5.0.12"
vite-svg-loader: "npm:^5.1.0"
@@ -4964,6 +5318,7 @@ __metadata:
vue3-marquee: "npm:^4.2.0-beta.1"
vueuc: "npm:^0.4.58"
worker-timers: "npm:^7.1.1"
xlsx: "npm:^0.18.5"
languageName: unknown
linkType: soft
@@ -5156,6 +5511,20 @@ __metadata:
languageName: node
linkType: hard
"webpack-sources@npm:^3.2.3":
version: 3.2.3
resolution: "webpack-sources@npm:3.2.3"
checksum: 2ef63d77c4fad39de4a6db17323d75eb92897b32674e97d76f0a1e87c003882fc038571266ad0ef581ac734cbe20952912aaa26155f1905e96ce251adbb1eb4e
languageName: node
linkType: hard
"webpack-virtual-modules@npm:^0.6.1":
version: 0.6.1
resolution: "webpack-virtual-modules@npm:0.6.1"
checksum: 696bdc1acf3806374bdeb4b9b9856b79ee70b31e92f325dfab9b8c8c7e14bb6ddffa9f895a214770c4fb8fea45a21f34ca64310f74e877292a90f4a9966c9c2f
languageName: node
linkType: hard
"which-boxed-primitive@npm:^1.0.2":
version: 1.0.2
resolution: "which-boxed-primitive@npm:1.0.2"
@@ -5204,6 +5573,20 @@ __metadata:
languageName: node
linkType: hard
"wmf@npm:~1.0.1":
version: 1.0.2
resolution: "wmf@npm:1.0.2"
checksum: 3fa5806f382632cadfe65d4ef24f7a583b0c0720171edb00e645af5248ad0bb6784e8fcee1ccd9f475a1a12a7523e2512e9c063731fbbdae14dc469e1c033d93
languageName: node
linkType: hard
"word@npm:~0.3.0":
version: 0.3.0
resolution: "word@npm:0.3.0"
checksum: c6da2a9f7a0d81a32fa6768a638d21b153da2be04f94f3964889c7cc1365d74b6ecb43b42256c3f926cd59512d8258206991c78c21000c3da96d42ff1238b840
languageName: node
linkType: hard
"worker-timers-broker@npm:^6.1.1":
version: 6.1.1
resolution: "worker-timers-broker@npm:6.1.1"
@@ -5267,6 +5650,23 @@ __metadata:
languageName: node
linkType: hard
"xlsx@npm:^0.18.5":
version: 0.18.5
resolution: "xlsx@npm:0.18.5"
dependencies:
adler-32: "npm:~1.3.0"
cfb: "npm:~1.2.1"
codepage: "npm:~1.15.0"
crc-32: "npm:~1.2.1"
ssf: "npm:~0.11.2"
wmf: "npm:~1.0.1"
word: "npm:~0.3.0"
bin:
xlsx: bin/xlsx.njs
checksum: 787cfa77034a3e86fdcde21572f1011c8976f87823a5e0ee5057f13b2f6e48f17a1710732a91b8ae15d7794945c7cba8a3ca904ea7150e028260b0ab8e1158c8
languageName: node
linkType: hard
"xml-name-validator@npm:^4.0.0":
version: 4.0.0
resolution: "xml-name-validator@npm:4.0.0"