Files
vtsuru.live/src/views/manage/HistoryView.vue

849 lines
22 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 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'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
} from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
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'
import { HISTORY_API_URL } from '@/data/constants'
// 初始化ECharts组件
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
LineChart,
ToolboxComponent,
BarChart,
])
// 定义数据模型类型
interface HistoryModel {
fan: {
records: HistoryRecordModel[]
updateAt: number
}
guard: {
records: HistoryRecordModel[]
updateAt: number
}
upstat: {
records: HistoryUpstatRecordModel[]
updateAt: number
}
}
interface HistoryRecordModel {
time: number
count: number
}
interface HistoryUpstatRecordModel {
time: number
stats: {
views: number
likes: number
}
}
interface GuardMemberModel {
guardOUId: string
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()
// 历史数据引用
const fansHistory = ref<HistoryRecordModel[]>()
const guardHistory = ref<HistoryRecordModel[]>()
const upstatHistory = ref<HistoryUpstatRecordModel[]>()
// 更新时间引用
const fansUpdateAt = ref(0)
const guardUpdateAt = ref(0)
const upstatUpdateAt = ref(0)
// 图表配置引用
const fansOption = ref()
const guardsOption = ref()
const upstatViewOption = ref()
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: 'OUID',
key: 'guardOUId',
width: 250,
ellipsis: {
tooltip: true,
},
},
{
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()
// 日期范围选择(毫秒时间戳区间)
const dateRange = ref<[number, number] | null>(null)
const dateShortcuts: Record<string, [number, number] | (() => [number, number])> = {
最近7天: () => {
const end = endOfDay(new Date()).getTime()
const start = startOfDay(addDays(new Date(), -6)).getTime()
return [start, end] as [number, number]
},
最近30天: () => {
const end = endOfDay(new Date()).getTime()
const start = startOfDay(addDays(new Date(), -29)).getTime()
return [start, end] as [number, number]
},
最近90天: () => {
const end = endOfDay(new Date()).getTime()
const start = startOfDay(addDays(new Date(), -89)).getTime()
return [start, end] as [number, number]
},
}
// 响应式图表高度
const chartHeight = computed(() => {
// 可以根据窗口大小动态调整图表高度
return window.innerWidth < 768 ? '250px' : '300px'
})
/**
* 获取所有历史数据
*/
async function getHistory() {
try {
const response = await QueryGetAPI<HistoryModel>(`${HISTORY_API_URL}get-all`)
if (response.code === 200) {
fansHistory.value = response.data.fan.records
guardHistory.value = response.data.guard.records
upstatHistory.value = response.data.upstat.records
fansUpdateAt.value = response.data.fan.updateAt
guardUpdateAt.value = response.data.guard.updateAt
upstatUpdateAt.value = response.data.upstat.updateAt
} else {
message.error(`加载失败: ${response.message}`)
}
} catch {
message.error('加载失败')
}
}
/**
* 获取基础图表配置
*/
function getBaseChartOptions() {
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
},
toolbox: {
feature: {
dataView: { show: true, readOnly: false },
magicType: { show: true, type: ['line', 'bar'] },
restore: { show: true },
saveAsImage: { show: true },
},
},
legend: {},
dataZoom: [
{
show: true,
realtime: true,
start: 0,
end: 100,
xAxisIndex: [0, 1],
},
],
}
}
/**
* 生成每日时间序列数据的通用函数
* @param historyData 原始历史记录
* @param startTime 开始时间
* @param endTime 结束时间
* @param initialTimeIndex 起始索引
* @param initialCount 初始计数值
*/
function generateTimeSeries(
historyData: HistoryRecordModel[],
startTime: Date,
endTime: Date,
initialTimeIndex: number,
initialCount: number,
) {
const timeSeries: { time: Date, count: number, change: boolean, exist: boolean }[] = []
let lastDayCount = initialCount
let lastTimeIndex = initialTimeIndex
let currentTime = startTime
while (currentTime <= endTime) {
const dayEndTime = endOfDay(currentTime).getTime()
let dayExist = false
while (true) {
const data = historyData[lastTimeIndex]
if (!data) {
break
}
if ((historyData[lastTimeIndex + 1]?.time ?? Number.MAX_VALUE) > dayEndTime) {
const _changed = data.count !== lastDayCount
lastDayCount = data.count
dayExist = true
break
}
lastTimeIndex++
}
timeSeries.push({
time: currentTime,
count: lastDayCount,
change: lastDayCount !== (timeSeries[timeSeries.length - 1]?.count ?? initialCount),
exist: dayExist,
})
currentTime = addDays(currentTime, 1)
}
return timeSeries
}
/**
* 处理粉丝历史数据并生成图表选项
*/
function processFansChartOptions() {
if (!fansHistory.value || fansHistory.value.length === 0) return
let startTimeBase = new Date(accountInfo.value?.createAt ?? Date.now())
startTimeBase = startTimeBase < statisticStartDate ? statisticStartDate : startTimeBase
const startTime = startOfDay(dateRange.value ? new Date(dateRange.value[0]) : startTimeBase)
const endTime = dateRange.value ? new Date(dateRange.value[1]) : new Date()
if (startTime > endTime) {
fansOption.value = { ...getBaseChartOptions(), series: [] } // Simplified empty state
return
}
const initialIndex = fansHistory.value.findIndex(entry => entry.time >= statisticStartDateTime)
const initialCount = initialIndex >= 0 ? fansHistory.value[initialIndex].count : 0
const completeTimeSeries = generateTimeSeries(fansHistory.value, startTime, endTime, initialIndex, initialCount)
const fansIncreacement: { time: Date, count: number }[] = []
let previousDayCount = completeTimeSeries[0]?.count ?? 0
completeTimeSeries.forEach((entry, index) => {
if (index > 0) {
const dailyIncrement = entry.count - previousDayCount
fansIncreacement.push({ time: startOfDay(entry.time), count: dailyIncrement })
}
previousDayCount = entry.count
})
const chartData = {
xAxisData: completeTimeSeries.map(entry => format(entry.time, 'yyyy-MM-dd')),
seriesData: completeTimeSeries.map(entry => entry.count),
incrementData: fansIncreacement.map(entry => entry.count),
}
fansOption.value = {
...getBaseChartOptions(),
tooltip: {
...getBaseChartOptions().tooltip,
formatter: (param: any) => {
const name = `${param[0].name}<br>`
let str = ''
for (let i = 0; i < param.length; i++) {
const status
= param[i].seriesName === '粉丝数' ? (completeTimeSeries[param[i].dataIndex].exist ? '' : '(未获取)') : ''
const statusHtml = status === '' ? '' : `&nbsp;<span style="color:gray">${status}</span>`
str += `${param[i].marker + param[i].seriesName}${param[i].data}${statusHtml}<br>`
}
return name + str
},
},
yAxis: [
{ type: 'value', name: '粉丝数', min: 'dataMin' },
{ type: 'value', name: '每日增量' },
],
xAxis: [
{ type: 'category', data: chartData.xAxisData },
{ type: 'category', data: chartData.xAxisData.slice(1) }, // Align increments
],
series: [
{
name: '粉丝数',
type: 'line',
data: chartData.seriesData,
itemStyle: {
color: (data: any) => {
const item = completeTimeSeries[data.dataIndex]
return !item.exist ? '#cccccc' : item.change ? '#18a058' : '#5470C6'
},
},
},
{
name: '增量 /日',
type: 'bar',
yAxisIndex: 1,
xAxisIndex: 1,
data: chartData.incrementData,
itemStyle: { color: (params: any) => (params.value < 0 ? '#FF4D4F' : '#3398DB') },
},
],
}
}
/**
* 处理舰长历史数据并生成图表选项
*/
function processGuardsChartOptions() {
if (!guardHistory.value || guardHistory.value.length === 0) return
let startTimeBase = new Date(accountInfo.value?.createAt ?? Date.now())
startTimeBase = startTimeBase < statisticStartDate ? statisticStartDate : startTimeBase
const startTime = startOfDay(dateRange.value ? new Date(dateRange.value[0]) : startTimeBase)
const endTime = dateRange.value ? new Date(dateRange.value[1]) : new Date()
if (startTime > endTime) {
guardsOption.value = { ...getBaseChartOptions(), series: [] } // Simplified empty state
return
}
const initialIndex = guardHistory.value.findIndex(entry => entry.time >= startTime.getTime())
const initialCount = initialIndex >= 0 ? guardHistory.value[initialIndex].count : 0
const completeTimeSeries = generateTimeSeries(guardHistory.value, startTime, endTime, initialIndex, initialCount)
const guardIncrements: number[] = []
let previousDayCount = completeTimeSeries[0]?.count ?? 0
completeTimeSeries.forEach((entry, index) => {
if (index > 0) {
guardIncrements.push(entry.count - previousDayCount)
}
previousDayCount = entry.count
})
const xAxisData = completeTimeSeries.map(entry => format(entry.time, 'yyyy-MM-dd'))
guardsOption.value = {
...getBaseChartOptions(),
yAxis: [
{ type: 'value', name: '舰长数', min: 'dataMin' },
{ type: 'value', name: '日增' },
],
xAxis: [
{ type: 'category', data: xAxisData },
{ type: 'category', data: xAxisData.slice(1) },
],
series: [
{
name: '舰长数',
type: 'line',
step: 'middle',
data: completeTimeSeries.map(item => item.count),
itemStyle: {
color: (data: any) => completeTimeSeries[data.dataIndex].exist ? '#5470C6' : '#cccccc',
},
},
{
name: '日增',
type: 'bar',
yAxisIndex: 1,
xAxisIndex: 1,
data: guardIncrements,
itemStyle: { color: (params: any) => (params.value < 0 ? '#FF4D4F' : '#3398DB') },
},
],
}
}
/**
* 处理投稿数据并生成图表选项的通用函数
* @param {('views' | 'likes')} dataType - 要处理的数据类型 ('views' 或 'likes')
* @param {string} title - 图表主标题
*/
function processUpstatChartOptions(dataType: 'views' | 'likes', title: string) {
if (!upstatHistory.value || upstatHistory.value.length === 0) {
return {
...getBaseChartOptions(),
xAxis: [{ type: 'category', data: [] }],
yAxis: [{ type: 'value' }, { type: 'value' }],
series: [
{ name: title, type: 'line', data: [] },
{ name: '日增', type: 'bar', data: [], yAxisIndex: 1 },
],
}
}
const rangeStart = dateRange.value ? dateRange.value[0] : -Infinity
const rangeEnd = dateRange.value ? dateRange.value[1] : Infinity
const filtered = upstatHistory.value.filter(u => u.time >= rangeStart && u.time <= rangeEnd)
if (filtered.length === 0) {
return {
...getBaseChartOptions(),
xAxis: [{ type: 'category', data: [] }],
yAxis: [{ type: 'value' }, { type: 'value' }],
series: [
{ name: title, type: 'line', data: [] },
{ name: '日增', type: 'bar', data: [], yAxisIndex: 1 },
],
}
}
const increments: { time: number, value: number }[] = []
let lastValue = filtered[0].stats[dataType]
filtered.forEach((u) => {
const currentValue = u.stats[dataType]
increments.push({
time: u.time,
value: currentValue - lastValue,
})
lastValue = currentValue
})
return {
...getBaseChartOptions(),
yAxis: [
{ type: 'value', min: 'dataMin' },
{ type: 'value' },
],
xAxis: [
{
type: 'category',
axisTick: { alignWithLabel: true },
axisLine: { onZero: false, lineStyle: { color: '#EE6666' } },
data: filtered.map(f => format(f.time, 'yyyy-MM-dd')),
},
],
series: [
{
name: title,
type: 'line',
emphasis: { focus: 'series' },
data: filtered.map(f => f.stats[dataType]),
itemStyle: {
color(data: any) {
return increments[data.dataIndex].value !== 0 ? '#5470C6' : '#cccccc'
},
},
},
{
name: '日增',
type: 'bar',
yAxisIndex: 1,
emphasis: { focus: 'series' },
data: increments.map(f => f.value),
},
],
}
}
/**
* 处理播放量历史数据并生成图表选项
*/
function processUpstatViewChartOptions() {
upstatViewOption.value = processUpstatChartOptions('views', '播放数')
}
/**
* 处理点赞量历史数据并生成图表选项
*/
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
}
}
/**
* 处理所有图表选项
*/
function processAllChartOptions() {
processFansChartOptions()
processGuardsChartOptions()
processUpstatViewChartOptions()
processUpstatLikeChartOptions()
}
onMounted(async () => {
if (accountInfo.value?.isBiliVerified === true) {
await getHistory()
processAllChartOptions()
await loadGuardList() // 加载舰长列表
isLoading.value = false
}
})
// 选择日期范围后,自动刷新图表
watch(
() => dateRange.value,
() => {
if (!isLoading.value) processAllChartOptions()
},
)
</script>
<template>
<NAlert
v-if="accountInfo?.isBiliVerified !== true"
type="info"
>
尚未进行Bilibili认证
</NAlert>
<NSpin
v-else-if="isLoading"
show
/>
<NCard
v-else
size="small"
class="history-card"
>
<NAlert type="warning">
由于B站继续收紧风控策略, 本站已无法再爬取相关数据, 请需要使用此功能的用户下载并安装1.0.6.4及以上版本的
<NButton
text
type="info"
tag="a"
href="https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs"
target="_blank"
>
VTsuruEventFetcher
</NButton>
来帮助本站获取你的数据记录
</NAlert>
<br>
<NTooltip
trigger="click"
placement="bottom"
>
<template #trigger>
<NButton type="info">
<template #icon>
<NIcon :component="Info24Filled" />
</template>
关于数据更新
</NButton>
</template>
<NSpace vertical>
<NText strong>
所有数据改为每天更新一次
</NText>
<NDivider style="margin: 0" />
<NText
delete
:depth="3"
>
粉丝数: 200粉以下: 每3天一次, 200-1000: 每24小时一次, 1000-10000: 每6小时一次, 10000粉以上: 每小时一次
</NText>
<NText
delete
:depth="3"
>
舰长数: 10舰以下: 每24小时一次, 10-50: 每12小时一次, 50舰以上: 每6小时一次
</NText>
<NText
delete
:depth="3"
>
投稿数据: 500粉以上: 每天一次
</NText>
</NSpace>
</NTooltip>
<br>
<br>
<NSpace align="center">
<NText depth="3">
日期范围
</NText>
<NDatePicker
v-model:value="dateRange"
type="daterange"
clearable
separator="至"
:shortcuts="dateShortcuts"
/>
</NSpace>
<br>
<NSpace
vertical
class="charts-container"
>
<NDivider>
粉丝
<NDivider vertical />
<NTooltip>
<template #trigger>
<span>
<NTime
:time="fansUpdateAt"
type="relative"
/>
更新
</span>
</template>
<NTime :time="fansUpdateAt" />
</NTooltip>
</NDivider>
<VChart
:option="fansOption"
:style="{ height: chartHeight }"
class="chart"
/>
<NDivider>
舰长
<NDivider vertical />
<NTooltip>
<template #trigger>
<span>
<NTime
:time="guardUpdateAt"
type="relative"
/>
更新
</span>
</template>
<NTime :time="guardUpdateAt" />
</NTooltip>
</NDivider>
<VChart
:option="guardsOption"
: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>
投稿播放量
<NDivider vertical />
<NTooltip>
<template #trigger>
<span>
<NTime
:time="upstatUpdateAt"
type="relative"
/>
更新
</span>
</template>
<NTime :time="upstatUpdateAt" />
</NTooltip>
</NDivider>
<VChart
:option="upstatViewOption"
:style="{ height: chartHeight }"
class="chart"
/>
<NDivider>
投稿点赞量
<NDivider vertical />
<NTooltip>
<template #trigger>
<span>
<NTime
:time="upstatUpdateAt"
type="relative"
/>
更新
</span>
</template>
<NTime :time="upstatUpdateAt" />
</NTooltip>
</NDivider>
<VChart
:option="upstatLikeOption"
:style="{ height: chartHeight }"
class="chart"
/> -->
</NSpace>
</NCard>
</template>
<style scoped>
.history-card {
width: 100%;
}
.charts-container {
width: 100%;
}
.chart {
width: 100%;
min-height: 200px;
transition: height 0.3s ease;
}
@media (max-width: 768px) {
.chart {
min-height: 250px;
}
}
</style>