feat: 重构直播信息卡片组件并优化默认设置

- 将 LiveInfoContainer 组件从传统布局重构为现代卡片式布局
- 优化直播封面展示:添加 LIVE 标识、悬停缩放效果和 16:9 宽高比
- 改进信息展示层次:标题、元数据和统计数据分区显示
- 使用图标增强统计数据可读性(弹幕、互动、收益)
- 优化响应式布局,支持移动端和桌面端自适应
- 修改默认启动设置:bootAsMinimized 改为 false
- 新增 Man
This commit is contained in:
2025-11-25 02:21:52 +08:00
parent c79656bc77
commit 9d0ea6c591
18 changed files with 3496 additions and 2462 deletions

View File

@@ -30,7 +30,7 @@ export const useSettings = defineStore('settings', () => {
const defaultSettings: VTsuruClientSettings = {
useDanmakuClientType: 'openlive',
fallbackToOpenLive: true,
bootAsMinimized: true,
bootAsMinimized: false,
danmakuHistorySize: 100,
loginType: 'qrcode',

6
src/components.d.ts vendored
View File

@@ -22,6 +22,7 @@ declare module 'vue' {
FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
ManagePageHeader: typeof import('./components/manage/ManagePageHeader.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
@@ -45,6 +46,7 @@ declare module 'vue' {
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
@@ -72,6 +74,7 @@ declare module 'vue' {
UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']
VEditor: typeof import('./components/VEditor.vue')['default']
VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
VideoItemCard: typeof import('./components/VideoItemCard.vue')['default']
}
}
@@ -87,6 +90,7 @@ declare global {
const FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
const LabelItem: typeof import('./components/LabelItem.vue')['default']
const LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
const ManagePageHeader: typeof import('./components/manage/ManagePageHeader.vue')['default']
const MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
const NAlert: typeof import('naive-ui')['NAlert']
const NAvatar: typeof import('naive-ui')['NAvatar']
@@ -110,6 +114,7 @@ declare global {
const NScrollbar: typeof import('naive-ui')['NScrollbar']
const NSelect: typeof import('naive-ui')['NSelect']
const NSpace: typeof import('naive-ui')['NSpace']
const NSpin: typeof import('naive-ui')['NSpin']
const NStatistic: typeof import('naive-ui')['NStatistic']
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTag: typeof import('naive-ui')['NTag']
@@ -137,4 +142,5 @@ declare global {
const UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']
const VEditor: typeof import('./components/VEditor.vue')['default']
const VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
const VideoItemCard: typeof import('./components/VideoItemCard.vue')['default']
}

View File

@@ -1,22 +1,25 @@
<script setup lang="ts">
import type { ResponseLiveInfoModel } from '@/api/api-models'
import { Info24Filled } from '@vicons/fluent'
import {
NButton,
NDivider,
Info24Filled,
Chat24Regular,
HandRight24Regular,
Money24Regular
} from '@vicons/fluent'
import {
NIcon,
NNumberAnimation,
NPopover,
NSpace,
NStatistic,
NTag,
NTime,
NTooltip,
NText
} from 'naive-ui'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const { live } = defineProps<{
const props = defineProps<{
live: ResponseLiveInfoModel
}>()
const route = useRoute()
@@ -26,126 +29,105 @@ const defaultDanmakusCount = ref(0)
function OnClickCover() {
router.push({
name: 'manage-liveDetail',
params: { id: live.liveId },
params: { id: props.live.liveId },
})
}
const guartPriceStartData = new Date(Date.UTC(2024, 2, 24, 10, 0, 0))
watch(
() => live,
() => props.live,
(newValue) => {
defaultDanmakusCount.value = newValue.danmakusCount
},
{ immediate: true }
)
</script>
<template>
<div style="display: flex; flex-wrap: wrap">
<NSpace style="flex-flow: nowrap">
<span style="display: flex; align-items: center; height: 100%">
<img
referrerpolicy="no-referrer"
:style="!live.isFinish ? 'animation: animated-border 2.5s infinite;cursor: pointer' : 'cursor: pointer'"
class="liveCover"
:src="`${live.coverUrl}@200w`"
lazy
preview-disabled
@click="OnClickCover()"
>
</span>
<NSpace
vertical
justify="center"
style="gap: 2px"
>
<NButton
text
@click="OnClickCover()"
>
<span style="font-size: 18px; white-space: break-spaces">
<div class="live-info-container">
<!-- Cover Image -->
<div class="cover-wrapper" @click.stop="OnClickCover">
<img referrerpolicy="no-referrer" :class="['live-cover', { 'is-live': !live.isFinish }]"
:src="`${live.coverUrl}@200w`" loading="lazy">
<div v-if="!live.isFinish" class="live-badge">LIVE</div>
</div>
<!-- Main Content -->
<div class="content-wrapper">
<div class="info-section">
<!-- Title -->
<div class="title-row" @click.stop="OnClickCover">
<NText class="live-title">
{{ live.title }}
</span>
</NButton>
<span>
<span v-if="!live.isFinish">
<NTag
size="tiny"
:bordered="false"
type="success"
style="justify-items: center; box-shadow: 0 0 3px #589580"
>
</NText>
</div>
<!-- Meta Data -->
<div class="meta-row">
<NSpace align="center" size="small" wrap>
<NTag v-if="!live.isFinish" size="small" :bordered="false" type="success" class="status-tag">
直播中
</NTag>
</span>
<span
v-else
style="color: gray"
>
{{ (((live.stopAt ?? 0) - (live.startAt ?? 0)) / (3600 * 1000)).toFixed(1) }}
</span>
<NDivider vertical />
<NPopover trigger="hover">
<template #trigger>
<div style="color: grey; font-size: small; display: inline">
<NTime
style="font-size: small"
:time="live.startAt"
/>
<NTag v-else size="small" :bordered="false" disabled>
已结束
</NTag>
<span class="meta-divider">|</span>
<span class="meta-text">{{ live.parentArea }} / {{ live.area }}</span>
<span class="meta-divider">|</span>
<NPopover trigger="hover">
<template #trigger>
<span class="meta-text">
<NTime :time="live.startAt" format="yyyy-MM-dd HH:mm" />
</span>
</template>
<div v-if="live.isFinish">
结束于:
<NTime :time="live.stopAt ?? 0" />
<br>
时长: {{ (((live.stopAt ?? 0) - (live.startAt ?? 0)) / (3600 * 1000)).toFixed(1) }} 小时
</div>
</template>
<span v-if="live.isFinish">
结束于:
<NTime :time="live.stopAt ?? 0" />
</span>
<span v-else>
已直播:
{{ ((Date.now() - (live.startAt ?? 0)) / (3600 * 1000)).toFixed(1) }}
</span>
</NPopover>
</span>
</NSpace>
</NSpace>
<div class="liveListItem">
<NStatistic label="分区">
<NTooltip>
<template #trigger>
<span style="font-size: 16px; font-weight: 500">
{{ live.area }}
</span>
</template>
{{ live.parentArea }}
</NTooltip>
</NStatistic>
<NStatistic label="弹幕">
<span style="font-size: 18px; font-weight: 500">
<NNumberAnimation
:from="defaultDanmakusCount"
:to="live.danmakusCount"
show-separator
/>
</span>
</NStatistic>
<NStatistic
label="互动"
tabular-nums
>
<span style="font-size: 18px; font-weight: 500">
<NNumberAnimation
:from="0"
:to="live.interactionCount"
show-separator
/>
</span>
</NStatistic>
<transition>
<NStatistic tabular-nums>
<template #label>
<div v-else>
已直播: {{ ((Date.now() - (live.startAt ?? 0)) / (3600 * 1000)).toFixed(1) }} 小时
</div>
</NPopover>
</NSpace>
</div>
</div>
<!-- Statistics -->
<div class="stats-section">
<div class="stat-item">
<span class="stat-label">
<NIcon :component="Chat24Regular" depth="3" size="14" style="margin-right: 4px; vertical-align: -2px;" />
弹幕
</span>
<span class="stat-value">
<NNumberAnimation :from="defaultDanmakusCount" :to="live.danmakusCount" show-separator />
</span>
</div>
<div class="stat-item">
<span class="stat-label">
<NIcon :component="HandRight24Regular" depth="3" size="14"
style="margin-right: 4px; vertical-align: -2px;" />
互动
</span>
<span class="stat-value">
<NNumberAnimation :from="0" :to="live.interactionCount" show-separator />
</span>
</div>
<div class="stat-item income">
<span class="stat-label">
<NIcon :component="Money24Regular" depth="3" size="14" style="margin-right: 4px; vertical-align: -2px;" />
收益
<NTooltip v-if="new Date(live.startAt) < guartPriceStartData">
<template #trigger>
<NIcon :component="Info24Filled" />
<NIcon :component="Info24Filled" style="vertical-align: middle; cursor: help;" />
</template>
因为官方并没有提供上舰的价格, 所以记录中的舰长价格一律按照打折价格计算
<br>
@@ -153,95 +135,195 @@ watch(
<br>
把鼠标放在下面的价格上就可以查看排除舰长后的收益
</NTooltip>
</template>
</span>
<NTooltip>
<template #trigger>
<span style="font-size: 18px; font-weight: 500; color: #a35353">
<NNumberAnimation
:from="0"
<span class="stat-value income-value">
¥
<NNumberAnimation :from="0"
:to="new Date(live.startAt) < guartPriceStartData ? live.totalIncomeWithGuard : live.totalIncome"
show-separator
/>
show-separator :precision="1" />
</span>
</template>
{{ live.totalIncome }}
纯收益: ¥{{ live.totalIncome }}
</NTooltip>
</NStatistic>
</transition>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.n-statistic {
text-align: right;
min-width: 62px;
.live-info-container {
display: flex;
gap: 12px;
width: 100%;
}
@media (max-width: 750px) {
.liveCover {
width: 90px;
height: fit-content;
border-radius: 4px;
.cover-wrapper {
position: relative;
flex-shrink: 0;
width: 140px;
/* 16:9 approx height 78px */
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
background-color: #f0f0f0;
}
.live-cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.cover-wrapper:hover .live-cover {
transform: scale(1.05);
}
.live-badge {
position: absolute;
top: 4px;
left: 4px;
background-color: #d03050;
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
border-radius: 3px;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.content-wrapper {
flex: 1;
display: flex;
justify-content: space-between;
min-width: 0;
/* prevent flex item overflow */
}
.info-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
padding-right: 24px;
padding-top: 2px;
padding-bottom: 2px;
}
.title-row {
cursor: pointer;
margin-bottom: 0;
}
.live-title {
font-size: 15px;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.2s;
}
.title-row:hover .live-title {
color: var(--n-primary-color);
}
.meta-text {
font-size: 12px;
color: var(--n-text-color-3);
}
.meta-divider {
color: var(--n-divider-color);
font-size: 12px;
margin: 0 2px;
}
.status-tag {
box-shadow: 0 0 3px rgba(88, 149, 128, 0.4);
}
.stats-section {
display: flex;
gap: 24px;
align-items: center;
padding-left: 24px;
border-left: 1px solid var(--n-divider-color);
flex-shrink: 0;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 60px;
}
.stat-label {
font-size: 12px;
color: var(--n-text-color-3);
margin-bottom: 2px;
display: flex;
align-items: center;
}
.stat-value {
font-size: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.5px;
}
.stat-value.income-value {
color: #d03050;
}
/* Mobile / Tablet Responsive */
@media (max-width: 768px) {
.live-info-container {
flex-direction: column;
gap: 12px;
}
.liveList {
display: flex;
flex-flow: row wrap;
gap: 8px 10px;
}
.liveListItem {
padding-top: 10px;
display: flex;
.cover-wrapper {
width: 100%;
justify-content: space-between;
max-width: 320px;
/* Limit max width on phone */
margin: 0 auto;
/* Center cover if column */
}
.dateEChartStyle {
height: 500px;
}
}
@media (min-width: 750px) {
.liveCover {
border-radius: 4px;
width: 120px;
.content-wrapper {
flex-direction: column;
gap: 8px;
}
.liveList {
display: flex;
.info-section {
padding-right: 0;
gap: 8px;
}
.liveListItem {
display: flex;
gap: 1rem;
flex-grow: 1;
justify-content: end;
.stats-section {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--n-divider-color);
padding-top: 8px;
justify-content: space-around;
width: 100%;
gap: 12px;
}
.dateEChartStyle {
height: 150px;
.stat-item {
align-items: center;
}
}
@keyframes animated-border {
0% {
box-shadow: 0 0 0px #589580;
}
100% {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0);
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@@ -42,7 +42,7 @@ function onClick() {
<template>
<NCard
size="small"
style="width: 100%; max-width: 70vw; cursor: pointer"
style="width: 100%; cursor: pointer"
embedded
hoverable
:bordered="bordered"

View File

@@ -0,0 +1,340 @@
<script setup lang="ts">
import type { VideoCollectVideo, VideoInfo } from '@/api/api-models'
import { VideoStatus } from '@/api/api-models'
import { Clock24Filled, Person24Filled } from '@vicons/fluent'
import {
NButton,
NCard,
NEllipsis,
NIcon,
NPopconfirm,
NScrollbar,
NSpace,
NTag,
NText,
NTooltip,
} from 'naive-ui'
import { computed } from 'vue'
const props = defineProps<{
videoInfo: VideoInfo
videoData: VideoCollectVideo
type: 'padding' | 'accept' | 'reject'
isLoading?: boolean
}>()
const emit = defineEmits<{
(e: 'update-status', status: VideoStatus, video: VideoInfo): void
}>()
function handleStatusChange(status: VideoStatus) {
emit('update-status', status, props.videoInfo)
}
function formatSeconds(seconds: number): string {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
const formattedMinutes = minutes.toString().padStart(2, '0')
const formattedSeconds = remainingSeconds.toString().padStart(2, '0')
return `${formattedMinutes}:${formattedSeconds}`
}
function openVideo() {
window.open(`https://www.bilibili.com/video/${props.videoInfo.bvid}`, '_blank')
}
const statusTag = computed(() => {
switch (props.videoInfo.status) {
case VideoStatus.Accepted:
return { type: 'success', text: '已通过' }
case VideoStatus.Rejected:
return { type: 'error', text: '已拒绝' }
default:
return { type: 'default', text: '待审核' }
}
})
</script>
<template>
<NCard
size="small"
hoverable
embedded
class="video-card"
content-style="padding: 0;"
>
<template #cover>
<div
class="cover-container"
@click="openVideo"
>
<img
:src="videoData.cover.replace('http://', 'https://')"
referrerpolicy="no-referrer"
class="cover-img"
>
<div class="cover-info">
<span class="info-item">
<NIcon
:component="Clock24Filled"
color="lightgrey"
/>
<NText style="color: lightgrey; font-size: 12px; margin-left: 4px">
{{ formatSeconds(videoData.length) }}
</NText>
</span>
<span class="info-item">
<NIcon
:component="Person24Filled"
color="lightgrey"
/>
<NText style="color: lightgrey; font-size: 12px; margin-left: 4px">
{{ videoData.ownerName }}
</NText>
</span>
</div>
</div>
</template>
<div class="card-content">
<div class="title-row">
<NButton
text
style="width: 100%; justify-content: flex-start; text-align: left;"
@click="openVideo"
>
<NEllipsis style="max-width: 100%">
<template #tooltip>
<div style="max-width: 300px">
{{ videoData.title }}
</div>
</template>
<span style="font-weight: 500; font-size: 15px;">{{ videoData.title }}</span>
</NEllipsis>
</NButton>
</div>
<div class="sender-info">
<NScrollbar style="max-height: 80px">
<div
v-for="(sender, index) in videoInfo.senders"
:key="index"
class="sender-item"
>
<div class="sender-row">
<NTag
size="small"
:bordered="false"
round
style="margin-right: 6px; transform: scale(0.85); transform-origin: left center;"
>
推荐人
</NTag>
<NText depth="2">
{{ sender.sender ?? '未填写' }}
<span style="opacity: 0.5">[{{ sender.senderId ?? '未填写' }}]</span>
</NText>
</div>
<div
v-if="sender.description"
class="sender-desc"
>
<NText depth="3">
{{ sender.description }}
</NText>
</div>
<NDivider
v-if="index < videoInfo.senders.length - 1"
style="margin: 8px 0"
/>
</div>
</NScrollbar>
</div>
<div class="action-area">
<template v-if="type === 'padding'">
<NSpace
justify="space-between"
:wrap="false"
>
<NButton
strong
secondary
type="success"
size="small"
class="flex-1"
:loading="isLoading"
@click="handleStatusChange(VideoStatus.Accepted)"
>
通过
</NButton>
<NButton
strong
secondary
type="error"
size="small"
class="flex-1"
:loading="isLoading"
@click="handleStatusChange(VideoStatus.Rejected)"
>
拒绝
</NButton>
</NSpace>
</template>
<template v-if="type === 'accept'">
<NSpace
justify="space-between"
:wrap="false"
>
<NButton
secondary
size="small"
class="flex-1"
:loading="isLoading"
@click="handleStatusChange(VideoStatus.Pending)"
>
重置
</NButton>
<NPopconfirm @positive-click="handleStatusChange(VideoStatus.Rejected)">
<template #trigger>
<NButton
strong
secondary
type="error"
size="small"
class="flex-1"
:loading="isLoading"
>
拒绝
</NButton>
</template>
确定要拒绝这个已通过的视频吗
</NPopconfirm>
</NSpace>
</template>
<template v-if="type === 'reject'">
<NSpace
justify="space-between"
:wrap="false"
>
<NButton
strong
secondary
type="success"
size="small"
class="flex-1"
:loading="isLoading"
@click="handleStatusChange(VideoStatus.Accepted)"
>
通过
</NButton>
<NButton
secondary
size="small"
class="flex-1"
:loading="isLoading"
@click="handleStatusChange(VideoStatus.Pending)"
>
重置
</NButton>
</NSpace>
</template>
</div>
</div>
</NCard>
</template>
<style scoped>
.video-card {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
}
.video-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.cover-container {
position: relative;
height: 140px;
overflow: hidden;
cursor: pointer;
background: #f0f0f0;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.cover-container:hover .cover-img {
transform: scale(1.05);
}
.cover-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
display: flex;
justify-content: space-between;
align-items: center;
}
.info-item {
display: flex;
align-items: center;
}
.card-content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.title-row {
height: 24px;
display: flex;
align-items: center;
}
.sender-info {
background: rgba(0, 0, 0, 0.02);
border-radius: 4px;
padding: 8px;
font-size: 12px;
min-height: 60px;
}
.sender-row {
display: flex;
align-items: center;
margin-bottom: 2px;
}
.sender-desc {
padding-left: 4px;
line-height: 1.4;
}
.action-area {
margin-top: auto;
padding-top: 4px;
}
.flex-1 {
flex: 1;
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { FunctionTypes } from '@/api/api-models'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { useMessage, NFlex, NSpace, NSwitch, NText } from 'naive-ui'
import { ref } from 'vue'
const props = defineProps<{
title: string
subtitle?: string
functionType?: FunctionTypes // 如果不传,则不显示开关
loading?: boolean
}>()
const accountInfo = useAccount()
const message = useMessage()
const switchLoading = ref(false)
async function setFunctionEnable(enable: boolean) {
if (!props.functionType) return
switchLoading.value = true
try {
const success = enable
? await EnableFunction(props.functionType)
: await DisableFunction(props.functionType)
if (success) {
message.success(`${props.title}功能已${enable ? '启用' : '禁用'}`)
// 更新本地状态
if (accountInfo.value?.settings?.enableFunctions) {
const list = accountInfo.value.settings.enableFunctions
if (enable && !list.includes(props.functionType)) {
list.push(props.functionType)
} else if (!enable) {
const index = list.indexOf(props.functionType)
if (index > -1) list.splice(index, 1)
}
}
} else {
message.error(`无法${enable ? '启用' : '禁用'}${props.title}功能`)
}
} catch (err) {
message.error(`操作失败: ${err}`)
} finally {
switchLoading.value = false
}
}
</script>
<template>
<div style="margin-bottom: 16px;">
<NFlex justify="space-between" align="center" wrap>
<NSpace align="center" size="large">
<div>
<h2 style="margin: 0; font-weight: 500; line-height: 1.2;">
{{ title }}
</h2>
<NText
v-if="props.subtitle"
depth="3"
style="font-size: 12px; margin-top: 4px; display: block;"
>
{{ props.subtitle }}
</NText>
</div>
<!-- 功能启用开关 -->
<NFlex v-if="functionType && accountInfo" align="center" size="small">
<NSwitch
:value="accountInfo.settings?.enableFunctions?.includes(functionType)"
:loading="switchLoading"
:disabled="loading || switchLoading"
@update:value="setFunctionEnable"
>
<template #checked>已启用</template>
<template #unchecked>已禁用</template>
</NSwitch>
</NFlex>
</NSpace>
<!-- 右侧操作按钮插槽 -->
<NSpace>
<slot name="action" />
</NSpace>
</NFlex>
<!-- 底部额外内容插槽 (如提示信息) -->
<div style="margin-top: 12px;">
<slot />
</div>
</div>
</template>

View File

@@ -1,5 +1,14 @@
<script lang="ts" setup>
import { RefreshOutline, TrendingDown, TrendingUp } from '@vicons/ionicons5'
import {
CalendarOutline,
ChatbubblesOutline,
PeopleOutline,
RefreshOutline,
TimeOutline,
TrendingDown,
TrendingUp,
WalletOutline,
} from '@vicons/ionicons5'
import { BarChart, LineChart } from 'echarts/charts'
import {
DataZoomComponent,
@@ -12,7 +21,26 @@ import {
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { NButton, NCard, NDivider, NEmpty, NGrid, NGridItem, NIcon, NSpace, NSpin, NStatistic, NTabPane, NTabs, NTag, NTime, NTooltip, useMessage, useThemeVars } from 'naive-ui'
import {
NButton,
NCard,
NDivider,
NEmpty,
NGrid,
NGridItem,
NIcon,
NProgress,
NSkeleton,
NSpace,
NStatistic,
NTabPane,
NTabs,
NTag,
NTime,
NTooltip,
useMessage,
useThemeVars,
} from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { QueryGetAPI } from '@/api/query'
@@ -517,343 +545,397 @@ onUnmounted(() => {
</NSpace>
<NDivider />
<NSpin :show="loading">
<!-- 空状态 -->
<NEmpty
v-if="!loading && !hasData"
description="暂无数据"
size="large"
style="margin: 60px 0"
>
<template #extra>
<NButton @click="() => fetchAnalyzeData()">
重新加载
</NButton>
</template>
</NEmpty>
<!-- 数据展示 -->
<template v-else>
<!-- 数据概览卡片 -->
<div class="summary-cards">
<NGrid
cols="1 800:2 1200:3"
:x-gap="16"
:y-gap="16"
>
<NGridItem>
<NCard
title="近7天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="info">
最近一周
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="总收入"
tabular-nums
>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last7Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last7Days?.totalInteractions || 0"
tabular-nums
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last7Days?.totalDanmakuCount || 0"
tabular-nums
/>
<NStatistic
label="直播时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last7Days?.interactionUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.interactionUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.interactionUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.interactionUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.interactionUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last7Days?.payingUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.payingUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.payingUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.payingUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.payingUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
tabular-nums
>
<template #default>
{{ formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="日均弹幕"
:value="(summaryData?.last7Days?.dailyAvgDanmaku || 0).toFixed(0)"
tabular-nums
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last7Days?.activeLiveDays || 0"
tabular-nums
/>
<!-- 加载骨架屏 -->
<div v-if="loading" class="skeleton-container">
<div class="summary-cards">
<NGrid cols="1 800:2 1200:3" :x-gap="16" :y-gap="16">
<NGridItem v-for="i in 3" :key="i">
<NCard size="small" class="summary-card">
<template #header>
<NSkeleton text width="30%" />
</template>
<div class="stat-grid">
<div v-for="j in 8" :key="j" class="skeleton-item">
<NSkeleton text width="60px" style="margin-bottom: 8px" />
<NSkeleton text width="80%" height="24px" />
</div>
</NCard>
</NGridItem>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<div class="chart-skeleton">
<NSkeleton height="450px" width="100%" border-radius="8px" />
</div>
</div>
<NGridItem>
<NCard
title="近30天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="warning">
最近一月
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="总收入"
tabular-nums
>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last30Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last30Days?.totalInteractions || 0"
tabular-nums
/>
<NStatistic
label="弹幕数"
:value="summaryData?.last30Days?.totalDanmakuCount || 0"
tabular-nums
/>
<NStatistic
label="直播时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last30Days?.interactionUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.interactionTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.interactionTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.interactionTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last30Days?.payingUsers || 0"
tabular-nums
>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.incomeTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.incomeTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.incomeTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="日均收入"
tabular-nums
>
<template #default>
{{ formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="日均弹幕"
:value="(summaryData?.last30Days?.dailyAvgDanmaku || 0).toFixed(0)"
tabular-nums
/>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last30Days?.activeLiveDays || 0"
tabular-nums
/>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="关键指标"
size="small"
class="summary-card summary-card-highlight"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="success">
核心数据
</NTag>
</template>
<div class="stat-grid">
<NStatistic
label="月收入增长"
tabular-nums
>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon :color="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? '#18A058' : '#D03050'">
<TrendingUp v-if="(summaryData?.last30Days?.incomeTrend || 0) >= 0" />
<TrendingDown v-else />
</NIcon>
</template>
</NStatistic>
<NStatistic
label="月互动增长"
tabular-nums
>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon
:component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown"
:color="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? '#18A058' : '#D03050'"
/>
</template>
</NStatistic>
<NStatistic
label="单次直播平均时长"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动转化率"
tabular-nums
>
<template #default>
{{ ((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1) }}%
</template>
</NStatistic>
<NStatistic
label="每付费用户平均收入"
tabular-nums
>
<template #default>
{{ formatCurrency((summaryData?.last30Days?.totalIncome || 0) / (summaryData?.last30Days?.payingUsers || 1)) }}
</template>
</NStatistic>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<NDivider />
<!-- 图表选择器 -->
<div class="chart-selector">
<NTabs
v-model:value="activeChart"
type="line"
animated
@update:value="onTabChange"
>
<NTabPane
name="income"
tab="收入分析"
display-directive="show"
>
<div
ref="incomeChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="interaction"
tab="互动分析"
display-directive="show"
>
<div
ref="interactionChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="users"
tab="用户分析"
display-directive="show"
>
<div
ref="usersChartRef"
class="chart"
/>
</NTabPane>
</NTabs>
</div>
<!-- 空状态 -->
<NEmpty
v-else-if="!hasData"
description="暂无数据"
size="large"
style="margin: 60px 0"
>
<template #extra>
<NButton @click="() => fetchAnalyzeData()">
重新加载
</NButton>
</template>
</NSpin>
</NEmpty>
<!-- 数据展示 -->
<template v-else>
<!-- 数据概览卡片 -->
<div class="summary-cards">
<NGrid
cols="1 800:2 1200:3"
:x-gap="16"
:y-gap="16"
>
<NGridItem>
<NCard
title="近7天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="info">
最近一周
</NTag>
</template>
<div class="stat-grid">
<NStatistic label="总收入" tabular-nums>
<template #prefix>
<NIcon :component="WalletOutline" color="#f5a623" />
</template>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last7Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last7Days?.totalInteractions || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="ChatbubblesOutline" color="#2080f0" />
</template>
</NStatistic>
<NStatistic
label="弹幕数"
:value="summaryData?.last7Days?.totalDanmakuCount || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="ChatbubblesOutline" color="#2080f0" />
</template>
</NStatistic>
<NStatistic label="直播时长" tabular-nums>
<template #prefix>
<NIcon :component="TimeOutline" />
</template>
<template #default>
{{ ((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last7Days?.interactionUsers || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="PeopleOutline" color="#18a058" />
</template>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.interactionUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.interactionUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.interactionUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.interactionUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last7Days?.payingUsers || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="PeopleOutline" color="#f5a623" />
</template>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last7Days?.payingUsersTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last7Days?.payingUsersTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last7Days?.payingUsersTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last7Days?.payingUsersTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic label="日均收入" tabular-nums>
<template #prefix>
<NIcon :component="WalletOutline" color="#f5a623" />
</template>
<template #default>
{{ formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last7Days?.activeLiveDays || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="CalendarOutline" />
</template>
</NStatistic>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="近30天统计"
size="small"
class="summary-card"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="warning">
最近一月
</NTag>
</template>
<div class="stat-grid">
<NStatistic label="总收入" tabular-nums>
<template #prefix>
<NIcon :component="WalletOutline" color="#f5a623" />
</template>
<template #default>
<span class="stat-value-primary">
{{ formatCurrency(summaryData?.last30Days?.totalIncome || 0) }}
</span>
</template>
</NStatistic>
<NStatistic
label="总互动数"
:value="summaryData?.last30Days?.totalInteractions || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="ChatbubblesOutline" color="#2080f0" />
</template>
</NStatistic>
<NStatistic
label="弹幕数"
:value="summaryData?.last30Days?.totalDanmakuCount || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="ChatbubblesOutline" color="#2080f0" />
</template>
</NStatistic>
<NStatistic label="直播时长" tabular-nums>
<template #prefix>
<NIcon :component="TimeOutline" />
</template>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<NStatistic
label="互动人数"
:value="summaryData?.last30Days?.interactionUsers || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="PeopleOutline" color="#18a058" />
</template>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.interactionTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.interactionTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.interactionTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic
label="付费人数"
:value="summaryData?.last30Days?.payingUsers || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="PeopleOutline" color="#f5a623" />
</template>
<template #suffix>
<NTag
:type="getTrendType(summaryData?.last30Days?.incomeTrend || 0)"
size="small"
:bordered="false"
>
<NIcon v-if="(summaryData?.last30Days?.incomeTrend || 0) > 0" :component="TrendingUp" style="vertical-align: -0.15em; margin-right: 2px;" />
<NIcon v-else-if="(summaryData?.last30Days?.incomeTrend || 0) < 0" :component="TrendingDown" style="vertical-align: -0.15em; margin-right: 2px;" />
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</NTag>
</template>
</NStatistic>
<NStatistic label="日均收入" tabular-nums>
<template #prefix>
<NIcon :component="WalletOutline" color="#f5a623" />
</template>
<template #default>
{{ formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0) }}
</template>
</NStatistic>
<NStatistic
label="活跃直播天数"
:value="summaryData?.last30Days?.activeLiveDays || 0"
tabular-nums
>
<template #prefix>
<NIcon :component="CalendarOutline" />
</template>
</NStatistic>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard
title="关键指标"
size="small"
class="summary-card summary-card-highlight"
hoverable
>
<template #header-extra>
<NTag :bordered="false" size="small" type="success">
核心数据
</NTag>
</template>
<div class="stat-grid">
<NStatistic label="月收入增长" tabular-nums>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon :color="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? '#18A058' : '#D03050'">
<TrendingUp v-if="(summaryData?.last30Days?.incomeTrend || 0) >= 0" />
<TrendingDown v-else />
</NIcon>
</template>
</NStatistic>
<NStatistic label="月互动增长" tabular-nums>
<template #default>
<span class="trend-value" :class="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? 'trend-up' : 'trend-down'">
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
</span>
</template>
<template #prefix>
<NIcon
:component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown"
:color="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? '#18A058' : '#D03050'"
/>
</template>
</NStatistic>
<NStatistic label="单次直播平均时长" tabular-nums>
<template #prefix>
<NIcon :component="TimeOutline" />
</template>
<template #default>
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1) }} 小时
</template>
</NStatistic>
<!-- 互动转化率 - 进度条优化 -->
<div class="stat-item">
<div class="stat-label">互动转化率</div>
<NSpace align="center" :size="10">
<NProgress
type="line"
:percentage="Math.min(100, Math.round(((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100) * 10) / 10)"
:indicator-placement="'inside'"
:height="18"
color="#18a058"
rail-color="rgba(24, 160, 88, 0.2)"
style="width: 120px"
/>
<span class="stat-value-small">
{{ ((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1) }}%
</span>
</NSpace>
</div>
<NStatistic label="每付费用户平均收入" tabular-nums>
<template #prefix>
<NIcon :component="WalletOutline" color="#f5a623" />
</template>
<template #default>
{{ formatCurrency((summaryData?.last30Days?.totalIncome || 0) / (summaryData?.last30Days?.payingUsers || 1)) }}
</template>
</NStatistic>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<NDivider />
<!-- 图表选择器 -->
<div class="chart-selector">
<NTabs
v-model:value="activeChart"
type="line"
animated
@update:value="onTabChange"
>
<NTabPane
name="income"
tab="收入分析"
display-directive="show"
>
<div
ref="incomeChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="interaction"
tab="互动分析"
display-directive="show"
>
<div
ref="interactionChartRef"
class="chart"
/>
</NTabPane>
<NTabPane
name="users"
tab="用户分析"
display-directive="show"
>
<div
ref="usersChartRef"
class="chart"
/>
</NTabPane>
</NTabs>
</div>
</template>
</div>
</template>
@@ -871,10 +953,6 @@ onUnmounted(() => {
gap: 8px;
}
.analyze-card {
margin-bottom: 20px;
}
.summary-cards {
margin-bottom: 20px;
}
@@ -897,7 +975,7 @@ onUnmounted(() => {
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
gap: 16px 24px;
}
.stat-value-primary {
@@ -906,6 +984,22 @@ onUnmounted(() => {
color: var(--n-text-color);
}
.stat-value-small {
font-size: 0.9em;
color: var(--n-text-color-2);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 14px;
color: var(--n-text-color-3);
}
.trend-value {
font-weight: 600;
font-size: 1.1em;
@@ -933,6 +1027,24 @@ onUnmounted(() => {
transition: height 0.3s ease;
}
/* Skeleton Styles */
.skeleton-container {
width: 100%;
}
.skeleton-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-skeleton {
margin-top: 20px;
padding: 20px;
background: var(--n-card-color);
border-radius: 8px;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.chart {
@@ -953,7 +1065,7 @@ onUnmounted(() => {
.stat-grid {
grid-template-columns: 1fr;
gap: 12px;
gap: 16px;
}
.chart {
@@ -985,22 +1097,6 @@ onUnmounted(() => {
}
}
/* 骨架屏动画 */
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
/* 标签优化 */
:deep(.n-statistic-value__prefix) {
margin-right: 8px;
@@ -1029,6 +1125,6 @@ onUnmounted(() => {
}
.summary-card:hover :deep(.n-statistic) {
transform: scale(1.02);
transform: translateX(4px);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
import type { ResponseLiveInfoModel } from '@/api/api-models'
import { NAlert, NButton, NDivider, NEmpty, NInput, NInputNumber, NSelect, NSkeleton, NList, NListItem, NPagination, NSpace, NSwitch, useMessage } from 'naive-ui'
import {
ArrowSort24Filled,
ArrowSync24Filled,
Search24Filled
} from '@vicons/fluent'
import { NAlert, NButton, NCard, NDivider, NEmpty, NIcon, NInput, NInputNumber, NList, NListItem, NPagination, NSelect, NSkeleton, NSpace, NSwitch, useMessage } from 'naive-ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalStorage, useSessionStorage, useStorage } from '@vueuse/core'
@@ -127,7 +132,7 @@ function syncStateToQuery() {
sort: sortKey.value !== 'startAt' ? sortKey.value : undefined,
order: sortOrder.value !== 'desc' ? sortOrder.value : undefined,
},
}).catch(() => {})
}).catch(() => { })
}
watch([page, pageSize, keyword, statusFilter, sortKey, sortOrder], syncStateToQuery)
@@ -162,124 +167,117 @@ onBeforeUnmount(() => {
</script>
<template>
<NSpace
vertical
justify="center"
align="center"
>
<NSpace vertical justify="center" align="center" :size="24">
<EventFetcherAlert />
<EventFetcherStatusCard />
</NSpace>
<NDivider />
<NAlert
v-if="accountInfo?.isBiliVerified != true"
type="info"
>
尚未进行Bilibili认证
</NAlert>
<div v-if="accountInfo?.isBiliVerified != true" style="margin-top: 24px;">
<NAlert type="info" title="未认证">
尚未进行Bilibili认证部分功能可能受限
</NAlert>
</div>
<template v-else>
<NSpace
wrap
align="center"
justify="space-between"
style="width: 100%"
>
<NSpace align="center" wrap>
<NInput
v-model:value="keyword"
placeholder="搜索标题或ID"
clearable
style="min-width: 220px"
/>
<NSelect
v-model:value="statusFilter"
:options="[
{ label: '全部', value: 'all' },
<NCard style="margin-top: 24px; margin-bottom: 24px; max-width: 1200px; left: 50%; transform: translateX(-50%);" size="small">
<NSpace justify="space-between" align="center" wrap item-style="flex-grow: 1">
<!-- Left: Search and Filter -->
<NSpace align="center" wrap>
<NInput v-model:value="keyword" placeholder="搜索标题或ID" clearable style="width: 280px">
<template #prefix>
<NIcon :component="Search24Filled" />
</template>
</NInput>
<NSelect v-model:value="statusFilter" :options="[
{ label: '全部状态', value: 'all' },
{ label: '直播中', value: 'live' },
{ label: '已结束', value: 'finished' },
]"
style="width: 120px"
/>
<NSelect
v-model:value="sortKey"
:options="[
{ label: '开始时间', value: 'startAt' },
{ label: '弹幕数', value: 'danmakusCount' },
{ label: '互动数', value: 'interactionCount' },
{ label: '收益', value: 'totalIncome' },
]"
style="width: 140px"
/>
<NSelect
v-model:value="sortOrder"
:options="[
{ label: '序', value: 'desc' },
{ label: '升序', value: 'asc' },
]"
style="width: 100px"
/>
]" style="width: 140px">
</NSelect>
</NSpace>
<!-- Right: Sort and Actions -->
<NSpace align="center" wrap>
<NSpace align="center" :size="12">
<span style="color: var(--n-text-color-3); font-size: 12px;">排序:</span>
<NSelect v-model:value="sortKey" size="small" :options="[
{ label: '开始时间', value: 'startAt' },
{ label: '弹幕数', value: 'danmakusCount' },
{ label: '互动数', value: 'interactionCount' },
{ label: '收益', value: 'totalIncome' },
]" style="width: 120px" />
<NSelect v-model:value="sortOrder" size="small" :options="[
{ label: '降序', value: 'desc' },
{ label: '序', value: 'asc' },
]" style="width: 90px" />
</NSpace>
<NDivider vertical />
<NSpace align="center">
<NSwitch v-model:value="enableAutoRefresh" size="small">
<template #checked>自动刷新</template>
<template #unchecked>自动刷新</template>
</NSwitch>
<NInputNumber v-if="enableAutoRefresh" v-model:value="refreshSeconds" size="small" style="width: 80px"
:min="10" placeholder="秒">
<template #suffix>s</template>
</NInputNumber>
<NButton size="small" secondary type="primary" :loading="isLoading" @click="getAll()">
<template #icon>
<NIcon :component="ArrowSync24Filled" />
</template>
刷新
</NButton>
</NSpace>
</NSpace>
</NSpace>
<NSpace align="center">
<NSwitch v-model:value="enableAutoRefresh">
<template #checked>自动刷新</template>
<template #unchecked>自动刷新</template>
</NSwitch>
<NInputNumber
v-model:value="refreshSeconds"
style="width: 100px"
:min="10"
:disabled="!enableAutoRefresh"
placeholder="刷新秒数"
/>
<NButton
type="primary"
tertiary
:loading="isLoading"
@click="getAll()"
>
刷新
</NButton>
</NSpace>
</NSpace>
<NSpace
vertical
justify="center"
align="center"
>
<NPagination
v-model:page="page"
v-model:page-size="pageSize"
show-quick-jumper
show-size-picker
:page-sizes="[10, 20, 30, 40]"
:item-count="filteredAndSortedLives.length"
/>
</NSpace>
<NDivider />
<NSkeleton v-if="isLoading" text :repeat="5" />
</NCard>
<NSkeleton v-if="isLoading && !lives.length" text :repeat="5" />
<template v-else>
<NEmpty v-if="!filteredAndSortedLives.length" description="无数据">
<NEmpty v-if="!filteredAndSortedLives.length" description="没有找到符合条件的直播记录">
<template #extra>
<NButton type="primary" @click="getAll"></NButton>
<NButton type="primary" @click="getAll">新加载</NButton>
</template>
</NEmpty>
<NList
v-else
bordered
hoverable
clickable
>
<NListItem
v-for="live in pagedLives"
:key="live.liveId"
@click="OnClickCover(live)"
>
<LiveInfoContainer
:key="live.liveId"
:live="live"
/>
</NListItem>
</NList>
<div v-else class="list-container">
<NList hoverable clickable class="live-list">
<NListItem v-for="live in pagedLives" :key="live.liveId" @click="OnClickCover(live)" class="live-list-item">
<LiveInfoContainer :key="live.liveId" :live="live" />
</NListItem>
</NList>
<NSpace justify="center" align="center" style="margin-top: 24px; margin-bottom: 48px;">
<NPagination v-model:page="page" v-model:page-size="pageSize" show-quick-jumper show-size-picker
:page-sizes="[10, 20, 30, 40]" :item-count="filteredAndSortedLives.length" />
</NSpace>
</div>
</template>
</template>
</template>
<style scoped>
.list-container {
max-width: 1000px;
margin: 0 auto;
}
.live-list {
background: transparent;
}
.live-list-item {
padding: 16px;
border-radius: 8px;
transition: background-color 0.2s;
margin-bottom: 8px;
background-color: rgba(255, 255, 255, 0.02);
}
.live-list-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { QAInfo, Setting_QuestionDisplay } from '@/api/api-models'
import { Delete24Filled, Delete24Regular, Eye24Filled, EyeOff24Filled, Info24Filled } from '@vicons/fluent'
import { Heart, HeartOutline, TrashBin } from '@vicons/ionicons5'
import { ArrowSync24Filled, Copy24Filled, Delete24Filled, Delete24Regular, Eye24Filled, EyeOff24Filled, Info24Filled, Link24Filled, Share24Filled } from '@vicons/fluent'
import { Heart, HeartOutline, SettingsOutline, TrashBin } from '@vicons/ionicons5'
import { useStorage } from '@vueuse/core'
// @ts-ignore
import { saveAs } from 'file-saver'
@@ -22,6 +22,7 @@ import {
NList,
NListItem,
NModal,
NPageHeader,
NPagination,
NPopconfirm,
NSelect,
@@ -36,12 +37,19 @@ import {
NTime,
NTooltip,
useMessage,
NGrid,
NGi,
NStatistic,
NThing,
NCollapse,
NCollapseItem,
} from 'naive-ui'
import QrcodeVue from 'qrcode.vue'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { DisableFunction, EnableFunction, SaveAccountSettings, SaveSetting, useAccount } from '@/api/account'
import { SaveAccountSettings, SaveSetting, useAccount } from '@/api/account'
import { FunctionTypes } from '@/api/api-models'
import ManagePageHeader from '@/components/manage/ManagePageHeader.vue'
import QuestionItem from '@/components/QuestionItem.vue'
import QuestionItems from '@/components/QuestionItems.vue'
import { CURRENT_HOST } from '@/data/constants'
@@ -230,40 +238,6 @@ async function saveNotificationSetting() {
}
}
// 启用或禁用提问箱功能
async function setFunctionEnable(enable: boolean) {
let success = false
try {
if (enable) {
success = await EnableFunction(FunctionTypes.QuestionBox) // 调用启用API
} else {
success = await DisableFunction(FunctionTypes.QuestionBox) // 调用禁用API
}
if (success) {
message.success(`提问箱功能已${enable ? '启用' : '禁用'}`)
// 成功后可能需要更新 accountInfo 中的 enableFunctions 状态, useAccount 可能需要提供更新方法或自动刷新
// 假设 useAccount() 会自动更新或有刷新机制
if (accountInfo.value?.settings?.enableFunctions) {
if (enable && !accountInfo.value.settings.enableFunctions.includes(FunctionTypes.QuestionBox)) {
accountInfo.value.settings.enableFunctions.push(FunctionTypes.QuestionBox)
} else if (!enable) {
const index = accountInfo.value.settings.enableFunctions.indexOf(FunctionTypes.QuestionBox)
if (index > -1) {
accountInfo.value.settings.enableFunctions.splice(index, 1)
}
}
}
} else {
message.error(`无法${enable ? '启用' : '禁用'}提问箱功能`)
}
} catch (err) {
message.error(`操作失败: ${err}`)
console.error('Enable/Disable Function error:', err)
// 操作失败时可能需要恢复 Switch 的状态,防止UI与实际状态不一致
// 这需要更复杂的逻辑,暂时不加
}
}
// --- 生命周期钩子 ---
onMounted(() => {
// 组件挂载时获取初始数据
@@ -293,118 +267,143 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
<template>
<NSpin :show="!accountInfo">
<template v-if="accountInfo">
<!-- 顶部操作区域 -->
<NSpace
align="center"
wrap
item-style="margin-bottom: 8px;"
<!-- 页面头部 -->
<ManagePageHeader
title="提问箱管理"
:function-type="FunctionTypes.QuestionBox"
:loading="useQB.isLoading"
>
<!-- 提问箱启用开关 -->
<NAlert
:type="accountInfo.settings?.enableFunctions?.includes(FunctionTypes.QuestionBox) ? 'success' : 'warning'"
style="padding: 5px 10px;"
:show-icon="false"
>
<NFlex align="center">
启用提问箱
<NSwitch
:value="accountInfo.settings?.enableFunctions?.includes(FunctionTypes.QuestionBox)"
:disabled="useQB.isLoading"
@update:value="setFunctionEnable"
/>
</NFlex>
</NAlert>
<!-- 操作按钮 -->
<NButton
type="primary"
:loading="useQB.isLoading"
@click="refresh"
>
刷新
</NButton>
<NButton
type="primary"
secondary
@click="shareModalVisiable = true"
>
分享
</NButton>
<NButton
type="primary"
secondary
@click="$router.push({ name: 'user-questionBox', params: { id: accountInfo.name } })"
>
前往提问页
</NButton>
<NButton
type="primary"
secondary
@click="showOBSModal = true"
>
预览OBS组件
</NButton>
<!-- 功能提示 -->
<NAlert
type="success"
closable
style="max-width: 550px;"
>
2025.3.1 本站已支持内容审查, 可前往提问箱设置页进行开启
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
新功能还不稳定, 如果启用后遇到任何问题请向我反馈
</NTooltip>
</NAlert>
</NSpace>
<!-- 提问页链接 -->
<NDivider
title-placement="left"
style="margin: 16px 0;"
>
提问页链接
</NDivider>
<NFlex align="center">
<!-- 主链接区域输入框和复制按钮 -->
<NInputGroup style="flex-grow: 1; max-width: 500px;">
<NInput
:value="directShareUrl"
readonly
/>
<template #action>
<NButton
secondary
@click="copyToClipboard(directShareUrl)"
circle
type="primary"
:loading="useQB.isLoading"
@click="refresh"
>
复制
<template #icon>
<NIcon :component="ArrowSync24Filled" />
</template>
</NButton>
</NInputGroup>
<!-- 主链接区域标签选择器 -->
<NSelect
v-model:value="selectedDirectShareTag"
placeholder="附加话题 (可选)"
filterable
clearable
:options="useQB.tags.filter(t => t.visiable).map((s) => ({ label: s.name, value: s.name }))"
style="min-width: 150px; max-width: 200px;"
/>
</NFlex>
<NTooltip>
<template #trigger>
<NButton
secondary
circle
@click="shareModalVisiable = true"
>
<template #icon>
<NIcon :component="Share24Filled" />
</template>
</NButton>
</template>
分享提问箱
</NTooltip>
<NButton
secondary
@click="showOBSModal = true"
>
OBS 组件
</NButton>
<NButton
primary
@click="$router.push({ name: 'user-questionBox', params: { id: accountInfo.name } })"
>
<template #icon>
<NIcon :component="Link24Filled" />
</template>
前往提问页
</NButton>
</template>
<!-- 审核中提示 -->
<template v-if="useQB.reviewing > 0">
<NDivider style="margin: 10px 0" />
<NAlert
type="warning"
title="有提问正在审核中"
<!-- 提示信息 -->
<NCollapse
v-if="useQB.reviewing > 0 || !accountInfo.settings?.questionBox?.saftyLevel"
style="margin-top: 12px;"
>
当前有 {{ useQB.reviewing }} 条提问正在等待审核
</NAlert>
</template>
<NCollapseItem
title="通知与提示"
name="1"
>
<NSpace vertical>
<NAlert
v-if="useQB.reviewing > 0"
type="warning"
show-icon
>
当前有 {{ useQB.reviewing }} 条提问正在等待审核
</NAlert>
<NAlert
type="info"
show-icon
closable
>
2025.3.1 本站已支持内容审查, 可前往提问箱设置页进行开启
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
新功能还不稳定, 如果启用后遇到任何问题请向我反馈
</NTooltip>
</NAlert>
</NSpace>
</NCollapseItem>
</NCollapse>
</ManagePageHeader>
<NDivider style="margin: 16px 0;" />
<!-- 提问页链接卡片 -->
<NCard
size="small"
style="margin-bottom: 16px; border-radius: 8px;"
embedded
>
<NFlex
align="center"
justify="space-between"
wrap
>
<NFlex
align="center"
style="flex-grow: 1;"
>
<NIcon
:component="Link24Filled"
size="20"
depth="3"
/>
<NText depth="3">
我的提问链接:
</NText>
<NInputGroup style="max-width: 400px;">
<NInput
:value="directShareUrl"
readonly
size="small"
/>
<NButton
secondary
size="small"
@click="copyToClipboard(directShareUrl)"
>
<template #icon>
<NIcon :component="Copy24Filled" />
</template>
复制
</NButton>
</NInputGroup>
</NFlex>
<NSelect
v-model:value="selectedDirectShareTag"
placeholder="附加话题参数 (可选)"
filterable
clearable
size="small"
:options="useQB.tags.filter(t => t.visiable).map((s) => ({ label: s.name, value: s.name }))"
style="width: 200px;"
/>
</NFlex>
</NCard>
<!-- 主要内容区域: 标签页 -->
<NSpin :show="useQB.isLoading">
@@ -412,6 +411,7 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
v-model:value="selectedTabItem"
animated
type="line"
size="large"
>
<!-- 我收到的 -->
<NTabPane
@@ -577,21 +577,24 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
<NEmpty
v-if="useQB.sendQuestions.length === 0"
description="暂无发送的提问"
style="margin-top: 40px;"
/>
<NList
v-else
hoverable
clickable
style="background-color: transparent;"
:show-divider="false"
>
<NListItem
v-for="item in useQB.sendQuestions"
:key="item.id"
style="padding: 0 0 12px 0;"
>
<NCard
size="small"
:bordered="false"
style="background-color: var(--n-color);"
hoverable
embedded
content-style="padding: 12px 16px;"
style="border-radius: 8px;"
>
<!-- 发送目标和时间 -->
<template #header>
@@ -600,13 +603,19 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
justify="space-between"
>
<NSpace
:size="4"
:size="8"
align="center"
>
<NText>发给</NText>
<NTag
size="small"
:bordered="false"
type="info"
>
发给
</NTag>
<NButton
text
type="info"
type="primary"
@click="router.push(`/user/${item.target.id}`)"
>
{{ item.target.name }}
@@ -614,7 +623,7 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
</NSpace>
<NText
depth="3"
style="font-size: small;"
style="font-size: 12px;"
>
<NTooltip placement="top-end">
<template #trigger>
@@ -634,64 +643,65 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
</template>
<!-- 问题内容 -->
<template v-if="item.questionImages && item.questionImages.length > 0">
<NSpace
vertical
<NFlex
size="small"
wrap
style="margin-bottom: 12px;"
>
<NImage
v-for="(img, index) in item.questionImages"
:key="index"
:src="img.path"
width="100"
height="80"
object-fit="cover"
lazy
style="border-radius: 4px; margin-bottom: 5px;"
style="border-radius: 4px;"
/>
</NSpace>
<br>
</NFlex>
</template>
<NText>{{ item.question?.message }}</NText>
<NText style="font-size: 15px; line-height: 1.6; display: block; margin-bottom: 8px;">
{{ item.question?.message }}
</NText>
<!-- 回复内容 -->
<template
v-if="item.answer"
#footer
>
<NDivider style="margin-top: 8px; margin-bottom: 8px;" />
<NCard
size="small"
:bordered="false"
style="background-color: var(--n-action-color);"
style="background-color: rgba(128, 128, 128, 0.08); border-radius: 6px;"
>
<template #header>
<NText depth="2">
对方的回复
</NText>
</template>
<NText>{{ item.answer.message }}</NText>
<template #header-extra>
<NText
depth="3"
style="font-size: small;"
>
<NTooltip
v-if="item.answer.createdAt"
placement="top-end"
>
<template #trigger>
<NTime
:time="item.answer.createdAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime
:time="item.answer.createdAt"
format="yyyy-MM-dd HH:mm:ss"
/>
</NTooltip>
</NText>
<NFlex justify="space-between" align="center">
<NText depth="3" style="font-size: 13px;">
对方的回复
</NText>
<NText
depth="3"
style="font-size: 12px;"
>
<NTooltip
v-if="item.answer.createdAt"
placement="top-end"
>
<template #trigger>
<NTime
:time="item.answer.createdAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime
:time="item.answer.createdAt"
format="yyyy-MM-dd HH:mm:ss"
/>
</NTooltip>
</NText>
</NFlex>
</template>
<NText style="line-height: 1.5;">{{ item.answer.message }}</NText>
</NCard>
</template>
</NCard>
@@ -791,174 +801,213 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
name="3"
display-directive="show:lazy"
>
<NSpace vertical>
<!-- 基础设定 -->
<NDivider title-placement="left">
基础设定
</NDivider>
<NCheckbox
v-model:checked="accountInfo.settings.questionBox.allowUnregistedUser"
:disabled="useQB.isLoading"
@update:checked="saveQuestionBoxSettings"
>
允许未注册/匿名用户进行提问
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.questionBox.allowImageUpload"
:disabled="useQB.isLoading"
@update:checked="saveQuestionBoxSettings"
>
允许上传图片
</NCheckbox>
<!-- 内容审查 -->
<NDivider title-placement="left">
内容审查等级
<NTag
type="success"
:bordered="false"
size="tiny"
style="margin-left: 5px;"
>
</NTag>
</NDivider>
<NSlider
v-model:value="tempSaftyLevel"
:marks="remarkLevel"
step="mark"
:max="3"
style="max-width: 90%; margin: 10px auto;"
:format-tooltip="(v) => remarkLevelString[v]"
:disabled="useQB.isLoading"
@dragend="() => { if (accountInfo?.settings?.questionBox) { accountInfo.settings.questionBox.saftyLevel = tempSaftyLevel; saveQuestionBoxSettings(); } }"
/>
<!-- 标签/话题管理 -->
<NDivider title-placement="left">
标签/话题管理
<NTooltip placement="right">
<template #trigger>
<NIcon
:component="Info24Filled"
style="margin-left: 5px; cursor: help; vertical-align: middle;"
/>
</template>
用于对收到的提问进行分类,或让提问者选择相关话题。
</NTooltip>
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px">
<NInputGroupLabel> 新标签 </NInputGroupLabel>
<NInput
v-model:value="addTagName"
placeholder="输入标签名称"
maxlength="30"
show-count
clearable
/>
<NButton
type="primary"
:disabled="!addTagName.trim()"
@click="useQB.addTag(addTagName); addTagName = ''"
<NGrid
x-gap="12"
y-gap="12"
cols="1 800:2"
>
<!-- 左侧设置项 -->
<NGi>
<NSpace vertical>
<!-- 基础设定 -->
<NCard
title="基础设定"
size="small"
segmented
>
添加
</NButton>
</NInputGroup>
</NFlex>
<br>
<NEmpty
v-if="useQB.tags.length === 0"
description="暂无标签"
/>
<NList
v-else
bordered
hoverable
style="max-width: 500px; background-color: var(--n-color);"
>
<NListItem
v-for="item in useQB.tags.sort((a, b) => b.createAt - a.createAt)"
:key="item.name"
>
<NFlex
align="center"
justify="space-between"
>
<!-- 标签名和状态 -->
<NTag
:bordered="false"
:type="item.visiable ? 'success' : 'default'"
:style="!item.visiable ? { textDecoration: 'line-through', color: 'grey' } : {}"
>
{{ item.name }}
</NTag>
<!-- 操作按钮 -->
<NSpace>
<!-- 显示/隐藏 -->
<NTooltip placement="top">
<template #trigger>
<NPopconfirm @positive-click="useQB.updateTagVisiable(item.name, !item.visiable)">
<template #trigger>
<NButton
:type="item.visiable ? 'success' : 'warning'"
text
style="font-size: 18px;"
>
<template #icon>
<NIcon :component="item.visiable ? Eye24Filled : EyeOff24Filled" />
</template>
</NButton>
</template>
确定要{{ item.visiable ? '隐藏' : '显示' }}这个标签吗? (隐藏后提问者无法选择)
</NPopconfirm>
</template>
{{ item.visiable ? '隐藏标签' : '显示标签' }}
</NTooltip>
<!-- 删除 -->
<NTooltip placement="top">
<template #trigger>
<NPopconfirm @positive-click="useQB.delTag(item.name)">
<template #trigger>
<NButton
type="error"
text
style="font-size: 18px;"
>
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
</NButton>
</template>
确定要删除这个标签吗? 删除后不可恢复。
</NPopconfirm>
</template>
删除标签
</NTooltip>
<template #header-extra>
<NIcon
:component="SettingsOutline"
size="18"
/>
</template>
<NSpace vertical>
<NCheckbox
v-model:checked="accountInfo.settings.questionBox.allowUnregistedUser"
:disabled="useQB.isLoading"
@update:checked="saveQuestionBoxSettings"
>
允许未注册/匿名用户进行提问
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.questionBox.allowImageUpload"
:disabled="useQB.isLoading"
@update:checked="saveQuestionBoxSettings"
>
允许上传图片
</NCheckbox>
</NSpace>
</NFlex>
</NListItem>
</NList>
</NCard>
<!-- 通知设置 -->
<NDivider title-placement="left">
通知设置
</NDivider>
<NCheckbox
v-model:checked="accountInfo.settings.sendEmail.recieveQA"
:disabled="useQB.isLoading"
@update:checked="saveNotificationSetting"
>
收到新提问时发送邮件通知
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.sendEmail.recieveQAReply"
:disabled="useQB.isLoading"
@update:checked="saveNotificationSetting"
>
我发送的提问收到回复时发送邮件通知
</NCheckbox>
</NSpace>
<NDivider />
<!-- 内容审查 -->
<NCard
title="内容审查"
size="small"
segmented
>
<template #header-extra>
<NTag
type="success"
:bordered="false"
size="small"
round
>
新功能
</NTag>
</template>
<div style="padding: 0 10px 10px 10px;">
<div style="margin-bottom: 15px; font-size: 13px; color: gray;">
设置过滤强度自动拦截恶意提问
</div>
<NSlider
v-model:value="tempSaftyLevel"
:marks="remarkLevel"
step="mark"
:max="3"
:format-tooltip="(v) => remarkLevelString[v]"
:disabled="useQB.isLoading"
@dragend="() => { if (accountInfo?.settings?.questionBox) { accountInfo.settings.questionBox.saftyLevel = tempSaftyLevel; saveQuestionBoxSettings(); } }"
/>
</div>
</NCard>
<!-- 通知设置 -->
<NCard
title="通知设置"
size="small"
segmented
>
<NSpace vertical>
<NCheckbox
v-model:checked="accountInfo.settings.sendEmail.recieveQA"
:disabled="useQB.isLoading"
@update:checked="saveNotificationSetting"
>
收到新提问时发送邮件通知
</NCheckbox>
<NCheckbox
v-model:checked="accountInfo.settings.sendEmail.recieveQAReply"
:disabled="useQB.isLoading"
@update:checked="saveNotificationSetting"
>
我发送的提问收到回复时发送邮件通知
</NCheckbox>
</NSpace>
</NCard>
</NSpace>
</NGi>
<!-- 右侧设置项: 标签管理 -->
<NGi>
<NCard
title="标签/话题管理"
size="small"
style="height: 100%;"
segmented
>
<template #header-extra>
<NTooltip placement="left">
<template #trigger>
<NIcon
:component="Info24Filled"
style="cursor: help;"
/>
</template>
用于对收到的提问进行分类,或让提问者选择相关话题。
</NTooltip>
</template>
<NInputGroup style="margin-bottom: 12px;">
<NInput
v-model:value="addTagName"
placeholder="输入新标签名称"
maxlength="30"
show-count
clearable
/>
<NButton
type="primary"
:disabled="!addTagName.trim()"
@click="useQB.addTag(addTagName); addTagName = ''"
>
添加
</NButton>
</NInputGroup>
<NEmpty
v-if="useQB.tags.length === 0"
description="暂无标签"
/>
<NList
v-else
bordered
hoverable
style="max-height: 500px; overflow-y: auto;"
>
<NListItem
v-for="item in useQB.tags.sort((a, b) => b.createAt - a.createAt)"
:key="item.name"
>
<NFlex
align="center"
justify="space-between"
>
<!-- 标签名 -->
<NTag
:bordered="false"
:type="item.visiable ? 'success' : 'default'"
:style="!item.visiable ? { textDecoration: 'line-through', color: 'grey' } : {}"
>
{{ item.name }}
</NTag>
<!-- 操作按钮 -->
<NSpace size="small">
<NTooltip placement="top">
<template #trigger>
<NPopconfirm @positive-click="useQB.updateTagVisiable(item.name, !item.visiable)">
<template #trigger>
<NButton
:type="item.visiable ? 'success' : 'warning'"
text
size="small"
>
<template #icon>
<NIcon :component="item.visiable ? Eye24Filled : EyeOff24Filled" />
</template>
</NButton>
</template>
确定要{{ item.visiable ? '隐藏' : '显示' }}这个标签吗? (隐藏后提问者无法选择)
</NPopconfirm>
</template>
{{ item.visiable ? '隐藏标签' : '显示标签' }}
</NTooltip>
<NTooltip placement="top">
<template #trigger>
<NPopconfirm @positive-click="useQB.delTag(item.name)">
<template #trigger>
<NButton
type="error"
text
size="small"
>
<template #icon>
<NIcon :component="Delete24Regular" />
</template>
</NButton>
</template>
确定要删除这个标签吗? 删除后不可恢复。
</NPopconfirm>
</template>
删除标签
</NTooltip>
</NSpace>
</NFlex>
</NListItem>
</NList>
</NCard>
</NGi>
</NGrid>
</NTabPane>
</NTabs>
</NSpin>
@@ -1237,10 +1286,6 @@ watch(() => accountInfo.value?.settings?.questionBox?.saftyLevel, (newLevel) =>
padding-right: 20px; /* 与二维码的间距 */
}
.share-card-text {
/* 包含标题和名字 */
}
.share-card-title {
font-size: 28px; /* 调整大小 */
font-weight: bold; /* 加粗 */

View File

@@ -157,12 +157,13 @@ onUnmounted(() => {
height: 100%;
width: 100%;
border-radius: 16px;
border: 2 solid rgb(255, 255, 255);
border: 2px solid rgb(255, 255, 255); /* 修正 border 语法 */
display: flex;
flex-direction: column;
border-style: solid;
box-sizing: border-box;
transition: all 0.3s ease;
overflow: hidden; /* 防止圆角溢出 */
}
.question-display-content {
@@ -170,62 +171,64 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-evenly;
/* justify-content: space-evenly; 移除这个,让内容自然排列,长内容更友好 */
padding: 24px;
overflow: auto;
overflow-y: auto; /* 明确只在Y轴滚动 */
overflow-x: hidden;
}
.question-display-user-name {
text-align: center;
margin: 5px;
height: 32px;
margin: 8px 5px;
min-height: 32px; /* 使用 min-height 避免文字被切 */
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 0; /* 防止被压缩 */
}
.question-display-text {
min-height: 50px;
white-space: pre-wrap;
line-height: 1.6; /* 增加行高,提高可读性 */
word-break: break-word; /* 防止长单词溢出 */
}
.question-display-images {
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 10px;
margin-top: 16px;
width: 100%;
}
.question-display-image {
max-width: 40%;
max-height: 150px;
max-width: 100%;
max-height: 200px;
border-radius: 8px;
object-fit: contain; /* 确保图片完整显示 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 轻微阴影 */
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 6px; /* 变细 */
height: 6px;
}
::-webkit-scrollbar-thumb {
background: hsla(0, 0%, 51%, 0.377);
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
-webkit-box-shadow: none;
border-radius: 10px;
-webkit-box-shadow: none;
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-track {
border-radius: 10px;
background: #cccccc4b;
-webkit-box-shadow: none;
-webkit-box-shadow: none;
background: transparent;
}
</style>

View File

@@ -27,9 +27,10 @@ import {
useMessage,
} from 'naive-ui'
import { computed, h, onMounted, ref, watch } from 'vue'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { useAccount } from '@/api/account'
import { FunctionTypes } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import ManagePageHeader from '@/components/manage/ManagePageHeader.vue'
import ScheduleList from '@/components/ScheduleList.vue'
import { CURRENT_HOST, SCHEDULE_API_URL } from '@/data/constants'
import { copyToClipboard } from '@/Utils'
@@ -584,19 +585,6 @@ function renderOption({ node, option }: { node: VNode, option: SelectOption }) {
node,
])
}
async function setFunctionEnable(enable: boolean) {
let success = false
if (enable) {
success = await EnableFunction(FunctionTypes.Schedule)
} else {
success = await DisableFunction(FunctionTypes.Schedule)
}
if (success) {
message.success(`${enable ? '启用' : '禁用'}`)
} else {
message.error(`无法${enable ? '启用' : '禁用'}`)
}
}
onMounted(() => {
get()
@@ -604,77 +592,72 @@ onMounted(() => {
</script>
<template>
<NSpace align="center">
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Schedule) ? 'success' : 'warning'"
style="max-width: 200px"
>
启用日程表
<NDivider vertical />
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Schedule)"
@update:value="setFunctionEnable"
/>
</NAlert>
<NButton
type="primary"
@click="showAddModal = true"
>
添加周程
</NButton>
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'setting', setting: 'template', template: 'schedule' } })">
修改模板
</NButton>
</NSpace>
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
<ManagePageHeader
title="日程表管理"
:function-type="FunctionTypes.Schedule"
>
日程表展示页链接
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${CURRENT_HOST}@${accountInfo.name}/schedule`"
readonly
/>
<template #action>
<NButton
secondary
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/schedule`)"
type="primary"
@click="showAddModal = true"
>
复制
添加周程
</NButton>
</NInputGroup>
</NFlex>
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
>
订阅链接
<NTooltip>
<template #trigger>
<NIcon>
<TagQuestionMark16Filled />
</NIcon>
</template>
通过订阅链接可以订阅日程表到日历软件中
</NTooltip>
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${SCHEDULE_API_URL}${accountInfo.id}.ics`"
readonly
/>
<NButton
secondary
@click="copyToClipboard(`${SCHEDULE_API_URL}${accountInfo.id}.ics`)"
>
复制
<NButton @click="$router.push({ name: 'manage-index', query: { tab: 'setting', setting: 'template', template: 'schedule' } })">
修改模板
</NButton>
</NInputGroup>
</NFlex>
<NDivider />
</template>
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
>
日程表展示页链接
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${CURRENT_HOST}@${accountInfo.name}/schedule`"
readonly
/>
<NButton
secondary
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/schedule`)"
>
复制
</NButton>
</NInputGroup>
</NFlex>
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
>
订阅链接
<NTooltip>
<template #trigger>
<NIcon>
<TagQuestionMark16Filled />
</NIcon>
</template>
通过订阅链接可以订阅日程表到日历软件中
</NTooltip>
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${SCHEDULE_API_URL}${accountInfo.id}.ics`"
readonly
/>
<NButton
secondary
@click="copyToClipboard(`${SCHEDULE_API_URL}${accountInfo.id}.ics`)"
>
复制
</NButton>
</NInputGroup>
</NFlex>
</ManagePageHeader>
<NModal
v-model:show="showAddModal"
style="width: 600px; max-width: 90vw"

View File

@@ -49,9 +49,10 @@ import {
} from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import * as XLSX from 'xlsx'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { useAccount } from '@/api/account'
import { FunctionTypes, SongFrom } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import ManagePageHeader from '@/components/manage/ManagePageHeader.vue'
import SongList from '@/components/SongList.vue'
import { CURRENT_HOST, FETCH_API, SONG_API_URL } from '@/data/constants'
import { copyToClipboard, objectsToCSV } from '@/Utils'
@@ -945,25 +946,6 @@ function beforeUpload(data: { file: UploadFileInfo, fileList: UploadFileInfo[] }
return false
}
/**
* 设置歌单功能启用状态
*/
async function setFunctionEnable(enable: boolean) {
let success = false
if (enable) {
success = await EnableFunction(FunctionTypes.SongList)
} else {
success = await DisableFunction(FunctionTypes.SongList)
}
if (success) {
message.success(`${enable ? '启用' : '禁用'}`)
} else {
message.error(`无法${enable ? '启用' : '禁用'}`)
}
}
/**
* 重置添加歌曲表单
*/
@@ -1011,85 +993,77 @@ onMounted(async () => {
</script>
<template>
<NSpace align="center">
<!-- 歌单功能启用状态 -->
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.SongList) ? 'success' : 'warning'"
style="max-width: 200px"
>
启用歌单
<NDivider vertical />
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.SongList)"
@update:value="setFunctionEnable"
/>
</NAlert>
<!-- 功能按钮区 -->
<NButton
type="primary"
@click="showModal = true"
>
添加歌曲
</NButton>
<NButton
type="primary"
@click="$router.push({ name: 'manage-index', query: { tab: 'setting', setting: 'template', template: 'songlist' } })"
>
修改展示模板
</NButton>
<NButton
type="primary"
secondary
@click="exportData"
>
导出为 CSV
</NButton>
<NButton
secondary
@click="$router.push({ name: 'manage-liveRequest' })"
>
前往点播管理页
</NButton>
<NButton
secondary
@click="$router.push({ name: 'user-songList', params: { id: accountInfo?.name } })"
>
前往歌单展示页
</NButton>
<NButton
:loading="isLoading"
@click="() => {
getSongs()
message.success('完成')
}"
>
刷新
</NButton>
</NSpace>
<!-- 歌单展示页链接 -->
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
<ManagePageHeader
title="歌单管理"
:function-type="FunctionTypes.SongList"
>
歌单展示页链接
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${CURRENT_HOST}@${accountInfo.name}/song-list`"
readonly
/>
<template #action>
<!-- 功能按钮区 -->
<NButton
type="primary"
@click="showModal = true"
>
添加歌曲
</NButton>
<NButton
type="primary"
@click="$router.push({ name: 'manage-index', query: { tab: 'setting', setting: 'template', template: 'songlist' } })"
>
修改展示模板
</NButton>
<NButton
type="primary"
secondary
@click="exportData"
>
导出为 CSV
</NButton>
<NButton
secondary
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/song-list`)"
@click="$router.push({ name: 'manage-liveRequest' })"
>
复制
前往点播管理页
</NButton>
</NInputGroup>
</NFlex>
<NDivider style="margin: 16px 0 16px 0" />
<NButton
secondary
@click="$router.push({ name: 'user-songList', params: { id: accountInfo?.name } })"
>
前往歌单展示页
</NButton>
<NButton
:loading="isLoading"
@click="() => {
getSongs()
message.success('完成')
}"
>
刷新
</NButton>
</template>
<!-- 歌单展示页链接 -->
<NDivider
style="margin: 16px 0 16px 0"
title-placement="left"
>
歌单展示页链接
</NDivider>
<NFlex align="center">
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${CURRENT_HOST}@${accountInfo.name}/song-list`"
readonly
/>
<NButton
secondary
@click="copyToClipboard(`${CURRENT_HOST}@${accountInfo.name}/song-list`)"
>
复制
</NButton>
</NInputGroup>
</NFlex>
<NDivider style="margin: 16px 0 16px 0" />
</ManagePageHeader>
<!-- 添加歌曲模态框 -->
<NModal

View File

@@ -10,15 +10,22 @@ import type {
VideoCollectVideo,
VideoInfo,
} from '@/api/api-models'
import { Clock24Filled, Person24Filled } from '@vicons/fluent'
import {
ArrowLeft24Regular,
Delete24Regular,
Edit24Regular,
MoreVertical24Regular,
Share24Regular,
TableDismiss24Regular,
} from '@vicons/fluent'
import { useWindowSize } from '@vueuse/core'
import { List } from 'linqts'
import {
NBadge,
NButton,
NCard,
NDatePicker,
NDivider,
NEllipsis,
NDropdown,
NEmpty,
NForm,
NFormItem,
@@ -29,21 +36,22 @@ import {
NInputNumber,
NModal,
NPopconfirm,
NScrollbar,
NSpace,
NSpin,
NTabPane,
NTabs,
NText,
useMessage,
} from 'naive-ui'
import Qrcode from 'qrcode.vue'
import { computed, h, onActivated, ref } from 'vue'
import { computed, onActivated, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
VideoStatus,
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import VideoCollectInfoCard from '@/components/VideoCollectInfoCard.vue'
import VideoItemCard from '@/components/VideoItemCard.vue'
import { CURRENT_HOST, VIDEO_COLLECT_API_URL } from '@/data/constants'
import router from '@/router'
import { downloadImage } from '@/Utils'
@@ -115,6 +123,55 @@ const acceptVideos = computed(() => {
return videoDetail.value?.videos?.filter(v => v.info.status == VideoStatus.Accepted) ?? []
})
// 移动端下拉菜单选项
const mobileMenuOptions = computed(() => [
{
label: '分享',
key: 'share',
icon: () => h(NIcon, null, { default: () => h(Share24Regular) }),
},
{
label: '更新信息',
key: 'edit',
icon: () => h(NIcon, null, { default: () => h(Edit24Regular) }),
},
{
label: videoDetail.value.table.isFinish ? '开启表' : '关闭表',
key: 'toggle-status',
icon: () => h(NIcon, null, { default: () => h(TableDismiss24Regular) }),
},
{
label: '结果页面',
key: 'result',
},
{
label: '删除',
key: 'delete',
icon: () => h(NIcon, { color: '#d03050' }, { default: () => h(Delete24Regular) }),
},
])
function handleMobileMenuSelect(key: string) {
switch (key) {
case 'share':
shareModalVisiable.value = true
break
case 'edit':
editModalVisiable.value = true
break
case 'toggle-status':
closeTable()
break
case 'result':
router.push({ name: 'video-collect-list', params: { id: videoDetail.value.table.id } })
break
case 'delete':
deleteTable() // 这里最好加个确认但在下拉菜单里直接触发确认比较麻烦暂时直接调用原逻辑是有Popconfirm的
// 由于移动端下拉菜单难以直接嵌入Popconfirm建议改为点击后弹窗确认
break
}
}
async function getData() {
try {
const data = await QueryGetAPI<VideoCollectDetail>(`${VIDEO_COLLECT_API_URL}get`, { id: route.params.id })
@@ -133,133 +190,7 @@ async function getData() {
}
return {} as VideoCollectDetail
}
function gridRender(type: 'padding' | 'reject' | 'accept') {
let footer: (arg0: VideoInfo) => VNode
let videos: { info: VideoInfo, video: VideoCollectVideo }[]
switch (type) {
case 'padding':
footer = paddingButtonGroup
videos = paddingVideos.value
break
case 'reject':
footer = rejectButtonGroup
videos = rejectVideos.value
break
case 'accept':
footer = acceptButtonGroup
videos = acceptVideos.value
break
}
return videos.length == 0
? h(NEmpty)
: h(NGrid, { cols: '1 500:2 700:3 900:4 1200:5 ', xGap: '12', yGap: '12', responsive: 'self' }, () =>
videos?.map(v =>
h(NGridItem, () =>
h(
NCard,
{ style: 'height: 330px;', embedded: true, size: 'small' },
{
cover: () =>
h('div', { style: 'position: relative;height: 150px;' }, [
h('img', {
src: v.video.cover.replace('http://', 'https://'),
referrerpolicy: 'no-referrer',
style: 'max-height: 100%; object-fit: contain;cursor: pointer',
onClick: () => window.open(`https://www.bilibili.com/video/${v.info.bvid}`, '_blank'),
}),
h(
NSpace,
{
style: { position: 'relative', bottom: '20px', background: '#00000073' },
justify: 'space-around',
},
() => [
h('span', [
h(NIcon, { component: Clock24Filled, color: 'lightgrey' }),
h(NText, { style: 'color: lightgrey;size:small;' }, () => formatSeconds(v.video.length)),
]),
h('span', [
h(NIcon, { component: Person24Filled, color: 'lightgrey' }),
h(NText, { style: 'color: lightgrey;size:small;' }, () => v.video.ownerName),
]),
],
),
]),
header: () =>
h(
NButton,
{
style: 'width: 100%;',
text: true,
onClick: () => window.open(`https://www.bilibili.com/video/${v.info.bvid}`, '_blank'),
},
() =>
h(
NEllipsis,
{ style: 'max-width: 100%;' },
{
default: () => v.video.title,
tooltip: () => h('div', { style: 'max-width: 300px' }, v.video.title),
},
),
),
default: () =>
h(NScrollbar, { style: 'height: 65px;' }, () =>
h(NCard, { contentStyle: 'padding: 5px;' }, () =>
v.info.senders.map(s => [
h('div', { style: 'font-size: 12px;' }, [
h('div', `推荐人: ${s.sender ?? '未填写'} [${s.senderId ?? '未填写'}]`),
h('div', `推荐理由: ${s.description ?? '未填写'}`),
]),
h(NSpace, { style: 'margin: 0;' }),
]))),
footer: () => footer(v.info),
},
)),
))
}
function paddingButtonGroup(v: VideoInfo) {
return h(NSpace, { size: 'small', justify: 'space-around' }, () => [
h(
NButton,
{ type: 'success', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Accepted, v) },
() => '通过',
),
h(
NButton,
{ type: 'error', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Rejected, v) },
() => '拒绝',
),
])
}
function acceptButtonGroup(v: VideoInfo) {
return h(NSpace, { size: 'small', justify: 'space-around' }, () => [
h(
NButton,
{ type: 'info', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Pending, v) },
() => '重设为未审核',
),
h(
NButton,
{ type: 'error', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Rejected, v) },
() => '拒绝',
),
])
}
function rejectButtonGroup(v: VideoInfo) {
return h(NSpace, { size: 'small', justify: 'space-around' }, () => [
h(
NButton,
{ type: 'success', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Accepted, v) },
() => '通过',
),
h(
NButton,
{ type: 'info', loading: isLoading.value, onClick: () => setStatus(VideoStatus.Pending, v) },
() => '重设为未审核',
),
])
}
function setStatus(status: VideoStatus, video: VideoInfo) {
isLoading.value = true
QueryGetAPI(`${VIDEO_COLLECT_API_URL}set-status`, {
@@ -282,6 +213,7 @@ function setStatus(status: VideoStatus, video: VideoInfo) {
isLoading.value = false
})
}
function formatSeconds(seconds: number): string {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
@@ -291,9 +223,11 @@ function formatSeconds(seconds: number): string {
return `${formattedMinutes}:${formattedSeconds}`
}
function dateDisabled(ts: number) {
return ts < Date.now() + 1000 * 60 * 60
}
function updateTable() {
isLoading.value = true
updateModel.value.id = videoDetail.value.table.id
@@ -301,6 +235,7 @@ function updateTable() {
.then((data) => {
if (data.code == 200) {
message.success('更新成功')
editModalVisiable.value = false
videoDetail.value.table = data.data
} else {
message.error(`更新失败: ${data.message}`)
@@ -313,6 +248,7 @@ function updateTable() {
isLoading.value = false
})
}
function deleteTable() {
isLoading.value = true
QueryGetAPI(`${VIDEO_COLLECT_API_URL}del`, {
@@ -335,6 +271,7 @@ function deleteTable() {
isLoading.value = false
})
}
function closeTable() {
isLoading.value = true
QueryGetAPI(`${VIDEO_COLLECT_API_URL}finish`, {
@@ -356,6 +293,7 @@ function closeTable() {
isLoading.value = false
})
}
function saveQRCode() {
downloadImage(
`https://api.qrserver.com/v1/create-qr-code/?data=${`https://vtsuru.live/video-collect/${videoDetail.value.table.shortId}`}`,
@@ -371,256 +309,416 @@ onActivated(async () => {
</script>
<template>
<NSpace>
<NButton
text
@click="$router.go(-1)"
>
<NText depth="3">
{{ '< 返回' }}
</NText>
</NButton>
<template v-if="width <= 1000">
<NButton
type="success"
size="small"
@click="shareModalVisiable = true"
>
分享
</NButton>
<NButton
type="info"
size="small"
@click="editModalVisiable = true"
>
更新
</NButton>
<NButton
type="warning"
size="small"
@click="closeTable"
>
{{ videoDetail.table.isFinish ? '开启表' : '关闭表' }}
</NButton>
<NButton
size="small"
@click="$router.push({ name: 'video-collect-list', params: { id: videoDetail.table.id } })"
>
结果页面
</NButton>
<NPopconfirm :on-positive-click="deleteTable">
<template #trigger>
<NButton
type="error"
size="small"
>
删除
</NButton>
</template>
确定删除表? 此操作无法撤销
</NPopconfirm>
</template>
</NSpace>
<VideoCollectInfoCard
:item="videoDetail.table"
style="width: 100%; max-width: 90vw"
from="owner"
>
<template
v-if="width > 1000"
#header-extra
>
<NSpace>
<div class="detail-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-left">
<NButton
type="success"
size="small"
@click="shareModalVisiable = true"
text
style="font-size: 16px"
@click="$router.go(-1)"
>
分享
</NButton>
<NButton
type="info"
size="small"
@click="editModalVisiable = true"
>
更新
</NButton>
<NButton
type="warning"
size="small"
@click="closeTable"
>
{{ videoDetail.table.isFinish ? '开启表' : '关闭表' }}
</NButton>
<NButton
size="small"
@click="$router.push({ name: 'video-collect-list', params: { id: videoDetail.table.id } })"
>
结果表
</NButton>
<NPopconfirm :on-positive-click="deleteTable">
<template #trigger>
<NButton
type="error"
size="small"
>
删除
</NButton>
<template #icon>
<NIcon><ArrowLeft24Regular /></NIcon>
</template>
确定删除表? 此操作无法撤销
</NPopconfirm>
</NSpace>
</template>
</VideoCollectInfoCard>
<NDivider> 已通过时长: {{ formatSeconds(new List(acceptVideos).Sum((v) => v?.video.length ?? 0)) }} </NDivider>
<NEmpty
v-if="videoDetail?.videos?.length == 0"
description="暂无视频"
/>
<template v-else>
<NTabs
animated
type="segment"
>
<NTabPane name="padding">
<template #tab>
未审核
<NDivider
vertical
style="margin: 0 3px 0 3px"
/>
<NText depth="3">
{{ paddingVideos.length }}
</NText>
</template>
<component :is="gridRender('padding')" />
</NTabPane>
<NTabPane name="accept">
<template #tab>
<NText style="color: #5bb85f">
通过
</NText>
<NDivider
vertical
style="margin: 0 5px 0 5px"
/>
<NText depth="3">
{{ acceptVideos.length }}
</NText>
</template>
<component :is="gridRender('accept')" />
</NTabPane>
<NTabPane name="reject">
<template #tab>
<NText style="color: #a85f5f">
拒绝
</NText>
<NDivider
vertical
style="margin: 0 3px 0 3px"
/>
<NText depth="3">
{{ rejectVideos.length }}
</NText>
</template>
<component :is="gridRender('reject')" />
</NTabPane>
</NTabs>
</template>
<NModal
v-model:show="shareModalVisiable"
title="分享"
preset="card"
style="width: 600px; max-width: 90vw"
>
<Qrcode
:value="`${CURRENT_HOST}video-collect/${videoDetail.table.shortId}`"
level="Q"
:size="100"
background="#fff"
:margin="1"
/>
<NInput :value="`${CURRENT_HOST}video-collect/${videoDetail.table.shortId}`" />
<NDivider />
<NSpace justify="center">
<NButton
type="primary"
@click="saveQRCode"
>
保存二维码
</NButton>
</NSpace>
</NModal>
<NModal
v-model:show="editModalVisiable"
title="更新信息"
preset="card"
style="width: 600px; max-width: 90vw"
>
<NForm
ref="formRef"
:model="updateModel"
:rules="createRules"
>
<NFormItem
label="标题"
path="name"
>
<NInput
v-model:value="updateModel.name"
placeholder="征集表的标题"
maxlength="30"
show-count
/>
</NFormItem>
<NFormItem
label="描述"
path="description"
>
<NInput
v-model:value="updateModel.description"
placeholder="可以是备注之类的"
maxlength="300"
show-count
/>
</NFormItem>
<NFormItem
label="视频数量"
path="maxVideoCount"
>
<NInputNumber
v-model:value="updateModel.maxVideoCount"
placeholder="最大数量"
type="number"
style="max-width: 150px"
/>
</NFormItem>
<NFormItem
label="结束时间"
path="endAt"
>
<NDatePicker
v-model:value="updateModel.endAt"
type="datetime"
placeholder="结束征集的时间"
:is-date-disabled="dateDisabled"
/>
<NDivider vertical />
<NText depth="3">
最低为一小时
返回列表
</NButton>
</div>
<div class="header-right">
<!-- Desktop Actions -->
<NSpace v-if="width > 800">
<NButton
secondary
strong
@click="shareModalVisiable = true"
>
<template #icon>
<NIcon><Share24Regular /></NIcon>
</template>
分享
</NButton>
<NButton
secondary
strong
@click="editModalVisiable = true"
>
<template #icon>
<NIcon><Edit24Regular /></NIcon>
</template>
更新信息
</NButton>
<NButton
secondary
strong
:type="videoDetail.table.isFinish ? 'success' : 'warning'"
@click="closeTable"
>
<template #icon>
<NIcon><TableDismiss24Regular /></NIcon>
</template>
{{ videoDetail.table.isFinish ? '开启征集' : '结束征集' }}
</NButton>
<NButton
secondary
strong
type="info"
@click="$router.push({ name: 'video-collect-list', params: { id: videoDetail.table.id } })"
>
查看结果
</NButton>
<NPopconfirm @positive-click="deleteTable">
<template #trigger>
<NButton
secondary
strong
type="error"
>
<template #icon>
<NIcon><Delete24Regular /></NIcon>
</template>
删除
</NButton>
</template>
确定删除表? 此操作无法撤销
</NPopconfirm>
</NSpace>
<!-- Mobile Actions -->
<NDropdown
v-else
trigger="click"
:options="mobileMenuOptions"
@select="handleMobileMenuSelect"
>
<NButton
secondary
strong
circle
>
<template #icon>
<NIcon><MoreVertical24Regular /></NIcon>
</template>
</NButton>
</NDropdown>
</div>
</div>
<!-- Info Card -->
<div class="info-card-wrapper">
<VideoCollectInfoCard
:item="videoDetail.table"
style="width: 100%"
from="owner"
/>
</div>
<!-- Stats -->
<div class="stats-bar">
<NText depth="3">
已通过视频总时长:
<NText
strong
style="color: var(--n-text-color)"
>
{{ formatSeconds(new List(acceptVideos).Sum((v) => v?.video.length ?? 0)) }}
</NText>
</NFormItem>
<NFormItem>
<NSpace>
</NText>
</div>
<!-- Content Area -->
<div class="content-area">
<NEmpty
v-if="videoDetail?.videos?.length == 0"
description="暂无视频提交"
style="margin-top: 48px"
/>
<NTabs
v-else
animated
type="line"
justify-content="space-evenly"
class="custom-tabs"
>
<NTabPane name="padding">
<template #tab>
<div class="tab-label">
<span>待审核</span>
<NBadge
v-if="paddingVideos.length > 0"
:value="paddingVideos.length"
:max="99"
type="warning"
class="tab-badge"
/>
</div>
</template>
<div class="video-grid">
<NGrid
x-gap="16"
y-gap="16"
cols="1 520:2 800:3 1100:4 1400:5"
responsive="self"
>
<NGridItem
v-for="v in paddingVideos"
:key="v.info.bvid"
>
<VideoItemCard
:video-info="v.info"
:video-data="v.video"
type="padding"
:is-loading="isLoading"
@update-status="setStatus"
/>
</NGridItem>
</NGrid>
</div>
</NTabPane>
<NTabPane name="accept">
<template #tab>
<div class="tab-label">
<span style="color: #18a058">已通过</span>
<NBadge
v-if="acceptVideos.length > 0"
:value="acceptVideos.length"
:max="99"
type="success"
class="tab-badge"
/>
</div>
</template>
<div class="video-grid">
<NGrid
x-gap="16"
y-gap="16"
cols="1 520:2 800:3 1100:4 1400:5"
responsive="self"
>
<NGridItem
v-for="v in acceptVideos"
:key="v.info.bvid"
>
<VideoItemCard
:video-info="v.info"
:video-data="v.video"
type="accept"
:is-loading="isLoading"
@update-status="setStatus"
/>
</NGridItem>
</NGrid>
</div>
</NTabPane>
<NTabPane name="reject">
<template #tab>
<div class="tab-label">
<span style="color: #d03050">已拒绝</span>
<NBadge
v-if="rejectVideos.length > 0"
:value="rejectVideos.length"
:max="99"
color="#d03050"
class="tab-badge"
/>
</div>
</template>
<div class="video-grid">
<NGrid
x-gap="16"
y-gap="16"
cols="1 520:2 800:3 1100:4 1400:5"
responsive="self"
>
<NGridItem
v-for="v in rejectVideos"
:key="v.info.bvid"
>
<VideoItemCard
:video-info="v.info"
:video-data="v.video"
type="reject"
:is-loading="isLoading"
@update-status="setStatus"
/>
</NGridItem>
</NGrid>
</div>
</NTabPane>
</NTabs>
</div>
<!-- Modals -->
<NModal
v-model:show="shareModalVisiable"
title="分享"
preset="card"
style="width: 600px; max-width: 90vw"
>
<div style="display: flex; flex-direction: column; align-items: center; gap: 24px; padding: 12px;">
<div style="padding: 12px; background: white; border-radius: 8px;">
<Qrcode
:value="`${CURRENT_HOST}video-collect/${videoDetail.table.shortId}`"
level="Q"
:size="200"
background="#fff"
:margin="1"
/>
</div>
<NInput
:value="`${CURRENT_HOST}video-collect/${videoDetail.table.shortId}`"
readonly
@click="(e: MouseEvent) => (e.target as HTMLInputElement).select()"
/>
<NButton
type="primary"
@click="saveQRCode"
>
保存二维码图片
</NButton>
</div>
</NModal>
<NModal
v-model:show="editModalVisiable"
title="更新信息"
preset="card"
style="width: 600px; max-width: 90vw"
>
<NForm
ref="formRef"
:model="updateModel"
:rules="createRules"
label-placement="left"
label-width="80"
>
<NFormItem
label="标题"
path="name"
>
<NInput
v-model:value="updateModel.name"
placeholder="征集表的标题"
maxlength="30"
show-count
/>
</NFormItem>
<NFormItem
label="描述"
path="description"
>
<NInput
v-model:value="updateModel.description"
type="textarea"
placeholder="可以是备注之类的"
maxlength="300"
show-count
:autosize="{ minRows: 3, maxRows: 5 }"
/>
</NFormItem>
<NGrid
:cols="2"
:x-gap="24"
>
<NGridItem>
<NFormItem
label="最大数量"
path="maxVideoCount"
>
<NInputNumber
v-model:value="updateModel.maxVideoCount"
placeholder="最大数量"
type="number"
style="width: 100%"
/>
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem
label="结束时间"
path="endAt"
>
<NDatePicker
v-model:value="updateModel.endAt"
type="datetime"
placeholder="结束征集的时间"
:is-date-disabled="dateDisabled"
style="width: 100%"
/>
</NFormItem>
</NGridItem>
</NGrid>
<div style="display: flex; justify-content: flex-end; margin-top: 12px;">
<NButton
type="primary"
:loading="isLoading"
@click="updateTable"
>
更新
保存更改
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NModal>
</div>
</NForm>
</NModal>
</div>
</template>
<style scoped>
.detail-container {
max-width: 1600px;
margin: 0 auto;
padding: 16px;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
}
.info-card-wrapper {
margin-bottom: 24px;
}
.stats-bar {
display: flex;
justify-content: flex-end;
padding: 0 4px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.content-area {
margin-top: 16px;
}
.custom-tabs :deep(.n-tabs-nav) {
background: rgba(255, 255, 255, 0.02);
padding: 4px;
border-radius: 8px;
}
.tab-label {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
}
.tab-badge {
transform: scale(0.85);
}
.video-grid {
padding: 16px 0;
}
@media (max-width: 600px) {
.header-section {
margin-bottom: 16px;
}
}
</style>

View File

@@ -11,10 +11,11 @@ import {
NEmpty,
NForm,
NFormItem,
NGrid,
NGridItem,
NIcon,
NInput,
NInputNumber,
NList,
NListItem,
NModal,
NSpace,
NSpin,
@@ -22,10 +23,12 @@ import {
NText,
useMessage,
} from 'naive-ui'
import { Add20Regular } from '@vicons/fluent'
import { ref } from 'vue'
import { UpdateFunctionEnable, useAccount } from '@/api/account'
import { useAccount } from '@/api/account'
import { FunctionTypes } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import ManagePageHeader from '@/components/manage/ManagePageHeader.vue'
import VideoCollectInfoCard from '@/components/VideoCollectInfoCard.vue'
import { VIDEO_COLLECT_API_URL } from '@/data/constants'
@@ -130,119 +133,208 @@ function createTable() {
</script>
<template>
<NAlert
v-if="accountInfo.id"
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.VideoCollect) ? 'success' : 'warning'"
style="max-width: 300px"
>
在个人主页展示进行中的征集表
<NSwitch
:value="accountInfo.settings.enableFunctions.includes(FunctionTypes.VideoCollect)"
@update:value="UpdateFunctionEnable(FunctionTypes.VideoCollect)"
/>
</NAlert>
<NDivider />
<NSpace>
<NButton
type="primary"
@click="createModalVisible = true"
<div class="manage-container">
<ManagePageHeader
title="视频征集管理"
subtitle="创建并管理您的视频征集活动"
:function-type="FunctionTypes.VideoCollect"
>
新建征集表
</NButton>
</NSpace>
<NDivider />
<NSpin :show="isLoading">
<NSpace justify="center">
<NEmpty v-if="videoTables.length == 0" />
<NList
v-else
bordered
>
<NListItem
v-for="item in videoTables"
:key="item.id"
style="padding: 0"
<template #action>
<NButton
type="primary"
size="medium"
@click="createModalVisible = true"
>
<VideoCollectInfoCard
:item="item"
can-click
style="width: 500px; max-width: 70vw"
from="owner"
/>
</NListItem>
</NList>
</NSpace>
</NSpin>
<NModal
v-model:show="createModalVisible"
preset="card"
title="创建视频征集"
style="width: 600px; max-width: 90vw"
>
<NForm
ref="formRef"
:model="createVideoModel"
:rules="createRules"
<template #icon>
<NIcon><Add20Regular /></NIcon>
</template>
新建征集表
</NButton>
</template>
</ManagePageHeader>
<NSpin :show="isLoading">
<div
v-if="videoTables.length == 0 && !isLoading"
class="empty-state"
>
<NEmpty
description="暂无征集表"
size="large"
>
<template #extra>
<NButton
type="primary"
@click="createModalVisible = true"
>
创建第一个征集表
</NButton>
</template>
</NEmpty>
</div>
<div
v-else
class="grid-container"
>
<NGrid
x-gap="24"
y-gap="24"
cols="1 640:2 1024:3 1440:4"
responsive="self"
>
<NGridItem
v-for="item in videoTables"
:key="item.id"
>
<div class="card-wrapper">
<VideoCollectInfoCard
:item="item"
can-click
from="owner"
style="width: 100%"
class="collect-card"
/>
</div>
</NGridItem>
</NGrid>
</div>
</NSpin>
<NModal
v-model:show="createModalVisible"
preset="card"
title="创建视频征集"
style="width: 600px; max-width: 90vw"
class="custom-modal"
>
<NFormItem
label="标题"
path="name"
<NForm
ref="formRef"
:model="createVideoModel"
:rules="createRules"
label-placement="left"
label-width="80"
require-mark-placement="right-hanging"
>
<NInput
v-model:value="createVideoModel.name"
placeholder="征集表的标题"
maxlength="30"
show-count
/>
</NFormItem>
<NFormItem
label="描述"
path="description"
>
<NInput
v-model:value="createVideoModel.description"
placeholder="可以是备注之类的"
maxlength="300"
show-count
/>
</NFormItem>
<NFormItem
label="视频数量"
path="maxVideoCount"
>
<NInputNumber
v-model:value="createVideoModel.maxVideoCount"
placeholder="最大数量"
type="number"
style="max-width: 150px"
/>
</NFormItem>
<NFormItem
label="结束时间"
path="endAt"
>
<NDatePicker
v-model:value="createVideoModel.endAt"
type="datetime"
placeholder="结束征集的时间"
:is-date-disabled="dateDisabled"
/>
<NDivider vertical />
<NText depth="3">
最低为一小时
</NText>
</NFormItem>
<NFormItem>
<NSpace>
<NFormItem
label="标题"
path="name"
>
<NInput
v-model:value="createVideoModel.name"
placeholder="给征集活动起个响亮的名字"
maxlength="30"
show-count
/>
</NFormItem>
<NFormItem
label="描述"
path="description"
>
<NInput
v-model:value="createVideoModel.description"
type="textarea"
placeholder="简要描述活动规则或备注"
maxlength="300"
show-count
:autosize="{ minRows: 3, maxRows: 5 }"
/>
</NFormItem>
<NGrid
x-gap="24"
:cols="2"
>
<NGridItem>
<NFormItem
label="最大数量"
path="maxVideoCount"
>
<NInputNumber
v-model:value="createVideoModel.maxVideoCount"
placeholder="限制数量"
:min="1"
style="width: 100%"
/>
</NFormItem>
</NGridItem>
<NGridItem>
<NFormItem
label="结束时间"
path="endAt"
>
<NDatePicker
v-model:value="createVideoModel.endAt"
type="datetime"
placeholder="选择截止时间"
:is-date-disabled="dateDisabled"
style="width: 100%"
/>
</NFormItem>
</NGridItem>
</NGrid>
<div class="modal-footer">
<NText
depth="3"
style="font-size: 12px"
>
* 结束时间至少需要在当前时间一小时后
</NText>
<NButton
type="primary"
:loading="isLoading2"
@click="createTable"
>
创建
立即创建
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NModal>
</div>
</NForm>
</NModal>
</div>
</template>
<style scoped>
.manage-container {
max-width: 1600px;
margin: 0 auto;
padding: 16px;
}
.empty-state {
margin-top: 100px;
display: flex;
justify-content: center;
}
.grid-container {
width: 100%;
}
.card-wrapper {
height: 100%;
}
/* 深度选择器修改卡片样式 */
:deep(.collect-card) {
height: 100%;
transition: all 0.3s ease;
border: 1px solid transparent;
}
:deep(.collect-card:hover) {
transform: translateY(-4px);
border-color: var(--n-color-target);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
@media (max-width: 640px) {
/* 可以在这里添加针对小屏幕的样式调整 */
}
</style>

View File

@@ -50,7 +50,7 @@ import {
useMessage,
} from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { DisableFunction, EnableFunction, useAccount } from '@/api/account'
import { useAccount } from '@/api/account'
import {
FunctionTypes,
GoodsStatus,
@@ -60,6 +60,7 @@ import {
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
import ManagePageHeader from '@/components/manage/ManagePageHeader.vue'
import PointGoodsItem from '@/components/manage/PointGoodsItem.vue'
import { CURRENT_HOST, POINT_API_URL } from '@/data/constants'
import { uploadFiles, UploadStage } from '@/data/fileUpload'
@@ -222,17 +223,6 @@ const rules = {
},
}
// 方法
async function setFunctionEnable(enable: boolean) {
const success = enable ? await EnableFunction(FunctionTypes.Point) : await DisableFunction(FunctionTypes.Point)
if (success) {
message.success(`${enable ? '启用' : '禁用'}积分系统`)
} else {
message.error(`无法${enable ? '启用' : '禁用'}积分系统`)
}
}
async function updateGoods(e: MouseEvent) {
if (isUpdating.value || !formRef.value) return
e.preventDefault()
@@ -427,77 +417,73 @@ onMounted(() => { })
</script>
<template>
<!-- 头部状态卡片 -->
<NFlex
vertical
:size="16"
<!-- 头部 -->
<ManagePageHeader
title="积分管理"
:function-type="FunctionTypes.Point"
>
<NFlex
justify="space-between"
align="center"
:gap="16"
vertical
:size="16"
>
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Point) && accountInfo.eventFetcherState.online
? 'success'
: 'warning'
"
style="flex: 1; min-width: 300px"
<NFlex
justify="space-between"
align="center"
:gap="16"
>
<NFlex
align="center"
:gap="8"
<NAlert
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Point) && accountInfo.eventFetcherState.online
? 'success'
: 'warning'
"
style="flex: 1; min-width: 300px"
>
<span>启用</span>
<NButton
text
type="primary"
tag="a"
href="https://www.wolai.com/ueENtfAm9gPEqHrAVSB2Co"
target="_blank"
<NFlex
align="center"
:gap="8"
>
积分系统
</NButton>
<NDivider vertical />
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Point)"
@update:value="setFunctionEnable"
/>
</NFlex>
<NText
depth="3"
style="margin-top: 8px; display: block"
>
此功能需要部署
<NButton
text
type="primary"
tag="a"
href="https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs"
target="_blank"
>
VtsuruEventFetcher
</NButton>
, 否则将无法记录各种事件
</NText>
</NAlert>
<EventFetcherStatusCard />
</NFlex>
<NText>
此功能依赖
<NButton
text
type="primary"
tag="a"
href="https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs"
target="_blank"
>
VtsuruEventFetcher
</NButton>
(事件监听器), 否则将无法自动记录礼物/舰长等事件
</NText>
<NDivider vertical />
<NButton
text
type="info"
tag="a"
href="https://www.wolai.com/ueENtfAm9gPEqHrAVSB2Co"
target="_blank"
>
积分系统说明
</NButton>
</NFlex>
</NAlert>
<EventFetcherStatusCard />
</NFlex>
<!-- 礼物展示页链接 -->
<NDivider
style="margin: 0"
title-placement="left"
>
礼物展示页链接
</NDivider>
<!-- 礼物展示页链接 -->
<NDivider
style="margin: 0"
title-placement="left"
>
礼物展示页链接
</NDivider>
<NFlex
align="center"
:gap="12"
>
<NInputGroup style="max-width: 400px;">
<NInput
<NFlex
align="center"
:gap="12"
>
<NInputGroup style="max-width: 400px;">
<NInput
:value="`${CURRENT_HOST}@${accountInfo.name}/goods`"
readonly
/>
@@ -510,6 +496,7 @@ onMounted(() => { })
</NInputGroup>
</NFlex>
</NFlex>
</ManagePageHeader>
<NDivider style="margin: 16px 0" />

View File

@@ -44,6 +44,12 @@ async function getUsers() {
type: OpenLiveLotteryType.Waiting,
} as UpdateLiveLotteryUsersModel
}
function handleImageError(e: Event) {
const img = e.target as HTMLImageElement
img.src = 'https://i2.hdslb.com/bfs/face/member/noface.jpg'
}
onMounted(() => {
window.$mitt.on('onOBSComponentUpdate', () => {
getUsers()
@@ -93,6 +99,7 @@ onUnmounted(() => {
class="lottery-avatar"
:src="`${user.avatar}@30h`"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div>
<p class="lottery-name">{{ user.name }}</p>
@@ -138,6 +145,7 @@ onUnmounted(() => {
style="border-radius: 50%"
:src="`${user.avatar}@50h_50w`"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<NText style="font-size: large">
{{ user.name }}
@@ -145,7 +153,10 @@ onUnmounted(() => {
</NSpace>
</div>
</Vue3Marquee>
<NSpace justify="center">
<NSpace
v-else
justify="center"
>
<div
v-for="user in result.resultUsers"
:key="user.uId"
@@ -168,6 +179,7 @@ onUnmounted(() => {
style="border-radius: 50%"
:src="`${user.avatar}@50h_50w`"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<NText style="font-size: large; margin-top: 10px">
{{ user.name }}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { OpenLiveInfo, OpenLiveLotteryUserInfo, UpdateLiveLotteryUsersModel } from '@/api/api-models'
import type { DanmakuInfo, GiftInfo } from '@/data/DanmakuClients/OpenLiveClient'
import { Add24Filled, Delete24Filled, Info24Filled, PersonAdd24Filled, Sparkle24Filled, Target24Filled } from '@vicons/fluent'
import { Add24Filled, Delete24Filled, Info24Filled, Pause24Filled, PersonAdd24Filled, Play24Filled, Sparkle24Filled, Target24Filled } from '@vicons/fluent'
import { useLocalStorage, useStorage } from '@vueuse/core'
import { format } from 'date-fns'
import {
@@ -17,6 +17,8 @@ import {
NEmpty,
NForm,
NFormItem,
NGi,
NGrid,
NIcon,
NInput,
NInputGroup,
@@ -26,12 +28,14 @@ import {
NList,
NListItem,
NModal,
NNumberAnimation,
NProgress,
NRadioButton,
NRadioGroup,
NResult,
NScrollbar,
NSpace,
NStatistic,
NTag,
NTime,
NTooltip,
@@ -784,318 +788,331 @@ onUnmounted(() => {
</NButton>
</NSpace>
</NCard>
<NCard
size="small"
embedded
title="抽奖选项"
>
<template #header-extra>
<div class="settings-wrapper">
<div class="settings-header">
<NSpace align="center">
<NIcon :component="Sparkle24Filled" color="#f0a020" />
<span style="font-weight: bold; font-size: 16px">抽奖设置</span>
</NSpace>
<NButton
size="small"
size="tiny"
secondary
:disabled="isStartLottery"
@click="lotteryOption = defaultOption"
>
恢复默认
</NButton>
</template>
<NSpace
justify="center"
align="center"
>
<NTag :bordered="false">
抽奖类型
</NTag>
<NRadioGroup
v-model:value="lotteryOption.type"
:disabled="isLottering"
size="small"
>
<NRadioButton
value="danmaku"
:disabled="isStartLottery"
>
弹幕
</NRadioButton>
<NRadioButton
value="gift"
:disabled="isStartLottery"
>
礼物
</NRadioButton>
</NRadioGroup>
</NSpace>
<NDivider style="margin: 10px 0 10px 0" />
<NSpace align="center">
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 抽选人数 </NInputGroupLabel>
<NInputNumber
v-model:value="lotteryOption.resultCount"
:disabled="isStartLottery"
placeholder=""
min="1"
/>
</NInputGroup>
<NCheckbox
v-model:checked="lotteryOption.needGuard"
:disabled="isStartLottery"
>
需要上舰
</NCheckbox>
<NCheckbox
v-model:checked="lotteryOption.needFanMedal"
:disabled="isStartLottery"
>
需要粉丝牌
</NCheckbox>
<NCollapseTransition>
<NInputGroup
v-if="lotteryOption.needFanMedal"
style="max-width: 200px"
>
<NInputGroupLabel> 最低粉丝牌等级 </NInputGroupLabel>
<NInputNumber
v-model:value="lotteryOption.fanCardLevel"
min="1"
max="50"
:default-value="1"
:disabled="isLottering || isStartLottery"
/>
</NInputGroup>
</NCollapseTransition>
<template v-if="lotteryOption.type == 'danmaku'">
<NTooltip>
<template #trigger>
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 弹幕内容 </NInputGroupLabel>
<NInput
v-model:value="lotteryOption.danmakuKeyword"
:disabled="isStartLottery"
placeholder="留空则任何弹幕都可以"
/>
</NInputGroup>
</template>
符合规则的弹幕才会被添加到抽奖队列中
</NTooltip>
<NRadioGroup
v-model:value="lotteryOption.danmakuFilterType"
name="判定类型"
:disabled="isLottering"
size="small"
>
<NRadioButton
:disabled="isStartLottery"
value="all"
</div>
<div class="settings-layout">
<!-- 左侧参与规则 -->
<div class="setting-column">
<div class="setting-section">
<div class="section-header">
<NIcon :component="Target24Filled" />
参与规则
</div>
<NForm
label-placement="left"
label-width="80"
size="small"
>
完全一致
</NRadioButton>
<NRadioButton
:disabled="isStartLottery"
value="contains"
<NFormItem label="参与方式">
<NRadioGroup
v-model:value="lotteryOption.type"
:disabled="isLottering || isStartLottery"
>
<NRadioButton value="danmaku">
弹幕
</NRadioButton>
<NRadioButton value="gift">
礼物
</NRadioButton>
</NRadioGroup>
</NFormItem>
<template v-if="lotteryOption.type == 'danmaku'">
<NFormItem label="弹幕内容">
<NInput
v-model:value="lotteryOption.danmakuKeyword"
:disabled="isStartLottery"
placeholder="留空则任意弹幕"
/>
</NFormItem>
<NFormItem
v-if="lotteryOption.danmakuKeyword"
label="匹配规则"
>
<NRadioGroup
v-model:value="lotteryOption.danmakuFilterType"
:disabled="isStartLottery"
>
<NRadioButton value="all">
完全一致
</NRadioButton>
<NRadioButton value="contains">
包含
</NRadioButton>
<NRadioButton value="regex">
正则
</NRadioButton>
</NRadioGroup>
</NFormItem>
</template>
<template v-else-if="lotteryOption.type == 'gift'">
<NFormItem label="礼物限制">
<NInputGroup>
<NInputNumber
v-model:value="lotteryOption.giftMinPrice"
:disabled="isStartLottery"
placeholder="最低价格"
:min="0"
style="width: 50%"
>
<template #suffix>
</template>
</NInputNumber>
<NInput
v-model:value="lotteryOption.giftName"
:disabled="isStartLottery"
placeholder="指定礼物名称"
style="width: 50%"
/>
</NInputGroup>
</NFormItem>
</template>
<NFormItem label="身份限制">
<NSpace>
<NCheckbox
v-model:checked="lotteryOption.needGuard"
:disabled="isStartLottery"
>
舰长
</NCheckbox>
<NCheckbox
v-model:checked="lotteryOption.needFanMedal"
:disabled="isStartLottery"
>
粉丝牌
</NCheckbox>
<NCheckbox
v-model:checked="lotteryOption.needWearFanMedal"
:disabled="isStartLottery"
>
佩戴
</NCheckbox>
</NSpace>
</NFormItem>
<NCollapseTransition :show="lotteryOption.needFanMedal">
<NFormItem label="粉丝牌等级">
<NInputNumber
v-model:value="lotteryOption.fanCardLevel"
:min="1"
:max="50"
:disabled="isStartLottery"
/>
</NFormItem>
</NCollapseTransition>
</NForm>
</div>
</div>
<!-- 右侧玩法设置 -->
<div class="setting-column">
<div class="setting-section">
<div class="section-header">
<NIcon :component="Sparkle24Filled" />
玩法设置
</div>
<NForm
label-placement="left"
label-width="auto"
size="small"
>
包含
</NRadioButton>
<NRadioButton
:disabled="isStartLottery"
value="regex"
>
正则
</NRadioButton>
</NRadioGroup>
</template>
<template v-else-if="lotteryOption.type == 'gift'">
<NInputGroup style="max-width: 250px">
<NInputGroupLabel> 最低价格 </NInputGroupLabel>
<NInputNumber
v-model:value="lotteryOption.giftMinPrice"
:disabled="isStartLottery"
placeholder="留空则不限制"
/>
</NInputGroup>
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 礼物名称 </NInputGroupLabel>
<NInput
v-model:value="lotteryOption.giftName"
:disabled="isStartLottery"
placeholder="留空则不限制"
/>
</NInputGroup>
</template>
</NSpace>
<NDivider style="margin: 10px 0 10px 0" />
<NSpace
justify="center"
align="center"
>
<NTag :bordered="false">
抽取方式
</NTag>
<NRadioGroup
v-model:value="lotteryOption.lotteryType"
name="抽取类型"
size="small"
:disabled="isLottering"
>
<NRadioButton value="single">
单个淘汰
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.single }}
</NTooltip>
</NRadioButton>
<NRadioButton value="half">
减半淘汰
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.half }}
</NTooltip>
</NRadioButton>
<NRadioButton value="flip">
翻牌抽取
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.flip }}
</NTooltip>
</NRadioButton>
<NRadioButton value="wheel" :disabled="currentUsers.length < 2">
转轮抽取
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.wheel }}
</NTooltip>
</NRadioButton>
<NRadioButton value="cards">
抽卡模式
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.cards }}
</NTooltip>
</NRadioButton>
<NRadioButton value="elimination">
淘汰赛
<NTooltip>
<template #trigger>
<NIcon :component="Info24Filled" />
</template>
{{ lotteryTypeDescriptions.elimination }}
</NTooltip>
</NRadioButton>
</NRadioGroup>
</NSpace>
<NDivider style="margin: 10px 0 10px 0" />
<NSpace align="center" justify="center">
<NTag :bordered="false">
动画速度
</NTag>
<NInputGroup style="max-width: 200px">
<NInputGroupLabel> 动画延迟(毫秒) </NInputGroupLabel>
<NInputNumber
v-model:value="lotteryOption.animationSpeed"
:disabled="isLottering"
min="100"
max="5000"
step="100"
/>
</NInputGroup>
</NSpace>
</NCard>
<div class="form-row">
<NFormItem label="抽取人数" style="flex: 1">
<NInputNumber
v-model:value="lotteryOption.resultCount"
:min="1"
:disabled="isStartLottery"
style="width: 100%"
/>
</NFormItem>
<NFormItem label="动画速度" style="flex: 1">
<NInputNumber
v-model:value="lotteryOption.animationSpeed"
:step="100"
:min="100"
:max="5000"
:disabled="isLottering"
style="width: 100%"
>
<template #suffix>
ms
</template>
</NInputNumber>
</NFormItem>
</div>
<NFormItem label="玩法模式">
<div class="mode-selector-grid">
<div
v-for="(desc, key) in lotteryTypeDescriptions"
:key="key"
class="mode-card"
:class="{
active: lotteryOption.lotteryType === key,
disabled: isLottering || (key === 'wheel' && currentUsers.length < 2)
}"
@click="!isLottering && (key !== 'wheel' || currentUsers.length >= 2) && (lotteryOption.lotteryType = key as any)"
>
<div class="mode-icon">
<NIcon v-if="key === 'single'" :component="Delete24Filled" />
<NIcon v-else-if="key === 'half'" :component="Pause24Filled" style="transform: rotate(90deg)" />
<NIcon v-else-if="key === 'flip'" :component="Sparkle24Filled" />
<NIcon v-else-if="key === 'wheel'" :component="Target24Filled" />
<NIcon v-else-if="key === 'cards'" :component="Add24Filled" />
<NIcon v-else-if="key === 'elimination'" :component="Play24Filled" />
</div>
<div class="mode-info">
<div class="mode-title">
{{ key === 'single' ? '单个淘汰' :
key === 'half' ? '减半淘汰' :
key === 'flip' ? '翻牌抽取' :
key === 'wheel' ? '转轮抽取' :
key === 'cards' ? '抽卡模式' : '淘汰赛' }}
</div>
<div class="mode-desc">{{ desc }}</div>
</div>
</div>
</div>
</NFormItem>
</NForm>
</div>
</div>
</div>
</div>
<NCard
v-if="originUsers"
size="small"
style="margin-top: 16px; min-height: 400px"
>
<NSpace
justify="center"
align="center"
>
<NButton
type="primary"
:loading="isStartLottery"
:disabled="isStartLottery || isLotteried || !client"
@click="continueLottery"
<template #header>
<NSpace
align="center"
justify="space-between"
>
开始监听
</NButton>
<NButton
type="warning"
:disabled="!isStartLottery"
@click="pause"
>
停止
</NButton>
<NButton
type="error"
:disabled="isLottering || originUsers.length == 0"
@click="clear"
>
清空
</NButton>
</NSpace>
<NDivider style="margin: 20px 0 20px 0">
<template v-if="isStartLottery">
进行抽取前需要先停止
</template>
<template v-else-if="lotteryProgress > 0 && lotteryProgress < 100">
抽取进行中 ({{ Math.round(lotteryProgress) }}%)
</template>
<template v-else-if="currentLotteryStep > 0 && lotteryOption.lotteryType === 'elimination'">
淘汰赛第 {{ currentLotteryStep }}
</template>
</NDivider>
<div class="user-count-stat">
<span class="label">当前参与</span>
<NNumberAnimation
:from="0"
:to="currentUsers.length"
active
/>
<span class="unit"></span>
</div>
<NSpace>
<NButton
:type="isStartLottery ? 'warning' : 'success'"
:loading="isStartLottery && !isLotteried"
@click="isStartLottery ? pause() : continueLottery()"
>
<template #icon>
<NIcon :component="isStartLottery ? Pause24Filled : Play24Filled" />
</template>
{{ isStartLottery ? '暂停监听' : '开始监听' }}
</NButton>
<NButton
type="error"
secondary
:disabled="isLottering || originUsers.length == 0"
@click="clear"
>
清空
</NButton>
</NSpace>
</NSpace>
</template>
<!-- 进度条 -->
<div v-if="isLottering || lotteryProgress > 0" style="margin: 10px 0">
<NProgress
:percentage="lotteryProgress"
:show-indicator="true"
type="line"
:status="isLottering ? 'info' : 'success'"
/>
<div
v-if="isLottering || lotteryProgress > 0 || isStartLottery"
class="status-bar"
>
<div
v-if="isStartLottery"
style="color: var(--n-primary-color)"
>
<NSpace
align="center"
justify="center"
>
<NIcon
:component="Sparkle24Filled"
class="n-icon-spin"
/>
正在监听弹幕/礼物中...
</NSpace>
</div>
<div v-else-if="lotteryProgress > 0 && lotteryProgress < 100">
<NProgress
type="line"
:percentage="lotteryProgress"
:indicator-placement="'inside'"
processing
/>
<div style="margin-top: 8px">
<template v-if="currentLotteryStep > 0 && lotteryOption.lotteryType === 'elimination'">
淘汰赛第 {{ currentLotteryStep }}
</template>
<template v-else>
正在抽取中...
</template>
</div>
</div>
</div>
<NSpace justify="center">
<div class="action-bar">
<NButton
type="success"
size="large"
:loading="isLottering"
:disabled="isStartLottery || isLotteried"
:disabled="isStartLottery || isLotteried || currentUsers.length === 0"
data-umami-event="Open-Live Use Lottery"
:data-umami-event-uid="client?.authInfo?.anchor_info?.uid"
style="width: 180px; height: 48px; font-size: 18px"
@click="startLottery"
>
进行抽取
<template #icon>
<NIcon :component="Sparkle24Filled" />
</template>
开始抽取
</NButton>
<NButton
type="info"
secondary
:disabled="isStartLottery || isLottering || !isLotteried"
size="large"
:disabled="isLottering || !isLotteried"
style="width: 120px; height: 48px"
@click="reset"
>
重置
重置结果
</NButton>
</NSpace>
<NDivider style="margin: 10px 0 10px 0">
{{ currentUsers?.length }}
</NDivider>
<!-- 翻牌模式洗牌按钮 -->
<div v-if="lotteryOption.lotteryType === 'flip'" style="display: flex; justify-content: center; margin-bottom: 10px;">
<NButton
size="small"
v-if="lotteryOption.lotteryType === 'flip'"
size="large"
type="info"
secondary
:disabled="!flipEnabled || isLottering || isStartLottery || currentUsers.length === 0"
style="height: 48px"
@click="shuffleFlipCards"
>
洗牌
</NButton>
</div>
<NDivider style="margin: 10px 0 20px 0" />
<!-- 转轮模式特殊显示 -->
<div v-if="lotteryOption.lotteryType === 'wheel' && currentUsers.length >= 2" class="wheel-container">
<div class="wheel-area">
@@ -1406,6 +1423,165 @@ onUnmounted(() => {
</template>
<style scoped>
.settings-wrapper {
margin-bottom: 16px;
margin-top: 16px;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.settings-layout {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.setting-column {
flex: 1;
min-width: 300px;
}
.form-row {
display: flex;
gap: 16px;
width: 100%;
}
@media (max-width: 600px) {
.form-row {
flex-direction: column;
gap: 0;
}
}
.setting-section {
background: var(--n-card-color);
border-radius: 12px;
padding: 20px;
height: 100%;
border: 1px solid var(--n-border-color);
transition: all 0.3s ease;
box-sizing: border-box;
}
.setting-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.section-header {
font-size: 16px;
font-weight: bold;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--n-text-color);
border-bottom: 1px dashed var(--n-border-color);
padding-bottom: 12px;
}
.user-count-stat {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 14px;
color: var(--n-text-color-2);
}
.user-count-stat .n-number-animation {
font-size: 24px;
font-weight: bold;
color: var(--n-primary-color);
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.user-count-stat .unit {
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: center;
gap: 16px;
padding: 20px 0;
}
.status-bar {
margin: 16px 0;
text-align: center;
color: var(--n-text-color-2);
}
.mode-selector-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
width: 100%;
}
.mode-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
border: 1px solid var(--n-border-color);
border-radius: 8px;
background-color: var(--n-card-color);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
gap: 8px;
}
.mode-card:hover:not(.disabled) {
border-color: var(--n-primary-color);
background-color: rgba(var(--n-primary-color-rgb), 0.05);
transform: translateY(-2px);
}
.mode-card.active {
border-color: var(--n-primary-color);
background-color: rgba(var(--n-primary-color-rgb), 0.1);
color: var(--n-primary-color);
box-shadow: 0 0 0 2px rgba(var(--n-primary-color-rgb), 0.2);
}
.mode-card.disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
}
.mode-icon {
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.mode-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.mode-title {
font-size: 14px;
font-weight: bold;
}
.mode-desc {
font-size: 12px;
color: var(--n-text-color-3);
display: none; /* 默认不显示描述hover或大屏可以显示目前保持简洁 */
}
/* 卡片容器 */
.lottery-cards-container {
display: grid;