mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
572 lines
18 KiB
Vue
572 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { isDarkMode } from '@/Utils'
|
||
import { useAccount } from '@/api/account'
|
||
import { QueryGetAPI } from '@/api/query'
|
||
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
|
||
import EventFetcherAlert from '@/components/EventFetcherAlert.vue' // 添加缺失的组件导入
|
||
import { AVATAR_URL, BASE_API_URL, EVENT_API_URL } from '@/data/constants'
|
||
import { Grid28Filled, List16Filled } from '@vicons/fluent'
|
||
import { format } from 'date-fns'
|
||
import { saveAs } from 'file-saver'
|
||
import { List } from 'linqts'
|
||
import {
|
||
NAlert,
|
||
NAvatar,
|
||
NButton,
|
||
NCard,
|
||
NCollapse,
|
||
NCollapseItem,
|
||
NDatePicker,
|
||
NDivider,
|
||
NEllipsis,
|
||
NGrid,
|
||
NGridItem,
|
||
NH3,
|
||
NIcon,
|
||
NLi,
|
||
NRadioButton,
|
||
NRadioGroup,
|
||
NSpace,
|
||
NSpin,
|
||
NTable,
|
||
NTag,
|
||
NText,
|
||
NTime,
|
||
NUl,
|
||
useMessage,
|
||
NInfiniteScroll,
|
||
} from 'naive-ui'
|
||
import { computed, ref, watch } from 'vue'
|
||
import { EventDataTypes, EventModel } from '@/api/api-models'
|
||
|
||
const accountInfo = useAccount()
|
||
const message = useMessage()
|
||
|
||
// 日期选择快捷方式
|
||
const rangeShortcuts = {
|
||
上个月: () => {
|
||
const cur = new Date()
|
||
const lastMonth = new Date(cur.getFullYear(), cur.getMonth() - 1)
|
||
return [
|
||
new Date(cur.getFullYear(), cur.getMonth() - 1, 1).getTime(),
|
||
new Date(cur.getFullYear(), cur.getMonth(), 1).getTime(),
|
||
] as const
|
||
},
|
||
本月: () => {
|
||
const cur = new Date()
|
||
return [new Date(cur.getFullYear(), cur.getMonth(), 1).getTime(), cur.getTime()] as const
|
||
},
|
||
}
|
||
|
||
// 响应式状态
|
||
const selectedDate = ref<[number, number]>([rangeShortcuts.本月()[0], rangeShortcuts.本月()[1]])
|
||
const selectedType = ref(EventDataTypes.Guard)
|
||
const events = ref<EventModel[]>([]) // 初始为空数组
|
||
const isLoading = ref(false) // 用于初始加载
|
||
const isLoadingMore = ref(false) // 用于无限滚动加载
|
||
const displayMode = ref<'grid' | 'column'>('grid')
|
||
const exportType = ref<'json' | 'csv'>('csv')
|
||
const offset = ref(0) // 当前偏移量
|
||
const limit = ref(20) // 每次加载数量
|
||
const hasMore = ref(true) // 是否还有更多数据
|
||
|
||
// 根据类型过滤事件 - 这个计算属性现在可能只显示当前已加载的事件
|
||
// 如果需要导出 *所有* 选定日期/类型的数据,导出逻辑需要调整
|
||
const selectedEvents = computed(() => {
|
||
return events.value.filter((e) => e.type == selectedType.value)
|
||
})
|
||
|
||
// API请求获取数据 - 修改为支持分页
|
||
async function get(currentOffset: number, currentLimit: number) {
|
||
try {
|
||
const data = await QueryGetAPI<EventModel[]>(EVENT_API_URL + 'get', {
|
||
start: selectedDate.value[0],
|
||
end: selectedDate.value[1],
|
||
offset: currentOffset, // 添加 offset 参数
|
||
limit: currentLimit, // 添加 limit 参数
|
||
})
|
||
if (data.code == 200) {
|
||
message.success(`成功获取 ${data.data.length} 条数据`) // 调整提示
|
||
return data.data // 直接返回数据数组
|
||
} else {
|
||
message.error('获取数据失败: ' + data.message)
|
||
return []
|
||
}
|
||
} catch (err) {
|
||
message.error('获取数据失败: ' + (err as Error).message) // 提供更详细的错误信息
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 封装的数据获取函数
|
||
async function fetchData(isInitialLoad = false) {
|
||
if (isLoading.value || isLoadingMore.value) return // 防止重复加载
|
||
|
||
if (isInitialLoad) {
|
||
isLoading.value = true
|
||
offset.value = 0 // 重置偏移量
|
||
events.value = [] // 清空现有事件
|
||
hasMore.value = true // 假设有更多数据
|
||
} else {
|
||
isLoadingMore.value = true
|
||
}
|
||
|
||
const currentOffset = offset.value
|
||
const fetchedData = await get(currentOffset, limit.value)
|
||
|
||
if (fetchedData.length > 0) {
|
||
// 使用 Linqts 进行排序后追加或设置
|
||
const sortedData = new List(fetchedData).OrderByDescending((d) => d.time).ToArray()
|
||
events.value = isInitialLoad ? sortedData : [...events.value, ...sortedData]
|
||
offset.value += fetchedData.length // 更新偏移量
|
||
hasMore.value = fetchedData.length === limit.value // 如果返回的数量等于请求的数量,则可能还有更多
|
||
} else {
|
||
hasMore.value = false // 没有获取到数据,说明没有更多了
|
||
}
|
||
|
||
if (isInitialLoad) {
|
||
isLoading.value = false
|
||
} else {
|
||
isLoadingMore.value = false
|
||
}
|
||
}
|
||
|
||
// 日期或类型变化时重新加载
|
||
async function onFilterChange() {
|
||
await fetchData(true) // 初始加载
|
||
}
|
||
|
||
// 无限滚动加载更多
|
||
async function loadMore() {
|
||
if (!hasMore.value || isLoadingMore.value || isLoading.value) return
|
||
console.log('Loading more...') // 调试信息
|
||
await fetchData(false)
|
||
}
|
||
|
||
// 监视日期和类型变化
|
||
watch([selectedDate, selectedType], onFilterChange, { immediate: true }) // 初始加载数据
|
||
|
||
// 获取SC颜色
|
||
function GetSCColor(price: number): string {
|
||
if (price === 0) return `#2a60b2`
|
||
if (price > 0 && price < 30) return `#2a60b2`
|
||
if (price >= 30 && price < 50) return `#2a60b2`
|
||
if (price >= 50 && price < 100) return `#427d9e`
|
||
if (price >= 100 && price < 500) return `#c99801`
|
||
if (price >= 500 && price < 1000) return `#e09443`
|
||
if (price >= 1000 && price < 2000) return `#e54d4d`
|
||
if (price >= 2000) return `#ab1a32`
|
||
return ''
|
||
}
|
||
|
||
// 获取舰长颜色
|
||
function GetGuardColor(price: number | null | undefined): string {
|
||
if (price) {
|
||
if (price < 138) return ''
|
||
if (price >= 138 && price < 1598) return 'rgb(104, 136, 241)'
|
||
if (price >= 1598 && price < 15998) return 'rgb(157, 155, 255)'
|
||
if (price >= 15998) return 'rgb(122, 4, 35)'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// 导出数据功能 - 注意:这现在只导出已加载的数据
|
||
function exportData() {
|
||
if(hasMore.value) {
|
||
message.warning('当前导出的是已加载的部分数据,并非所有数据。')
|
||
}
|
||
let text = ''
|
||
const fileName = generateExportFileName()
|
||
|
||
switch (exportType.value) {
|
||
case 'json': {
|
||
text = JSON.stringify(selectedEvents.value)
|
||
break
|
||
}
|
||
case 'csv': {
|
||
text = objectsToCSV(
|
||
selectedEvents.value.map((v) => ({
|
||
type: v.type,
|
||
time: format(v.time, 'yyyy-MM-dd HH:mm:ss'),
|
||
name: v.uname,
|
||
uId: v.uid,
|
||
num: v.num,
|
||
price: v.price,
|
||
msg: v.msg,
|
||
})),
|
||
)
|
||
break
|
||
}
|
||
}
|
||
|
||
saveAs(
|
||
new Blob([text], { type: 'text/plain;charset=utf-8' }),
|
||
fileName
|
||
)
|
||
}
|
||
|
||
// 生成导出文件名
|
||
function generateExportFileName() {
|
||
return `${format(Date.now(), 'yyyy-MM-dd HH:mm:ss')}_${format(selectedDate.value[0], 'yyyy-MM-dd HH:mm:ss')}_${format(selectedDate.value[1], 'yyyy-MM-dd HH:mm:ss')}_${accountInfo.value?.id}_${accountInfo.value?.name}_${selectedType.value}.${exportType.value}`
|
||
}
|
||
|
||
// 将对象数组转换为CSV格式
|
||
function objectsToCSV(arr: any[]) {
|
||
if (arr.length === 0) return ''
|
||
|
||
const array = [Object.keys(arr[0])].concat(arr)
|
||
return array
|
||
.map((row) => {
|
||
return Object.values(row)
|
||
.map((value) => {
|
||
return typeof value === 'string' ? JSON.stringify(value) : value
|
||
})
|
||
.toString()
|
||
})
|
||
.join('\n')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<NSpace vertical>
|
||
<EventFetcherAlert />
|
||
<EventFetcherStatusCard />
|
||
</NSpace>
|
||
<NDivider />
|
||
<NCard
|
||
size="small"
|
||
style="width: 100%"
|
||
>
|
||
<template v-if="accountInfo?.isBiliVerified">
|
||
<!-- 日期选择和类型选择区域 -->
|
||
<NSpace
|
||
justify="center"
|
||
align="center"
|
||
class="control-panel"
|
||
>
|
||
<NDatePicker
|
||
v-model:value="selectedDate"
|
||
type="datetimerange"
|
||
:shortcuts="rangeShortcuts"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
:disabled="isLoading || isLoadingMore"
|
||
/>
|
||
<NRadioGroup
|
||
v-model:value="selectedType"
|
||
:disabled="isLoading || isLoadingMore"
|
||
>
|
||
<NRadioButton :value="EventDataTypes.Guard">
|
||
舰长
|
||
</NRadioButton>
|
||
<NRadioButton :value="EventDataTypes.SC">
|
||
Superchat
|
||
</NRadioButton>
|
||
</NRadioGroup>
|
||
</NSpace>
|
||
<br>
|
||
|
||
<!-- 导出选项区域 -->
|
||
<NCard
|
||
title="导出"
|
||
size="small"
|
||
>
|
||
<NSpace>
|
||
<NRadioGroup
|
||
v-model:value="exportType"
|
||
style="margin: 0 auto"
|
||
>
|
||
<NRadioButton value="csv">
|
||
CSV
|
||
</NRadioButton>
|
||
<NRadioButton value="json">
|
||
Json
|
||
</NRadioButton>
|
||
</NRadioGroup>
|
||
<NButton
|
||
type="primary"
|
||
:disabled="selectedEvents.length === 0 || isLoading || isLoadingMore"
|
||
@click="exportData"
|
||
>
|
||
导出{{ hasMore ? ' (已加载部分)' : ' (全部)' }}
|
||
</NButton>
|
||
</NSpace>
|
||
<NText
|
||
v-if="hasMore && selectedEvents.length > 0"
|
||
type="warning"
|
||
style="font-size: smaller; display: block; margin-top: 5px;"
|
||
>
|
||
当前仅显示已加载的部分数据,滚动到底部可加载更多。导出功能也仅导出已加载数据。
|
||
</NText>
|
||
</NCard>
|
||
|
||
<NDivider>
|
||
共加载 {{ selectedEvents.length }} 条 {{ hasMore ? '(滚动加载更多...)' : '' }}
|
||
</NDivider>
|
||
|
||
<!-- 数据展示区域 -->
|
||
<NSpin :show="isLoading">
|
||
<!-- 主 Spinner 只在初始加载时显示 -->
|
||
<!-- 显示模式切换 -->
|
||
<NRadioGroup
|
||
v-model:value="displayMode"
|
||
style="display: flex; justify-content: center"
|
||
size="small"
|
||
>
|
||
<NRadioButton value="grid">
|
||
<NIcon :component="Grid28Filled" />
|
||
</NRadioButton>
|
||
<NRadioButton value="column">
|
||
<NIcon :component="List16Filled" />
|
||
</NRadioButton>
|
||
</NRadioGroup>
|
||
<br>
|
||
|
||
<!-- 数据展示区域 - 网格或表格 -->
|
||
<Transition
|
||
mode="out-in"
|
||
name="fade"
|
||
appear
|
||
>
|
||
<!-- 网格视图 -->
|
||
<div v-if="displayMode == 'grid'">
|
||
<NInfiniteScroll
|
||
:distance="100"
|
||
:disabled="isLoadingMore || !hasMore || isLoading"
|
||
@load="loadMore"
|
||
>
|
||
<NGrid
|
||
cols="1 500:2 800:3 1000:4 1200:5"
|
||
:x-gap="12"
|
||
:y-gap="8"
|
||
style="min-height: 200px;"
|
||
>
|
||
<NGridItem
|
||
v-for="item in selectedEvents"
|
||
:key="item.time + '_' + item.uid + '_' + item.price"
|
||
>
|
||
<NCard
|
||
size="small"
|
||
:style="`height: ${selectedType == EventDataTypes.Guard ? '175px' : '220px'}`"
|
||
embedded
|
||
hoverable
|
||
>
|
||
<NSpace
|
||
align="center"
|
||
vertical
|
||
:size="5"
|
||
>
|
||
<NAvatar
|
||
round
|
||
lazy
|
||
borderd
|
||
:size="64"
|
||
:src="item.uid ? AVATAR_URL + item.uid : item.uface"
|
||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||
style="box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)"
|
||
/>
|
||
<NSpace>
|
||
<NTag
|
||
v-if="selectedType == EventDataTypes.Guard"
|
||
size="tiny"
|
||
:bordered="false"
|
||
>
|
||
{{ item.msg }}
|
||
</NTag>
|
||
<NTag
|
||
size="tiny"
|
||
round
|
||
:color="{
|
||
color: selectedType == EventDataTypes.Guard ? GetGuardColor(item.price) : GetSCColor(item.price),
|
||
textColor: 'white',
|
||
borderColor: isDarkMode ? 'white' : '#00000000',
|
||
}"
|
||
>
|
||
{{ item.price }}
|
||
</NTag>
|
||
</NSpace>
|
||
<NText
|
||
:depth="1"
|
||
style="font-weight: 500;"
|
||
>
|
||
<!-- 用户名加粗一点 -->
|
||
{{ item.uname }}
|
||
</NText>
|
||
<NText
|
||
depth="3"
|
||
style="font-size: small"
|
||
>
|
||
<NTime :time="item.time" />
|
||
</NText>
|
||
<NEllipsis
|
||
v-if="selectedType == EventDataTypes.SC"
|
||
:line-clamp="3"
|
||
>
|
||
<!-- SC 消息限制行数 -->
|
||
{{ item.msg }}
|
||
</NEllipsis>
|
||
</NSpace>
|
||
</NCard>
|
||
</NGridItem>
|
||
</NGrid>
|
||
<!-- 加载更多指示器 -->
|
||
<div
|
||
v-if="isLoadingMore"
|
||
style="text-align: center; padding: 10px;"
|
||
>
|
||
<NSpin size="small" />
|
||
<NText
|
||
depth="3"
|
||
style="margin-left: 5px;"
|
||
>
|
||
加载中...
|
||
</NText>
|
||
</div>
|
||
<div
|
||
v-if="!hasMore && !isLoading && selectedEvents.length > 0"
|
||
style="text-align: center; padding: 10px;"
|
||
>
|
||
<NText depth="3">
|
||
没有更多数据了
|
||
</NText>
|
||
</div>
|
||
</NInfiniteScroll>
|
||
</div>
|
||
|
||
<!-- 表格视图 (未应用无限滚动) -->
|
||
<NTable v-else-if="!isLoading && selectedEvents.length > 0">
|
||
<!-- 添加 v-else-if 避免初始加载时显示空表格 -->
|
||
<thead>
|
||
<tr>
|
||
<th>用户名</th>
|
||
<th>UId</th>
|
||
<th>时间</th>
|
||
<th v-if="selectedType == EventDataTypes.Guard">
|
||
类型
|
||
</th>
|
||
<th>价格</th>
|
||
<th v-if="selectedType == EventDataTypes.SC">
|
||
内容
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="item in selectedEvents"
|
||
:key="item.time + '_' + item.uid + '_' + item.price"
|
||
>
|
||
<td>{{ item.uname }}</td>
|
||
<td>{{ item.uid }}</td>
|
||
<td>
|
||
<NTime
|
||
:time="item.time"
|
||
format="yyyy-MM-dd HH:mm:ss"
|
||
/> <!-- 指定格式 -->
|
||
</td>
|
||
<td v-if="selectedType == EventDataTypes.Guard">
|
||
{{ item.msg }}
|
||
</td>
|
||
<td>
|
||
<NTag
|
||
size="small"
|
||
:color="{
|
||
color: selectedType == EventDataTypes.Guard ? GetGuardColor(item.price) : GetSCColor(item.price),
|
||
textColor: 'white',
|
||
borderColor: isDarkMode ? 'white' : '#00000000',
|
||
}"
|
||
>
|
||
{{ item.price }}
|
||
</NTag>
|
||
</td>
|
||
<td v-if="selectedType == EventDataTypes.SC">
|
||
<NEllipsis style="max-width: 300px">
|
||
{{ item.msg }}
|
||
</NEllipsis>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</NTable>
|
||
<!-- 初始加载时或无数据时的提示 -->
|
||
<NAlert
|
||
v-else-if="!isLoading && selectedEvents.length === 0"
|
||
title="无数据"
|
||
type="info"
|
||
>
|
||
在选定的时间范围和类型内没有找到数据。
|
||
</NAlert>
|
||
</Transition>
|
||
</NSpin>
|
||
</template>
|
||
|
||
<!-- 未认证用户提示区域 -->
|
||
<template v-else>
|
||
<NCollapse :default-expanded-names="['1']">
|
||
<NCollapseItem
|
||
title="这是什么?"
|
||
name="1"
|
||
>
|
||
可以查看曾经收到的Superchat以及上舰记录, 并导出为 CSV 之类的表格
|
||
</NCollapseItem>
|
||
<NCollapseItem title="可以直接用吗">
|
||
遗憾的是并不能, 使用这个功能需要你拥有一个可以7*24小时运行 Docker 容器或者 Node.js 脚本的环境,
|
||
并且可以访问互联网
|
||
</NCollapseItem>
|
||
<NCollapseItem title="有没有什么要求?">
|
||
关于环境的话理论上能够运行 Docker 或者 Node.js 的环境都能可以
|
||
<br><br>
|
||
此外, 你至少需要以下技能之一:
|
||
<NUl>
|
||
<NLi>了解并能够使用 Docker 容器</NLi>
|
||
<NLi>了解并能够运行 Node.js</NLi>
|
||
<NLi>熟悉互联网冲浪, 能够跟着教程点击鼠标</NLi>
|
||
<NLi>拥有掌握以上技能的 stf 或者朋友</NLi>
|
||
</NUl>
|
||
<NH3>
|
||
<NText strong>
|
||
即使你对相关知识一窍不通也不用担心, 跟着后面的傻瓜教程中的 Koyeb 也可以完成部署.
|
||
理论上这玩意里头的免费套餐就够用了, 当然如果你想要更稳一点上个付费套餐也不影响
|
||
</NText>
|
||
</NH3>
|
||
</NCollapseItem>
|
||
</NCollapse>
|
||
<NDivider style="margin-bottom: 10px" />
|
||
<NSpace justify="center">
|
||
<NButton
|
||
tag="a"
|
||
href="https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs"
|
||
target="_blank"
|
||
type="primary"
|
||
>
|
||
部署指南
|
||
</NButton>
|
||
</NSpace>
|
||
</template>
|
||
</NCard>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 响应式样式调整 */
|
||
@media (max-width: 600px) {
|
||
.control-panel {
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
|
||
/* 添加过渡效果 */
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
/* 网格卡片样式微调 */
|
||
.n-card {
|
||
transition: box-shadow 0.3s ease; /* 平滑阴影过渡 */
|
||
}
|
||
</style>
|