feat: 更新组件声明和优化自动操作逻辑

- 移除了旧的关注和舰长事件处理逻辑,简化了代码结构。
- 优化了定时弹幕和自动回复的处理逻辑
- 更新了数据获取逻辑,支持分页加载和无限滚动
This commit is contained in:
2025-04-20 17:25:27 +08:00
parent f9417870ce
commit aa2d63a33c
13 changed files with 617 additions and 350 deletions

View File

@@ -90,14 +90,6 @@ export function useFollowThank(
} }
} }
/**
* 处理关注事件 - 旧方式实现,用于兼容现有代码
*/
function onFollow(event: EventModel) {
// 将在useAutoAction.ts中进行迁移此方法保留但不实现具体逻辑
console.log('关注事件处理已迁移到新的AutoActionItem结构');
}
/** /**
* 清理计时器 * 清理计时器
*/ */
@@ -109,7 +101,6 @@ export function useFollowThank(
} }
return { return {
onFollow,
processFollow, processFollow,
clearTimer clearTimer
}; };

View File

@@ -155,17 +155,8 @@ export function useGuardPm(
} }
} }
/**
* 处理上舰事件 - 旧方式实现,用于兼容现有代码
*/
function onGuard(event: EventModel) {
// 将在useAutoAction.ts中进行迁移此方法保留但不实现具体逻辑
console.log('舰长事件处理已迁移到新的AutoActionItem结构');
}
return { return {
config, config,
onGuard,
processGuard processGuard
}; };
} }

View File

@@ -73,20 +73,6 @@ export function useScheduledDanmaku(
}); });
} }
/**
* 启动定时弹幕 (旧方式)
*/
function startScheduledDanmaku() {
console.log('定时弹幕已迁移到新的AutoActionItem结构');
}
/**
* 停止定时弹幕 (旧方式)
*/
function stopScheduledDanmaku() {
console.log('定时弹幕已迁移到新的AutoActionItem结构');
}
/** /**
* 格式化剩余时间为分:秒格式 * 格式化剩余时间为分:秒格式
*/ */
@@ -111,8 +97,6 @@ export function useScheduledDanmaku(
} }
return { return {
startScheduledDanmaku,
stopScheduledDanmaku,
processScheduledActions, processScheduledActions,
clearTimer, clearTimer,
remainingSeconds, remainingSeconds,

View File

@@ -55,14 +55,12 @@ export const useAutoAction = defineStore('autoAction', () => {
(roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message) (roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message)
); );
// @ts-ignore - 忽略类型错误以保持功能正常
const guardPm = useGuardPm( const guardPm = useGuardPm(
isLive, isLive,
roomId, roomId,
(userId: number, message: string) => biliFunc.sendPrivateMessage(userId, message) (userId: number, message: string) => biliFunc.sendPrivateMessage(userId, message)
); );
// @ts-ignore - 忽略类型错误以保持功能正常
const followThank = useFollowThank( const followThank = useFollowThank(
isLive, isLive,
roomId, roomId,
@@ -70,7 +68,6 @@ export const useAutoAction = defineStore('autoAction', () => {
(roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message) (roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message)
); );
// @ts-ignore - 忽略类型错误以保持功能正常
const entryWelcome = useEntryWelcome( const entryWelcome = useEntryWelcome(
isLive, isLive,
roomId, roomId,
@@ -78,14 +75,12 @@ export const useAutoAction = defineStore('autoAction', () => {
(roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message) (roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message)
); );
// @ts-ignore - 忽略类型错误以保持功能正常
const autoReply = useAutoReply( const autoReply = useAutoReply(
isLive, isLive,
roomId, roomId,
(roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message) (roomId: number, message: string) => biliFunc.sendLiveDanmaku(roomId, message)
); );
// @ts-ignore - 忽略类型错误以保持功能正常
const scheduledDanmaku = useScheduledDanmaku( const scheduledDanmaku = useScheduledDanmaku(
isLive, isLive,
roomId, roomId,
@@ -168,11 +163,11 @@ export const useAutoAction = defineStore('autoAction', () => {
break; break;
case TriggerType.GUARD: case TriggerType.GUARD:
guardPm.onGuard(event); guardPm.processGuard(event, autoActions.value, runtimeState.value);
break; break;
case TriggerType.FOLLOW: case TriggerType.FOLLOW:
followThank.onFollow(event); followThank.processFollow(event, autoActions.value, runtimeState.value);
break; break;
case TriggerType.ENTER: case TriggerType.ENTER:
@@ -366,7 +361,7 @@ export const useAutoAction = defineStore('autoAction', () => {
if (!roomId.value) return; if (!roomId.value) return;
// 使用专用模块处理定时发送 // 使用专用模块处理定时发送
scheduledDanmaku.startScheduledDanmaku(); scheduledDanmaku.processScheduledActions(autoActions.value, runtimeState.value);
// 同时处理自定义的定时任务 // 同时处理自定义的定时任务
const scheduledActions = autoActions.value.filter( const scheduledActions = autoActions.value.filter(
@@ -382,37 +377,33 @@ export const useAutoAction = defineStore('autoAction', () => {
const intervalSeconds = action.triggerConfig.intervalSeconds || 300; // 默认5分钟 const intervalSeconds = action.triggerConfig.intervalSeconds || 300; // 默认5分钟
const timerFunc = () => { const timerFunc = () => {
if (!isLive.value && action.triggerConfig.onlyDuringLive) { // 仅在检查时判断直播状态,不停止定时器
// 如果设置了仅直播时发送,且当前未直播,则跳过 const shouldExecute =
return; !action.triggerConfig.onlyDuringLive || isLive.value;
if (shouldExecute && !(action.triggerConfig.ignoreTianXuan && isTianXuanActive.value)) {
// 创建执行上下文
const context: ExecutionContext = {
roomId: roomId.value,
variables: {
date: {
formatted: new Date().toLocaleString(),
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate(),
hour: new Date().getHours(),
minute: new Date().getMinutes(),
second: new Date().getSeconds(),
}
},
timestamp: Date.now()
};
// 执行定时操作
executeAction(action, context);
} }
if (action.triggerConfig.ignoreTianXuan && isTianXuanActive.value) { // 无论是否执行,都设置下一次定时
// 如果设置了天选时刻不发送,且当前有天选,则跳过
return;
}
// 创建执行上下文
const context: ExecutionContext = {
roomId: roomId.value,
variables: {
date: {
formatted: new Date().toLocaleString(),
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate(),
hour: new Date().getHours(),
minute: new Date().getMinutes(),
second: new Date().getSeconds(),
}
},
timestamp: Date.now()
};
// 执行定时操作
executeAction(action, context);
// 设置下一次执行
runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, intervalSeconds * 1000); runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, intervalSeconds * 1000);
}; };
@@ -465,8 +456,48 @@ export const useAutoAction = defineStore('autoAction', () => {
// 如果是定时操作,重新配置定时器 // 如果是定时操作,重新配置定时器
if (action.triggerType === TriggerType.SCHEDULED) { if (action.triggerType === TriggerType.SCHEDULED) {
if (enabled) { if (enabled) {
// 启用时重新启动定时器 // 如果已有定时器,先清理
startScheduledActions(); if (runtimeState.value.scheduledTimers[id]) {
clearTimeout(runtimeState.value.scheduledTimers[id]!);
runtimeState.value.scheduledTimers[id] = null;
}
// 启用时单独启动这个定时器
const intervalSeconds = action.triggerConfig.intervalSeconds || 300;
const timerFunc = () => {
// 仅在检查时判断直播状态,不停止定时器
const shouldExecute =
!action.triggerConfig.onlyDuringLive || isLive.value;
if (shouldExecute && !(action.triggerConfig.ignoreTianXuan && isTianXuanActive.value)) {
// 创建执行上下文
const context: ExecutionContext = {
roomId: roomId.value,
variables: {
date: {
formatted: new Date().toLocaleString(),
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate(),
hour: new Date().getHours(),
minute: new Date().getMinutes(),
second: new Date().getSeconds(),
}
},
timestamp: Date.now()
};
// 执行定时操作
executeAction(action, context);
}
// 无论是否执行,都设置下一次定时
runtimeState.value.scheduledTimers[id] = setTimeout(timerFunc, intervalSeconds * 1000);
};
// 启动定时器
runtimeState.value.scheduledTimers[id] = setTimeout(timerFunc, intervalSeconds * 1000);
} else if (runtimeState.value.scheduledTimers[id]) { } else if (runtimeState.value.scheduledTimers[id]) {
// 禁用时清理定时器 // 禁用时清理定时器
clearTimeout(runtimeState.value.scheduledTimers[id]!); clearTimeout(runtimeState.value.scheduledTimers[id]!);
@@ -484,14 +515,14 @@ export const useAutoAction = defineStore('autoAction', () => {
// 启动所有定时发送任务 // 启动所有定时发送任务
startScheduledActions(); startScheduledActions();
// 监听直播状态变化,自动启停定时任务 // 不再根据直播状态停止定时任务,只在回调中判断
watch(isLive, (newIsLive) => { /*watch(isLive, (newIsLive) => {
if (newIsLive) { if (newIsLive) {
startScheduledActions(); startScheduledActions();
} else { } else {
stopAllScheduledActions(); stopAllScheduledActions();
} }
}); });*/
// 安全地订阅事件 // 安全地订阅事件
try { try {
@@ -510,115 +541,6 @@ export const useAutoAction = defineStore('autoAction', () => {
clearAllTimers(); clearAllTimers();
}); });
} }
// 迁移旧的配置
migrateAutoReplyConfig();
migrateGiftThankConfig();
}
/**
* 迁移旧的自动回复配置到新的AutoActionItem格式
*/
function migrateAutoReplyConfig() {
try {
// 尝试从localStorage获取旧配置
const oldConfigStr = localStorage.getItem('autoAction.autoReplyConfig');
if (!oldConfigStr) return;
const oldConfig = JSON.parse(oldConfigStr);
if (!oldConfig.enabled || !oldConfig.rules || !Array.isArray(oldConfig.rules)) return;
// 检查是否已经迁移过(防止重复迁移)
const migratedKey = 'autoAction.autoReplyMigrated';
if (localStorage.getItem(migratedKey) === 'true') return;
// 将旧规则转换为新的AutoActionItem
const newItems = oldConfig.rules.map((rule: any) => {
const item = createDefaultAutoAction(TriggerType.DANMAKU);
item.name = `弹幕回复: ${rule.keywords.join(',')}`;
item.enabled = oldConfig.enabled;
item.templates = rule.replies || ['感谢您的弹幕'];
item.triggerConfig = {
...item.triggerConfig,
keywords: rule.keywords || [],
blockwords: rule.blockwords || [],
onlyDuringLive: oldConfig.onlyDuringLive,
userFilterEnabled: oldConfig.userFilterEnabled,
requireMedal: oldConfig.requireMedal,
requireCaptain: oldConfig.requireCaptain
};
item.actionConfig = {
...item.actionConfig,
cooldownSeconds: oldConfig.cooldownSeconds || 5
};
return item;
});
// 添加到现有的autoActions中
autoActions.value = [...autoActions.value, ...newItems];
// 标记为已迁移
localStorage.setItem(migratedKey, 'true');
console.log(`成功迁移 ${newItems.length} 条自动回复规则`);
} catch (error) {
console.error('迁移自动回复配置失败:', error);
}
}
/**
* 迁移旧的礼物感谢配置到新的AutoActionItem格式
*/
function migrateGiftThankConfig() {
try {
// 尝试从localStorage获取旧配置
const oldConfigStr = localStorage.getItem('autoAction.giftThankConfig');
if (!oldConfigStr) return;
const oldConfig = JSON.parse(oldConfigStr);
if (!oldConfig.enabled || !oldConfig.templates || !Array.isArray(oldConfig.templates)) return;
// 检查是否已经迁移过(防止重复迁移)
const migratedKey = 'autoAction.giftThankMigrated';
if (localStorage.getItem(migratedKey) === 'true') return;
// 创建新的礼物感谢项
const item = createDefaultAutoAction(TriggerType.GIFT);
item.name = '礼物感谢';
item.enabled = oldConfig.enabled;
item.templates = oldConfig.templates;
// 设置触发配置
item.triggerConfig = {
...item.triggerConfig,
onlyDuringLive: oldConfig.onlyDuringLive ?? true,
ignoreTianXuan: oldConfig.ignoreTianXuan ?? true,
userFilterEnabled: oldConfig.userFilterEnabled ?? false,
requireMedal: oldConfig.requireMedal ?? false,
requireCaptain: oldConfig.requireCaptain ?? false,
filterMode: oldConfig.filterModes?.useWhitelist ? 'whitelist' :
oldConfig.filterModes?.useBlacklist ? 'blacklist' : undefined,
filterGiftNames: oldConfig.filterGiftNames || [],
minValue: oldConfig.minValue || 0
};
// 设置操作配置
item.actionConfig = {
...item.actionConfig,
delaySeconds: oldConfig.delaySeconds || 0,
cooldownSeconds: 5,
maxUsersPerMsg: oldConfig.maxUsersPerMsg || 3,
maxItemsPerUser: oldConfig.maxGiftsPerUser || 3
};
// 添加到现有的autoActions中
autoActions.value.push(item);
// 标记为已迁移
localStorage.setItem(migratedKey, 'true');
console.log('成功迁移礼物感谢配置');
} catch (error) {
console.error('迁移礼物感谢配置失败:', error);
}
} }
// 卸载时清理 // 卸载时清理

1
src/components.d.ts vendored
View File

@@ -18,6 +18,7 @@ declare module 'vue' {
LabelItem: typeof import('./components/LabelItem.vue')['default'] LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar'] NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge'] NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']

View File

@@ -99,7 +99,7 @@ function InitVersionCheck() {
const path = url.pathname const path = url.pathname
if (!path.startsWith('/obs')) { if (!path.startsWith('/obs')) {
if (isTauri) { if (isTauri()) {
location.reload(); location.reload();
} }
else { else {

View File

@@ -88,6 +88,9 @@ export function checkUpdateNote() {
positiveText: '下次更新前不再提示', positiveText: '下次更新前不再提示',
onPositiveClick: () => { onPositiveClick: () => {
savedUpdateNoteVer.value = currentUpdateNoteVer; savedUpdateNoteVer.value = currentUpdateNoteVer;
},
onClose: () => {
savedUpdateNoteVer.value = currentUpdateNoteVer;
} }
}); });
} }

View File

@@ -11,7 +11,7 @@ const failoverAPI = `https://failover-api.vtsuru.suki.club/`;
export const isBackendUsable = ref(true); export const isBackendUsable = ref(true);
export const isDev = import.meta.env.MODE === 'development'; export const isDev = import.meta.env.MODE === 'development';
// @ts-ignore // @ts-ignore
export const isTauri = window.__TAURI__ !== undefined || window.__TAURI_INTERNAL__ !== undefined; export const isTauri = () => window.__TAURI__ !== undefined || window.__TAURI_INTERNAL__ !== undefined || '__TAURI__' in window;
export const AVATAR_URL = 'https://workers.vrp.moe/api/bilibili/avatar/'; export const AVATAR_URL = 'https://workers.vrp.moe/api/bilibili/avatar/';
export const FILE_BASE_URL = 'https://files.vtsuru.suki.club'; export const FILE_BASE_URL = 'https://files.vtsuru.suki.club';

View File

@@ -196,7 +196,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
danmakuClientState.value = 'connected'; // 明确设置状态 danmakuClientState.value = 'connected'; // 明确设置状态
danmakuServerUrl.value = client.danmakuClient!.serverUrl; // 获取服务器地址 danmakuServerUrl.value = client.danmakuClient!.serverUrl; // 获取服务器地址
// 启动事件发送定时器 (如果之前没有启动) // 启动事件发送定时器 (如果之前没有启动)
timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件 timer ??= setInterval(sendEvents, 2000); // 每 2 秒尝试发送一次事件
return { success: true, message: '弹幕客户端已启动' }; return { success: true, message: '弹幕客户端已启动' };
} else { } else {
console.error(prefix.value + '弹幕客户端启动失败'); console.error(prefix.value + '弹幕客户端启动失败');
@@ -301,7 +301,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
Data: string; Data: string;
}; };
async function onRequest(url: string, method: string, body: string, useCookie: boolean) { async function onRequest(url: string, method: string, body: string, useCookie: boolean) {
if (!isTauri) { if (!isTauri()) {
console.error(prefix.value + '非Tauri环境下无法处理请求: ' + url); console.error(prefix.value + '非Tauri环境下无法处理请求: ' + url);
return { return {
Message: '非Tauri环境', Message: '非Tauri环境',
@@ -386,7 +386,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
} }
// 批量处理事件每次最多发送20条 // 批量处理事件每次最多发送20条
const batchSize = 20; const batchSize = 30;
const batch = events.slice(0, batchSize); const batch = events.slice(0, batchSize);
try { try {

View File

@@ -1,20 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTimelineItem } from 'naive-ui' import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTimelineItem, NTag, NIcon } from 'naive-ui'
import { h } from 'vue'
import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/ionicons5'
</script> </script>
<template> <template>
<NLayoutContent style="height: 100vh"> <NLayoutContent style="height: 100vh; padding: 20px 0;">
<NSpace <NSpace
style="margin-top: 50px" style="margin-top: 30px"
justify="center" justify="center"
align="center" align="center"
vertical vertical
> >
<NCard <NCard
style="max-width: 80vw; width: 700px" style="max-width: 80vw; width: 700px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);"
embedded embedded
> >
<template #header> <template #header>
关于 <div style="font-size: 22px; font-weight: bold; padding: 8px 0;">
关于
</div>
</template> </template>
<NText> <NText>
一个兴趣使然的网站 一个兴趣使然的网站
@@ -52,23 +56,174 @@ import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTi
<NSpace <NSpace
vertical vertical
align="center" align="center"
style="margin-bottom: 16px;"
> >
<span style="color: gray"> <span style="color: #666; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 6px;">
MADE WITH BY MADE WITH <NIcon
size="18"
color="#f56c6c"
><HeartOutline /></NIcon> BY
<NButton <NButton
type="primary" type="primary"
tag="a" tag="a"
href="https://space.bilibili.com/10021741" href="https://space.bilibili.com/10021741"
target="_blank" target="_blank"
text text
style="" style="font-weight: bold;"
> >
Megghy Megghy
</NButton> </NButton>
</span> </span>
<div style="margin-top: 8px; display: flex; align-items: center; gap: 8px;">
<NButton
tag="a"
href="https://github.com/Megghy/vtsuru.live"
target="_blank"
text
style="display: flex; align-items: center; gap: 4px; color: #666;"
>
<NIcon size="16">
<LogoGithub />
</NIcon>
<span>源代码仓库</span>
</NButton>
</div>
</NSpace>
<NDivider
title-placement="left"
style="margin-top: 12px;"
>
<div style="display: flex; align-items: center; gap: 6px;">
<span>技术栈</span>
</div>
</NDivider>
<NSpace
vertical
style="padding: 0 12px; margin-bottom: 16px;"
>
<div style="display: flex; align-items: center; gap: 8px;">
<NIcon
size="20"
color="#709e7e"
>
<CodeOutline />
</NIcon>
<NText style="font-weight: 500;">
前端:
</NText>
<NTag
type="success"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://vuejs.org/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
Vue.js
</NButton>
</NTag>
<NTag
type="info"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://www.typescriptlang.org/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
TypeScript
</NButton>
</NTag>
<NTag
type="warning"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://www.naiveui.com/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
Naive UI
</NButton>
</NTag>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<NIcon
size="20"
color="#7c7ca0"
>
<ServerOutline />
</NIcon>
<NText style="font-weight: 500;">
后端:
</NText>
<NTag
type="primary"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://dotnet.microsoft.com/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
C# .NET 10
</NButton>
</NTag>
<NTag
type="error"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://www.postgresql.org/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
PostgreSQL
</NButton>
</NTag>
<NTag
type="default"
size="small"
round
:bordered="false"
>
<NButton
tag="a"
href="https://keydb.dev/"
target="_blank"
text
style="padding: 0; color: inherit;"
>
KeyDB
</NButton>
</NTag>
</div>
</NSpace> </NSpace>
<NDivider title-placement="left"> <NDivider title-placement="left">
赞助我 <div style="display: flex; align-items: center; gap: 6px;">
<span>赞助我</span>
</div>
</NDivider> </NDivider>
<iframe <iframe
id="afdian_leaflet_vtsuru" id="afdian_leaflet_vtsuru"
@@ -77,10 +232,15 @@ import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTi
scrolling="no" scrolling="no"
height="200" height="200"
frameborder="0" frameborder="0"
style="border-radius: 8px;"
/> />
<NDivider title-placement="left"> <NDivider title-placement="left">
更新日志 <div style="display: flex; align-items: center; gap: 6px;">
<span>更新日志</span>
</div>
</NDivider> </NDivider>
<UpdateNoteContainer />
<NDivider />
<NTimeline> <NTimeline>
<NTimelineItem <NTimelineItem
type="info" type="info"
@@ -241,9 +401,46 @@ import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTi
</NTimeline> </NTimeline>
</template> </template>
</NCard> </NCard>
<NButton @click="$router.push({ name: 'manage-index' })"> <NButton
type="primary"
style="margin-top: 20px; padding: 8px 24px; font-size: 15px; border-radius: 8px;"
@click="$router.push({ name: 'manage-index' })"
>
回到控制台 回到控制台
</NButton> </NButton>
</NSpace> </NSpace>
</NLayoutContent> </NLayoutContent>
</template> </template>
<style scoped>
:deep(.n-timeline) {
padding: 0 8px;
}
:deep(.n-timeline-item-content) {
margin-bottom: 12px;
}
:deep(.n-card-header) {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:deep(.n-divider.n-divider--title-position-left .n-divider__title) {
font-weight: 600;
font-size: 16px;
color: #444;
}
:deep(.n-timeline-item-content__title) {
font-weight: 600;
}
:deep(.n-timeline-item-content__content) {
color: #555;
}
:deep(.n-timeline-item-content__time) {
color: #888;
font-size: 13px;
}
</style>

View File

@@ -21,6 +21,7 @@ import {
TabletSpeaker24Filled, TabletSpeaker24Filled,
VehicleShip24Filled, VehicleShip24Filled,
VideoAdd20Filled, VideoAdd20Filled,
Mail24Filled,
} from '@vicons/fluent' } from '@vicons/fluent'
import { AnalyticsSharp, BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, Eye } from '@vicons/ionicons5' import { AnalyticsSharp, BrowsersOutline, Chatbox, Moon, MusicalNote, Sunny, Eye } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core' import { useElementSize, useStorage } from '@vueuse/core'
@@ -561,37 +562,90 @@ onMounted(() => {
</RouterView> </RouterView>
<!-- 未验证邮箱的提示 --> <!-- 未验证邮箱的提示 -->
<template v-else> <template v-else>
<NAlert type="info"> <NCard>
请进行邮箱验证 <NSpace
<br><br> vertical
<NSpace> size="large"
<NButton align="center"
size="small" >
type="info" <NFlex
:disabled="!canResendEmail" justify="center"
@click="resendEmail" align="center"
vertical
> >
重新发送验证邮件 <NIcon
</NButton> size="48"
<NCountdown color="#2080f0"
v-if="!canResendEmail" >
:duration="(accountInfo?.nextSendEmailTime ?? 0) - Date.now()" <Mail24Filled />
@finish="canResendEmail = true" </NIcon>
/> <NText style="font-size: 20px; margin-top: 16px; font-weight: 500;">
请验证您的邮箱
</NText>
<NText
depth="3"
style="text-align: center; margin-top: 8px;"
>
我们已向您的邮箱发送了验证链接请查收并点击链接完成验证
</NText>
</NFlex>
<NPopconfirm <NAlert
size="small" type="warning"
@positive-click="logout" style="max-width: 450px;"
> >
<template #icon>
<NIcon>
<Info24Filled />
</NIcon>
</template>
如果长时间未收到邮件请检查垃圾邮件文件夹或点击下方按钮重新发送
</NAlert>
<NSpace>
<NButton
type="primary"
:disabled="!canResendEmail"
style="min-width: 140px;"
@click="resendEmail"
>
<template #icon>
<NIcon>
<Mail24Filled />
</NIcon>
</template>
重新发送验证邮件
</NButton>
<NTag
v-if="!canResendEmail"
type="warning"
round
>
<NCountdown
:duration="(accountInfo?.nextSendEmailTime ?? 0) - Date.now()"
@finish="canResendEmail = true"
/>
后可重新发送
</NTag>
</NSpace>
<NDivider style="width: 80%; min-width: 250px;" />
<NPopconfirm @positive-click="logout">
<template #trigger> <template #trigger>
<NButton type="error"> <NButton secondary>
登出 <template #icon>
<NIcon>
<PersonFeedback24Filled />
</NIcon>
</template>
切换账号
</NButton> </NButton>
</template> </template>
确定登出? 确定登出当前账号吗
</NPopconfirm> </NPopconfirm>
</NSpace> </NSpace>
</NAlert> </NCard>
</template> </template>
<NBackTop /> <NBackTop />
</NElement> </NElement>

View File

@@ -13,7 +13,7 @@
VideoAdd20Filled, VideoAdd20Filled,
WindowWrench20Filled, WindowWrench20Filled,
} from '@vicons/fluent'; } from '@vicons/fluent';
import { Chatbox, Home, Moon, MusicalNote, Sunny } from '@vicons/ionicons5'; import { BrowsersOutline, Chatbox, Home, Moon, MusicalNote, Sunny } from '@vicons/ionicons5';
import { useElementSize, useStorage } from '@vueuse/core'; import { useElementSize, useStorage } from '@vueuse/core';
import { import {
MenuOption, MenuOption,
@@ -491,6 +491,36 @@
:auto-focus="false" :auto-focus="false"
:mask-closable="false" :mask-closable="false"
> >
<NAlert
type="info"
style="border-radius: 8px;"
>
<NFlex
vertical
align="center"
size="small"
>
<div style="text-align: center;">
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录
</div>
<NFlex
justify="center"
style="width: 100%; margin-top: 8px;"
>
<NButton
type="primary"
size="small"
@click="$router.push({ name: 'bili-user'})"
>
<template #icon>
<NIcon :component="BrowsersOutline" />
</template>
前往 Bilibili 认证用户主页
</NButton>
</NFlex>
</NFlex>
</NAlert>
<br>
<!-- 异步加载注册登录组件优化初始加载性能 --> <!-- 异步加载注册登录组件优化初始加载性能 -->
<RegisterAndLogin @close="registerAndLoginModalVisiable = false" /> <RegisterAndLogin @close="registerAndLoginModalVisiable = false" />
</NModal> </NModal>

View File

@@ -34,26 +34,10 @@ import {
NTime, NTime,
NUl, NUl,
useMessage, useMessage,
NInfiniteScroll,
} from 'naive-ui' } from 'naive-ui'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { EventDataTypes, EventModel } from '@/api/api-models'
// 事件类型枚举
enum EventType {
Guard,
SC,
}
// 事件数据模型接口
interface EventModel {
type: EventType
name: string
uid: number
msg: string
time: number
num: number
price: number
uface: string
}
const accountInfo = useAccount() const accountInfo = useAccount()
const message = useMessage() const message = useMessage()
@@ -76,45 +60,92 @@ const rangeShortcuts = {
// 响应式状态 // 响应式状态
const selectedDate = ref<[number, number]>([rangeShortcuts.本月()[0], rangeShortcuts.本月()[1]]) const selectedDate = ref<[number, number]>([rangeShortcuts.本月()[0], rangeShortcuts.本月()[1]])
const selectedType = ref(EventType.Guard) const selectedType = ref(EventDataTypes.Guard)
const events = ref<EventModel[]>(await get()) const events = ref<EventModel[]>([]) // 初始为空数组
const isLoading = ref(false) const isLoading = ref(false) // 用于初始加载
const isLoadingMore = ref(false) // 用于无限滚动加载
const displayMode = ref<'grid' | 'column'>('grid') const displayMode = ref<'grid' | 'column'>('grid')
const exportType = ref<'json' | 'csv'>('csv') // 移除了未实现的xml选项 const exportType = ref<'json' | 'csv'>('csv')
const offset = ref(0) // 当前偏移量
const limit = ref(20) // 每次加载数量
const hasMore = ref(true) // 是否还有更多数据
// 根据类型过滤事件 // 根据类型过滤事件 - 这个计算属性现在可能只显示当前已加载的事件
// 如果需要导出 *所有* 选定日期/类型的数据,导出逻辑需要调整
const selectedEvents = computed(() => { const selectedEvents = computed(() => {
return events.value.filter((e) => e.type == selectedType.value) return events.value.filter((e) => e.type == selectedType.value)
}) })
// 获取事件数据 // API请求获取数据 - 修改为支持分页
async function onDateChange() { async function get(currentOffset: number, currentLimit: number) {
isLoading.value = true
const data = await get()
events.value = data
isLoading.value = false
}
// API请求获取数据
async function get() {
try { try {
const data = await QueryGetAPI<EventModel[]>(EVENT_API_URL + 'get', { const data = await QueryGetAPI<EventModel[]>(EVENT_API_URL + 'get', {
start: selectedDate.value[0], start: selectedDate.value[0],
end: selectedDate.value[1], end: selectedDate.value[1],
offset: currentOffset, // 添加 offset 参数
limit: currentLimit, // 添加 limit 参数
}) })
if (data.code == 200) { if (data.code == 200) {
message.success('已获取数据') message.success(`成功获取 ${data.data.length} 条数据`) // 调整提示
return new List(data.data).OrderByDescending((d) => d.time).ToArray() return data.data // 直接返回数据数组
} else { } else {
message.error('获取数据失败: ' + data.message) message.error('获取数据失败: ' + data.message)
return [] return []
} }
} catch (err) { } catch (err) {
message.error('获取数据失败') message.error('获取数据失败: ' + (err as Error).message) // 提供更详细的错误信息
return [] 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颜色 // 获取SC颜色
function GetSCColor(price: number): string { function GetSCColor(price: number): string {
if (price === 0) return `#2a60b2` if (price === 0) return `#2a60b2`
@@ -139,8 +170,11 @@ function GetGuardColor(price: number | null | undefined): string {
return '' return ''
} }
// 导出数据功能 // 导出数据功能 - 注意:这现在只导出已加载的数据
function exportData() { function exportData() {
if(hasMore.value) {
message.warning('当前导出的是已加载的部分数据,并非所有数据。')
}
let text = '' let text = ''
const fileName = generateExportFileName() const fileName = generateExportFileName()
@@ -154,7 +188,7 @@ function exportData() {
selectedEvents.value.map((v) => ({ selectedEvents.value.map((v) => ({
type: v.type, type: v.type,
time: format(v.time, 'yyyy-MM-dd HH:mm:ss'), time: format(v.time, 'yyyy-MM-dd HH:mm:ss'),
name: v.name, name: v.uname,
uId: v.uid, uId: v.uid,
num: v.num, num: v.num,
price: v.price, price: v.price,
@@ -202,7 +236,7 @@ function objectsToCSV(arr: any[]) {
<NCard <NCard
size="small" size="small"
style="width: 100%" style="width: 100%"
> >
<template v-if="accountInfo?.isBiliVerified"> <template v-if="accountInfo?.isBiliVerified">
<!-- 日期选择和类型选择区域 --> <!-- 日期选择和类型选择区域 -->
<NSpace <NSpace
@@ -216,23 +250,19 @@ function objectsToCSV(arr: any[]) {
:shortcuts="rangeShortcuts" :shortcuts="rangeShortcuts"
start-placeholder="开始时间" start-placeholder="开始时间"
end-placeholder="结束时间" end-placeholder="结束时间"
@update:value="onDateChange" :disabled="isLoading || isLoadingMore"
/> />
<NRadioGroup v-model:value="selectedType"> <NRadioGroup
<NRadioButton :value="EventType.Guard"> v-model:value="selectedType"
:disabled="isLoading || isLoadingMore"
>
<NRadioButton :value="EventDataTypes.Guard">
舰长 舰长
</NRadioButton> </NRadioButton>
<NRadioButton :value="EventType.SC"> <NRadioButton :value="EventDataTypes.SC">
Superchat Superchat
</NRadioButton> </NRadioButton>
</NRadioGroup> </NRadioGroup>
<NButton
type="primary"
:loading="isLoading"
@click="onDateChange"
>
刷新
</NButton>
</NSpace> </NSpace>
<br> <br>
@@ -255,18 +285,28 @@ function objectsToCSV(arr: any[]) {
</NRadioGroup> </NRadioGroup>
<NButton <NButton
type="primary" type="primary"
:disabled="selectedEvents.length === 0" :disabled="selectedEvents.length === 0 || isLoading || isLoadingMore"
@click="exportData" @click="exportData"
> >
导出 导出{{ hasMore ? ' (已加载部分)' : ' (全部)' }}
</NButton> </NButton>
</NSpace> </NSpace>
<NText
v-if="hasMore && selectedEvents.length > 0"
type="warning"
style="font-size: smaller; display: block; margin-top: 5px;"
>
当前仅显示已加载的部分数据滚动到底部可加载更多导出功能也仅导出已加载数据
</NText>
</NCard> </NCard>
<NDivider> {{ selectedEvents.length }} </NDivider> <NDivider>
共加载 {{ selectedEvents.length }} {{ hasMore ? '(滚动加载更多...)' : '' }}
</NDivider>
<!-- 数据展示区域 --> <!-- 数据展示区域 -->
<NSpin :show="isLoading"> <NSpin :show="isLoading">
<!-- Spinner 只在初始加载时显示 -->
<!-- 显示模式切换 --> <!-- 显示模式切换 -->
<NRadioGroup <NRadioGroup
v-model:value="displayMode" v-model:value="displayMode"
@@ -290,85 +330,122 @@ function objectsToCSV(arr: any[]) {
> >
<!-- 网格视图 --> <!-- 网格视图 -->
<div v-if="displayMode == 'grid'"> <div v-if="displayMode == 'grid'">
<NGrid <NInfiniteScroll
cols="1 500:2 800:3 1000:4 1200:5" :distance="100"
:x-gap="12" :disabled="isLoadingMore || !hasMore || isLoading"
:y-gap="8" @load="loadMore"
> >
<NGridItem <NGrid
v-for="item in selectedEvents" cols="1 500:2 800:3 1000:4 1200:5"
:key="item.time" :x-gap="12"
:y-gap="8"
style="min-height: 200px;"
> >
<NCard <NGridItem
size="small" v-for="item in selectedEvents"
:style="`height: ${selectedType == EventType.Guard ? '175px' : '220px'}`" :key="item.time + '_' + item.uid + '_' + item.price"
embedded >
hoverable <NCard
size="small"
:style="`height: ${selectedType == EventDataTypes.Guard ? '175px' : '220px'}`"
embedded
hoverable
> >
<NSpace <NSpace
align="center" align="center"
vertical vertical
:size="5" :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 == EventType.Guard"
size="tiny"
:bordered="false"
>
{{ item.msg }}
</NTag>
<NTag
size="tiny"
round
:color="{
color: selectedType == EventType.Guard ? GetGuardColor(item.price) : GetSCColor(item.price),
textColor: 'white',
borderColor: isDarkMode ? 'white' : '#00000000',
}"
>
{{ item.price }}
</NTag>
</NSpace>
<NText>
{{ item.name }}
</NText>
<NText
depth="3"
style="font-size: small"
> >
<NTime :time="item.time" /> <NAvatar
</NText> round
<NEllipsis v-if="selectedType == EventType.SC"> lazy
{{ item.msg }} borderd
</NEllipsis> :size="64"
</NSpace> :src="item.uid ? AVATAR_URL + item.uid : item.uface"
</NCard> :img-props="{ referrerpolicy: 'no-referrer' }"
</NGridItem> style="box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)"
</NGrid> />
<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> </div>
<!-- 表格视图 --> <!-- 表格视图 (未应用无限滚动) -->
<NTable v-else> <NTable v-else-if="!isLoading && selectedEvents.length > 0">
<!-- 添加 v-else-if 避免初始加载时显示空表格 -->
<thead> <thead>
<tr> <tr>
<th>用户名</th> <th>用户名</th>
<th>UId</th> <th>UId</th>
<th>时间</th> <th>时间</th>
<th v-if="selectedType == EventType.Guard"> <th v-if="selectedType == EventDataTypes.Guard">
类型 类型
</th> </th>
<th>价格</th> <th>价格</th>
<th v-if="selectedType == EventType.SC"> <th v-if="selectedType == EventDataTypes.SC">
内容 内容
</th> </th>
</tr> </tr>
@@ -376,20 +453,24 @@ function objectsToCSV(arr: any[]) {
<tbody> <tbody>
<tr <tr
v-for="item in selectedEvents" v-for="item in selectedEvents"
:key="item.time" :key="item.time + '_' + item.uid + '_' + item.price"
> >
<td>{{ item.name }}</td> <td>{{ item.uname }}</td>
<td>{{ item.uid }}</td> <td>{{ item.uid }}</td>
<td> <td>
<NTime :time="item.time" /> <NTime
:time="item.time"
format="yyyy-MM-dd HH:mm:ss"
/> <!-- 指定格式 -->
</td> </td>
<td v-if="selectedType == EventType.Guard"> <td v-if="selectedType == EventDataTypes.Guard">
{{ item.msg }} {{ item.msg }}
</td> </td>
<td> <td>
<NTag <NTag
size="small"
:color="{ :color="{
color: selectedType == EventType.Guard ? GetGuardColor(item.price) : GetSCColor(item.price), color: selectedType == EventDataTypes.Guard ? GetGuardColor(item.price) : GetSCColor(item.price),
textColor: 'white', textColor: 'white',
borderColor: isDarkMode ? 'white' : '#00000000', borderColor: isDarkMode ? 'white' : '#00000000',
}" }"
@@ -397,7 +478,7 @@ function objectsToCSV(arr: any[]) {
{{ item.price }} {{ item.price }}
</NTag> </NTag>
</td> </td>
<td v-if="selectedType == EventType.SC"> <td v-if="selectedType == EventDataTypes.SC">
<NEllipsis style="max-width: 300px"> <NEllipsis style="max-width: 300px">
{{ item.msg }} {{ item.msg }}
</NEllipsis> </NEllipsis>
@@ -405,6 +486,14 @@ function objectsToCSV(arr: any[]) {
</tr> </tr>
</tbody> </tbody>
</NTable> </NTable>
<!-- 初始加载时或无数据时的提示 -->
<NAlert
v-else-if="!isLoading && selectedEvents.length === 0"
title="无数据"
type="info"
>
在选定的时间范围和类型内没有找到数据
</NAlert>
</Transition> </Transition>
</NSpin> </NSpin>
</template> </template>
@@ -474,4 +563,9 @@ function objectsToCSV(arr: any[]) {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
/* 网格卡片样式微调 */
.n-card {
transition: box-shadow 0.3s ease; /* 平滑阴影过渡 */
}
</style> </style>