mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: Enhance HistoryView with Guard List and Stats
- Added guard member model and statistics interface. - Implemented loading functionality for guard list and statistics. - Introduced a data table to display current guard members with pagination. - Enhanced error handling and loading states for guard data. - Updated UI components to include refresh button and statistics display. feat: Update PointManage and PointGoodsView for Improved User Experience - Added management flag to PointGoodsItem for better handling in PointManage. - Enhanced PointGoodsView with improved tooltip logic for purchase status. - Implemented detailed purchase history alerts and restrictions based on user actions. - Improved visual feedback for purchased and non-purchasable items. docs: Add comprehensive API integration documentation - Documented API modules, data models, and request types. - Included details on live platform integration and data storage mechanisms. docs: Establish development workflow guidelines - Outlined project configuration, environment setup, and code style practices. - Provided deployment process using Docker. docs: Create detailed documentation for the live request system - Described the functionality and data flow of the song request system. - Included main files and features related to the song request functionality. docs: Define project structure and UI components - Documented the overall project structure and key directories. - Listed main UI components and their usage within the project. chore: Analyze and document improvements in AnalyzeView - Detailed enhancements made to the AnalyzeView for better user experience. - Included visual comparisons and technical highlights of the optimizations.
This commit is contained in:
@@ -51,6 +51,10 @@ export async function GetSelfAccount(token?: string) {
|
||||
|
||||
export function UpdateAccountLoop() {
|
||||
setInterval(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
if (urlParams.get('as')) {
|
||||
return
|
||||
}
|
||||
if (ACCOUNT.value && window.$route?.name != 'question-display') {
|
||||
// 防止在问题详情页刷新
|
||||
GetSelfAccount()
|
||||
|
||||
@@ -715,6 +715,12 @@ export interface ResponsePointGoodModel {
|
||||
allowGuardLevel: GuardLevel
|
||||
setting: PointGoodsSetting
|
||||
|
||||
// 购买状态信息
|
||||
purchasedCount: number
|
||||
hasPurchased: boolean
|
||||
canPurchase: boolean
|
||||
cannotPurchaseReason?: string
|
||||
|
||||
// 添加虚拟礼物多Key支持
|
||||
virtualKeys?: string[]
|
||||
keySelectionMode?: KeySelectionMode
|
||||
|
||||
7
src/components.d.ts
vendored
7
src/components.d.ts
vendored
@@ -19,19 +19,14 @@ declare module 'vue' {
|
||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTime: typeof import('naive-ui')['NTime']
|
||||
|
||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
||||
goods: ResponsePointGoodModel | undefined
|
||||
contentStyle?: string | undefined
|
||||
size?: 'small' | 'default'
|
||||
isManage?: boolean
|
||||
}>()
|
||||
|
||||
// 默认封面图片
|
||||
@@ -62,16 +63,16 @@ const emptyCover = `${IMGUR_URL}None.png`
|
||||
:bordered="true"
|
||||
style="background-color: transparent;"
|
||||
:style="{
|
||||
color: goods.type == GoodsTypes.Physical ? '#006633' : '#0066cc',
|
||||
borderColor: goods.type == GoodsTypes.Physical ? '#009966' : '#3399ff',
|
||||
backgroundColor: goods.type == GoodsTypes.Physical ? '#c2e6d290' : '#c2d6eb90',
|
||||
color: goods.type === GoodsTypes.Physical ? '#006633' : '#0066cc',
|
||||
borderColor: goods.type === GoodsTypes.Physical ? '#009966' : '#3399ff',
|
||||
backgroundColor: goods.type === GoodsTypes.Physical ? '#c2e6d290' : '#c2d6eb90',
|
||||
}"
|
||||
>
|
||||
{{ goods.type == GoodsTypes.Physical ? '实物' : '虚拟' }}
|
||||
{{ goods.type === GoodsTypes.Physical ? '实物' : '虚拟' }}
|
||||
</NTag>
|
||||
<!-- 状态标签 -->
|
||||
<NTag
|
||||
v-if="goods.count == 0"
|
||||
v-if="goods.count === 0"
|
||||
size="small"
|
||||
type="error"
|
||||
:bordered="false"
|
||||
@@ -133,7 +134,7 @@ const emptyCover = `${IMGUR_URL}None.png`
|
||||
{{ goods.count }}
|
||||
</NText>
|
||||
<NText
|
||||
v-else-if="goods.count == 0"
|
||||
v-else-if="goods.count === 0"
|
||||
size="small"
|
||||
type="error"
|
||||
>
|
||||
@@ -174,12 +175,152 @@ const emptyCover = `${IMGUR_URL}None.png`
|
||||
</NText>
|
||||
</NEllipsis>
|
||||
|
||||
<!-- 礼物信息卡片 - 仅在后台管理页面显示 -->
|
||||
<div
|
||||
v-if="isManage"
|
||||
class="info-cards"
|
||||
>
|
||||
<!-- 兑换数量限制 -->
|
||||
<div
|
||||
v-if="goods.type === GoodsTypes.Physical && goods.maxBuyCount"
|
||||
class="info-item"
|
||||
>
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
📦 限购
|
||||
</NText>
|
||||
<NText class="info-value">
|
||||
{{ goods.maxBuyCount }}件
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 是否允许重复兑换 -->
|
||||
<div class="info-item">
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
🔄 重购
|
||||
</NText>
|
||||
<NText
|
||||
class="info-value"
|
||||
:type="goods.isAllowRebuy ? 'success' : 'error'"
|
||||
>
|
||||
{{ goods.isAllowRebuy ? '允许' : '禁止' }}
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 舰长等级限制 -->
|
||||
<div
|
||||
v-if="goods.setting?.allowGuardLevel && goods.setting.allowGuardLevel > 0"
|
||||
class="info-item"
|
||||
>
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
⚓ 等级
|
||||
</NText>
|
||||
<NText
|
||||
class="info-value"
|
||||
type="warning"
|
||||
>
|
||||
{{ goods.setting.allowGuardLevel === 1 ? '总督' : goods.setting.allowGuardLevel === 2 ? '提督' : '舰长' }}+
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 舰长免费 -->
|
||||
<div
|
||||
v-if="goods.setting?.guardFree"
|
||||
class="info-item"
|
||||
>
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
⭐ 舰长
|
||||
</NText>
|
||||
<NText
|
||||
class="info-value"
|
||||
type="success"
|
||||
>
|
||||
免费
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟礼物密钥数量 -->
|
||||
<div
|
||||
v-if="goods.type === GoodsTypes.Virtual && goods.virtualKeys && goods.virtualKeys.length > 0"
|
||||
class="info-item"
|
||||
>
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
🔑 密钥
|
||||
</NText>
|
||||
<NText class="info-value">
|
||||
{{ goods.virtualKeys.length }}个
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 收集地址方式 -->
|
||||
<div
|
||||
v-if="goods.type === GoodsTypes.Physical"
|
||||
class="info-item"
|
||||
>
|
||||
<NText
|
||||
depth="3"
|
||||
class="info-label"
|
||||
>
|
||||
📮 地址
|
||||
</NText>
|
||||
<NText class="info-value">
|
||||
{{ goods.collectUrl ? '站外' : '本站' }}
|
||||
</NText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户自定义标签展示 -->
|
||||
<div
|
||||
v-if="goods.tags && goods.tags.length > 0"
|
||||
v-if="(goods.tags && goods.tags.length > 0) || (!isManage && ((goods.setting?.allowGuardLevel ?? 0) > 0 || goods.setting?.guardFree || !goods.isAllowRebuy))"
|
||||
class="tags-container"
|
||||
>
|
||||
<div class="tags-wrapper">
|
||||
<!-- 用户页面:显示重要信息标签 -->
|
||||
<template v-if="!isManage">
|
||||
<NTag
|
||||
v-if="goods.setting?.allowGuardLevel && goods.setting.allowGuardLevel > 0"
|
||||
:bordered="false"
|
||||
size="tiny"
|
||||
class="user-tag important-tag"
|
||||
style="color: #fff; background-color: rgba(255, 170, 0, 0.85);"
|
||||
>
|
||||
⚓ {{ goods.setting.allowGuardLevel === 1 ? '总督' : goods.setting.allowGuardLevel === 2 ? '提督' : '舰长' }}+
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="goods.setting?.guardFree"
|
||||
:bordered="false"
|
||||
size="tiny"
|
||||
class="user-tag important-tag"
|
||||
style="color: #fff; background-color: rgba(24, 160, 88, 0.85);"
|
||||
>
|
||||
⭐ 舰长免费
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="!goods.isAllowRebuy"
|
||||
:bordered="false"
|
||||
size="tiny"
|
||||
class="user-tag important-tag"
|
||||
style="color: #fff; background-color: rgba(208, 48, 80, 0.85);"
|
||||
>
|
||||
🔒 限购一次
|
||||
</NTag>
|
||||
</template>
|
||||
|
||||
<!-- 用户自定义标签 -->
|
||||
<NTag
|
||||
v-for="tag in goods.tags"
|
||||
:key="tag"
|
||||
@@ -399,6 +540,18 @@ const emptyCover = `${IMGUR_URL}None.png`
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 重要信息标签样式 */
|
||||
.important-tag {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.important-tag:hover {
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-2px) scale(1.08);
|
||||
}
|
||||
|
||||
.stock-info {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color-3);
|
||||
@@ -408,4 +561,45 @@ const emptyCover = `${IMGUR_URL}None.png`
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 信息卡片样式 */
|
||||
.info-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
background-color: var(--action-color);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--divider-color);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.info-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -764,7 +764,7 @@ onMounted(() => {
|
||||
<NLayout>
|
||||
<!-- 主内容区域 -->
|
||||
<NScrollbar :style="`height: calc(100vh - var(--vtsuru-header-height) - ${aplayerHeight}px)`">
|
||||
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px)">
|
||||
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px); padding-bottom: 32px;">
|
||||
<NElement>
|
||||
<!-- 已验证邮箱的用户显示内容 -->
|
||||
<RouterView
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { TrendingDown, TrendingUp } from '@vicons/ionicons5'
|
||||
import { RefreshOutline, TrendingDown, TrendingUp } from '@vicons/ionicons5'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import {
|
||||
DataZoomComponent,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { NCard, NDivider, NGrid, NGridItem, NIcon, NSpace, NSpin, NStatistic, NTabPane, NTabs, NTag, useMessage, useThemeVars } from 'naive-ui'
|
||||
import { NButton, NCard, NDivider, NEmpty, NGrid, NGridItem, NIcon, NSpace, NSpin, 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'
|
||||
@@ -83,14 +83,17 @@ interface AnalyzeData {
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const message = useMessage()
|
||||
const analyzeData = ref<AnalyzeData>()
|
||||
const summaryData = computed(() => analyzeData.value?.summary)
|
||||
const activeChart = ref('income')
|
||||
const themeVars = useThemeVars()
|
||||
const lastUpdateTime = ref<number>(0)
|
||||
const hasData = computed(() => analyzeData.value && Object.keys(analyzeData.value.chartData || {}).length > 0)
|
||||
|
||||
// 处理标签页变化
|
||||
function onTabChange(value: string) {
|
||||
function onTabChange(_value: string) {
|
||||
nextTick(() => {
|
||||
handleResize()
|
||||
})
|
||||
@@ -212,7 +215,7 @@ function initCharts() {
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
bottom: '15%',
|
||||
top: '60px',
|
||||
containLabel: true,
|
||||
},
|
||||
@@ -418,14 +421,22 @@ function updateChartTheme() {
|
||||
}
|
||||
|
||||
// 获取分析数据
|
||||
async function fetchAnalyzeData() {
|
||||
async function fetchAnalyzeData(isRefresh = false) {
|
||||
try {
|
||||
loading.value = true
|
||||
if (isRefresh) {
|
||||
refreshing.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
}
|
||||
const data = await QueryGetAPI<AnalyzeData>(`${ANALYZE_API_URL}all`)
|
||||
|
||||
if (data.code === 200) {
|
||||
analyzeData.value = data.data
|
||||
lastUpdateTime.value = Date.now()
|
||||
nextTick(() => initCharts())
|
||||
if (isRefresh) {
|
||||
message.success('数据已刷新')
|
||||
}
|
||||
} else {
|
||||
message.error(`获取数据失败: ${data.message}`)
|
||||
}
|
||||
@@ -434,9 +445,15 @@ async function fetchAnalyzeData() {
|
||||
console.error('获取数据出错:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
function handleRefresh() {
|
||||
fetchAnalyzeData(true)
|
||||
}
|
||||
|
||||
// 窗口大小变化时重绘图表
|
||||
function handleResize() {
|
||||
incomeChart?.resize()
|
||||
@@ -465,246 +482,395 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="analyze-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
class="header-actions"
|
||||
>
|
||||
<EventFetcherAlert />
|
||||
<EventFetcherStatusCard />
|
||||
<NSpace align="center">
|
||||
<EventFetcherAlert />
|
||||
<EventFetcherStatusCard />
|
||||
</NSpace>
|
||||
<NSpace align="center">
|
||||
<NTooltip v-if="lastUpdateTime > 0">
|
||||
<template #trigger>
|
||||
<NTag size="small" :bordered="false">
|
||||
<NIcon :component="TrendingUp" style="margin-right: 4px;" />
|
||||
<NTime :time="lastUpdateTime" type="relative" />更新
|
||||
</NTag>
|
||||
</template>
|
||||
<NTime :time="lastUpdateTime" />
|
||||
</NTooltip>
|
||||
<NButton
|
||||
:loading="refreshing"
|
||||
:disabled="loading"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="RefreshOutline" />
|
||||
</template>
|
||||
刷新数据
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
|
||||
<NSpin :show="loading">
|
||||
<!-- 数据概览卡片 -->
|
||||
<div class="summary-cards">
|
||||
<NGrid
|
||||
cols="1 800:2 1200:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<NGridItem>
|
||||
<NCard
|
||||
title="近7天统计"
|
||||
size="small"
|
||||
class="summary-card"
|
||||
>
|
||||
<div class="stat-grid">
|
||||
<NStatistic
|
||||
label="总收入"
|
||||
:value="formatCurrency(summaryData?.last7Days?.totalIncome || 0)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="总互动数"
|
||||
:value="summaryData?.last7Days?.totalInteractions || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="弹幕数"
|
||||
:value="summaryData?.last7Days?.totalDanmakuCount || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="直播时长(小时)"
|
||||
:value="((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="互动人数"
|
||||
:value="summaryData?.last7Days?.interactionUsers || 0"
|
||||
>
|
||||
<template #suffix>
|
||||
<NTag
|
||||
:type="getTrendType(summaryData?.last7Days?.interactionUsersTrend || 0)"
|
||||
size="small"
|
||||
>
|
||||
{{ formatTrend(summaryData?.last7Days?.interactionUsersTrend || 0) }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NStatistic>
|
||||
<NStatistic
|
||||
label="付费人数"
|
||||
:value="summaryData?.last7Days?.payingUsers || 0"
|
||||
>
|
||||
<template #suffix>
|
||||
<NTag
|
||||
:type="getTrendType(summaryData?.last7Days?.payingUsersTrend || 0)"
|
||||
size="small"
|
||||
>
|
||||
{{ formatTrend(summaryData?.last7Days?.payingUsersTrend || 0) }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NStatistic>
|
||||
<NStatistic
|
||||
label="日均收入"
|
||||
:value="formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="日均弹幕"
|
||||
:value="summaryData?.last7Days?.dailyAvgDanmaku || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="活跃直播天数"
|
||||
:value="summaryData?.last7Days?.activeLiveDays || 0"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard
|
||||
title="近30天统计"
|
||||
size="small"
|
||||
class="summary-card"
|
||||
>
|
||||
<div class="stat-grid">
|
||||
<NStatistic
|
||||
label="总收入"
|
||||
:value="formatCurrency(summaryData?.last30Days?.totalIncome || 0)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="总互动数"
|
||||
:value="summaryData?.last30Days?.totalInteractions || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="弹幕数"
|
||||
:value="summaryData?.last30Days?.totalDanmakuCount || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="直播时长(小时)"
|
||||
:value="((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="互动人数"
|
||||
:value="summaryData?.last30Days?.interactionUsers || 0"
|
||||
>
|
||||
<template #suffix>
|
||||
<NTag
|
||||
:type="getTrendType(summaryData?.last30Days?.interactionTrend || 0)"
|
||||
size="small"
|
||||
>
|
||||
{{ formatTrend(summaryData?.last30Days?.interactionTrend || 0) }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NStatistic>
|
||||
<NStatistic
|
||||
label="付费人数"
|
||||
:value="summaryData?.last30Days?.payingUsers || 0"
|
||||
>
|
||||
<template #suffix>
|
||||
<NTag
|
||||
:type="getTrendType(summaryData?.last30Days?.incomeTrend || 0)"
|
||||
size="small"
|
||||
>
|
||||
{{ formatTrend(summaryData?.last30Days?.incomeTrend || 0) }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NStatistic>
|
||||
<NStatistic
|
||||
label="日均收入"
|
||||
:value="formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0)"
|
||||
/>
|
||||
<NStatistic
|
||||
label="日均弹幕"
|
||||
:value="summaryData?.last30Days?.dailyAvgDanmaku || 0"
|
||||
/>
|
||||
<NStatistic
|
||||
label="活跃直播天数"
|
||||
:value="summaryData?.last30Days?.activeLiveDays || 0"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard
|
||||
title="关键指标"
|
||||
size="small"
|
||||
class="summary-card"
|
||||
>
|
||||
<div class="stat-grid">
|
||||
<NStatistic
|
||||
label="月收入增长"
|
||||
:value="formatTrend(summaryData?.last30Days?.incomeTrend || 0)"
|
||||
>
|
||||
<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="月互动增长"
|
||||
:value="formatTrend(summaryData?.last30Days?.interactionTrend || 0)"
|
||||
>
|
||||
<template #prefix>
|
||||
<NIcon
|
||||
:component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown"
|
||||
:color="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? '#18A058' : '#D03050'"
|
||||
>
|
||||
<TrendingUp v-if="(summaryData?.last30Days?.interactionTrend || 0) >= 0" />
|
||||
<TrendingDown v-else />
|
||||
</NIcon>
|
||||
</template>
|
||||
</NStatistic>
|
||||
<NStatistic
|
||||
label="单次直播平均时长"
|
||||
:value="`${((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1)}小时`"
|
||||
/>
|
||||
<NStatistic
|
||||
label="互动转化率"
|
||||
:value="`${((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1)}%`"
|
||||
/>
|
||||
<NStatistic
|
||||
label="每付费用户平均打米"
|
||||
:value="formatCurrency((summaryData?.last30Days?.totalIncome || 0) / (summaryData?.last30Days?.payingUsers || 1))"
|
||||
/>
|
||||
</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"
|
||||
<!-- 空状态 -->
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
ref="incomeChartRef"
|
||||
class="chart"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="interaction"
|
||||
tab="互动分析"
|
||||
display-directive="show"
|
||||
<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>
|
||||
</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 #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"
|
||||
>
|
||||
<div
|
||||
ref="interactionChartRef"
|
||||
class="chart"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="users"
|
||||
tab="用户分析"
|
||||
display-directive="show"
|
||||
>
|
||||
<div
|
||||
ref="usersChartRef"
|
||||
class="chart"
|
||||
/>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</div>
|
||||
<NDivider />
|
||||
<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>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analyze-container {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.analyze-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -715,33 +881,154 @@ onUnmounted(() => {
|
||||
|
||||
.summary-card {
|
||||
height: 100%;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.summary-card-highlight {
|
||||
background: linear-gradient(135deg, rgba(24, 160, 88, 0.03) 0%, rgba(24, 160, 88, 0.01) 100%);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-value-primary {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #18A058;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: #D03050;
|
||||
}
|
||||
|
||||
.chart-selector {
|
||||
margin-top: 20px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 450px;
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1400px) {
|
||||
.chart {
|
||||
height: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chart {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analyze-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.header-actions :deep(.n-space) {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chart {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.stat-value-primary {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* 骨架屏动画 */
|
||||
@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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.n-statistic-value__suffix) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 图表容器优化 */
|
||||
:deep(.n-tabs-nav) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 卡片标题优化 */
|
||||
:deep(.n-card-header__main) {
|
||||
font-weight: 600;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
/* Hover效果 */
|
||||
.summary-card :deep(.n-statistic) {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.summary-card:hover :deep(.n-statistic) {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { Info24Filled } from '@vicons/fluent'
|
||||
import { addDays, endOfDay, format, startOfDay } from 'date-fns'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
} from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { NAlert, NButton, NCard, NDatePicker, NDivider, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NDataTable, NDatePicker, NDivider, NEmpty, NIcon, NSpace, NSpin, NText, NTime, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useAccount } from '@/api/account'
|
||||
import { QueryGetAPI } from '@/api/query'
|
||||
@@ -62,6 +63,25 @@ interface HistoryUpstatRecordModel {
|
||||
}
|
||||
}
|
||||
|
||||
interface GuardMemberModel {
|
||||
guardUid: number
|
||||
username: string
|
||||
guardLevel: string
|
||||
accompanyDays: number
|
||||
isActive: boolean
|
||||
lastUpdateTime: string
|
||||
}
|
||||
|
||||
interface GuardStatsModel {
|
||||
totalCount: number
|
||||
governorCount: number
|
||||
admiralCount: number
|
||||
captainCount: number
|
||||
avgAccompanyDays: number
|
||||
maxAccompanyDays: number
|
||||
lastUpdateTime: string
|
||||
}
|
||||
|
||||
const accountInfo = useAccount()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -83,6 +103,66 @@ const upstatLikeOption = ref()
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
// 舰长列表相关
|
||||
const guardList = ref<GuardMemberModel[]>([])
|
||||
const guardStats = ref<GuardStatsModel | null>(null)
|
||||
const guardListLoading = ref(false)
|
||||
const guardPaginationPage = ref(1)
|
||||
const guardPaginationPageSize = ref(30)
|
||||
const guardPagination = computed(() => ({
|
||||
page: guardPaginationPage.value,
|
||||
pageSize: guardPaginationPageSize.value,
|
||||
itemCount: guardList.value.length,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
onChange: (page: number) => {
|
||||
guardPaginationPage.value = page
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
guardPaginationPageSize.value = pageSize
|
||||
guardPaginationPage.value = 1
|
||||
},
|
||||
}))
|
||||
|
||||
// 舰长列表表格列定义
|
||||
const guardColumns: DataTableColumns<GuardMemberModel> = [
|
||||
{
|
||||
title: 'UID',
|
||||
key: 'guardUid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
key: 'username',
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '等级',
|
||||
key: 'guardLevel',
|
||||
width: 80,
|
||||
render: (row) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
总督: '#FF6B9D',
|
||||
提督: '#C59AFF',
|
||||
舰长: '#00D1FF',
|
||||
}
|
||||
return h(
|
||||
'span',
|
||||
{ style: { color: colorMap[row.guardLevel] || '#333', fontWeight: 'bold' } },
|
||||
row.guardLevel,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '陪伴天数',
|
||||
key: 'accompanyDays',
|
||||
width: 100,
|
||||
sorter: (a, b) => a.accompanyDays - b.accompanyDays,
|
||||
},
|
||||
]
|
||||
|
||||
// 统计开始日期
|
||||
const statisticStartDate = new Date(2023, 10, 4)
|
||||
const statisticStartDateTime = statisticStartDate.getTime()
|
||||
@@ -119,7 +199,7 @@ const chartHeight = computed(() => {
|
||||
async function getHistory() {
|
||||
try {
|
||||
const response = await QueryGetAPI<HistoryModel>(`${HISTORY_API_URL}get-all`)
|
||||
if (response.code == 200) {
|
||||
if (response.code === 200) {
|
||||
fansHistory.value = response.data.fan.records
|
||||
guardHistory.value = response.data.guard.records
|
||||
upstatHistory.value = response.data.upstat.records
|
||||
@@ -129,7 +209,7 @@ async function getHistory() {
|
||||
} else {
|
||||
message.error(`加载失败: ${response.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('加载失败')
|
||||
}
|
||||
}
|
||||
@@ -200,7 +280,7 @@ function generateTimeSeries(
|
||||
}
|
||||
|
||||
if ((historyData[lastTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
|
||||
const changed = data.count !== lastDayCount
|
||||
const _changed = data.count !== lastDayCount
|
||||
lastDayCount = data.count
|
||||
dayExist = true
|
||||
break
|
||||
@@ -267,8 +347,8 @@ function processFansChartOptions() {
|
||||
let str = ''
|
||||
for (let i = 0; i < param.length; i++) {
|
||||
const status
|
||||
= param[i].seriesName == '粉丝数' ? (completeTimeSeries[param[i].dataIndex].exist ? '' : '(未获取)') : ''
|
||||
const statusHtml = status == '' ? '' : ` <span style="color:gray">${status}</span>`
|
||||
= param[i].seriesName === '粉丝数' ? (completeTimeSeries[param[i].dataIndex].exist ? '' : '(未获取)') : ''
|
||||
const statusHtml = status === '' ? '' : ` <span style="color:gray">${status}</span>`
|
||||
str += `${param[i].marker + param[i].seriesName}:${param[i].data}${statusHtml}<br>`
|
||||
}
|
||||
return name + str
|
||||
@@ -467,6 +547,38 @@ function processUpstatLikeChartOptions() {
|
||||
upstatLikeOption.value = processUpstatChartOptions('likes', '点赞数')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载舰长列表
|
||||
*/
|
||||
async function loadGuardList() {
|
||||
guardListLoading.value = true
|
||||
try {
|
||||
const [listResponse, statsResponse] = await Promise.all([
|
||||
QueryGetAPI<GuardMemberModel[]>(
|
||||
`${HISTORY_API_URL}guards-list?activeOnly=true`,
|
||||
),
|
||||
QueryGetAPI<GuardStatsModel>(`${HISTORY_API_URL}guards/stats`),
|
||||
])
|
||||
|
||||
if (listResponse.code === 200) {
|
||||
guardList.value = listResponse.data
|
||||
} else {
|
||||
message.error(`加载舰长列表失败: ${listResponse.message}`)
|
||||
}
|
||||
|
||||
if (statsResponse.code === 200) {
|
||||
guardStats.value = statsResponse.data
|
||||
} else {
|
||||
message.error(`加载舰长统计失败: ${statsResponse.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('加载舰长数据失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
guardListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理所有图表选项
|
||||
*/
|
||||
@@ -478,9 +590,10 @@ function processAllChartOptions() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (accountInfo.value?.isBiliVerified == true) {
|
||||
if (accountInfo.value?.isBiliVerified === true) {
|
||||
await getHistory()
|
||||
processAllChartOptions()
|
||||
await loadGuardList() // 加载舰长列表
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
@@ -496,7 +609,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<NAlert
|
||||
v-if="accountInfo?.isBiliVerified != true"
|
||||
v-if="accountInfo?.isBiliVerified !== true"
|
||||
type="info"
|
||||
>
|
||||
尚未进行Bilibili认证
|
||||
@@ -622,6 +735,45 @@ watch(
|
||||
:style="{ height: chartHeight }"
|
||||
class="chart"
|
||||
/>
|
||||
|
||||
<!-- 舰长列表 -->
|
||||
<NCard
|
||||
title="当前在舰用户"
|
||||
size="small"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<NSpace
|
||||
vertical
|
||||
size="small"
|
||||
>
|
||||
<NSpace align="center">
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="guardListLoading"
|
||||
@click="loadGuardList"
|
||||
>
|
||||
刷新列表
|
||||
</NButton>
|
||||
<NText v-if="guardStats">
|
||||
总计: {{ guardStats.totalCount }} (总督: {{ guardStats.governorCount }}, 提督: {{ guardStats.admiralCount }}, 舰长: {{ guardStats.captainCount }})
|
||||
</NText>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
v-if="guardList?.length > 0"
|
||||
:columns="guardColumns"
|
||||
:data="guardList"
|
||||
:pagination="guardPagination"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty
|
||||
v-else-if="!guardListLoading"
|
||||
description="暂无在舰用户"
|
||||
/>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NDivider />
|
||||
<!-- <NDivider>
|
||||
投稿播放量
|
||||
|
||||
@@ -562,6 +562,7 @@ onMounted(() => { })
|
||||
>
|
||||
<PointGoodsItem
|
||||
:goods="item"
|
||||
:is-manage="true"
|
||||
class="point-goods-card"
|
||||
>
|
||||
<template #footer>
|
||||
@@ -641,6 +642,7 @@ onMounted(() => { })
|
||||
>
|
||||
<PointGoodsItem
|
||||
:goods="item"
|
||||
:is-manage="true"
|
||||
class="point-goods-card"
|
||||
>
|
||||
<template #footer>
|
||||
|
||||
@@ -101,13 +101,24 @@ const addressOptions = computed(() => {
|
||||
// 判断是否可以执行购买操作
|
||||
const canDoBuy = computed(() => {
|
||||
if (!currentGoods.value) return false
|
||||
|
||||
// 优先使用后端返回的购买状态
|
||||
if (!currentGoods.value.canPurchase) return false
|
||||
|
||||
// 额外的前端检查
|
||||
// 检查购买数量是否超出限制
|
||||
const totalCount = (currentGoods.value.purchasedCount ?? 0) + buyCount.value
|
||||
if (totalCount > (currentGoods.value.maxBuyCount ?? Number.MAX_VALUE)) return false
|
||||
|
||||
// 检查积分是否足够
|
||||
const pointCheck = currentGoods.value.price * buyCount.value <= currentPoint.value
|
||||
|
||||
// 如果是实物礼物且没有外部收集链接,则必须选择地址
|
||||
const addressCheck
|
||||
= currentGoods.value.type !== GoodsTypes.Physical
|
||||
|| currentGoods.value.collectUrl
|
||||
|| !!selectedAddress.value
|
||||
|
||||
return pointCheck && addressCheck
|
||||
})
|
||||
|
||||
@@ -177,18 +188,26 @@ const selectedItems = computed(() => {
|
||||
// --- 方法 ---
|
||||
|
||||
// 获取礼物兑换按钮的提示文本
|
||||
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' | '舰长等级不足' | '兑换时间未到' | '已达兑换上限' | '需要设置地址' {
|
||||
if (!biliAuth.value.id) return '请先进行账号认证' // 未认证
|
||||
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足' // 库存不足
|
||||
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足' // 积分不足且不能免费兑换
|
||||
function getTooltip(goods: ResponsePointGoodModel): string {
|
||||
// 优先使用后端返回的购买状态信息
|
||||
if (!goods.canPurchase && goods.cannotPurchaseReason) {
|
||||
return goods.cannotPurchaseReason
|
||||
}
|
||||
|
||||
// 后备检查逻辑
|
||||
if (!biliAuth.value.id) return '请先进行账号认证'
|
||||
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足'
|
||||
if (!goods.isAllowRebuy && goods.hasPurchased) return '该礼物不允许重复兑换'
|
||||
if (goods.purchasedCount >= (goods.maxBuyCount ?? Number.MAX_VALUE)) return `已达兑换上限(${goods.maxBuyCount})`
|
||||
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足'
|
||||
|
||||
// 检查舰长等级要求
|
||||
// 使用 guardInfo 判断用户在当前主播房间的舰长等级
|
||||
const currentGuardLevel = biliAuth.value.guardInfo?.[props.userInfo.id] ?? 0
|
||||
if (goods.allowGuardLevel > 0 && currentGuardLevel < goods.allowGuardLevel) {
|
||||
return '舰长等级不足'
|
||||
}
|
||||
|
||||
return '开始兑换' // 可以兑换
|
||||
return '开始兑换'
|
||||
}
|
||||
|
||||
// 重置购买模态框状态
|
||||
@@ -219,6 +238,20 @@ async function buyGoods() {
|
||||
message.error('兑换数量必须为整数')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查后端购买状态
|
||||
if (!currentGoods.value?.canPurchase) {
|
||||
message.error(currentGoods.value?.cannotPurchaseReason || '无法兑换该礼物')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否超出兑换次数限制
|
||||
const totalCount = (currentGoods.value.purchasedCount ?? 0) + buyCount.value
|
||||
if (totalCount > (currentGoods.value.maxBuyCount ?? Number.MAX_VALUE)) {
|
||||
message.error(`超出最大兑换次数限制(${currentGoods.value.maxBuyCount})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
currentGoods.value?.type === GoodsTypes.Physical // 是实物
|
||||
&& !currentGoods.value.collectUrl // 没有外部收集链接
|
||||
@@ -559,28 +592,59 @@ onMounted(async () => {
|
||||
:goods="item"
|
||||
content-style="max-width: 300px; min-width: 250px; height: 380px;"
|
||||
class="goods-item"
|
||||
:class="{ 'pinned-item': item.isPinned }"
|
||||
:class="{
|
||||
'pinned-item': item.isPinned,
|
||||
'purchased-item': item.hasPurchased,
|
||||
'cannot-purchase-item': !item.canPurchase,
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<NFlex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
class="goods-footer"
|
||||
vertical
|
||||
:size="8"
|
||||
>
|
||||
<NTooltip placement="bottom">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
:disabled="getTooltip(item) !== '开始兑换'"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="exchange-btn"
|
||||
@click="onBuyClick(item)"
|
||||
>
|
||||
{{ item.isPinned ? '🔥 兑换' : '兑换' }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ getTooltip(item) }}
|
||||
</NTooltip>
|
||||
<NFlex
|
||||
v-if="item.hasPurchased || !item.canPurchase"
|
||||
:size="4"
|
||||
wrap
|
||||
>
|
||||
<NTag
|
||||
v-if="item.hasPurchased"
|
||||
:type="item.isAllowRebuy ? 'info' : 'warning'"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ item.isAllowRebuy ? `已兑换 ${item.purchasedCount} 次` : '已兑换' }}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="!item.canPurchase && item.cannotPurchaseReason"
|
||||
type="error"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ item.cannotPurchaseReason }}
|
||||
</NTag>
|
||||
</NFlex>
|
||||
<NFlex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
class="goods-footer"
|
||||
>
|
||||
<NTooltip placement="bottom">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
:disabled="getTooltip(item) !== '开始兑换'"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="exchange-btn"
|
||||
@click="onBuyClick(item)"
|
||||
>
|
||||
{{ item.isPinned ? '🔥 兑换' : '兑换' }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ getTooltip(item) }}
|
||||
</NTooltip>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</template>
|
||||
</PointGoodsItem>
|
||||
@@ -620,6 +684,23 @@ onMounted(async () => {
|
||||
content-style="height: auto;"
|
||||
/>
|
||||
|
||||
<!-- 购买历史提示 -->
|
||||
<NAlert
|
||||
v-if="currentGoods.hasPurchased"
|
||||
:type="currentGoods.isAllowRebuy ? 'info' : 'warning'"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
<template #header>
|
||||
{{ currentGoods.isAllowRebuy ? '购买记录' : '重要提示' }}
|
||||
</template>
|
||||
你已兑换过此礼物 <strong>{{ currentGoods.purchasedCount }}</strong> 次
|
||||
<span v-if="!currentGoods.isAllowRebuy">,该礼物不允许重复兑换</span>
|
||||
<span v-else-if="currentGoods.maxBuyCount">
|
||||
,最多可兑换 <strong>{{ currentGoods.maxBuyCount }}</strong> 次
|
||||
(剩余 <strong>{{ currentGoods.maxBuyCount - currentGoods.purchasedCount }}</strong> 次)
|
||||
</span>
|
||||
</NAlert>
|
||||
|
||||
<!-- 兑换选项 (仅对实物或需要数量选择的礼物显示) -->
|
||||
<template v-if="currentGoods.type === GoodsTypes.Physical || (currentGoods.maxBuyCount ?? 1) > 1 || true">
|
||||
<NDivider style="margin-top: 12px; margin-bottom: 12px;">
|
||||
@@ -636,7 +717,10 @@ onMounted(async () => {
|
||||
<NInputNumber
|
||||
v-model:value="buyCount"
|
||||
:min="1"
|
||||
:max="currentGoods.maxBuyCount ?? 100000"
|
||||
:max="Math.min(
|
||||
currentGoods.maxBuyCount ?? 100000,
|
||||
(currentGoods.maxBuyCount ?? 100000) - (currentGoods.purchasedCount ?? 0),
|
||||
)"
|
||||
style="max-width: 120px"
|
||||
step="1"
|
||||
:precision="0"
|
||||
@@ -645,7 +729,13 @@ onMounted(async () => {
|
||||
depth="3"
|
||||
style="margin-left: 8px;"
|
||||
>
|
||||
(最多可兑换 {{ currentGoods.maxBuyCount ?? '无限' }} 个)
|
||||
({{
|
||||
currentGoods.hasPurchased
|
||||
? `已兑换 ${currentGoods.purchasedCount} 个,还可兑换 ${
|
||||
(currentGoods.maxBuyCount ?? 100000) - (currentGoods.purchasedCount ?? 0)
|
||||
} 个`
|
||||
: `最多可兑换 ${currentGoods.maxBuyCount ?? '无限'} 个`
|
||||
}})
|
||||
</NText>
|
||||
</NFormItem>
|
||||
<!-- 地址选择 (仅对无外部收集链接的实物礼物显示) -->
|
||||
@@ -688,7 +778,11 @@ onMounted(async () => {
|
||||
|
||||
<NDivider style="margin-top: 16px; margin-bottom: 16px;">
|
||||
<NTag :type="!canDoBuy ? 'error' : 'success'">
|
||||
{{ !canDoBuy ? (currentGoods.price * buyCount > currentPoint ? '积分不足' : '信息不完整') : '可兑换' }}
|
||||
{{
|
||||
!canDoBuy
|
||||
? (currentGoods.cannotPurchaseReason || (currentGoods.price * buyCount > currentPoint ? '积分不足' : '信息不完整'))
|
||||
: '可兑换'
|
||||
}}
|
||||
</NTag>
|
||||
</NDivider>
|
||||
|
||||
@@ -879,6 +973,20 @@ onMounted(async () => {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.purchased-item {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.cannot-purchase-item {
|
||||
opacity: 0.7;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.cannot-purchase-item:hover {
|
||||
opacity: 0.85;
|
||||
filter: grayscale(0.15);
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user