particularly complete forum function, add point order export and user delete

This commit is contained in:
2024-03-22 01:47:55 +08:00
parent 87df8d5966
commit 932b83ddcd
52 changed files with 2806 additions and 132 deletions

View File

@@ -1,6 +1,8 @@
import { useStorage } from '@vueuse/core'
import { UploadFileInfo, createDiscreteApi, useOsTheme } from 'naive-ui'
import { ThemeType } from './api/api-models'
import { computed } from 'vue'
import { VTSURU_API_URL } from './data/constants'
const { message } = createDiscreteApi(['message'])
@@ -9,10 +11,10 @@ export function NavigateToNewTab(url: string) {
window.open(url, '_blank')
}
const themeType = useStorage('Settings.Theme', ThemeType.Auto)
export function isDarkMode(): boolean {
export const isDarkMode = computed(() => {
if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark'
else return themeType.value == ThemeType.Dark
}
})
export function copyToClipboard(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
@@ -106,6 +108,13 @@ export async function getImageUploadModel(
}
return result
}
export function getUserAvatarUrl(userId: number) {
return VTSURU_API_URL + 'user-face/' + userId
}
export function getOUIdAvatarUrl(ouid: string) {
return VTSURU_API_URL + 'face/' + ouid
}
export class GuidUtils {
// 将数字转换为GUID
public static numToGuid(value: number): string {

View File

@@ -9,7 +9,6 @@ import { useRoute } from 'vue-router'
export const ACCOUNT = ref<AccountInfo>({} as AccountInfo)
export const isLoadingAccount = ref(true)
const route = useRoute()
const { message } = createDiscreteApi(['message'])
const cookie = useLocalStorage('JWT_Token', '')
@@ -42,6 +41,7 @@ export async function GetSelfAccount() {
}
export function UpdateAccountLoop() {
setInterval(() => {
const route = useRoute()
if (ACCOUNT.value && route?.name != 'question-display') {
// 防止在问题详情页刷新
GetSelfAccount()
@@ -67,18 +67,14 @@ export async function UpdateFunctionEnable(func: FunctionTypes) {
if (ACCOUNT.value) {
const oldValue = JSON.parse(JSON.stringify(ACCOUNT.value.settings.enableFunctions))
if (ACCOUNT.value?.settings.enableFunctions.includes(func)) {
ACCOUNT.value.settings.enableFunctions = ACCOUNT.value.settings.enableFunctions.filter(
(f) => f != func,
)
ACCOUNT.value.settings.enableFunctions = ACCOUNT.value.settings.enableFunctions.filter((f) => f != func)
} else {
ACCOUNT.value.settings.enableFunctions.push(func)
}
await SaveEnableFunctions(ACCOUNT.value?.settings.enableFunctions)
.then((data) => {
if (data.code == 200) {
message.success(
`${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}`,
)
message.success(`${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}`)
} else {
if (ACCOUNT.value) {
ACCOUNT.value.settings.enableFunctions = oldValue
@@ -89,9 +85,7 @@ export async function UpdateFunctionEnable(func: FunctionTypes) {
}
})
.catch((err) => {
message.error(
`${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${err}`,
)
message.error(`${ACCOUNT.value?.settings.enableFunctions.includes(func) ? '启用' : '禁用'}失败: ${err}`)
})
}
}

View File

@@ -3,12 +3,11 @@ export interface APIRoot<T> {
message: string
data: T
}
export interface PaginationResponse<T> {
export interface PaginationResponse<T> extends APIRoot<T> {
total: number
index: number
size: number
hasMore: boolean
datas: T
pn: number
ps: number
more: boolean
}
export enum IndexTypes {
Default,
@@ -661,7 +660,9 @@ export interface ResponsePointOrder2OwnerModel {
customer: BiliAuthModel
address?: AddressInfo
goodsId: number
count: number
createAt: number
updateAt: number
status: PointOrderStatus
trackingNumber?: string
@@ -692,6 +693,7 @@ export interface ResponsePointHisrotyModel {
type: EventDataTypes
from: PointFrom
createAt: number
count: number
extra?: any
}

122
src/api/models/forum.ts Normal file
View File

@@ -0,0 +1,122 @@
import { UserBasicInfo } from '../api-models'
export enum ForumTopicSortTypes {
Time,
Comment,
Like,
}
export enum ForumCommentSortTypes {
Time,
Like,
}
export enum ForumUserLevels {
Guest,
User,
Member,
AuthedMember,
Admin,
}
export interface ForumSetting {
allowedViewerLevel: ForumUserLevels // Assuming the default value is handled elsewhere
allowPost: boolean // Assuming the default value is handled elsewhere
allowedPostLevel: ForumUserLevels // Assuming the default value is handled elsewhere
requireApply: boolean // Assuming the default value is handled elsewhere
requireAuthedToJoin: boolean // Assuming the default value is handled elsewhere
sendTopicDelay: number // Assuming the default value is handled elsewhere
}
export interface ForumUserModel extends UserBasicInfo {
isAdmin: boolean
}
export type ForumModel = {
id: number
name: string
owner: ForumUserModel
description: string
topicCount: number
settings: ForumSetting
admins: ForumUserModel[]
members: ForumUserModel[]
applying: ForumUserModel[]
blackList: ForumUserModel[]
level: ForumUserLevels
isApplied: boolean
sections: ForumSectionModel[]
createAt: number
isAdmin: boolean
}
export type ForumSectionModel = {
id: number
name: string
description: string
createAt: number
}
export enum ForumTopicTypes {
Default,
Vote,
}
export type ForumTopicSetting = {
canReply?: boolean
}
export interface ForumTopicBaseModel {
id: number // Primary and identity fields in C# typically correspond to required fields in TS
user: UserBasicInfo
section: ForumSectionModel
title: string
content: string
latestRepliedBy?: UserBasicInfo
repliedAt?: number
likeCount: number // Assuming the default value is handled elsewhere
commentCount: number // Assuming the default value is handled elsewhere
viewCount: number // Assuming the default value is handled elsewhere
sampleLikedBy: number[]
createAt: Date // DateTime in C# translates to Date in TS
editAt?: Date | null // Nullable DateTime in C# is optional or null in TS
isLiked: boolean
isLocked?: boolean // Assuming the default value is handled elsewhere
isPinned?: boolean // Assuming the default value is handled elsewhere
isHighlighted?: boolean // Assuming the default value is handled elsewhere
}
export interface ForumTopicModel extends ForumTopicBaseModel {
isLocked?: boolean // Assuming the default value is handled elsewhere
isDeleted?: boolean // Assuming the default value is handled elsewhere
isHidden?: boolean // Assuming the default value is handled elsewhere
type?: ForumTopicTypes // Assuming the default value is handled elsewhere
extraTypeId?: number | null // Nullable int in C# is optional or null in TS
likedBy?: number[] // Assuming the default value is handled elsewhere
}
export interface ForumCommentModel {
id: number
user: UserBasicInfo
content: string
replies: ForumReplyModel[]
sendAt: Date
likeCount: number
isLiked: boolean
}
export interface ForumReplyModel {
id: number
user: UserBasicInfo
content: string
replyTo?: number
sendAt: Date
}
export interface ForumPostTopicModel {
section?: number
title: string
content: string
owner: number
type?: ForumTopicTypes
}

View File

@@ -19,23 +19,34 @@ export async function QueryPostAPIWithParams<T>(
contentType?: string,
headers?: [string, string][],
): Promise<APIRoot<T>> {
return await QueryPostAPIWithParamsInternal<APIRoot<T>>(urlString, params, body, contentType, headers)
}
async function QueryPostAPIWithParamsInternal<T>(
urlString: string,
params?: any,
body?: any,
contentType: string = 'application/json',
headers: [string, string][] = [],
) {
const url = new URL(urlString)
url.search = getParams(params)
headers ??= []
if (cookie.value) headers?.push(['Authorization', `Bearer ${cookie.value}`])
if (contentType) headers?.push(['Content-Type', contentType])
return await QueryAPIInternal<T>(url, {
method: 'post',
headers: headers,
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
async function QueryAPIInternal<T>(url: URL, init: RequestInit) {
try {
const data = await fetch(url, {
method: 'post',
headers: headers,
body: typeof body === 'string' ? body : JSON.stringify(body),
})
const result = (await data.json()) as APIRoot<T>
const data = await fetch(url, init)
const result = (await data.json()) as T
return result
} catch (e) {
console.error(`[POST] API调用失败: ${e}`)
console.error(`[${init.method}] API调用失败: ${e}`)
if (!apiFail.value) {
apiFail.value = true
console.log('默认API异常, 切换至故障转移节点')
@@ -48,30 +59,34 @@ export async function QueryGetAPI<T>(
params?: any,
headers?: [string, string][],
): Promise<APIRoot<T>> {
return await QueryGetAPIInternal<APIRoot<T>>(urlString, params, headers)
}
async function QueryGetAPIInternal<T>(urlString: string, params?: any, headers?: [string, string][]) {
const url = new URL(urlString)
url.search = getParams(params)
if (cookie.value) {
headers ??= []
if (cookie.value) headers?.push(['Authorization', `Bearer ${cookie.value}`])
}
try {
const data = await fetch(url.toString(), {
method: 'get',
headers: headers,
})
const result = (await data.json()) as APIRoot<T>
return result
} catch (e) {
console.error(`[GET] API调用失败: ${e}`)
if (!apiFail.value) {
apiFail.value = true
console.log('默认API异常, 切换至故障转移节点')
}
throw e
}
return await QueryAPIInternal<T>(url, {
method: 'get',
headers: headers,
})
}
function getParams(params?: [string, string][]) {
function getParams(params: any) {
const urlParams = new URLSearchParams(window.location.search)
if (params) {
const keys = Object.keys(params)
if (keys.length > 0) {
keys.forEach((k) => {
if (params[k] == undefined) {
delete params[k]
}
})
}
}
const resultParams = new URLSearchParams(params)
if (urlParams.has('as')) {
resultParams.set('as', urlParams.get('as') || '')
@@ -81,12 +96,12 @@ function getParams(params?: [string, string][]) {
}
return resultParams.toString()
}
export async function QueryPostPaginationAPI<T>(url: string, body?: unknown): Promise<APIRoot<PaginationResponse<T>>> {
return await QueryPostAPI<PaginationResponse<T>>(url, body)
export async function QueryPostPaginationAPI<T>(url: string, body?: unknown): Promise<PaginationResponse<T>> {
return await QueryPostAPIWithParamsInternal<PaginationResponse<T>>(url, undefined, body)
}
export async function QueryGetPaginationAPI<T>(
urlString: string,
params?: unknown,
): Promise<APIRoot<PaginationResponse<T>>> {
return await QueryGetAPI<PaginationResponse<T>>(urlString, params)
export async function QueryGetPaginationAPI<T>(urlString: string, params?: unknown): Promise<PaginationResponse<T>> {
return await QueryGetAPIInternal<PaginationResponse<T>>(urlString, params)
}
export function GetHeaders(): [string, string][] {
return [['Authorization', `Bearer ${cookie.value}`]]
}

View File

@@ -1,9 +1,8 @@
import { QueryGetAPI } from '@/api/query'
import { BASE_API, USER_API_URL, apiFail } from '@/data/constants'
import { APIRoot, UserInfo } from './api-models'
import { USER_API_URL, apiFail } from '@/data/constants'
import { ref } from 'vue'
import { useRouteParams } from '@vueuse/router'
import { useRoute } from 'vue-router'
import { APIRoot, UserInfo } from './api-models'
export const USERS = ref<{ [id: string]: UserInfo }>({})

View File

@@ -0,0 +1,63 @@
.dark-theme {
--w-e-toolbar-active-bg-color: #2c2c2c;
transition: all 1s;
}
.dark-theme {
background-color: #333;
color: #ccc;
}
.dark-theme .w-e-text-container {
background: #333;
color: #ccc;
}
.dark-theme .w-e-bar-item-menus-container{
background: #333;
color: #ccc;
}
.dark-theme .w-e-toolbar {
background: #444;
border-bottom: 1px solid #555;
}
.dark-theme .w-e-menu {
color: #fff;
/* 更明亮的文字颜色 */
}
.dark-theme .w-e-bar-item button {
color: #fff!important;
/* 更明亮的图标颜色 */
}
.dark-theme .w-e-bar svg {
fill: #fff;
}
.dark-theme .w-e-icon {
color: #fff;
/* 更明亮的图标颜色 */
}
.dark-theme .w-e-menu.active {
color: #409EFF;
/* 菜单选中时的颜色 */
}
.dark-theme .w-e-menu-text {
color: #fff;
/* 更明亮的菜单文字颜色 */
}
.dark-theme .w-e-bar{
background-color: #ffffff00;
}
.dark-theme .w-e-droplist.w-e-list {
background-color: #444;
}
.dark-theme .w-e-list,
.dark-theme .w-e-list li a {
color: #ccc;
}
.dark-theme .w-e-text a {
color: #5dbeff;
}

View File

@@ -0,0 +1,53 @@
.editor-content-view {
border-radius: 5px;
overflow-x: auto;
}
.editor-content-view p,
.editor-content-view li {
white-space: pre-wrap;
/* 保留空格 */
}
.editor-content-view blockquote {
border-left: 8px solid #d0e5f2;
padding: 10px 10px;
margin: 10px 0;
background-color: #f1f1f1;
}
.editor-content-view code {
font-family: monospace;
background-color: #b6b6b679;
padding: 3px;
border-radius: 3px;
}
.editor-content-view pre > code {
display: block;
padding: 10px;
}
.editor-content-view table {
border-collapse: collapse;
}
.editor-content-view td,
.editor-content-view th {
border: 1px solid #ccc;
min-width: 50px;
height: 20px;
}
.editor-content-view th {
background-color: #f1f1f1;
}
.editor-content-view ul,
.editor-content-view ol {
padding-left: 20px;
}
.editor-content-view input[type='checkbox'] {
margin-right: 5px;
}

View File

@@ -87,7 +87,7 @@ watch(
</span>
<span v-else>
已直播:
{{ ((Date.now() - (live.stopAt ?? 0)) / (3600 * 1000)).toFixed(1) }}
{{ ((Date.now() - (live.startAt ?? 0)) / (3600 * 1000)).toFixed(1) }}
</span>
</NPopover>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { TURNSTILE_KEY } from '@/data/constants'
import { onUnmounted, ref } from 'vue'
import { onMounted } from 'vue'
import VueTurnstile from 'vue-turnstile'
const turnstile = ref()
const token = defineModel<string>('token', {
default: '',
})
onUnmounted(() => {
turnstile.value?.remove()
})
defineExpose({
reset,
})
function reset() {
turnstile.value?.reset()
}
</script>
<template>
<VueTurnstile ref="turnstile" :site-key="TURNSTILE_KEY" v-model="token" theme="auto" style="text-align: center" />
</template>

119
src/components/VEditor.vue Normal file
View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { isDarkMode } from '@/Utils'
import { APIRoot } from '@/api/api-models'
import { GetHeaders } from '@/api/query'
import '@/assets/editorDarkMode.css'
import { BASE_URL, VTSURU_API_URL } from '@/data/constants'
import { DomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { NotificationReactive, useMessage } from 'naive-ui'
import { onBeforeUnmount, ref, shallowRef, onMounted } from 'vue'
type InsertFnType = (url: string, alt: string, href: string) => void
const props = defineProps({
defaultValue: {
type: String,
default: '',
},
mode: {
type: String,
default: 'default',
},
maxLength: {
type: Number,
default: 10000,
},
})
const message = useMessage()
const editorRef = shallowRef()
const toolbar = DomEditor.getToolbar(editorRef.value)
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video', 'group-lineHeight', 'insertImage', 'fullScreen'],
}
const uploadProgressRef = ref<NotificationReactive>()
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
maxLength: props.maxLength,
MENU_CONF: {
uploadImage: {
maxFileSize: 10 * 1024 * 1024,
maxNumberOfFiles: 10,
async customUpload(file: File, insertFn: InsertFnType) {
const formData = new FormData() //创建一个FormData实例。
message.info('图片上传中')
formData.append('file', file)
const resp = await fetch(VTSURU_API_URL + 'image/upload', {
method: 'POST',
body: formData,
headers: GetHeaders(),
})
if (resp.ok) {
const data = (await resp.json()) as APIRoot<string>
if (data.code == 200) {
insertFn(data.data, '', '')
} else {
message.error('图片上传失败: ' + data.message)
}
} else {
message.error('图片上传失败: ' + resp.statusText)
}
},
onProgress(progress: number) {
console.log(progress)
},
onSuccess(file: File, res: any) {
console.log(`${file.name} 上传成功`, res)
message.success('图片上传成功')
},
},
},
}
const value = defineModel<string>('value')
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
onMounted(() => {
//editorRef.value?.setHtml(props.defaultValue)
})
function handleCreated(editor: unknown) {
editorRef.value = editor // 记录 editor 实例,重要!
}
function getText() {
return editorRef.value?.getText()
}
function getHtml() {
return editorRef.value?.getText()
}
defineExpose({
getText,
getHtml,
})
</script>
<template>
<div :class="{ 'dark-theme': isDarkMode }" style="border: 1px solid #ccc">
<Toolbar
ref="toolbarRef"
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="height: 500px; overflow-y: hidden"
v-model="value"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div>
</template>

View File

@@ -119,7 +119,7 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
h(
NTag,
{ type: 'warning', size: 'tiny', style: { margin: '0' }, bordered: false },
() => (row.extra?.danmaku.num ?? 1) + '个',
() => (row.count ?? 1) + '个',
),
])
case EventDataTypes.SC:

View File

@@ -98,6 +98,17 @@ const orderColumn: DataTableColumns<ResponsePointOrder2UserModel | ResponsePoint
title: '订单号',
key: 'id',
},
{
title: '礼物名',
key: 'giftName',
render: (row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.instanceOf == 'user' ? row.goods.name : props.goods?.find((g) => g.id == row.goodsId)?.name
},
},
{
title: '数量',
key: 'count',
},
{
title: '时间',
key: 'time',
@@ -118,9 +129,12 @@ const orderColumn: DataTableColumns<ResponsePointOrder2UserModel | ResponsePoint
{
title: '订单状态',
key: 'status',
filter: (filterOptionValue: unknown, row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.status == filterOptionValue
},
filter:
props.type == 'owner'
? undefined
: (filterOptionValue: unknown, row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.status == filterOptionValue
},
filterOptions: [
{
label: '等待发货',
@@ -151,9 +165,12 @@ const orderColumn: DataTableColumns<ResponsePointOrder2UserModel | ResponsePoint
{
title: '订单类型',
key: 'type',
filter: (filterOptionValue: unknown, row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.type == filterOptionValue
},
filter:
props.type == 'owner'
? undefined
: (filterOptionValue: unknown, row: ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel) => {
return row.type == filterOptionValue
},
filterOptions: [
{
label: '实体礼物',
@@ -360,7 +377,10 @@ onMounted(() => {
></iframe>
</template>
</template>
<template v-else-if="orderDetail.instanceOf == 'owner'">
<template v-else-if="orderDetail.instanceOf == 'owner'"
><NFlex justify="center">
<PointGoodsItem style="max-width: 300px" :goods="currentGoods" />
</NFlex>
<NDivider> 设置订单状态 </NDivider>
<NFlex justify="center" style="width: 100%">
<NSteps

View File

@@ -13,9 +13,11 @@ 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 = {
toString: () =>
(process.env.NODE_ENV === 'development' ? debugAPI : apiFail.value ? failoverAPI : releseAPI) + 'api/',
export const BASE_URL = {
toString: () => (process.env.NODE_ENV === 'development' ? debugAPI : apiFail.value ? failoverAPI : releseAPI),
}
export const BASE_API_URL = {
toString: () => BASE_URL + 'api/',
}
export const FETCH_API = 'https://fetch.vtsuru.live/'
export const BASE_HUB_URL = {
@@ -25,26 +27,27 @@ export const BASE_HUB_URL = {
export const TURNSTILE_KEY = '0x4AAAAAAAETUSAKbds019h0'
export const USER_API_URL = { toString: () => `${BASE_API}user/` }
export const ACCOUNT_API_URL = { toString: () => `${BASE_API}account/` }
export const BILI_API_URL = { toString: () => `${BASE_API}bili/` }
export const SONG_API_URL = { toString: () => `${BASE_API}song-list/` }
export const NOTIFACTION_API_URL = { toString: () => `${BASE_API}notifaction/` }
export const QUESTION_API_URL = { toString: () => `${BASE_API}qa/` }
export const LOTTERY_API_URL = { toString: () => `${BASE_API}lottery/` }
export const HISTORY_API_URL = { toString: () => `${BASE_API}history/` }
export const SCHEDULE_API_URL = { toString: () => `${BASE_API}schedule/` }
export const VIDEO_COLLECT_API_URL = { toString: () => `${BASE_API}video-collect/` }
export const OPEN_LIVE_API_URL = { toString: () => `${BASE_API}open-live/` }
export const SONG_REQUEST_API_URL = { toString: () => `${BASE_API}live-request/` }
export const QUEUE_API_URL = { toString: () => `${BASE_API}queue/` }
export const EVENT_API_URL = { toString: () => `${BASE_API}event/` }
export const LIVE_API_URL = { toString: () => `${BASE_API}live/` }
export const FEEDBACK_API_URL = { toString: () => `${BASE_API}feedback/` }
export const MUSIC_REQUEST_API_URL = { toString: () => `${BASE_API}music-request/` }
export const VTSURU_API_URL = { toString: () => `${BASE_API}vtsuru/` }
export const POINT_API_URL = { toString: () => `${BASE_API}point/` }
export const BILI_AUTH_API_URL = { toString: () => `${BASE_API}bili-auth/` }
export const USER_API_URL = { toString: () => `${BASE_API_URL}user/` }
export const ACCOUNT_API_URL = { toString: () => `${BASE_API_URL}account/` }
export const BILI_API_URL = { toString: () => `${BASE_API_URL}bili/` }
export const SONG_API_URL = { toString: () => `${BASE_API_URL}song-list/` }
export const NOTIFACTION_API_URL = { toString: () => `${BASE_API_URL}notifaction/` }
export const QUESTION_API_URL = { toString: () => `${BASE_API_URL}qa/` }
export const LOTTERY_API_URL = { toString: () => `${BASE_API_URL}lottery/` }
export const HISTORY_API_URL = { toString: () => `${BASE_API_URL}history/` }
export const SCHEDULE_API_URL = { toString: () => `${BASE_API_URL}schedule/` }
export const VIDEO_COLLECT_API_URL = { toString: () => `${BASE_API_URL}video-collect/` }
export const OPEN_LIVE_API_URL = { toString: () => `${BASE_API_URL}open-live/` }
export const SONG_REQUEST_API_URL = { toString: () => `${BASE_API_URL}live-request/` }
export const QUEUE_API_URL = { toString: () => `${BASE_API_URL}queue/` }
export const EVENT_API_URL = { toString: () => `${BASE_API_URL}event/` }
export const LIVE_API_URL = { toString: () => `${BASE_API_URL}live/` }
export const FEEDBACK_API_URL = { toString: () => `${BASE_API_URL}feedback/` }
export const MUSIC_REQUEST_API_URL = { toString: () => `${BASE_API_URL}music-request/` }
export const VTSURU_API_URL = { toString: () => `${BASE_API_URL}vtsuru/` }
export const POINT_API_URL = { toString: () => `${BASE_API_URL}point/` }
export const BILI_AUTH_API_URL = { toString: () => `${BASE_API_URL}bili-auth/` }
export const FORUM_API_URL = { toString: () => `${BASE_API_URL}forum/` }
export const ScheduleTemplateMap = {
'': {

View File

@@ -0,0 +1,24 @@
以下是您需要遵守的规则和条件,只有在您同意并接受所有条款后,您才能获准在本站开通粉丝讨论区功能。
1. **用户行为规则**
用户必须過適當和尊重的言語交换观点和想法。禁止在讨论区发布任何色情、恶心、仇恨、歧视、威胁或骚扰的内容。此外,禁止发布任何可能侵犯他人知识产权、隐私权或其他权利的内容。
2. **内容所有权**
发布在讨论区中的所有内容,包括文字、图片、视频等,版权均属于原作者。用户需保证拥有发布内容的全部权利或已取得必要许可。
3. **隐私政策**
我们维护所有注册用户的隐私,将严格遵守我们的隐私政策。严禁未经许可收集、发布或使用其他用户的个人信息。
4. **违规处理**
如果发现用户违反本协议,我们将保留采取包括但不限于删除内容、禁言、封号等一切必要行动的权利。
5. **免责声明**
本站对涉及讨论区的任何争议和纠纷不承担任何责任。
在进行任何活动之前,确保您理解并遵守这些规则。您确定开始使用我们的随附服务,即表示您已经阅读、理解并同意接受本协议的条款和条件。

View File

@@ -1,5 +1,5 @@
import { QueryGetAPI } from '@/api/query'
import { BASE_API, apiFail } from '@/data/constants'
import { BASE_API_URL, apiFail } from '@/data/constants'
import EasySpeech from 'easy-speech'
import { NButton, NFlex, NText, createDiscreteApi } from 'naive-ui'
import { createPinia } from 'pinia'
@@ -16,13 +16,11 @@ const pinia = createPinia()
const app = createApp(App)
app.use(router).use(pinia).mount('#app')
const route = useRoute()
let currentVersion: string
let isHaveNewVersion = false
const { notification } = createDiscreteApi(['notification'])
QueryGetAPI<string>(BASE_API + 'vtsuru/version')
QueryGetAPI<string>(BASE_API_URL + 'vtsuru/version')
.then((version) => {
if (version.code == 200) {
currentVersion = version.data
@@ -45,12 +43,13 @@ QueryGetAPI<string>(BASE_API + 'vtsuru/version')
if (isHaveNewVersion) {
return
}
QueryGetAPI<string>(BASE_API + 'vtsuru/version').then((keepCheckData) => {
QueryGetAPI<string>(BASE_API_URL + 'vtsuru/version').then((keepCheckData) => {
if (keepCheckData.code == 200 && keepCheckData.data != currentVersion) {
isHaveNewVersion = true
currentVersion = version.data
localStorage.setItem('Version', currentVersion)
const route = useRoute()
if (!route.path.startsWith('/obs')) {
const n = notification.info({
title: '发现新的版本更新',

View File

@@ -166,5 +166,13 @@ export default //管理页面
title: '积分',
},
},
{
path: 'forum',
name: 'manage-forum',
component: () => import('@/views/manage/ForumManage.vue'),
meta: {
title: '粉丝讨论区',
},
},
],
}

View File

@@ -64,4 +64,22 @@ export default [
keepAlive: true,
},
},
{
path: 'forum/topic/:topicId',
name: 'user-forum-topic-detail',
component: () => import('@/views/view/forumViews/ForumTopicDetail.vue'),
meta: {
title: '帖子详情',
keepAlive: true,
},
},
{
path: 'forum',
name: 'user-forum',
component: () => import('@/views/view/forumViews/ForumView.vue'),
meta: {
title: '讨论区',
keepAlive: true,
},
},
]

View File

@@ -21,6 +21,7 @@ export const useAuthStore = defineStore('BiliAuth', () => {
const isLoading = ref(false)
const isAuthed = computed(() => currentToken.value != null && currentToken.value.length > 0)
const isInvalid = ref(false)
async function setCurrentAuth(token: string) {
if (!token) {
@@ -58,9 +59,12 @@ export const useAuthStore = defineStore('BiliAuth', () => {
})
console.log('添加新的认证账户: ' + biliAuth.value.userId)
}
isInvalid.value = false
return true
} else {
console.error('[bili-auth] 无法获取 Bilibili 认证信息: ' + data.message)
isInvalid.value = true
logout()
//message.error('无法获取 Bilibili 认证信息: ' + data.message)
}
})
@@ -133,6 +137,7 @@ export const useAuthStore = defineStore('BiliAuth', () => {
biliTokens,
isLoading,
isAuthed,
isInvalid,
currentToken,
getAuthInfo,
QueryBiliAuthGetAPI,

402
src/store/useForumStore.ts Normal file
View File

@@ -0,0 +1,402 @@
import {
ForumCommentModel,
ForumCommentSortTypes,
ForumModel,
ForumPostTopicModel,
ForumReplyModel,
ForumTopicBaseModel,
ForumTopicModel,
ForumTopicSortTypes,
} from '@/api/models/forum'
import { QueryGetAPI, QueryGetPaginationAPI, QueryPostAPI } from '@/api/query'
import { FORUM_API_URL } from '@/data/constants'
import { createDiscreteApi } from 'naive-ui'
import { MessageApiInjection } from 'naive-ui/es/message/src/MessageProvider'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useForumStore = defineStore('forum', () => {
const { message } = createDiscreteApi(['message'])
const isLoading = ref(false)
const isLikeLoading = ref(false)
const replyingComment = ref<ForumCommentModel>()
const replyingReply = ref<ForumReplyModel>()
const showReplyModal = ref(false)
async function GetForumInfo(owner: number) {
try {
isLoading.value = true
const data = await QueryGetAPI<ForumModel>(FORUM_API_URL + 'get-forum', { owner: owner })
if (data.code == 200) {
return data.data
} else if (data.code != 404) {
message?.error('无法获取数据: ' + data.message)
return undefined
}
} catch (err) {
message?.error('无法获取数据: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function GetManagedForums() {
try {
isLoading.value = true
const data = await QueryGetAPI<ForumModel[]>(FORUM_API_URL + 'get-managed-forums')
if (data.code == 200) {
return data.data
} else {
message?.error('无法获取数据: ' + data.message)
return undefined
}
} catch (err) {
message?.error('无法获取数据: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function GetTopics(
owner: number,
pn: number,
ps: number,
sort: ForumTopicSortTypes,
section?: number,
message?: MessageApiInjection,
) {
try {
isLoading.value = true
const data = await QueryGetPaginationAPI<ForumTopicBaseModel[]>(FORUM_API_URL + 'get-topics', {
owner,
pageSize: ps,
page: pn,
sort,
section: section,
})
if (data.code == 200) {
return {
data: data.data,
total: data.total,
more: data.more,
}
} else {
message?.error('无法获取数据: ' + data.message)
console.error('无法获取数据: ' + data.message)
return undefined
}
} catch (err) {
message?.error('无法获取数据: ' + err)
console.error('无法获取数据: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function GetTopicDetail(topic: number) {
try {
isLoading.value = true
const data = await QueryGetAPI<ForumTopicModel>(FORUM_API_URL + 'get-topic', { topic: topic })
if (data.code == 200) {
return data.data
} else {
message?.error('无法获取数据: ' + data.message)
console.error('无法获取数据: ' + data.message)
return undefined
}
} catch (err) {
message?.error('无法获取数据: ' + err)
console.error('无法获取数据: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function GetComments(topic: number, pn: number, ps: number, sort: ForumCommentSortTypes) {
try {
isLoading.value = true
const data = await QueryGetPaginationAPI<ForumCommentModel[]>(FORUM_API_URL + 'get-comments', {
topic,
pageSize: ps,
page: pn,
sort,
})
if (data.code == 200) {
return data.data
} else {
console.error('无法获取数据: ' + data.message)
message?.error('无法获取数据: ' + data.message)
return undefined
}
} catch (err) {
console.error('无法获取数据: ' + err)
message?.error('无法获取数据: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function ApplyToForum(owner: number) {
try {
isLoading.value = true
const data = await QueryGetAPI<ForumModel>(FORUM_API_URL + 'apply', { owner: owner })
if (data.code == 200) {
message?.success('已提交申请, 等待管理员审核')
return true
} else {
message?.error('无法获取数据: ' + data.message)
console.error('无法获取数据: ' + data.message)
return false
}
} catch (err) {
message?.error('无法获取数据: ' + err)
console.error('无法获取数据: ' + err)
return false
} finally {
isLoading.value = false
}
}
async function PostTopic(topic: ForumPostTopicModel, token: string) {
try {
isLoading.value = true
const data = await QueryPostAPI<ForumTopicModel>(FORUM_API_URL + 'post-topic', topic, [['Turnstile', token]])
if (data.code == 200) {
message?.success('发布成功')
return data.data
} else {
message?.error('发布失败: ' + data.message)
console.error('发布失败: ' + data.message)
return undefined
}
} catch (err) {
message?.error('发布失败: ' + err)
console.error('发布失败: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function PostComment(model: { topic: number; content: string }, token: string) {
try {
isLoading.value = true
const data = await QueryPostAPI<ForumCommentModel>(FORUM_API_URL + 'post-comment', model, [['Turnstile', token]])
if (data.code == 200) {
message?.success('评论成功')
return data.data
} else {
message?.error('评论失败: ' + data.message)
console.error('评论失败: ' + data.message)
return undefined
}
} catch (err) {
message?.error('评论失败: ' + err)
console.error('评论失败: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function PostReply(model: { comment: number; content: string; replyTo?: number }, token: string) {
try {
isLoading.value = true
const data = await QueryPostAPI<ForumCommentModel>(FORUM_API_URL + 'post-reply', model, [['Turnstile', token]])
if (data.code == 200) {
message?.success('评论成功')
return data.data
} else {
message?.error('评论失败: ' + data.message)
console.error('评论失败: ' + data.message)
return undefined
}
} catch (err) {
message?.error('评论失败: ' + err)
console.error('评论失败: ' + err)
return undefined
} finally {
isLoading.value = false
}
}
async function LikeTopic(topic: number, like: boolean) {
try {
isLikeLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'like-topic', { topic: topic, like: like })
if (data.code == 200) {
//message?.success('已点赞')
return true
} else {
message?.error('点赞失败: ' + data.message)
console.error('点赞失败: ' + data.message)
return false
}
} catch (err) {
message?.error('点赞失败: ' + err)
console.error('点赞失败: ' + err)
return false
} finally {
isLikeLoading.value = false
}
}
async function LikeComment(comment: number, like: boolean) {
try {
isLikeLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'like-comment', { comment: comment, like: like })
if (data.code == 200) {
//message?.success('已点赞')
return true
} else {
message?.error('点赞失败: ' + data.message)
console.error('点赞失败: ' + data.message)
return false
}
} catch (err) {
message?.error('点赞失败: ' + err)
console.error('点赞失败: ' + err)
return false
} finally {
isLikeLoading.value = false
}
}
async function SetReplyingComment(
comment: ForumCommentModel | undefined = undefined,
reply: ForumReplyModel | undefined = undefined,
) {
if (!comment) {
replyingComment.value = undefined
replyingReply.value = undefined
showReplyModal.value = false
return
}
replyingComment.value = comment
replyingReply.value = reply
showReplyModal.value = true
}
async function SetTopicTop(topic: number, top: boolean) {
try {
isLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'manage/set-topic-top', { topic: topic, top: top })
if (data.code == 200) {
message?.success('完成')
return true
} else {
message?.error('操作失败: ' + data.message)
console.error('操作失败: ' + data.message)
return false
}
} catch (err) {
message?.error('操作失败: ' + err)
console.error('操作失败: ' + err)
return false
} finally {
isLoading.value = false
}
}
async function DelTopic(topic: number) {
try {
isLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'manage/delete-topic', { topic: topic })
if (data.code == 200) {
message?.success('删除成功')
return true
} else {
message?.error('删除失败: ' + data.message)
console.error('删除失败: ' + data.message)
return false
}
} catch (err) {
message?.error('删除失败: ' + err)
console.error('删除失败: ' + err)
return false
} finally {
isLoading.value = false
}
}
async function DelComment(comment: number) {
try {
isLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'manage/delete-comment', { comment: comment })
if (data.code == 200) {
message?.success('删除成功')
return true
} else {
message?.error('删除失败: ' + data.message)
console.error('删除失败: ' + data.message)
return false
}
} catch (err) {
message?.error('删除失败: ' + err)
console.error('删除失败: ' + err)
return false
} finally {
isLoading.value = false
}
}
async function DelReply(reply: number) {
try {
isLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'manage/delete-reply', { reply: reply })
if (data.code == 200) {
message?.success('删除成功')
return true
} else {
message?.error('删除失败: ' + data.message)
console.error('删除失败: ' + data.message)
return false
}
} catch (err) {
message?.error('删除失败: ' + err)
console.error('删除失败: ' + err)
return false
} finally {
isLoading.value = false
}
}
async function ConfirmApply(owner: number, id: number) {
try {
isLoading.value = true
const data = await QueryGetAPI(FORUM_API_URL + 'manage/confirm-apply', { owner: owner, id: id })
if (data.code == 200) {
message?.success('已通过申请')
return true
} else {
message?.error('确认失败: ' + data.message)
console.error('确认失败: ' + data.message)
return false
}
} catch (err) {
message?.error('确认失败: ' + err)
console.error('确认失败: ' + err)
return false
} finally {
isLoading.value = false
}
}
return {
GetForumInfo,
GetManagedForums,
GetTopics,
GetTopicDetail,
GetComments,
ApplyToForum,
PostTopic,
PostComment,
PostReply,
LikeTopic,
LikeComment,
SetReplyingComment,
SetTopicTop,
DelTopic,
DelComment,
DelReply,
ConfirmApply,
isLoading,
isLikeLoading,
replyingComment,
replyingReply,
showReplyModal,
}
})

View File

@@ -0,0 +1,49 @@
import { ResponsePointGoodModel } from "@/api/api-models";
import { QueryGetAPI } from "@/api/query";
import { POINT_API_URL } from "@/data/constants";
import { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider";
import { defineStore } from "pinia";
import { useAuthStore } from "./useAuthStore";
export const usePointStore = defineStore('point', () => {
const useAuth = useAuthStore()
async function GetSpecificPoint(id: number) {
try {
const data = await useAuth.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 {
const 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 {
GetSpecificPoint,
GetGoods
}
})

View File

@@ -36,6 +36,7 @@ import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTi
</NSpace>
<NDivider title-placement="left"> 更新日志 </NDivider>
<NTimeline>
<NTimelineItem type="info" title="功能更新" content="积分订单添加导出功能, 允许删除积分用户" time="2024-3-22" />
<NTimelineItem type="info" title="功能更新" content="1. 点歌(歌势) 修改为点播 2. 棉花糖支持创建话题(标签) 3. 一些bug修复" time="2024-3-12" />
<NTimelineItem type="info" title="功能更新" content="棉花糖添加展示页面" time="2024-2-20" />
<NTimelineItem type="info" title="功能更新" content="歌单新增从文件导入" time="2024-2-10" />

View File

@@ -15,6 +15,7 @@ import {
Live24Filled,
Lottery24Filled,
PeopleQueue24Filled,
Person48Filled,
PersonFeedback24Filled,
TabletSpeaker24Filled,
VehicleShip24Filled,
@@ -463,7 +464,7 @@ onMounted(() => {
<template #extra>
<NSpace align="center" justify="center">
<NSwitch
:default-value="!isDarkMode()"
:default-value="!isDarkMode"
@update:value="
(value: string & number & boolean) => (themeType = value ? ThemeType.Light : ThemeType.Dark)
"
@@ -481,7 +482,7 @@ onMounted(() => {
type="primary"
@click="$router.push({ name: 'user-index', params: { id: accountInfo?.name } })"
>
回到
回到展示
</NButton>
</NSpace>
</template>
@@ -520,7 +521,7 @@ onMounted(() => {
</NSpace>
<NButton v-if="accountInfo.biliUserAuthInfo" @click="gotoAuthPage()" type="info" secondary>
<template #icon>
<NIcon :component="BrowsersOutline" />
<NIcon :component="Person48Filled" />
</template>
<template v-if="width >= 180"> 认证用户主页 </template>
</NButton>

View File

@@ -154,7 +154,7 @@ onUnmounted(() => {
{{ client?.roomAuthInfo.value ? `已连接 | ${client.roomAuthInfo.value?.anchor_info.uname}` : '未连接' }}
</NTag>
<NSwitch
:default-value="!isDarkMode()"
:default-value="!isDarkMode"
@update:value="
(value: string & number & boolean) => (themeType = value ? ThemeType.Light : ThemeType.Dark)
"
@@ -198,7 +198,7 @@ onUnmounted(() => {
round
bordered
:style="{
boxShadow: isDarkMode() ? 'rgb(195 192 192 / 35%) 0px 0px 8px' : '0 2px 3px rgba(0, 0, 0, 0.1)',
boxShadow: isDarkMode ? 'rgb(195 192 192 / 35%) 0px 0px 8px' : '0 2px 3px rgba(0, 0, 0, 0.1)',
}"
/>
<NEllipsis v-if="width > 100" style="max-width: 100%">

View File

@@ -7,7 +7,13 @@ import { useUser } from '@/api/user'
import RegisterAndLogin from '@/components/RegisterAndLogin.vue'
import { FETCH_API } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import { BookCoins20Filled, CalendarClock24Filled, VideoAdd20Filled } from '@vicons/fluent'
import {
BookCoins20Filled,
CalendarClock24Filled,
Person48Filled,
VideoAdd20Filled,
WindowWrench20Filled,
} from '@vicons/fluent'
import { Chatbox, Home, Moon, MusicalNote, Sunny } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
import {
@@ -51,6 +57,7 @@ const notfount = ref(false)
const registerAndLoginModalVisiable = ref(false)
const sider = ref()
const { width } = useElementSize(sider)
const windowWidth = window.innerWidth
function renderIcon(icon: unknown) {
return () => h(NIcon, null, { default: () => h(icon as any) })
@@ -73,11 +80,12 @@ function gotoAuthPage() {
message.error('你尚未进行 Bilibili 认证, 请前往面板进行认证和绑定')
return
}
useAuthStore()
/*useAuthStore()
.setCurrentAuth(accountInfo.value?.biliUserAuthInfo.token)
.then(() => {
NavigateToNewTab('/bili-user')
})
})*/
NavigateToNewTab('/bili-user')
}
onMounted(async () => {
userInfo.value = await useUser(id.value?.toString())
@@ -193,7 +201,7 @@ onMounted(async () => {
<template #extra>
<NSpace align="center">
<NSwitch
:default-value="!isDarkMode()"
:default-value="!isDarkMode"
@update:value="
(value: string & number & boolean) => (themeType = value ? ThemeType.Light : ThemeType.Dark)
"
@@ -211,11 +219,16 @@ onMounted(async () => {
v-if="useAuth.isAuthed || accountInfo.biliUserAuthInfo"
style="right: 0px; position: relative"
type="primary"
@click="gotoAuthPage"
tag="a"
href="/bili-user"
target="_blank"
size="small"
secondary
>
认证用户中心
<template #icon>
<NIcon :component="Person48Filled" />
</template>
<span v-if="windowWidth >= 768"> 认证用户中心 </span>
</NButton>
<NButton
style="right: 0px; position: relative"
@@ -223,7 +236,10 @@ onMounted(async () => {
@click="$router.push({ name: 'manage-index' })"
size="small"
>
个人中心
<template #icon>
<NIcon :component="WindowWrench20Filled" />
</template>
<span v-if="windowWidth >= 768"> 主播后台 </span>
</NButton>
</NSpace>
</template>
@@ -265,7 +281,7 @@ onMounted(async () => {
round
bordered
:style="{
boxShadow: isDarkMode() ? 'rgb(195 192 192 / 35%) 0px 0px 8px' : '0 2px 3px rgba(0, 0, 0, 0.1)',
boxShadow: isDarkMode ? 'rgb(195 192 192 / 35%) 0px 0px 8px' : '0 2px 3px rgba(0, 0, 0, 0.1)',
}"
/>
<NEllipsis v-if="width > 100" style="max-width: 100%">
@@ -295,7 +311,7 @@ onMounted(async () => {
<NLayout style="height: 100%">
<div
class="viewer-page-content"
:style="`box-shadow:${isDarkMode() ? 'rgb(28 28 28 / 9%) 5px 5px 6px inset, rgba(139, 139, 139, 0.09) -5px -5px 6px inset' : 'inset 5px 5px 6px #8b8b8b17, inset -5px -5px 6px #8b8b8b17;'}`"
:style="`box-shadow:${isDarkMode ? 'rgb(28 28 28 / 9%) 5px 5px 6px inset, rgba(139, 139, 139, 0.09) -5px -5px 6px inset' : 'inset 5px 5px 6px #8b8b8b17, inset -5px -5px 6px #8b8b8b17;'}`"
>
<RouterView v-if="userInfo" v-slot="{ Component }">
<KeepAlive>

View File

@@ -3,7 +3,7 @@ import { isDarkMode } from '@/Utils'
import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import { AVATAR_URL, BASE_API, EVENT_API_URL } from '@/data/constants'
import { AVATAR_URL, BASE_API_URL, EVENT_API_URL } from '@/data/constants'
import { Grid28Filled, List16Filled } from '@vicons/fluent'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
@@ -253,7 +253,7 @@ function objectsToCSV(arr: any[]) {
:color="{
color: selectedType == EventType.Guard ? GetGuardColor(item.price) : GetSCColor(item.price),
textColor: 'white',
borderColor: isDarkMode() ? 'white' : '#00000000',
borderColor: isDarkMode ? 'white' : '#00000000',
}"
>
{{ item.price }}

View File

@@ -0,0 +1,249 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { QueryPostAPI } from '@/api/query'
import { useForumStore } from '@/store/useForumStore'
import {
DataTableColumns,
NAlert,
NButton,
NCard,
NCheckbox,
NDataTable,
NDescriptions,
NDescriptionsItem,
NDivider,
NFlex,
NInput,
NInputGroup,
NInputGroupLabel,
NModal,
NSelect,
NSpin,
NTabPane,
NTabs,
NTag,
NTime,
useMessage,
} from 'naive-ui'
import { h, ref } from 'vue'
import { ForumModel, ForumUserModel } from '@/api/models/forum'
import { FORUM_API_URL } from '@/data/constants'
// @ts-ignore
import Agreement from '@/document/EnableForumAgreement.md'
import { UserBasicInfo } from '@/api/api-models'
const useForum = useForumStore()
const accountInfo = useAccount()
const message = useMessage()
const managedForums = ref((await useForum.GetManagedForums()) ?? [])
const currentForum = ref((await useForum.GetForumInfo(accountInfo.value.id)) ?? ({} as ForumModel))
const selectedForum = ref(accountInfo.value.id)
const readedAgreement = ref(false)
const showAgreement = ref(false)
const create_Name = ref('')
const create_Description = ref('')
const paginationSetting = { defaultPageSize: 20, showSizePicker: true, pageSizes: [20, 50, 100] }
async function createForum() {
if (!readedAgreement.value) {
message.warning('请先阅读并同意服务协议')
return
}
if (!create_Name.value) {
message.warning('请输入名称')
return
}
try {
const data = await QueryPostAPI<ForumModel>(FORUM_API_URL + 'create', {
name: create_Name.value,
})
if (data.code == 200) {
message.success('创建成功')
currentForum.value = data.data
} else {
message.error('创建失败:' + data.message)
console.error(data.message)
}
} catch (err) {
console.error(err)
message.error('创建失败:' + err)
}
}
async function SwitchForum(owner: number) {
currentForum.value = (await useForum.GetForumInfo(owner)) ?? ({} as ForumModel)
}
const defaultColumns: DataTableColumns<ForumUserModel> = [
{
title: '名称',
key: 'name',
},
{
title: 'B站账号',
key: 'biliId',
render(row) {
return h(NTag, { type: row.isBiliAuthed ? 'success' : 'warning' }, () => (row.isBiliAuthed ? '已绑定' : '未绑定'))
},
},
]
const applyingColumns: DataTableColumns<ForumUserModel> = [
...defaultColumns,
{
title: '操作',
key: 'action',
render(row) {
return h(
NButton,
{
text: true,
type: 'success',
onClick: () =>
useForum.ConfirmApply(currentForum.value.owner.id, row.id).then((success) => {
if (success) message.success('操作成功')
currentForum.value.applying = currentForum.value.applying.filter((u) => u.id != row.id)
}),
},
{ default: () => '通过申请' },
)
},
},
]
const memberColumns: DataTableColumns<ForumUserModel> = [
...defaultColumns,
{
title: '操作',
key: 'action',
render(row) {
return h(
NButton,
{
text: true,
type: 'success',
onClick: () =>
useForum.ConfirmApply(currentForum.value.owner.id, row.id).then((success) => {
if (success) message.success('操作成功')
currentForum.value.applying = currentForum.value.applying.filter((u) => u.id != row.id)
}),
},
{ default: () => '通过申请' },
)
},
},
]
const banColumns: DataTableColumns<ForumUserModel> = [
...defaultColumns,
{
title: '操作',
key: 'action',
render(row) {
return h(
NButton,
{
text: true,
type: 'success',
onClick: () =>
useForum.ConfirmApply(currentForum.value.owner.id, row.id).then((success) => {
if (success) message.success('操作成功')
currentForum.value.applying = currentForum.value.applying.filter((u) => u.id != row.id)
}),
},
{ default: () => '通过申请' },
)
},
},
]
</script>
<template>
<NCard v-if="!currentForum.name" size="small" title="啊哦">
<NAlert type="error"> 你尚未创建粉丝讨论区 </NAlert>
<NDivider />
<NFlex justify="center">
<NFlex vertical>
<NButton @click="createForum" size="large" type="primary"> 创建粉丝讨论区 </NButton>
<NInputGroup>
<NInputGroupLabel> 名称 </NInputGroupLabel>
<NInput v-model:value="create_Name" placeholder="就是名称" maxlength="20" minlength="1" show-count />
</NInputGroup>
<NInput
v-model:value="create_Description"
placeholder="(可选) 公告/描述"
maxlength="200"
show-count
type="textarea"
/>
<NCheckbox v-model:checked="readedAgreement">
已阅读并同意 <NButton @click="showAgreement = true" text type="info"> 服务协议 </NButton>
</NCheckbox>
</NFlex>
</NFlex>
</NCard>
<template v-else>
<NSpin :show="useForum.isLoading">
<NSelect
v-model:value="selectedForum"
:options="
managedForums.map((f) => ({
label: (f.owner.id == accountInfo.id ? '[我的] ' : '') + f.name + ` (${f.owner.name})`,
value: f.owner.id,
}))
"
@update:value="(v) => SwitchForum(v)"
>
<template #header>
<NButton @click="SwitchForum(accountInfo.id)" size="small" type="primary"> 我的粉丝讨论区 </NButton>
</template>
</NSelect>
<NDivider />
<NTabs animated v-bind:key="selectedForum" type="segment">
<NTabPane tab="信息" name="info">
<NDescriptions bordered size="small">
<NDescriptionsItem label="名称"> {{ currentForum.name }} </NDescriptionsItem>
<NDescriptionsItem label="公告"> {{ currentForum.description ?? '无' }} </NDescriptionsItem>
<NDescriptionsItem label="创建者"> {{ currentForum.owner.name }} </NDescriptionsItem>
<NDescriptionsItem label="创建时间"> <NTime :time="currentForum.createAt" /> </NDescriptionsItem>
<NDescriptionsItem label="帖子数量"> {{ currentForum.topicCount }} </NDescriptionsItem>
<NDescriptionsItem v-if="currentForum.settings.requireApply" label="成员数量">
{{ currentForum.members?.length ?? 0 }}
</NDescriptionsItem>
<NDescriptionsItem label="管理员数量"> {{ currentForum.admins?.length ?? 0 }} </NDescriptionsItem>
</NDescriptions>
<NDivider> 设置 </NDivider>
</NTabPane>
<NTabPane tab="成员" name="member">
<NDivider> 申请 </NDivider>
<NDataTable
:columns="applyingColumns"
:data="currentForum.applying"
:pagination="paginationSetting"
/>
<template v-if="currentForum.settings.requireApply">
<NDivider> 成员 </NDivider>
<NDataTable
:columns="memberColumns"
:data="currentForum.members.sort((a, b) => (a.isAdmin ? 1 : 0) - (b.isAdmin ? 1 : 0))"
:pagination="paginationSetting"
/>
</template>
<NDivider> 封禁用户 </NDivider>
<NDataTable
:columns="banColumns"
:data="currentForum.blackList"
:pagination="paginationSetting"
/>
</NTabPane>
</NTabs>
</NSpin>
</template>
<NModal
v-model:show="showAgreement"
preset="card"
title="开通粉丝讨论区用户协议"
style="width: 600px; max-width: 90vw"
>
<Agreement />
</NModal>
</template>

View File

@@ -2,6 +2,7 @@
import { useAccount } from '@/api/account'
import { ResponseLiveInfoModel } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import LiveInfoContainer from '@/components/LiveInfoContainer.vue'
import { LIVE_API_URL } from '@/data/constants'
import { NAlert, NButton, NDivider, NList, NListItem, NPagination, NSpace, useMessage } from 'naive-ui'
@@ -43,7 +44,6 @@ function OnClickCover(live: ResponseLiveInfoModel) {
<template>
<NSpace vertical>
<NAlert type="warning"> 测试功能, 尚不稳定 </NAlert>
<NAlert type="error" title="2024.2.26">
近期逸站对开放平台直播弹幕流进行了极为严格的限制, 目前本站服务器只能连接个位数的直播间, 这使得在不使用
<NButton tag="a" href="https://www.yuque.com/megghy/dez70g/vfvcyv3024xvaa1p" target="_blank" type="primary" text>
@@ -57,6 +57,8 @@ function OnClickCover(live: ResponseLiveInfoModel) {
</NButton>
, 否则只能记录直播的时间而不包含弹幕
</NAlert>
<EventFetcherStatusCard />
</NSpace>
<NDivider />
<NAlert v-if="accountInfo?.isBiliVerified != true" type="info"> 尚未进行Bilibili认证 </NAlert>

View File

@@ -160,7 +160,10 @@ onMounted(() => {
<template>
<NSpace align="center">
<NAlert type="info" style="max-width: 200px">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.QuestionBox) ? 'success' : 'warning'"
style="max-width: 200px"
>
启用提问箱
<NDivider vertical />
<NSwitch

View File

@@ -262,7 +262,10 @@ onMounted(() => {
<template>
<NSpace align="center">
<NAlert type="info" style="max-width: 200px">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Schedule) ? 'success' : 'warning'"
style="max-width: 200px"
>
启用日程表
<NDivider vertical />
<NSwitch

View File

@@ -571,7 +571,10 @@ onMounted(async () => {
<template>
<NSpace align="center">
<NAlert type="info" style="max-width: 200px">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.SongList) ? 'success' : 'warning'"
style="max-width: 200px"
>
启用歌单
<NDivider vertical />
<NSwitch

View File

@@ -127,7 +127,11 @@ function createTable() {
</script>
<template>
<NAlert type="info" v-if="accountInfo.id">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.VideoCollect) ? 'success' : 'warning'"
v-if="accountInfo.id"
style="max-width: 300px"
>
在个人主页展示进行中的征集表
<NSwitch
:value="accountInfo.settings.enableFunctions.includes(FunctionTypes.VideoCollect)"

View File

@@ -337,7 +337,14 @@ onMounted(() => {})
<template>
<NFlex>
<NAlert type="info" style="min-width: 400px">
<NAlert
:type="
accountInfo.settings.enableFunctions.includes(FunctionTypes.Point) && accountInfo.eventFetcherState.online
? 'success'
: 'warning'
"
style="min-width: 400px"
>
启用
<NButton text type="primary" tag="a" href="https://www.yuque.com/megghy/dez70g/ohulp2torghlqqn8" target="_blank">
积分系统
@@ -369,6 +376,9 @@ onMounted(() => {})
<NTabPane name="goods" tab="礼物">
<NFlex>
<NButton type="primary" @click="onModalOpen"> 添加礼物 </NButton>
<NButton @click="$router.push({ name: 'user-goods', params: { id: accountInfo?.name } })" secondary>
前往展示页
</NButton>
</NFlex>
<NDivider />
<NEmpty v-if="goods.filter((g) => g.status != GoodsStatus.Discontinued).length == 0" description="暂无礼物" />

View File

@@ -1,21 +1,47 @@
<script setup lang="ts">
import { ResponsePointGoodModel, ResponsePointOrder2OwnerModel } from '@/api/api-models'
import { useAccount } from '@/api/account'
import { GoodsTypes, PointOrderStatus, 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 { NEmpty, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { objectsToCSV } from '@/Utils'
import { useStorage } from '@vueuse/core'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
import { NButton, NCard, NCheckbox, NDivider, NEmpty, NFlex, NSelect, NSpin, useMessage } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
type OrderFilterSettings = {
type?: GoodsTypes
status?: PointOrderStatus
onlyRequireShippingInfo: boolean
}
const props = defineProps<{
goods: ResponsePointGoodModel[]
}>()
const defaultSettings = {
onlyRequireShippingInfo: false,
} as OrderFilterSettings
const filterSettings = useStorage<OrderFilterSettings>('Setting.Point.OrderFilter', defaultSettings)
const message = useMessage()
const accountInfo = useAccount()
const orders = ref<ResponsePointOrder2OwnerModel[]>([])
const filteredOrders = computed(() => {
return orders.value.filter((o) => {
if (filterSettings.value.type != undefined && o.type !== filterSettings.value.type) return false
if (filterSettings.value.status != undefined && o.status !== filterSettings.value.status) return false
if (filterSettings.value.onlyRequireShippingInfo && o.trackingNumber) return false
return true
})
})
const isLoading = ref(false)
async function getOrders() {
try {
isLoading.value = true
const data = await QueryGetAPI<ResponsePointOrder2OwnerModel[]>(POINT_API_URL + 'get-orders')
if (data.code == 200) {
return data.data
@@ -25,9 +51,53 @@ async function getOrders() {
} catch (err) {
console.log(err)
message.error('获取订单失败: ' + err)
} finally {
isLoading.value = false
}
return []
}
const statusText = {
[PointOrderStatus.Completed]: '已完成',
[PointOrderStatus.Pending]: '等待发货',
[PointOrderStatus.Shipped]: '已发货',
}
function exportData() {
const text = objectsToCSV(
filteredOrders.value.map((s) => {
const gift = props.goods.find((g) => g.id == s.goodsId)
return {
订单号: s.id,
订单类型: s.type == GoodsTypes.Physical ? '实体' : '虚拟',
订单状态: statusText[s.status],
用户名: s.customer.name ?? '未知',
用户UID: s.customer.userId,
联系人: s.address?.name,
联系电话: s.address?.phone,
地址: s.address
? `${s.address?.province}${s.address?.city}${s.address?.district}${s.address?.street}街道${s.address?.address}`
: '无',
礼物名: gift?.name ?? '已删除',
礼物数量: s.count,
礼物单价: gift?.price,
礼物总价: s.point,
快递公司: s.expressCompany,
快递单号: s.trackingNumber,
创建时间: format(s.createAt, 'yyyy-MM-dd HH:mm:ss'),
更新时间: s.updateAt ? format(s.updateAt, 'yyyy-MM-dd HH:mm:ss') : '未更新',
}
}),
)
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`,
)
}
async function refresh() {
orders.value = await getOrders()
}
onMounted(async () => {
orders.value = await getOrders()
@@ -35,6 +105,48 @@ onMounted(async () => {
</script>
<template>
<NEmpty v-if="orders.length == 0" description="暂无订单"></NEmpty>
<PointOrderCard v-else :order="orders" :goods="goods" type="owner" />
<NSpin :show="isLoading">
<NEmpty v-if="filteredOrders.length == 0" description="暂无订单"></NEmpty>
<template v-else>
<br />
<NFlex>
<NButton @click="refresh">刷新</NButton>
<NButton @click="exportData" secondary type="info">导出数据</NButton>
</NFlex>
<NDivider />
<NCard size="small" title="筛选订单">
<template #header-extra>
<NButton @click="filterSettings = JSON.parse(JSON.stringify(defaultSettings))" size="small" type="warning">
重置
</NButton>
</template>
<NFlex align="center">
<NSelect
v-model:value="filterSettings.type"
:options="[
{ label: '实体', value: GoodsTypes.Physical },
{ label: '虚拟', value: GoodsTypes.Virtual },
]"
clearable
placeholder="订单类型"
style="width: 150px"
/>
<NSelect
v-model:value="filterSettings.status"
:options="[
{ label: '已完成', value: PointOrderStatus.Completed },
{ label: '等待发货', value: PointOrderStatus.Pending },
{ label: '已发货', value: PointOrderStatus.Shipped },
]"
placeholder="订单状态"
clearable
style="width: 150px"
/>
<NCheckbox v-model:checked="filterSettings.onlyRequireShippingInfo" label="仅包含未填写快递单号的订单" />
</NFlex>
</NCard>
<NDivider />
<PointOrderCard :order="filteredOrders" :goods="goods" type="owner" />
</template>
</NSpin>
</template>

View File

@@ -111,6 +111,14 @@ async function updateGift() {
</script>
<template>
<NAlert v-if="!accountInfo.eventFetcherState.online" type="warning">
由于你尚未部署
<NButton text type="primary" tag="a" href="https://www.yuque.com/megghy/dez70g/vfvcyv3024xvaa1p" target="_blank">
VtsuruEventFetcher
</NButton>
, 以下选项设置了也没用
</NAlert>
<br />
<NAlert type="info"> 积分总是最多保留两位小数, 四舍五入 </NAlert>
<NDivider> 常用 </NDivider>
<NSpin :show="isLoading">

View File

@@ -133,6 +133,22 @@ const column: DataTableColumns<ResponsePointUserModel> = [
},
{ default: () => '详情' },
),
h(
NPopconfirm,
{ onPositiveClick: () => deleteUser(row) },
{
default: '确定要删除这个用户吗?记录将无法恢复',
trigger: () =>
h(
NButton,
{
type: 'error',
size: 'small',
},
{ default: () => '删除' },
),
},
),
])
},
},
@@ -176,7 +192,9 @@ async function givePoint() {
if (data.code == 200) {
message.success('添加成功')
showGivePointModal.value = false
await refresh()
setTimeout(() => {
refresh()
}, 1500)
addPointCount.value = 0
addPointReason.value = undefined
@@ -186,6 +204,37 @@ async function givePoint() {
}
} catch (err) {
message.error('添加失败: ' + err)
} finally {
isLoading.value = false
}
}
async function deleteUser(user: ResponsePointUserModel) {
isLoading.value = true
try {
const data = await QueryGetAPI(
POINT_API_URL + 'delete-user',
user.isAuthed
? {
authId: user.info.id,
}
: user.info.userId
? {
uId: user.info.userId,
}
: {
uId: user.info.openId,
},
)
if (data.code == 200) {
message.success('已删除')
users.value = users.value.filter((u) => u != user)
} else {
message.error('删除失败: ' + data.message)
}
} catch (err) {
message.error('删除失败: ' + err)
} finally {
isLoading.value = false
}
}
@@ -215,6 +264,7 @@ onMounted(async () => {
<NCheckbox v-model:checked="settings.onlyAuthed"> 只显示已认证用户 </NCheckbox>
</NFlex>
</NCard>
<NDivider />
<template v-if="filteredUsers.length == 0">
<NDivider />
<NEmpty :description="settings.onlyAuthed ? '没有已认证的用户' : '没有用户'" />

View File

@@ -747,7 +747,10 @@ onUnmounted(() => {
</script>
<template>
<NAlert type="info" v-if="accountInfo.id">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.SongRequest) ? 'success' : 'warning'"
v-if="accountInfo.id"
>
启用弹幕点播功能
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.SongRequest)"

View File

@@ -749,7 +749,10 @@ onUnmounted(() => {
</script>
<template>
<NAlert type="info" v-if="accountInfo.id">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue) ? 'success' : 'warning'"
v-if="accountInfo.id"
>
启用弹幕队列功能
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Queue)"

View File

@@ -199,7 +199,10 @@ onMounted(async () => {
<NCard v-else style="max-width: 600px" embedded hoverable>
<template #header> 你好, {{ useAuth.biliAuth.name }} </template>
<template #header-extra>
<NButton type="info" @click="gotoAuthPage" secondary size="small"> 前往认证用户中心 </NButton>
<NFlex>
<NButton type="info" @click="gotoAuthPage" secondary size="small"> 前往认证用户中心 </NButton>
<NButton @click="NavigateToNewTab('/bili-user#settings')" secondary size="small"> 切换账号 </NButton>
</NFlex>
</template>
<NText> 你在 {{ userInfo.extra?.streamerInfo?.name ?? userInfo.name }} 的直播间的积分为 {{ currentPoint }} </NText>
</NCard>

View File

@@ -3,7 +3,7 @@ import { ResponsePointOrder2UserModel } from '@/api/api-models'
import PointOrderCard from '@/components/manage/PointOrderCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import { NEmpty, useMessage } from 'naive-ui'
import { NEmpty, NSpin, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
const message = useMessage()
@@ -24,8 +24,9 @@ async function getOrders() {
} catch (err) {
console.log(err)
message.error('获取订单失败: ' + err)
} finally {
isLoading.value = false
}
isLoading.value = false
return []
}
@@ -35,6 +36,8 @@ onMounted(async () => {
</script>
<template>
<NEmpty v-if="orders.length == 0" description="暂无订单"></NEmpty>
<PointOrderCard v-else :order="orders" :loading="isLoading" type="user" />
<NSpin :show="isLoading">
<NEmpty v-if="orders.length == 0" description="暂无订单" />
<PointOrderCard v-else :order="orders" :loading="isLoading" type="user" />
</NSpin>
</template>

View File

@@ -3,7 +3,7 @@ import { ResponsePointHisrotyModel } from '@/api/api-models'
import PointHistoryCard from '@/components/manage/PointHistoryCard.vue'
import { POINT_API_URL } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import { useMessage } from 'naive-ui'
import { NSpin, useMessage } from 'naive-ui'
import { onMounted, ref } from 'vue'
const message = useMessage()
@@ -26,6 +26,8 @@ async function getHistories() {
} catch (err) {
message.error('获取积分历史失败: ' + err)
console.error(err)
} finally {
isLoading.value = false
}
return []
}
@@ -36,5 +38,7 @@ onMounted(async () => {
</script>
<template>
<PointHistoryCard :histories="history" />
<NSpin :show="isLoading">
<PointHistoryCard :histories="history" />
</NSpin>
</template>

View File

@@ -4,6 +4,7 @@ import { POINT_API_URL } from '@/data/constants'
import { useAuthStore } from '@/store/useAuthStore'
import { useRouteHash } from '@vueuse/router'
import {
NAlert,
NButton,
NCard,
NDataTable,
@@ -123,11 +124,12 @@ onMounted(async () => {
<template>
<NLayout>
<NSpin v-if="!biliAuth.id && useAuth.currentToken" :show="useAuth.isLoading" />
<NSpin v-if="useAuth.isLoading && useAuth.currentToken" :show="useAuth.isLoading" />
<NLayoutContent
v-else-if="!useAuth.currentToken && useAuth.biliTokens.length > 0"
v-else-if="(!useAuth.currentToken && useAuth.biliTokens.length > 0) || useAuth.isInvalid"
style="height: 100vh; padding: 50px"
>
<NAlert v-if="useAuth.isInvalid" type="error"> 当前登录的 Bilibili 账号已失效 </NAlert>
<NCard title="选择B站账号" embedded>
<template #header-extra>
<NButton type="primary" @click="$router.push({ name: 'bili-auth' })" size="small" secondary
@@ -142,6 +144,7 @@ onMounted(async () => {
</NCard>
</NLayoutContent>
<NLayoutContent v-else-if="!useAuth.currentToken" style="height: 100vh">
<NAlert v-if="useAuth.isInvalid" type="error"> 当前登录的 Bilibili 账号已失效 </NAlert>
<NResult status="error" title="你还未进行过B站账户验证" description="请先进行认证" style="padding-top: 64px">
<template #footer>
<NButton type="primary" @click="$router.push({ name: 'bili-auth' })">去认证</NButton>
@@ -156,7 +159,7 @@ onMounted(async () => {
</NLayoutHeader>
<NLayoutContent content-style="padding: 24px;">
<NFlex align="center" justify="center">
<div style="max-width: 95vw; width: 900px">
<div style="max-width: 95vw; width: 1200px">
<NCard title="我的信息">
<NDescriptions label-placement="left" bordered size="small">
<NDescriptionsItem label="用户名">

View File

@@ -25,6 +25,7 @@ import {
NSelect,
NSpin,
NTag,
NText,
useMessage,
} from 'naive-ui'
import { computed, ref } from 'vue'
@@ -284,7 +285,7 @@ function logout() {
<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 }}
{{ item.name }} <NDivider vertical style="margin: 0" /><NText depth="3"> {{ item.uId }} </NText>
</NFlex>
</NListItem>
</NList>

View File

@@ -247,7 +247,7 @@ onUnmounted(() => {
<NImage v-if="item.question.image" :src="item.question.image" height="100" lazy />
</NCard>
<template v-if="item.answer" #footer>
<NSpace align="center" :size="6">
<NSpace align="center" :size="6" :wrap="false">
<NAvatar :src="biliInfo.face + '@64w'" circle :size="45" :img-props="{ referrerpolicy: 'no-referrer' }" />
<NDivider vertical />
<NText style="font-size: 16px">

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { ForumCommentModel, ForumTopicModel } from '@/api/models/forum'
import { VTSURU_API_URL } from '@/data/constants'
import { useForumStore } from '@/store/useForumStore'
import { ArrowReply16Filled } from '@vicons/fluent'
import { Heart, HeartOutline } from '@vicons/ionicons5'
import { NAvatar, NButton, NCard, NDivider, NFlex, NIcon, NText, NTime, NTooltip } from 'naive-ui'
import ForumReplyItem from './ForumReplyItem.vue'
import { computed } from 'vue'
const props = defineProps<{
item: ForumCommentModel
topic: ForumTopicModel
}>()
const useForum = useForumStore()
const accountInfo = useAccount()
const canOprate = computed(() => {
return !props.topic.isLocked && accountInfo.value.id > 0
})
</script>
<template>
<NFlex>
<NAvatar
:src="VTSURU_API_URL + 'user-face/' + item.user.id + '?size=64'"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NFlex vertical style="flex: 1" :size="2">
<NFlex>
<NText>
{{ item.user.name }}
</NText>
<NText depth="3">
<NTooltip>
<template #trigger>
<NTime :time="item.sendAt" type="relative" />
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
</NFlex>
<div class="editor-content-view" v-html="item.content"></div>
<NDivider style="margin: 0" />
<NFlex>
<NTooltip>
<template #trigger>
<NButton
size="small"
@click="
useForum.LikeComment(item.id, !item.isLiked).then((success) => {
if (success) {
item.isLiked = !item.isLiked
item.likeCount += item.isLiked ? 1 : -1
}
})
"
text
:loading="useForum.isLikeLoading"
:disabled="!canOprate"
>
<template #icon>
<NIcon :component="item.isLiked ? Heart : HeartOutline" :color="item.isLiked ? '#dd484f' : ''" />
</template>
{{ item.likeCount }}
</NButton>
</template>
点赞
</NTooltip>
<NTooltip>
<template #trigger>
<NButton
size="small"
@click="useForum.SetReplyingComment(item)"
text
:disabled="!canOprate"
>
<template #icon>
<NIcon :component="ArrowReply16Filled" />
</template>
{{ item.replies.length }}
</NButton>
</template>
回复
</NTooltip>
</NFlex>
<NCard v-if="item.replies.length > 0" size="small">
<NFlex vertical>
<ForumReplyItem
v-for="reply in item.replies"
:key="reply.id"
:item="reply"
:comment="item"
:topic="topic"
showReplyButton
:reply-to="reply.replyTo ? item.replies.find((r) => r.id === reply.replyTo) : undefined"
/>
</NFlex>
</NCard>
</NFlex>
</NFlex>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ForumModel, ForumTopicBaseModel } from '@/api/models/forum'
import { useForumStore } from '@/store/useForumStore'
import { ArrowReply24Filled, Chat24Regular, MoreVertical24Filled, Star24Filled } from '@vicons/fluent'
import { NButton, NDropdown, NFlex, NIcon, NTag, NText, NTime, NTooltip, useDialog } from 'naive-ui'
const props = defineProps<{
item: ForumTopicBaseModel
forum: ForumModel
}>()
const useForum = useForumStore()
const dialog = useDialog()
function onDropdownSelect(key: string) {
switch (key) {
case 'delete':
dialog.warning({
title: '警告',
content: '确定要删除这条话题吗?',
positiveText: '确定',
negativeText: '再想想',
onPositiveClick: () => {
useForum.DelTopic(props.item.id).then((success) => {
if (success) {
setTimeout(() => {
window.location.reload()
}, 1000)
}
})
},
})
break
case 'top':
dialog.info({
title: '问',
content: `确定要${props.item.isPinned ? '取消' : ''}置顶这条话题吗?`,
positiveText: '确定',
negativeText: '再想想',
onPositiveClick: () => {
useForum.SetTopicTop(props.item.id, !props.item.isPinned).then((success) => {
if (success) {
props.item.isPinned = !props.item.isPinned
}
})
},
})
break
}
}
</script>
<template>
<NFlex align="center">
<NFlex align="center" :wrap="false">
<NTag v-if="item.isPinned" size="small" round>
<NIcon :component="Star24Filled" color="#dba913" />
</NTag>
<NTag size="small" style="color: gray">
<template #icon>
<NIcon :component="Chat24Regular" />
</template>
{{ item.commentCount }}
</NTag>
<NText style="font-size: large">
{{ item.title }}
</NText>
</NFlex>
<NFlex style="flex: 1; color: gray; font-size: small" justify="end" align="center">
<template v-if="item.latestRepliedBy">
<span>
<NIcon :component="ArrowReply24Filled" size="15" />
@{{ item.latestRepliedBy.name }}
</span>
</template>
<template v-else> @{{ item.user?.name }} 发布于 </template>
<NTooltip>
<template #trigger>
<NTime :time="item.createAt" type="relative" />
</template>
<NTime :time="item.createAt" />
</NTooltip>
<NDropdown
v-if="forum.isAdmin"
:options="[
{ label: '删除', key: 'delete' },
{ label: item.isPinned ? '取消置顶' : '置顶', key: 'top' },
]"
trigger="hover"
@select="onDropdownSelect"
>
<NButton text>
<template #icon>
<NIcon :component="MoreVertical24Filled" />
</template>
</NButton>
</NDropdown>
</NFlex>
</NFlex>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { getUserAvatarUrl } from '@/Utils'
import { useAccount } from '@/api/account'
import { ForumCommentModel, ForumReplyModel, ForumTopicModel } from '@/api/models/forum'
import { useForumStore } from '@/store/useForumStore'
import { ArrowReply16Filled } from '@vicons/fluent'
import { NAvatar, NButton, NCard, NFlex, NIcon, NText, NTime, NTooltip } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps<{
item: ForumReplyModel
replyTo?: ForumReplyModel
comment: ForumCommentModel
topic: ForumTopicModel
showReplyButton?: boolean
}>()
const useForum = useForumStore()
const accountInfo = useAccount()
const canOprate = computed(() => {
return !props.topic.isLocked && accountInfo.value.id > 0
})
</script>
<template>
<NFlex align="center" class="forum-reply-item">
<NFlex :wrap="false" align="center">
<NTooltip v-if="replyTo">
<template #trigger>
<NIcon :component="ArrowReply16Filled" />
</template>
<ForumReplyItem :item="replyTo" :comment="comment" :topic="topic" :show-reply-button="false" />
</NTooltip>
<NAvatar
:src="getUserAvatarUrl(item.user.id)"
:img-props="{ referrerpolicy: 'no-referrer' }"
size="small"
round
style="margin-top: -3px; min-width: 28px; min-height: 28px"
/>
<NText strong depth="3" style="white-space: nowrap">
{{ item.user.name }}
</NText>
</NFlex>
{{ item.content }}
<NFlex justify="end" align="center" :wrap="false" style="flex: 1">
<NTooltip>
<template #trigger>
<NText depth="3" style="font-size: small; min-width: 50px">
<NTime :time="item.sendAt" type="relative" />
</NText>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
<NTooltip v-if="showReplyButton">
<template #trigger>
<NButton
size="tiny"
@click="useForum.SetReplyingComment(comment, item)"
round
secondary
:disabled="!canOprate"
>
<template #icon>
<NIcon :component="ArrowReply16Filled" />
</template>
</NButton>
</template>
回复这条回复
</NTooltip>
</NFlex>
</NFlex>
</template>
<style scoped>
@media screen and (min-width: 900px) {
.forum-reply-item {
flex-wrap: nowrap !important;
}
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import { getUserAvatarUrl } from '@/Utils'
import { UserInfo } from '@/api/api-models'
import { ForumCommentModel, ForumCommentSortTypes, ForumTopicModel } from '@/api/models/forum'
import '@/assets/forumContentStyle.css'
import TurnstileVerify from '@/components/TurnstileVerify.vue'
import VEditor from '@/components/VEditor.vue'
import { VTSURU_API_URL } from '@/data/constants'
import { useForumStore } from '@/store/useForumStore'
import { ArrowCircleLeft12Filled, ArrowCircleLeft12Regular, Comment24Regular, Eye24Regular } from '@vicons/fluent'
import { Heart, HeartOutline } from '@vicons/ionicons5'
import {
NAvatar,
NAvatarGroup,
NBackTop,
NBadge,
NButton,
NCard,
NDivider,
NEllipsis,
NEmpty,
NFlex,
NIcon,
NInput,
NList,
NListItem,
NModal,
NText,
NTime,
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ForumCommentItem from './ForumCommentItem.vue'
import ForumReplyItem from './ForumReplyItem.vue'
import { useAccount } from '@/api/account'
type PostCommentModel = {
content: string
topic: number
}
type PostReplyModel = {
content: string
comment: number
replyTo?: number
}
const { biliInfo, userInfo } = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
biliInfo: any | undefined
userInfo: UserInfo | undefined
}>()
const route = useRoute()
const message = useMessage()
const accountInfo = useAccount()
const topicId = ref(-1)
const useForum = useForumStore()
const token = ref('')
const turnstile = ref()
const editorRef = ref()
const showCommentModal = ref(false)
const currentCommentContent = ref<PostCommentModel>({} as PostCommentModel)
const currentReplyContent = ref<PostReplyModel>({} as PostReplyModel)
const topic = ref<ForumTopicModel>({ id: -1 } as ForumTopicModel)
const comments = ref<ForumCommentModel[]>([])
const ps = ref(20)
const pn = ref(0)
const sort = ref(ForumCommentSortTypes.Time)
const canOprate = computed(() => {
return !topic.value.isLocked && accountInfo.value.id > 0
})
async function postComment() {
if (!topic.value.id) return
if (!currentCommentContent.value.content) {
message.error('评论内容不能为空')
return
}
currentCommentContent.value.topic = topic.value.id
useForum
.PostComment(currentCommentContent.value, token.value)
.then(async (comment) => {
if (comment) {
comments.value = (await useForum.GetComments(topic.value.id, pn.value, ps.value, sort.value)) ?? []
currentCommentContent.value = {} as PostCommentModel
showCommentModal.value = false
}
})
.finally(() => {
turnstile.value?.reset()
})
}
async function postReply() {
if (!topic.value.id) return
if (!currentReplyContent.value.content) {
message.error('回复内容不能为空')
return
}
currentReplyContent.value.comment = useForum.replyingComment?.id ?? -1
currentReplyContent.value.replyTo = useForum.replyingReply?.id
useForum
.PostReply(currentReplyContent.value, token.value)
.then(async (comment) => {
if (comment) {
comments.value = (await useForum.GetComments(topic.value.id, pn.value, ps.value, sort.value)) ?? []
currentReplyContent.value = {} as PostReplyModel
useForum.SetReplyingComment()
}
})
.finally(() => {
turnstile.value?.reset()
})
}
onMounted(async () => {
if (route.params.topicId) {
topicId.value = route.params.topicId as unknown as number
topic.value = (await useForum.GetTopicDetail(topicId.value)) ?? ({ id: -1 } as ForumTopicModel)
comments.value = (await useForum.GetComments(topicId.value, pn.value, ps.value, sort.value)) ?? []
}
})
</script>
<template>
<template v-if="!topic.id"> </template>
<template v-else>
<div size="small" embedded style="max-width: 1500px; margin: 0 auto">
<NBackTop />
<NBadge class="back-forum-badge" style="width: 100%; left: 0" type="info" :offset="[3, 3]">
<NCard size="small">
<NText style="font-size: large; font-weight: bold; text-align: center; width: 100%">
<NEllipsis style="width: 100%">
{{ topic.title }}
</NEllipsis>
</NText>
</NCard>
<template #value>
<NTooltip>
<template #trigger>
<NButton text @click="() => $router.push({ name: 'user-forum', params: { id: userInfo?.name } })">
<template #icon>
<NIcon :component="ArrowCircleLeft12Regular" color="white" />
</template>
</NButton>
</template>
返回
</NTooltip>
</template>
</NBadge>
<NCard content-style="padding: 0 12px 0 12px;" embedded>
<template #header>
<NFlex align="center" :size="5">
<NAvatar
:src="VTSURU_API_URL + 'user-face/' + topic?.user?.id + '?size=64'"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider vertical />
{{ topic.user?.name }}
</NFlex>
</template>
<template #header-extra>
<NTooltip>
<template #trigger>
<NText depth="3">
<NTime :time="topic.createAt" type="relative" />
</NText>
</template>
<NTime :time="topic.createAt" />
</NTooltip>
</template>
<template #footer>
<NAvatarGroup
:size="30"
:options="topic.sampleLikedBy?.map((u) => ({ src: getUserAvatarUrl(u) })) ?? []"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider style="margin: 5px 0 10px 0" />
<NFlex>
<NTooltip>
<template #trigger>
<NButton size="small" :bordered="topic.isLiked" text>
<template #icon>
<NIcon :component="Eye24Regular" />
</template>
{{ topic.viewCount }}
</NButton>
</template>
浏览
</NTooltip>
<NTooltip>
<template #trigger>
<NButton
size="small"
@click="
useForum.LikeTopic(topic.id, !topic.isLiked).then((success) => {
if (success) {
topic.isLiked = !topic.isLiked
topic.likeCount += topic.isLiked ? 1 : -1
}
})
"
:bordered="topic.isLiked"
secondary
:type="topic.isLiked ? 'primary' : 'default'"
:loading="useForum.isLikeLoading"
:disabled="!canOprate"
>
<template #icon>
<NIcon :component="topic.isLiked ? Heart : HeartOutline" :color="topic.isLiked ? '#dd484f' : ''" />
</template>
{{ topic.likeCount }}
</NButton>
</template>
点赞
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="small" @click="showCommentModal = true" secondary :disabled="!canOprate">
<template #icon>
<NIcon :component="Comment24Regular" />
</template>
{{ topic.commentCount }}
</NButton>
</template>
评论
</NTooltip>
</NFlex>
</template>
<div class="editor-content-view" v-html="topic.content"></div>
</NCard>
<NDivider>
<NButton @click="showCommentModal = true" type="primary" :disabled="!canOprate">发送评论</NButton>
</NDivider>
<NEmpty v-if="comments.length === 0" description="暂无评论" />
<NList v-else hoverable bordered size="small">
<NListItem v-for="item in comments" :key="item.id">
<ForumCommentItem :item="item" :topic="topic" />
</NListItem>
</NList>
<NDivider />
</div>
</template>
<NModal v-model:show="showCommentModal" preset="card" style="width: 1000px; max-width: 90vw; height: auto">
<template #header> 发送评论 </template>
<VEditor v-model:value="currentCommentContent.content" :max-length="1111" ref="editorRef" />
<NButton type="primary" @click="postComment" :loading="!token || useForum.isLoading"> 发布 </NButton>
</NModal>
<NModal v-model:show="useForum.showReplyModal" preset="card" style="width: 1000px; max-width: 90vw; height: auto">
<template #header> 发送回复 </template>
<template v-if="useForum.replyingReply">
<NCard size="small" title="正在回复" embedded>
<ForumReplyItem
v-if="useForum.replyingReply && useForum.replyingComment"
:item="useForum.replyingReply"
:comment="useForum.replyingComment"
:topic="topic"
:show-reply-button="false"
/>
</NCard>
<NDivider />
</template>
<NInput
v-model:value="currentReplyContent.content"
type="textarea"
placeholder="回复内容"
maxlength="233"
show-count
/>
<NDivider />
<NButton type="primary" @click="postReply" :loading="!token || useForum.isLoading"> 发布 </NButton>
</NModal>
<TurnstileVerify ref="turnstile" v-model="token" />
</template>
<style>
.n-badge-sup {
left: 0 !important;
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { UserInfo } from '@/api/api-models'
import { ForumPostTopicModel, ForumTopicBaseModel, ForumTopicSortTypes, ForumUserLevels } from '@/api/models/forum'
import TurnstileVerify from '@/components/TurnstileVerify.vue'
import VEditor from '@/components/VEditor.vue'
import { TURNSTILE_KEY } from '@/data/constants'
import { useForumStore } from '@/store/useForumStore'
import { useStorage } from '@vueuse/core'
import {
NAlert,
NButton,
NCard,
NDivider,
NFlex,
NInput,
NList,
NListItem,
NModal,
NText,
NTime,
useMessage,
} from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
import VueTurnstile from 'vue-turnstile'
import ForumPreviewItem from './ForumPreviewItem.vue'
import ForumCommentItem from './ForumCommentItem.vue'
const { biliInfo, userInfo } = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
biliInfo: any | undefined
userInfo: UserInfo | undefined
}>()
const token = ref('')
const turnstile = ref()
const editor = ref()
const postTopicBackup = useStorage<{ [key: number]: ForumPostTopicModel }>('Forum.PostTopic', {})
const showPostTopicModal = ref(false)
const currentPostTopicModel = ref<ForumPostTopicModel>({} as ForumPostTopicModel)
const lastBackupTopic = ref(Date.now())
const useForum = useForumStore()
const message = useMessage()
const ps = ref(20)
const pn = ref(0)
const sort = ref(ForumTopicSortTypes.Time)
const forumInfo = ref(await useForum.GetForumInfo(userInfo?.id ?? -1))
const topics = ref<{ data: ForumTopicBaseModel[]; total: number; more: boolean } | undefined>({
data: [],
total: 0,
more: false,
})
async function ApplyToForum() {
if (!forumInfo.value) return
if (await useForum.ApplyToForum(forumInfo.value.owner.id ?? -1)) {
forumInfo.value.isApplied = true
}
}
function backupTopic() {
if (!showPostTopicModal.value) {
return
}
postTopicBackup.value[forumInfo.value?.owner.id ?? -1] = currentPostTopicModel.value
lastBackupTopic.value = Date.now()
}
function postTopic() {
currentPostTopicModel.value.owner = forumInfo.value?.owner.id ?? -1
useForum
.PostTopic(currentPostTopicModel.value, token.value)
.then(async (topic) => {
if (topic) {
currentPostTopicModel.value = {} as ForumPostTopicModel
delete postTopicBackup.value[forumInfo.value?.owner.id ?? -1]
showPostTopicModal.value = false
topics.value = await useForum.GetTopics(forumInfo.value?.owner.id ?? -1, ps.value, pn.value, sort.value)
}
})
.finally(() => {
turnstile.value?.reset()
})
}
let timer: any
onMounted(async () => {
if (forumInfo.value) {
topics.value = await useForum.GetTopics(forumInfo.value.owner.id ?? -1, ps.value, pn.value, sort.value)
if (postTopicBackup.value[forumInfo.value.owner.id ?? -1]) {
currentPostTopicModel.value = postTopicBackup.value[forumInfo.value.owner.id ?? -1]
}
timer = setInterval(async () => {
backupTopic()
}, 10000)
}
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<template>
<NAlert v-if="!forumInfo" type="error"> 用户未创建粉丝讨论区 </NAlert>
<NCard
v-else-if="
(forumInfo.level < ForumUserLevels.Member && forumInfo.settings.requireApply) ||
forumInfo.settings.allowedViewerLevel > forumInfo.level
"
>
<NAlert type="warning"> 你需要成为成员才能访问 </NAlert>
<NAlert v-if="forumInfo.isApplied" type="success"> 已申请, 正在等待管理员审核 </NAlert>
<NCard v-else title="加入">
加入 {{ forumInfo.name }}
<NButton type="primary" @click="ApplyToForum" :loading="useForum.isLoading">
{{ forumInfo.settings.requireApply ? '申请' : '' }}加入
</NButton>
</NCard>
</NCard>
<template v-else>
<NFlex vertical>
<NCard size="small">
<template #header>
<NFlex justify="center">
<NText style="font-size: large">{{ forumInfo.name }}</NText>
</NFlex>
</template>
</NCard>
<NFlex>
<NCard style="max-width: 300px">
<NFlex vertical>
<NButton @click="showPostTopicModal = true"> 发布话题 </NButton>
</NFlex>
</NCard>
<NList bordered style="flex: 1" size="small" hoverable clickable>
<NListItem v-for="item in topics?.data ?? []" :key="item.id">
<a :href="`${$route.path}/topic/${item.id}`" target="_blank">
<ForumPreviewItem :item="item" :forum="forumInfo" />
</a>
</NListItem>
</NList>
</NFlex>
</NFlex>
<NModal preset="card" v-model:show="showPostTopicModal" style="width: 800px; max-width: 95%">
<template #header>
发布话题
<NDivider vertical />
<NText depth="3" style="font-size: small"> 保存于 <NTime :time="lastBackupTopic" format="HH:mm:ss" /> </NText>
</template>
<NFlex vertical>
<NInput v-model:value="currentPostTopicModel.title" placeholder="标题" />
<VEditor v-model:value="currentPostTopicModel.content" :max-length="2333" ref="editor" />
<NButton type="primary" @click="postTopic" :loading="!token || useForum.isLoading"> 发布 </NButton>
</NFlex>
</NModal>
<TurnstileVerify ref="turnstile" v-model="token" />
</template>
</template>
<style scoped>
a {
text-decoration: none;
}
</style>

View File

@@ -60,7 +60,7 @@ export const Config: TemplateConfig<ConfigType> = {
:img-props="{
referrerpolicy: 'no-referrer',
}"
:style="{ boxShadow: isDarkMode() ? 'rgb(195 192 192 / 35%) 0px 5px 20px' : '0 5px 15px rgba(0, 0, 0, 0.2)' }"
:style="{ boxShadow: isDarkMode ? 'rgb(195 192 192 / 35%) 0px 5px 20px' : '0 5px 15px rgba(0, 0, 0, 0.2)' }"
/>
<NSpace align="baseline" justify="center">
<NText strong style="font-size: 32px"> {{ biliInfo?.name }} </NText>