Files
vtsuru.live/src/views/manage/AnalyzeView.vue
Megghy 8e2fdb10f5 feat: 优化 Cookie 同步策略并增强身份码状态提示
- 将 CookieCloud 同步间隔从 30 分钟缩短至 10 分钟,提高同步频率
- 在 BiliCookie store 初始化时立即执行一次检查,强制尝试从 CookieCloud 同步
- 新增弹幕客户端启动失败时的错误通知提示
- 在管理页面加载时检测身份码失效状态并显示错误提示
- 优化邮箱验证状态检查时机,延迟 500ms 执行避免初始化冲突
- 自动注册 NTable 组件到全
2025-11-29 19:05:15 +08:00

767 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import {
BarChartOutline,
ChatbubblesOutline,
PeopleOutline,
RefreshOutline,
TimeOutline,
TrendingDown,
TrendingUp,
WalletOutline,
} from '@vicons/ionicons5'
import { BarChart, LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
MarkPointComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import {
NButton,
NCard,
NCheckbox,
NCheckboxGroup,
NDescriptions,
NDescriptionsItem,
NEmpty,
NGrid,
NGridItem,
NIcon,
NNumberAnimation,
NProgress,
NSkeleton,
NSpace,
NTag,
NTime,
NTooltip,
useMessage,
useThemeVars,
} from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { QueryGetAPI } from '@/api/query'
import { ANALYZE_API_URL } from '@/data/constants'
// 注册必要的组件
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
LineChart,
BarChart,
CanvasRenderer,
MarkPointComponent,
MarkLineComponent,
DataZoomComponent,
])
// types.ts
interface ChartItem {
income: number
interactionCount: number
danmakuCount: number
payingUsers: number
interactionUsers: number
liveMinutes: number
likeCount: number
}
interface Summary {
last7Days: {
totalIncome: number
totalInteractions: number
totalDanmakuCount: number
totalLiveMinutes: number
dailyAvgIncome: number
dailyAvgDanmaku: number
incomeTrend: number
interactionTrend: number
danmakuTrend: number
activeLiveDays: number
interactionUsers: number
payingUsers: number
interactionUsersTrend: number
payingUsersTrend: number
}
last30Days: {
totalIncome: number
totalInteractions: number
totalDanmakuCount: number
totalLiveMinutes: number
dailyAvgIncome: number
dailyAvgDanmaku: number
incomeTrend: number
interactionTrend: number
danmakuTrend: number
activeLiveDays: number
interactionUsers: number
payingUsers: number
}
}
interface AnalyzeData {
summary: Summary
chartData: Record<number, ChartItem> // 键是Unix时间戳值是对应的数据
}
// 状态管理
const loading = ref(true)
const refreshing = ref(false)
const message = useMessage()
const analyzeData = ref<AnalyzeData>()
const summaryData = computed(() => analyzeData.value?.summary)
const themeVars = useThemeVars()
const lastUpdateTime = ref<number>(0)
const hasData = computed(() => analyzeData.value && Object.keys(analyzeData.value.chartData || {}).length > 0)
// 图表配置状态
const chartRef = ref<HTMLElement | null>(null)
let mainChart: echarts.ECharts | null = null
const selectedMetrics = ref<string[]>(['income', 'interactionCount'])
const chartMetrics = [
{ label: '收入', value: 'income', color: '#f5a623', type: 'line', yAxisIndex: 1 },
{ label: '互动数', value: 'interactionCount', color: '#2080f0', type: 'line', yAxisIndex: 0 },
{ label: '弹幕数', value: 'danmakuCount', color: '#18a058', type: 'line', yAxisIndex: 0 },
{ label: '点赞数', value: 'likeCount', color: '#d03050', type: 'line', yAxisIndex: 0 },
{ label: '互动人数', value: 'interactionUsers', color: '#8a2be2', type: 'bar', yAxisIndex: 0 },
{ label: '付费人数', value: 'payingUsers', color: '#ff69b4', type: 'bar', yAxisIndex: 0 },
]
// 格式化工具函数
function formatCurrency(value: number): string {
return `¥${value.toFixed(2)}`
}
function formatNumber(value: number): string {
return value.toLocaleString()
}
function getTrendType(value: number): 'success' | 'error' | 'info' {
if (value > 0) return 'success'
if (value < 0) return 'error'
return 'info'
}
// 格式化时间戳为日期
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000)
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 从ChartData对象转换为数组并按时间戳排序
function getChartDataArray() {
if (!analyzeData.value?.chartData) return []
return Object.entries(analyzeData.value.chartData)
.map(([timestamp, data]) => ({
timestamp: Number.parseInt(timestamp, 10),
date: formatDate(Number.parseInt(timestamp, 10)),
...data,
}))
.sort((a, b) => a.timestamp - b.timestamp)
}
// 获取主题色配置
function getThemeColors() {
return {
textColor: themeVars.value.textColor2,
axisLineColor: themeVars.value.borderColor,
splitLineColor: themeVars.value.dividerColor,
cardColor: themeVars.value.cardColor,
}
}
// 初始化图表
function initChart() {
if (!chartRef.value) return
const chartData = getChartDataArray()
if (chartData.length === 0) return
mainChart = echarts.init(chartRef.value)
updateChartOption()
}
// 更新图表配置
function updateChartOption() {
if (!mainChart) return
const chartData = getChartDataArray()
const dates = chartData.map(item => item.date)
const themeColors = getThemeColors()
// 确定哪些 Y 轴需要显示
const showRightAxis = selectedMetrics.value.includes('income')
const showLeftAxis = selectedMetrics.value.some(m => m !== 'income')
const series = selectedMetrics.value.map((metricKey) => {
const metricConfig = chartMetrics.find(m => m.value === metricKey)
if (!metricConfig) return null
return {
name: metricConfig.label,
type: metricConfig.type,
data: chartData.map(item => (item as any)[metricKey]),
smooth: true,
yAxisIndex: (metricKey === 'income' && showLeftAxis) ? 1 : 0, // 如果同时显示左轴,收入走右轴;否则走左轴
itemStyle: {
color: metricConfig.color,
},
areaStyle: metricConfig.type === 'line' ? {
opacity: 0.1,
color: metricConfig.color,
} : undefined,
barMaxWidth: metricConfig.type === 'bar' ? '20%' : undefined,
}
}).filter(Boolean)
const yAxis = []
// 左轴 (默认)
if (showLeftAxis) {
yAxis.push({
type: 'value',
position: 'left',
name: '数量',
axisLine: { show: true, lineStyle: { color: themeColors.axisLineColor } },
axisLabel: { color: themeColors.textColor },
splitLine: { lineStyle: { color: themeColors.splitLineColor } },
nameTextStyle: { color: themeColors.textColor },
})
} else if (showRightAxis) {
// 只有金额,显示在左边
yAxis.push({
type: 'value',
position: 'left',
name: '金额',
axisLine: { show: true, lineStyle: { color: themeColors.axisLineColor } },
axisLabel: { color: themeColors.textColor },
splitLine: { lineStyle: { color: themeColors.splitLineColor } },
nameTextStyle: { color: themeColors.textColor },
})
}
// 右轴 (仅当同时显示数量和金额时)
if (showLeftAxis && showRightAxis) {
yAxis.push({
type: 'value',
position: 'right',
name: '金额',
axisLine: { show: true, lineStyle: { color: themeColors.axisLineColor } },
axisLabel: { color: themeColors.textColor },
splitLine: { show: false },
nameTextStyle: { color: themeColors.textColor },
})
}
const option = {
backgroundColor: 'transparent',
textStyle: {
color: themeColors.textColor,
},
tooltip: {
trigger: 'axis',
backgroundColor: themeVars.value.cardColor,
borderColor: themeVars.value.borderColor,
textStyle: { color: themeColors.textColor },
axisPointer: { type: 'shadow' },
},
legend: {
data: series.map(s => s!.name),
textStyle: { color: themeColors.textColor },
bottom: 0,
},
grid: {
left: '3%',
right: '3%',
bottom: '10%',
top: '10%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: themeColors.axisLineColor } },
axisLabel: { color: themeColors.textColor },
},
yAxis: yAxis,
series: series,
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
show: true,
type: 'slider',
bottom: 35,
height: 20,
start: 0,
end: 100,
borderColor: themeColors.axisLineColor,
textStyle: { color: themeColors.textColor },
},
],
}
mainChart.setOption(option, true)
}
// 监听指标选择变化
watch(selectedMetrics, () => {
updateChartOption()
})
// 获取分析数据
async function fetchAnalyzeData(isRefresh = false) {
try {
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(() => initChart())
if (isRefresh) {
message.success('数据已刷新')
}
} else {
message.error(`获取数据失败: ${data.message}`)
}
} catch (error) {
message.error(`获取数据出错:${(error as Error).message}`)
console.error('获取数据出错:', error)
} finally {
loading.value = false
refreshing.value = false
}
}
// 刷新数据
function handleRefresh() {
fetchAnalyzeData(true)
}
// 窗口大小变化时重绘图表
function handleResize() {
mainChart?.resize()
}
// 监听主题变化
watch(() => themeVars.value, () => {
nextTick(() => updateChartOption())
}, { deep: true })
// 组件挂载时初始化
onMounted(() => {
fetchAnalyzeData()
window.addEventListener('resize', handleResize)
})
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
mainChart?.dispose()
})
</script>
<template>
<div class="analyze-container">
<!-- 顶部操作栏 -->
<NSpace align="center" justify="space-between" class="header-actions">
<NSpace align="center">
<h2 style="margin: 0; font-weight: 500; font-size: 20px;">
<NIcon :component="BarChartOutline" style="vertical-align: middle; margin-right: 8px;" />
数据分析
</h2>
</NSpace>
<NSpace align="center">
<EventFetcherAlert />
<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" size="small" @click="handleRefresh">
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
刷新
</NButton>
</NSpace>
</NSpace>
<!-- 加载骨架屏 -->
<div v-if="loading" class="skeleton-container">
<NGrid cols="1 800:2 1200:4" :x-gap="16" :y-gap="16">
<NGridItem v-for="i in 4" :key="i">
<NCard size="small">
<NSkeleton text width="40%" style="margin-bottom: 12px;" />
<NSkeleton text height="30px" width="80%" />
</NCard>
</NGridItem>
</NGrid>
<div style="margin-top: 20px;">
<NSkeleton height="400px" width="100%" border-radius="8px" />
</div>
</div>
<!-- 空状态 -->
<NEmpty v-else-if="!hasData" description="暂无数据" size="large" style="margin: 60px 0">
<template #extra>
<NButton @click="() => fetchAnalyzeData()">重新加载</NButton>
</template>
</NEmpty>
<!-- 数据展示 -->
<template v-else>
<!-- 核心指标卡片 -->
<div class="core-metrics">
<NGrid cols="1 600:2 1000:4" :x-gap="16" :y-gap="16">
<!-- 收入 -->
<NGridItem>
<NCard size="small" class="metric-card income-card">
<div class="metric-content">
<div class="metric-header">
<span class="metric-label">近30天收入</span>
<NIcon :component="WalletOutline" class="metric-icon" />
</div>
<div class="metric-value">
<NNumberAnimation :from="0" :to="summaryData?.last30Days?.totalIncome || 0" :precision="2" />
<span class="currency-symbol">¥</span>
</div>
<div class="metric-footer">
<div class="trend-info">
<span :class="getTrendType(summaryData?.last30Days?.incomeTrend || 0)">
<NIcon :component="(summaryData?.last30Days?.incomeTrend || 0) >= 0 ? TrendingUp : TrendingDown" />
{{ Math.abs(summaryData?.last30Days?.incomeTrend || 0) }}%
</span>
<span class="trend-label">环比</span>
</div>
<div class="sub-stat">
日均 ¥{{ (summaryData?.last30Days?.dailyAvgIncome || 0).toFixed(0) }}
</div>
</div>
</div>
</NCard>
</NGridItem>
<!-- 互动 -->
<NGridItem>
<NCard size="small" class="metric-card interaction-card">
<div class="metric-content">
<div class="metric-header">
<span class="metric-label">近30天互动</span>
<NIcon :component="ChatbubblesOutline" class="metric-icon" />
</div>
<div class="metric-value">
<NNumberAnimation :from="0" :to="summaryData?.last30Days?.totalInteractions || 0" show-separator />
</div>
<div class="metric-footer">
<div class="trend-info">
<span :class="getTrendType(summaryData?.last30Days?.interactionTrend || 0)">
<NIcon :component="(summaryData?.last30Days?.interactionTrend || 0) >= 0 ? TrendingUp : TrendingDown" />
{{ Math.abs(summaryData?.last30Days?.interactionTrend || 0) }}%
</span>
<span class="trend-label">环比</span>
</div>
<div class="sub-stat">
{{ formatNumber(summaryData?.last30Days?.totalDanmakuCount || 0) }} 弹幕
</div>
</div>
</div>
</NCard>
</NGridItem>
<!-- 用户 -->
<NGridItem>
<NCard size="small" class="metric-card users-card">
<div class="metric-content">
<div class="metric-header">
<span class="metric-label">互动/付费人数</span>
<NIcon :component="PeopleOutline" class="metric-icon" />
</div>
<div class="metric-value">
<span>{{ formatNumber(summaryData?.last30Days?.interactionUsers || 0) }}</span>
<span class="separator">/</span>
<span class="highlight">{{ formatNumber(summaryData?.last30Days?.payingUsers || 0) }}</span>
</div>
<div class="metric-footer">
<div class="trend-info">
<NProgress
type="line"
:percentage="Math.min(100, Math.round(((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100) * 10) / 10)"
:height="6"
color="#ff69b4"
rail-color="rgba(255, 105, 180, 0.2)"
:show-indicator="false"
style="width: 60px; margin-right: 8px;"
/>
<span class="trend-label">{{ ((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1) }}% 付费率</span>
</div>
</div>
</div>
</NCard>
</NGridItem>
<!-- 直播 -->
<NGridItem>
<NCard size="small" class="metric-card time-card">
<div class="metric-content">
<div class="metric-header">
<span class="metric-label">近30天直播</span>
<NIcon :component="TimeOutline" class="metric-icon" />
</div>
<div class="metric-value">
{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1) }}
<span class="unit">小时</span>
</div>
<div class="metric-footer">
<div class="trend-info">
<span class="trend-label">活跃 {{ summaryData?.last30Days?.activeLiveDays || 0 }} </span>
</div>
<div class="sub-stat">
场均 {{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / (summaryData?.last30Days?.activeLiveDays || 1) / 60).toFixed(1) }}h
</div>
</div>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
<!-- 图表区域 -->
<NCard size="small" title="趋势分析" class="chart-card" style="margin-top: 16px;">
<template #header-extra>
<NCheckboxGroup v-model:value="selectedMetrics">
<NSpace>
<NCheckbox v-for="metric in chartMetrics" :key="metric.value" :value="metric.value">
<span :style="{ color: metric.color }">{{ metric.label }}</span>
</NCheckbox>
</NSpace>
</NCheckboxGroup>
</template>
<div ref="chartRef" class="main-chart" />
</NCard>
<!-- 详细数据对比 -->
<div class="details-section" style="margin-top: 16px;">
<NGrid cols="1 900:2" :x-gap="16" :y-gap="16">
<NGridItem>
<NCard size="small" title="近7天详细数据">
<template #header-extra>
<NTag type="info" size="small" :bordered="false">短期表现</NTag>
</template>
<NDescriptions label-placement="left" :column="2" bordered>
<NDescriptionsItem label="总收入">{{ formatCurrency(summaryData?.last7Days?.totalIncome || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="日均收入">{{ formatCurrency(summaryData?.last7Days?.dailyAvgIncome || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="总互动">{{ formatNumber(summaryData?.last7Days?.totalInteractions || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="弹幕数">{{ formatNumber(summaryData?.last7Days?.totalDanmakuCount || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="直播时长">{{ ((summaryData?.last7Days?.totalLiveMinutes || 0) / 60).toFixed(1) }}h</NDescriptionsItem>
<NDescriptionsItem label="活跃天数">{{ summaryData?.last7Days?.activeLiveDays || 0 }}</NDescriptionsItem>
<NDescriptionsItem label="互动人数">{{ formatNumber(summaryData?.last7Days?.interactionUsers || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="付费人数">{{ formatNumber(summaryData?.last7Days?.payingUsers || 0) }}</NDescriptionsItem>
</NDescriptions>
<div class="mini-funnel" style="margin-top: 16px;">
<div class="funnel-row">
<span class="label">互动转化</span>
<NProgress
type="line"
:percentage="Math.min(100, Math.round(((summaryData?.last7Days?.payingUsers || 0) / (summaryData?.last7Days?.interactionUsers || 1) * 100) * 10) / 10)"
:height="12"
color="#18a058"
rail-color="rgba(24, 160, 88, 0.1)"
>
{{ ((summaryData?.last7Days?.payingUsers || 0) / (summaryData?.last7Days?.interactionUsers || 1) * 100).toFixed(1) }}%
</NProgress>
</div>
</div>
</NCard>
</NGridItem>
<NGridItem>
<NCard size="small" title="近30天详细数据">
<template #header-extra>
<NTag type="warning" size="small" :bordered="false">中期表现</NTag>
</template>
<NDescriptions label-placement="left" :column="2" bordered>
<NDescriptionsItem label="总收入">{{ formatCurrency(summaryData?.last30Days?.totalIncome || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="日均收入">{{ formatCurrency(summaryData?.last30Days?.dailyAvgIncome || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="总互动">{{ formatNumber(summaryData?.last30Days?.totalInteractions || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="弹幕数">{{ formatNumber(summaryData?.last30Days?.totalDanmakuCount || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="直播时长">{{ ((summaryData?.last30Days?.totalLiveMinutes || 0) / 60).toFixed(1) }}h</NDescriptionsItem>
<NDescriptionsItem label="活跃天数">{{ summaryData?.last30Days?.activeLiveDays || 0 }}</NDescriptionsItem>
<NDescriptionsItem label="互动人数">{{ formatNumber(summaryData?.last30Days?.interactionUsers || 0) }}</NDescriptionsItem>
<NDescriptionsItem label="付费人数">{{ formatNumber(summaryData?.last30Days?.payingUsers || 0) }}</NDescriptionsItem>
</NDescriptions>
<div class="mini-funnel" style="margin-top: 16px;">
<div class="funnel-row">
<span class="label">互动转化</span>
<NProgress
type="line"
:percentage="Math.min(100, Math.round(((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100) * 10) / 10)"
:height="12"
color="#f5a623"
rail-color="rgba(245, 166, 35, 0.1)"
>
{{ ((summaryData?.last30Days?.payingUsers || 0) / (summaryData?.last30Days?.interactionUsers || 1) * 100).toFixed(1) }}%
</NProgress>
</div>
</div>
</NCard>
</NGridItem>
</NGrid>
</div>
</template>
</div>
</template>
<style scoped>
.analyze-container {
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: 0 4px;
}
.header-actions {
margin-bottom: 16px;
}
.metric-card {
height: 100%;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.metric-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.metric-header {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--n-text-color-3);
}
.metric-label {
font-size: 13px;
}
.metric-icon {
font-size: 16px;
opacity: 0.7;
}
.metric-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
display: flex;
align-items: baseline;
}
.currency-symbol {
font-size: 14px;
margin-left: 4px;
font-weight: normal;
color: var(--n-text-color-3);
}
.unit {
font-size: 14px;
margin-left: 4px;
font-weight: normal;
color: var(--n-text-color-3);
}
.separator {
margin: 0 4px;
color: var(--n-text-color-3);
font-size: 16px;
}
.highlight {
color: #ff69b4;
}
.metric-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
}
.trend-info {
display: flex;
align-items: center;
gap: 4px;
}
.success { color: var(--n-success-color); }
.error { color: var(--n-error-color); }
.info { color: var(--n-text-color-3); }
.trend-label {
color: var(--n-text-color-3);
}
.sub-stat {
color: var(--n-text-color-3);
}
/* 特定卡片样式微调 */
.income-card .metric-icon { color: #f5a623; }
.interaction-card .metric-icon { color: #2080f0; }
.users-card .metric-icon { color: #ff69b4; }
.time-card .metric-icon { color: #18a058; }
.chart-card {
min-height: 400px;
}
.main-chart {
width: 100%;
height: 400px;
}
.mini-funnel {
padding: 0 8px;
}
.funnel-row {
display: flex;
align-items: center;
gap: 12px;
}
.funnel-row .label {
font-size: 12px;
color: var(--n-text-color-3);
width: 60px;
}
</style>