mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 添加签到功能及相关设置
- 更新 .gitignore,添加 SpecStory 说明文件 - 在 App.vue 中引入 NGlobalStyle 组件 - 更新 api-models.ts,添加签到相关数据模型 - 在 CheckInSettings.vue 中实现签到功能的配置界面 - 添加签到排行榜功能,允许用户查看签到情况 - 更新 PointHistoryCard.vue,增加签到记录显示 - 在 PointSettings.vue 中添加签到相关设置项 - 更新路由,添加签到排行页面
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ pnpm-debug.log*
|
|||||||
env.d.ts
|
env.d.ts
|
||||||
/.specstory
|
/.specstory
|
||||||
/.cursor
|
/.cursor
|
||||||
|
# SpecStory explanation file
|
||||||
|
.specstory/.what-is-this.md
|
||||||
|
|||||||
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
## Project setup
|
## Project setup
|
||||||
```
|
```
|
||||||
yarn install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and hot-reloads for development
|
### Compiles and hot-reloads for development
|
||||||
```
|
```
|
||||||
yarn serve
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and minifies for production
|
### Compiles and minifies for production
|
||||||
```
|
```
|
||||||
yarn build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lints and fixes files
|
### Lints and fixes files
|
||||||
```
|
```
|
||||||
yarn lint
|
bun run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customize configuration
|
### Customize configuration
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
},
|
},
|
||||||
// ...
|
// ...
|
||||||
};
|
};
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (isDarkMode.value) {
|
if (isDarkMode.value) {
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
style="height: 100vh"
|
style="height: 100vh"
|
||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
|
inline-theme-disabled
|
||||||
>
|
>
|
||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<NNotificationProvider>
|
<NNotificationProvider>
|
||||||
@@ -93,6 +95,7 @@
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<TempComponent>
|
<TempComponent>
|
||||||
<NLayoutContent>
|
<NLayoutContent>
|
||||||
|
<NGlobalStyle />
|
||||||
<NElement style="height: 100vh;">
|
<NElement style="height: 100vh;">
|
||||||
<ViewerLayout v-if="layout == 'viewer'" />
|
<ViewerLayout v-if="layout == 'viewer'" />
|
||||||
<ManageLayout v-else-if="layout == 'manage'" />
|
<ManageLayout v-else-if="layout == 'manage'" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface UserInfo extends UserBasicInfo {
|
|||||||
isInBlackList: boolean
|
isInBlackList: boolean
|
||||||
templateTypes: { [key: string]: string }
|
templateTypes: { [key: string]: string }
|
||||||
streamerInfo?: StreamerModel
|
streamerInfo?: StreamerModel
|
||||||
|
allowCheckInRanking?: boolean // 是否允许查看签到排行
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export interface EventFetcherStateModel {
|
export interface EventFetcherStateModel {
|
||||||
@@ -226,6 +227,18 @@ export interface Setting_Point {
|
|||||||
scPointPercent: number // double maps to number in TypeScript
|
scPointPercent: number // double maps to number in TypeScript
|
||||||
giftPointPercent: number // double maps to number in TypeScript
|
giftPointPercent: number // double maps to number in TypeScript
|
||||||
giftAllowType: SettingPointGiftAllowType
|
giftAllowType: SettingPointGiftAllowType
|
||||||
|
|
||||||
|
// 签到系统设置
|
||||||
|
enableCheckIn: boolean // 是否启用签到功能
|
||||||
|
checkInKeyword: string // 签到关键词
|
||||||
|
givePointsForCheckIn: boolean // 是否为签到提供积分
|
||||||
|
baseCheckInPoints: number // 基础签到积分
|
||||||
|
enableConsecutiveBonus: boolean // 是否启用连续签到奖励
|
||||||
|
bonusPointsPerDay: number // 每天额外奖励积分
|
||||||
|
maxBonusPoints: number // 最大奖励积分
|
||||||
|
allowSelfCheckIn: boolean // 是否允许自己签到
|
||||||
|
requireAuth: boolean // 是否需要认证
|
||||||
|
allowCheckInRanking: boolean // 是否允许查询签到排行
|
||||||
}
|
}
|
||||||
export interface Setting_QuestionDisplay {
|
export interface Setting_QuestionDisplay {
|
||||||
font?: string // Optional string, with a maximum length of 30 characters
|
font?: string // Optional string, with a maximum length of 30 characters
|
||||||
@@ -801,7 +814,8 @@ export interface ResponsePointHisrotyModel {
|
|||||||
export enum PointFrom {
|
export enum PointFrom {
|
||||||
Danmaku,
|
Danmaku,
|
||||||
Manual,
|
Manual,
|
||||||
Use
|
Use,
|
||||||
|
CheckIn
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResponseUserIndexModel {
|
export interface ResponseUserIndexModel {
|
||||||
@@ -809,3 +823,24 @@ export interface ResponseUserIndexModel {
|
|||||||
videos: VideoCollectVideo[]
|
videos: VideoCollectVideo[]
|
||||||
links: { [key: string]: string }
|
links: { [key: string]: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 签到排行信息
|
||||||
|
export interface CheckInRankingInfo {
|
||||||
|
ouId: string
|
||||||
|
name: string
|
||||||
|
consecutiveDays: number
|
||||||
|
points: number
|
||||||
|
lastCheckInTime: number
|
||||||
|
isAuthed: boolean
|
||||||
|
monthlyCheckInCount?: number // 本月签到次数
|
||||||
|
totalCheckInCount?: number // 总签到次数
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签到结果
|
||||||
|
export interface CheckInResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
points: number
|
||||||
|
consecutiveDays: number
|
||||||
|
todayRank: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,9 +142,6 @@ function getParams(params: any) {
|
|||||||
resultParams.set('token', urlParams.get('token') || '')
|
resultParams.set('token', urlParams.get('token') || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加时间戳用于解决意外添加的缓存
|
|
||||||
resultParams.set('timestamp', Date.now().toString())
|
|
||||||
|
|
||||||
return resultParams.toString()
|
return resultParams.toString()
|
||||||
}
|
}
|
||||||
export async function QueryPostPaginationAPI<T>(
|
export async function QueryPostPaginationAPI<T>(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const columns = [
|
|||||||
title: '时间',
|
title: '时间',
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
width: 180,
|
width: 180,
|
||||||
|
sorter: (a: HistoryItem, b: HistoryItem) => a.timestamp - b.timestamp,
|
||||||
render: (row: HistoryItem) => {
|
render: (row: HistoryItem) => {
|
||||||
return h(NTime, {
|
return h(NTime, {
|
||||||
time: new Date(row.timestamp),
|
time: new Date(row.timestamp),
|
||||||
@@ -106,9 +107,9 @@ async function loadHistory() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
historyData.value = {
|
historyData.value = {
|
||||||
[HistoryType.DANMAKU]: danmakuHistory,
|
[HistoryType.DANMAKU]: danmakuHistory.sort((a, b) => b.timestamp - a.timestamp),
|
||||||
[HistoryType.PRIVATE_MSG]: privateMsgHistory,
|
[HistoryType.PRIVATE_MSG]: privateMsgHistory.sort((a, b) => b.timestamp - a.timestamp),
|
||||||
[HistoryType.COMMAND]: commandHistory
|
[HistoryType.COMMAND]: commandHistory.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史数据失败:', error);
|
console.error('加载历史数据失败:', error);
|
||||||
@@ -253,6 +254,7 @@ onUnmounted(() => {
|
|||||||
pageSizes: [10, 20, 50],
|
pageSizes: [10, 20, 50],
|
||||||
}"
|
}"
|
||||||
:row-key="row => row.id"
|
:row-key="row => row.id"
|
||||||
|
default-sort-order="descend"
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<NEmpty description="暂无历史记录" />
|
<NEmpty description="暂无历史记录" />
|
||||||
|
|||||||
@@ -12,62 +12,161 @@
|
|||||||
name="settings"
|
name="settings"
|
||||||
tab="签到设置"
|
tab="签到设置"
|
||||||
>
|
>
|
||||||
|
<NSpin :show="isLoading">
|
||||||
|
<NAlert
|
||||||
|
v-if="!canEdit"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
加载中或无法编辑设置,请稍后再试
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
<NForm
|
<NForm
|
||||||
label-placement="left"
|
label-placement="left"
|
||||||
label-width="auto"
|
:label-width="120"
|
||||||
|
:style="{
|
||||||
|
maxWidth: '650px'
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
|
<!-- 服务端签到设置 -->
|
||||||
|
<NDivider title-placement="left">
|
||||||
|
基本设置
|
||||||
|
</NDivider>
|
||||||
|
|
||||||
<NFormItem label="启用签到功能">
|
<NFormItem label="启用签到功能">
|
||||||
<NSwitch v-model:value="config.enabled" />
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.enableCheckIn"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
启用后,观众可以通过发送签到命令获得积分
|
||||||
|
</template>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<template v-if="config.enabled">
|
<template v-if="serverSetting.enableCheckIn">
|
||||||
<NFormItem label="签到指令">
|
<NFormItem label="签到命令">
|
||||||
|
<NInputGroup>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="config.command"
|
:value="serverSetting.checkInKeyword"
|
||||||
placeholder="例如:签到"
|
placeholder="例如:签到"
|
||||||
|
@update:value="(v: string) => serverSetting.checkInKeyword = v"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="updateServerSettings"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</NInputGroup>
|
||||||
|
<template #feedback>
|
||||||
|
观众发送此命令可以触发签到(注意:同时更新客户端命令设置)
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="为签到提供积分">
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.givePointsForCheckIn"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
/>
|
/>
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
观众发送此指令触发签到。
|
启用后,签到会获得积分奖励
|
||||||
</template>
|
</template>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="仅在直播时可签到">
|
<!-- 积分相关设置,只有在开启"为签到提供积分"后显示 -->
|
||||||
<NSwitch v-model:value="config.onlyDuringLive" />
|
<template v-if="serverSetting.givePointsForCheckIn">
|
||||||
<template #feedback>
|
<NFormItem label="基础签到积分">
|
||||||
启用后,仅在直播进行中才能签到,否则任何时候都可以签到。
|
|
||||||
</template>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="发送签到回复">
|
|
||||||
<NSwitch v-model:value="config.sendReply" />
|
|
||||||
<template #feedback>
|
|
||||||
启用后,签到成功或重复签到时会发送弹幕回复,关闭则只显示通知不发送弹幕。
|
|
||||||
</template>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="签到成功获得积分">
|
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
v-model:value="config.points"
|
v-model:value="serverSetting.baseCheckInPoints"
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="用户签到冷却时间 (秒)">
|
|
||||||
<NInputNumber
|
|
||||||
v-model:value="config.cooldownSeconds"
|
|
||||||
:min="0"
|
:min="0"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
/>
|
/>
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
每个用户在指定秒数内签到命令只会响应一次
|
每次签到获得的基础积分数量
|
||||||
</template>
|
</template>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="启用连续签到奖励">
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.enableConsecutiveBonus"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
启用后,连续签到会获得额外奖励
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<template v-if="serverSetting.enableConsecutiveBonus">
|
||||||
|
<NFormItem label="每天额外奖励积分">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="serverSetting.bonusPointsPerDay"
|
||||||
|
:min="0"
|
||||||
|
style="width: 100%"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
每天连续签到额外奖励的积分数量
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="最大奖励积分">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="serverSetting.maxBonusPoints"
|
||||||
|
:min="0"
|
||||||
|
style="width: 100%"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
连续签到奖励积分的上限
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NFormItem label="允许自己签到">
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.allowSelfCheckIn"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
启用后,主播自己也可以签到获得积分
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="要求用户已认证">
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.requireAuth"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
启用后,只有已认证的用户才能签到
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="允许查看签到排行">
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="serverSetting.allowCheckInRanking"
|
||||||
|
@update:value="updateServerSettings"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
启用后,用户可以查看签到排行榜
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 客户端回复设置 -->
|
||||||
<NDivider title-placement="left">
|
<NDivider title-placement="left">
|
||||||
回复消息设置
|
回复消息设置
|
||||||
</NDivider>
|
</NDivider>
|
||||||
|
|
||||||
|
<NFormItem label="发送签到回复">
|
||||||
|
<NSwitch v-model:value="config.sendReply" />
|
||||||
|
<template #feedback>
|
||||||
|
启用后,签到成功或重复签到时会发送弹幕回复,关闭则只显示通知不发送弹幕
|
||||||
|
</template>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<template v-if="config.sendReply">
|
||||||
<!-- 签到模板帮助信息组件 -->
|
<!-- 签到模板帮助信息组件 -->
|
||||||
<div style="margin-bottom: 12px">
|
<div style="margin-bottom: 12px">
|
||||||
<TemplateHelper :placeholders="checkInPlaceholders" />
|
<TemplateHelper :placeholders="checkInPlaceholders" />
|
||||||
@@ -91,82 +190,119 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 使用 AutoActionEditor 编辑 action 配置 -->
|
<!-- 使用 AutoActionEditor 编辑 action 配置 -->
|
||||||
|
<NFormItem label="签到成功回复">
|
||||||
<AutoActionEditor
|
<AutoActionEditor
|
||||||
:action="config.successAction"
|
:action="config.successAction"
|
||||||
:hide-name="true"
|
:hide-name="true"
|
||||||
:hide-enabled="true"
|
:hide-enabled="true"
|
||||||
/>
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="签到冷却回复">
|
||||||
<AutoActionEditor
|
<AutoActionEditor
|
||||||
:action="config.cooldownAction"
|
:action="config.cooldownAction"
|
||||||
:hide-name="true"
|
:hide-name="true"
|
||||||
:hide-enabled="true"
|
:hide-enabled="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NDivider title-placement="left">
|
|
||||||
早鸟奖励设置
|
|
||||||
</NDivider>
|
|
||||||
|
|
||||||
<NFormItem label="启用早鸟奖励">
|
|
||||||
<NSwitch v-model:value="config.earlyBird.enabled" />
|
|
||||||
<template #feedback>
|
|
||||||
在直播开始后的一段时间内签到可获得额外奖励。
|
|
||||||
</template>
|
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="config.earlyBird.enabled">
|
<NFormItem>
|
||||||
<NFormItem label="早鸟时间窗口 (分钟)">
|
<NButton
|
||||||
<NInputNumber
|
type="primary"
|
||||||
v-model:value="config.earlyBird.windowMinutes"
|
:disabled="!canEdit"
|
||||||
:min="1"
|
:loading="isLoading"
|
||||||
style="width: 100%"
|
@click="updateSettings"
|
||||||
/>
|
>
|
||||||
<template #feedback>
|
保存所有设置
|
||||||
直播开始后多少分钟内视为早鸟。
|
</NButton>
|
||||||
</template>
|
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="早鸟额外奖励积分">
|
|
||||||
<NInputNumber
|
|
||||||
v-model:value="config.earlyBird.bonusPoints"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<template #feedback>
|
|
||||||
成功触发早鸟签到的用户额外获得的积分。
|
|
||||||
</template>
|
|
||||||
</NFormItem>
|
|
||||||
<AutoActionEditor
|
|
||||||
:action="config.earlyBird.successAction"
|
|
||||||
:hide-name="true"
|
|
||||||
:hide-enabled="true"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</NForm>
|
</NForm>
|
||||||
|
</NSpin>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
|
||||||
<NTabPane
|
<NTabPane
|
||||||
name="userStats"
|
name="checkInRanking"
|
||||||
tab="用户签到情况"
|
tab="签到排行榜"
|
||||||
>
|
>
|
||||||
<div class="checkin-stats">
|
<div class="checkin-ranking">
|
||||||
<NSpace vertical>
|
<NSpace vertical>
|
||||||
<NAlert type="info">
|
<NAlert type="info">
|
||||||
以下显示用户的签到统计信息。包括累计签到次数、连续签到天数和早鸟签到次数等。
|
显示用户签到排行榜,包括连续签到天数和积分情况。选择时间段可查看不同期间的签到情况。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
|
<div class="ranking-filter">
|
||||||
|
<NSpace align="center">
|
||||||
|
<span>时间段:</span>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="timeRange"
|
||||||
|
style="width: 180px"
|
||||||
|
:options="timeRangeOptions"
|
||||||
|
@update:value="loadCheckInRanking"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>用户名:</span>
|
||||||
|
<NInput
|
||||||
|
v-model:value="userFilter"
|
||||||
|
placeholder="搜索用户"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="isLoadingRanking"
|
||||||
|
@click="loadCheckInRanking"
|
||||||
|
>
|
||||||
|
刷新排行榜
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NDataTable
|
<NDataTable
|
||||||
:columns="userStatsColumns"
|
:columns="rankingColumns"
|
||||||
:data="userStatsData"
|
:data="filteredRankingData"
|
||||||
:pagination="{ pageSize: 10 }"
|
:pagination="{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizePicker: true,
|
||||||
|
pageSizes: [10, 20, 50, 100],
|
||||||
|
onChange: (page: number) => pagination.page = page,
|
||||||
|
onUpdatePageSize: (pageSize: number) => pagination.pageSize = pageSize
|
||||||
|
}"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
|
:loading="isLoadingRanking"
|
||||||
striped
|
striped
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NEmpty
|
<NDivider />
|
||||||
v-if="!userStatsData.length"
|
|
||||||
description="暂无用户签到数据"
|
<div class="ranking-actions">
|
||||||
/>
|
<NSpace vertical>
|
||||||
|
<NAlert type="warning">
|
||||||
|
以下操作将重置用户的签到记录,请谨慎操作。重置后数据无法恢复。
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NPopconfirm @positive-click="resetAllCheckIn">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton
|
||||||
|
type="error"
|
||||||
|
:disabled="isResetting"
|
||||||
|
:loading="isResetting"
|
||||||
|
>
|
||||||
|
重置所有用户签到数据
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div style="max-width: 250px">
|
||||||
|
<p>警告:此操作将清空所有用户的签到记录,包括连续签到天数等数据,且不可恢复!</p>
|
||||||
|
<p>确定要继续吗?</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NPopconfirm>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</div>
|
</div>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
@@ -181,7 +317,7 @@
|
|||||||
在此可以模拟用户签到,测试签到功能是否正常工作。
|
在此可以模拟用户签到,测试签到功能是否正常工作。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NForm>
|
<NForm :label-width="100">
|
||||||
<NFormItem label="用户UID">
|
<NFormItem label="用户UID">
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
v-model:value="testUid"
|
v-model:value="testUid"
|
||||||
@@ -199,7 +335,7 @@
|
|||||||
<NFormItem>
|
<NFormItem>
|
||||||
<NButton
|
<NButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!testUid || !config?.enabled"
|
:disabled="!testUid || !serverSetting.enableCheckIn"
|
||||||
@click="handleTestCheckIn"
|
@click="handleTestCheckIn"
|
||||||
>
|
>
|
||||||
模拟签到
|
模拟签到
|
||||||
@@ -240,102 +376,342 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NCard, NForm, NFormItem, NSwitch, NInput, NInputNumber, NSpace, NText, NDivider, NAlert, NIcon, NTabs, NTabPane, NDataTable, NEmpty, NButton } from 'naive-ui';
|
import { SaveSetting, useAccount } from '@/api/account';
|
||||||
|
import { CheckInRankingInfo, CheckInResult } from '@/api/api-models';
|
||||||
|
import { QueryGetAPI } from '@/api/query';
|
||||||
import { useAutoAction } from '@/client/store/useAutoAction';
|
import { useAutoAction } from '@/client/store/useAutoAction';
|
||||||
import TemplateEditor from '../TemplateEditor.vue';
|
import { CHECKIN_API_URL } from '@/data/constants';
|
||||||
import TemplateHelper from '../TemplateHelper.vue';
|
import { GuidUtils } from '@/Utils';
|
||||||
import { TriggerType, ActionType, Priority, RuntimeState } from '@/client/store/autoAction/types';
|
|
||||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
|
||||||
import { Info24Filled } from '@vicons/fluent';
|
import { Info24Filled } from '@vicons/fluent';
|
||||||
import { computed, h, ref } from 'vue';
|
import type { DataTableColumns } from 'naive-ui';
|
||||||
import type { UserCheckInData } from '@/client/store/autoAction/modules/checkin';
|
import { NAlert, NButton, NCard, NDataTable, NDivider, NEmpty, NForm, NFormItem, NIcon, NInput, NInputGroup, NInputNumber, NPopconfirm, NSelect, NSpace, NSpin, NSwitch, NTabPane, NTabs, NText } from 'naive-ui';
|
||||||
|
import { computed, h, onMounted, ref, watch } from 'vue';
|
||||||
import AutoActionEditor from '../AutoActionEditor.vue';
|
import AutoActionEditor from '../AutoActionEditor.vue';
|
||||||
|
import TemplateHelper from '../TemplateHelper.vue';
|
||||||
|
|
||||||
|
interface LiveInfo {
|
||||||
|
roomId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const autoActionStore = useAutoAction();
|
const autoActionStore = useAutoAction();
|
||||||
const config = autoActionStore.checkInModule.checkInConfig;
|
const config = autoActionStore.checkInModule.checkInConfig;
|
||||||
const checkInStorage = autoActionStore.checkInModule.checkInStorage;
|
const accountInfo = useAccount();
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
// 签到模板的特定占位符
|
// 签到模板的特定占位符
|
||||||
const checkInPlaceholders = [
|
const checkInPlaceholders = [
|
||||||
{ name: '{{checkin.points}}', description: '基础签到积分' },
|
{ name: '{{checkin.points}}', description: '获得的总积分' },
|
||||||
{ name: '{{checkin.bonusPoints}}', description: '早鸟额外积分 (普通签到为0)' },
|
{ name: '{{checkin.consecutiveDays}}', description: '连续签到天数' },
|
||||||
{ name: '{{checkin.totalPoints}}', description: '本次总获得积分' },
|
{ name: '{{checkin.todayRank}}', description: '今日签到排名' },
|
||||||
{ name: '{{checkin.userPoints}}', description: '用户当前积分' },
|
|
||||||
{ name: '{{checkin.isEarlyBird}}', description: '是否是早鸟签到 (true/false)' },
|
|
||||||
{ name: '{{checkin.cooldownSeconds}}', description: '签到冷却时间(秒)' },
|
|
||||||
{ name: '{{checkin.time}}', description: '签到时间对象' }
|
{ name: '{{checkin.time}}', description: '签到时间对象' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 为签到模板自定义的测试上下文
|
// 服务端签到设置
|
||||||
const checkInTestContext = computed(() => {
|
const serverSetting = computed(() => {
|
||||||
if (!config) return undefined;
|
return accountInfo.value?.settings?.point || {};
|
||||||
|
|
||||||
return {
|
|
||||||
checkin: {
|
|
||||||
points: config.points || 0,
|
|
||||||
bonusPoints: config.earlyBird.enabled ? config.earlyBird.bonusPoints : 0,
|
|
||||||
totalPoints: (config.points || 0) + (config.earlyBird.enabled ? config.earlyBird.bonusPoints : 0),
|
|
||||||
userPoints: 1000, // 模拟用户当前积分
|
|
||||||
isEarlyBird: false,
|
|
||||||
cooldownSeconds: config.cooldownSeconds || 0,
|
|
||||||
time: new Date()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 用户签到数据表格列定义
|
// 是否可以编辑设置
|
||||||
const userStatsColumns = [
|
const canEdit = computed(() => {
|
||||||
|
return accountInfo.value && accountInfo.value.settings && accountInfo.value.settings.point;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新所有设置
|
||||||
|
async function updateSettings() {
|
||||||
|
// 先保存服务端设置
|
||||||
|
const serverSaved = await updateServerSettings();
|
||||||
|
|
||||||
|
if (serverSaved) {
|
||||||
|
window.$notification.success({
|
||||||
|
title: '设置已保存',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverSaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新服务端签到设置
|
||||||
|
async function updateServerSettings() {
|
||||||
|
if (!canEdit.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await SaveSetting('Point', accountInfo.value.settings.point);
|
||||||
|
if (msg) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
window.$notification.error({
|
||||||
|
title: '保存失败',
|
||||||
|
content: msg,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.$notification.error({
|
||||||
|
title: '保存失败',
|
||||||
|
content: String(err),
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
console.error('保存签到设置失败:', err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排行榜数据
|
||||||
|
const rankingData = ref<CheckInRankingInfo[]>([]);
|
||||||
|
const isLoadingRanking = ref(false);
|
||||||
|
const timeRange = ref<string>('all');
|
||||||
|
const userFilter = ref<string>('');
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// 时间段选项
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ label: '全部时间', value: 'all' },
|
||||||
|
{ label: '今日', value: 'today' },
|
||||||
|
{ label: '本周', value: 'week' },
|
||||||
|
{ label: '本月', value: 'month' },
|
||||||
|
{ label: '上个月', value: 'lastMonth' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 过滤后的排行榜数据
|
||||||
|
const filteredRankingData = computed(() => {
|
||||||
|
let filtered = rankingData.value;
|
||||||
|
|
||||||
|
// 按时间范围筛选
|
||||||
|
if (timeRange.value !== 'all') {
|
||||||
|
const now = new Date();
|
||||||
|
let startTime: Date;
|
||||||
|
|
||||||
|
if (timeRange.value === 'today') {
|
||||||
|
// 今天凌晨
|
||||||
|
startTime = new Date(now);
|
||||||
|
startTime.setHours(0, 0, 0, 0);
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
// 本周一
|
||||||
|
const dayOfWeek = now.getDay() || 7; // 把周日作为7处理
|
||||||
|
startTime = new Date(now);
|
||||||
|
startTime.setDate(now.getDate() - (dayOfWeek - 1));
|
||||||
|
startTime.setHours(0, 0, 0, 0);
|
||||||
|
} else if (timeRange.value === 'month') {
|
||||||
|
// 本月1号
|
||||||
|
startTime = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
} else if (timeRange.value === 'lastMonth') {
|
||||||
|
// 上月1号
|
||||||
|
startTime = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
// 本月1号作为结束时间
|
||||||
|
const endTime = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
filtered = filtered.filter(user => {
|
||||||
|
const checkInTime = new Date(user.lastCheckInTime);
|
||||||
|
return checkInTime >= startTime && checkInTime < endTime;
|
||||||
|
});
|
||||||
|
// 已经筛选完成,不需要再次筛选
|
||||||
|
startTime = new Date(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是上个月,用通用筛选逻辑
|
||||||
|
if (timeRange.value !== 'lastMonth') {
|
||||||
|
filtered = filtered.filter(user => {
|
||||||
|
const checkInTime = new Date(user.lastCheckInTime);
|
||||||
|
return checkInTime >= startTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按用户名筛选
|
||||||
|
if (userFilter.value) {
|
||||||
|
const keyword = userFilter.value.toLowerCase();
|
||||||
|
filtered = filtered.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 排行榜列定义
|
||||||
|
const rankingColumns: DataTableColumns<CheckInRankingInfo> = [
|
||||||
{
|
{
|
||||||
title: '用户ID',
|
title: '排名',
|
||||||
key: 'uid'
|
key: 'rank',
|
||||||
|
render: (row: CheckInRankingInfo, index: number) => h('span', {}, index + 1)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '用户名',
|
title: '用户名',
|
||||||
key: 'username'
|
key: 'name'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '首次签到时间',
|
title: '连续签到天数',
|
||||||
key: 'firstCheckInTime',
|
key: 'consecutiveDays',
|
||||||
render(row: UserCheckInData) {
|
sorter: 'default'
|
||||||
return h('span', {}, new Date(row.firstCheckInTime).toLocaleString());
|
},
|
||||||
}
|
{
|
||||||
|
title: '积分',
|
||||||
|
key: 'points',
|
||||||
|
sorter: 'default'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最近签到时间',
|
title: '最近签到时间',
|
||||||
key: 'lastCheckInTime',
|
key: 'lastCheckInTime',
|
||||||
sorter: true,
|
render(row: CheckInRankingInfo) {
|
||||||
defaultSortOrder: 'descend' as const,
|
|
||||||
render(row: UserCheckInData) {
|
|
||||||
return h('span', {}, new Date(row.lastCheckInTime).toLocaleString());
|
return h('span', {}, new Date(row.lastCheckInTime).toLocaleString());
|
||||||
|
},
|
||||||
|
sorter: 'default'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已认证',
|
||||||
|
key: 'isAuthed',
|
||||||
|
render(row: CheckInRankingInfo) {
|
||||||
|
return h('span', {}, row.isAuthed ? '是' : '否');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '累计签到',
|
title: '操作',
|
||||||
key: 'totalCheckins',
|
key: 'actions',
|
||||||
sorter: true
|
render(row: CheckInRankingInfo) {
|
||||||
|
return h(
|
||||||
|
NPopconfirm,
|
||||||
|
{
|
||||||
|
onPositiveClick: () => resetUserCheckInByGuid(row.ouId)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '连续签到',
|
trigger: () => h(
|
||||||
key: 'streakDays',
|
NButton,
|
||||||
sorter: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '早鸟签到次数',
|
size: 'small',
|
||||||
key: 'earlyBirdCount',
|
type: 'warning',
|
||||||
sorter: true
|
disabled: isResetting.value,
|
||||||
|
loading: isResetting.value && resetTargetId.value === row.ouId,
|
||||||
|
onClick: (e) => e.stopPropagation()
|
||||||
|
},
|
||||||
|
{ default: () => '重置签到' }
|
||||||
|
),
|
||||||
|
default: () => '确定要重置该用户的所有签到数据吗?此操作不可撤销。'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// 转换用户签到数据为表格可用格式
|
// 加载签到排行榜数据
|
||||||
const userStatsData = computed<UserCheckInData[]>(() => {
|
async function loadCheckInRanking() {
|
||||||
if (!checkInStorage?.users) {
|
if (isLoadingRanking.value) return;
|
||||||
return [];
|
|
||||||
|
isLoadingRanking.value = true;
|
||||||
|
try {
|
||||||
|
// 获取所有用户数据,不再根据时间范围过滤
|
||||||
|
const response = await QueryGetAPI<CheckInRankingInfo[]>(`${CHECKIN_API_URL}admin/users`);
|
||||||
|
|
||||||
|
if (response.code == 200) {
|
||||||
|
rankingData.value = response.data;
|
||||||
|
pagination.value.page = 1; // 重置为第一页
|
||||||
|
} else {
|
||||||
|
rankingData.value = [];
|
||||||
|
window.$message.error(`获取签到排行榜失败: ${response.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载签到排行榜失败:', error);
|
||||||
|
window.$notification.error({
|
||||||
|
title: '加载失败',
|
||||||
|
content: '无法加载签到排行榜数据',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
rankingData.value = [];
|
||||||
|
} finally {
|
||||||
|
isLoadingRanking.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将对象转换为数组
|
// 重置签到数据相关
|
||||||
return Object.values(checkInStorage.users);
|
const isResetting = ref(false);
|
||||||
|
const resetTargetId = ref<string>();
|
||||||
|
|
||||||
|
// 重置单个用户签到数据
|
||||||
|
async function resetUserCheckInByGuid(ouId: string) {
|
||||||
|
if (!ouId || isResetting.value) return;
|
||||||
|
|
||||||
|
isResetting.value = true;
|
||||||
|
resetTargetId.value = ouId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await QueryGetAPI(`${CHECKIN_API_URL}admin/reset`, {
|
||||||
|
ouId: ouId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response && response.code === 200) {
|
||||||
|
window.$notification.success({
|
||||||
|
title: '重置成功',
|
||||||
|
content: '用户签到数据已重置',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置成功后重新加载排行榜
|
||||||
|
await loadCheckInRanking();
|
||||||
|
} else {
|
||||||
|
window.$notification.error({
|
||||||
|
title: '重置失败',
|
||||||
|
content: response?.message || '无法重置用户签到数据',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置用户签到数据失败:', error);
|
||||||
|
window.$notification.error({
|
||||||
|
title: '重置失败',
|
||||||
|
content: '重置用户签到数据时发生错误',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isResetting.value = false;
|
||||||
|
resetTargetId.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置所有用户签到数据
|
||||||
|
async function resetAllCheckIn() {
|
||||||
|
if (isResetting.value) return;
|
||||||
|
|
||||||
|
isResetting.value = true;
|
||||||
|
try {
|
||||||
|
const response = await QueryGetAPI(`${CHECKIN_API_URL}admin/reset`, {});
|
||||||
|
|
||||||
|
if (response && response.code === 200) {
|
||||||
|
window.$notification.success({
|
||||||
|
title: '重置成功',
|
||||||
|
content: '所有用户的签到数据已重置',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置成功后重新加载排行榜
|
||||||
|
await loadCheckInRanking();
|
||||||
|
} else {
|
||||||
|
window.$notification.error({
|
||||||
|
title: '重置失败',
|
||||||
|
content: response?.message || '无法重置所有用户签到数据',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置所有用户签到数据失败:', error);
|
||||||
|
window.$notification.error({
|
||||||
|
title: '重置失败',
|
||||||
|
content: '重置所有用户签到数据时发生错误',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isResetting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 测试签到功能
|
// 测试签到功能
|
||||||
const testUid = ref<number>();
|
const testUid = ref<number>();
|
||||||
const testUsername = ref<string>('测试用户');
|
const testUsername = ref<string>('测试用户');
|
||||||
@@ -343,7 +719,7 @@ const testResult = ref<{ success: boolean; message: string }>();
|
|||||||
|
|
||||||
// 处理测试签到
|
// 处理测试签到
|
||||||
async function handleTestCheckIn() {
|
async function handleTestCheckIn() {
|
||||||
if (!testUid.value || !config?.enabled) {
|
if (!testUid.value || !serverSetting.value.enableCheckIn) {
|
||||||
testResult.value = {
|
testResult.value = {
|
||||||
success: false,
|
success: false,
|
||||||
message: '请输入有效的UID或确保签到功能已启用'
|
message: '请输入有效的UID或确保签到功能已启用'
|
||||||
@@ -352,49 +728,61 @@ async function handleTestCheckIn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 创建唯一标识符ouid,基于用户输入的uid
|
// 直接调用服务端签到API
|
||||||
const userOuid = testUid.value.toString();
|
const response = await QueryGetAPI<CheckInResult>(`${CHECKIN_API_URL}check-in-for`, {
|
||||||
|
uId: testUid.value,
|
||||||
|
name: testUsername.value || '测试用户'
|
||||||
|
});
|
||||||
|
|
||||||
// 创建模拟的事件对象
|
if (response.code === 200 && response.data) {
|
||||||
const mockEvent: EventModel = {
|
const result = response.data;
|
||||||
type: EventDataTypes.Message,
|
|
||||||
uname: testUsername.value || '测试用户',
|
|
||||||
uface: '',
|
|
||||||
uid: testUid.value,
|
|
||||||
open_id: '',
|
|
||||||
msg: config.command,
|
|
||||||
time: Date.now(),
|
|
||||||
num: 0,
|
|
||||||
price: 0,
|
|
||||||
guard_level: 0,
|
|
||||||
fans_medal_level: 0,
|
|
||||||
fans_medal_name: '',
|
|
||||||
fans_medal_wearing_status: false,
|
|
||||||
ouid: userOuid
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建模拟的运行时状态
|
|
||||||
const mockRuntimeState: RuntimeState = {
|
|
||||||
lastExecutionTime: {},
|
|
||||||
aggregatedEvents: {},
|
|
||||||
scheduledTimers: {},
|
|
||||||
timerStartTimes: {},
|
|
||||||
globalTimerStartTime: null,
|
|
||||||
sentGuardPms: new Set<number>()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理签到请求
|
|
||||||
await autoActionStore.checkInModule.processCheckIn(mockEvent, mockRuntimeState);
|
|
||||||
|
|
||||||
testResult.value = {
|
testResult.value = {
|
||||||
success: true,
|
success: result.success,
|
||||||
message: `已为用户 ${testUsername.value || '测试用户'}(UID: ${testUid.value}) 模拟签到操作,请查看用户签到情况选项卡确认结果`
|
message: result.success
|
||||||
|
? `签到成功!用户 ${testUsername.value || '测试用户'} 获得 ${result.points} 积分,连续签到 ${result.consecutiveDays} 天`
|
||||||
|
: result.message || '签到失败,可能今天已经签到过了'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 显示通知
|
||||||
|
window.$notification[result.success ? 'success' : 'info']({
|
||||||
|
title: result.success ? '测试签到成功' : '测试签到失败',
|
||||||
|
content: testResult.value.message,
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
testResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: `API返回错误: ${response.message || '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
testResult.value = {
|
testResult.value = {
|
||||||
success: false,
|
success: false,
|
||||||
message: `签到操作失败: ${error instanceof Error ? error.message : String(error)}`
|
message: `签到操作失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 显示错误通知
|
||||||
|
window.$notification.error({
|
||||||
|
title: '测试签到失败',
|
||||||
|
content: testResult.value.message,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 组件挂载时加载排行榜
|
||||||
|
onMounted(() => {
|
||||||
|
loadCheckInRanking();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-section {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-filter {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
import { ref, Ref, computed } from 'vue';
|
import { CheckInResult, EventDataTypes, EventModel } from '@/api/api-models';
|
||||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
import { QueryGetAPI } from '@/api/query';
|
||||||
import { ActionType, AutoActionItem, RuntimeState, TriggerType, Priority, KeywordMatchType } from '../types';
|
import { CHECKIN_API_URL } from '@/data/constants';
|
||||||
import { usePointStore } from '@/store/usePointStore';
|
|
||||||
import { processTemplate, executeActions } from '../actionUtils';
|
|
||||||
import { buildExecutionContext } from '../utils';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
|
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
|
||||||
import { GuidUtils } from '@/Utils';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createDefaultAutoAction } from '../utils';
|
import { Ref } from 'vue';
|
||||||
|
import { executeActions } from '../actionUtils';
|
||||||
|
import { ActionType, AutoActionItem, KeywordMatchType, Priority, RuntimeState, TriggerType } from '../types';
|
||||||
|
import { buildExecutionContext, createDefaultAutoAction } from '../utils';
|
||||||
|
import { useAccount } from '@/api/account';
|
||||||
|
|
||||||
// 签到配置接口
|
// 签到配置接口
|
||||||
export interface CheckInConfig {
|
export interface CheckInConfig {
|
||||||
enabled: boolean;
|
|
||||||
command: string;
|
|
||||||
points: number;
|
|
||||||
cooldownSeconds: number;
|
|
||||||
onlyDuringLive: boolean; // 仅在直播时可签到
|
|
||||||
sendReply: boolean; // 是否发送签到回复消息
|
sendReply: boolean; // 是否发送签到回复消息
|
||||||
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
||||||
cooldownAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
cooldownAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
||||||
earlyBird: {
|
earlyBird: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
windowMinutes: number;
|
|
||||||
bonusPoints: number;
|
|
||||||
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -31,7 +24,7 @@ export interface CheckInConfig {
|
|||||||
function createDefaultCheckInConfig(): CheckInConfig {
|
function createDefaultCheckInConfig(): CheckInConfig {
|
||||||
const successAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
const successAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
||||||
successAction.name = '签到成功回复';
|
successAction.name = '签到成功回复';
|
||||||
successAction.template = '@{{user.name}} 签到成功,获得 {{checkin.totalPoints}} 积分。';
|
successAction.template = '@{{user.name}} 签到成功,获得 {{checkin.points}} 积分,连续签到 {{checkin.consecutiveDays}} 天';
|
||||||
|
|
||||||
const cooldownAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
const cooldownAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
||||||
cooldownAction.name = '签到冷却回复';
|
cooldownAction.name = '签到冷却回复';
|
||||||
@@ -39,43 +32,19 @@ function createDefaultCheckInConfig(): CheckInConfig {
|
|||||||
|
|
||||||
const earlyBirdAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
const earlyBirdAction = createDefaultAutoAction(TriggerType.DANMAKU);
|
||||||
earlyBirdAction.name = '早鸟签到回复';
|
earlyBirdAction.name = '早鸟签到回复';
|
||||||
earlyBirdAction.template = '恭喜 {{user.name}} 完成早鸟签到!额外获得 {{bonusPoints}} 积分,共获得 {{totalPoints}} 积分!';
|
earlyBirdAction.template = '恭喜 {{user.name}} 完成早鸟签到!获得 {{checkin.points}} 积分,连续签到 {{checkin.consecutiveDays}} 天!';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
|
||||||
command: '签到',
|
|
||||||
points: 10,
|
|
||||||
cooldownSeconds: 3600, // 1小时
|
|
||||||
onlyDuringLive: true, // 默认仅在直播时可签到
|
|
||||||
sendReply: true, // 默认发送回复消息
|
sendReply: true, // 默认发送回复消息
|
||||||
successAction,
|
successAction,
|
||||||
cooldownAction,
|
cooldownAction,
|
||||||
earlyBird: {
|
earlyBird: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
windowMinutes: 30,
|
|
||||||
bonusPoints: 5,
|
|
||||||
successAction: earlyBirdAction
|
successAction: earlyBirdAction
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签到记录存储
|
|
||||||
interface CheckInStorage {
|
|
||||||
lastCheckIn: Record<string, number>; // ouid -> timestamp
|
|
||||||
users: Record<string, UserCheckInData>; // 用户签到详细数据
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户签到数据
|
|
||||||
export interface UserCheckInData {
|
|
||||||
ouid: string; // 用户ID
|
|
||||||
username: string; // 用户名称
|
|
||||||
totalCheckins: number; // 累计签到次数
|
|
||||||
streakDays: number; // 连续签到天数
|
|
||||||
lastCheckInTime: number; // 上次签到时间
|
|
||||||
earlyBirdCount: number; // 早鸟签到次数
|
|
||||||
firstCheckInTime: number; // 首次签到时间
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签到功能核心逻辑
|
* 签到功能核心逻辑
|
||||||
*/
|
*/
|
||||||
@@ -86,8 +55,6 @@ export function useCheckIn(
|
|||||||
isTianXuanActive: Ref<boolean>,
|
isTianXuanActive: Ref<boolean>,
|
||||||
sendDanmaku: (roomId: number, message: string) => Promise<boolean>
|
sendDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||||
) {
|
) {
|
||||||
const pointStore = usePointStore();
|
|
||||||
|
|
||||||
// 使用 IndexedDB 持久化存储签到配置
|
// 使用 IndexedDB 持久化存储签到配置
|
||||||
const { data: checkInConfig, isFinished: isConfigLoaded } = useIDBKeyval<CheckInConfig>(
|
const { data: checkInConfig, isFinished: isConfigLoaded } = useIDBKeyval<CheckInConfig>(
|
||||||
'autoAction.checkin.config',
|
'autoAction.checkin.config',
|
||||||
@@ -97,37 +64,21 @@ export function useCheckIn(
|
|||||||
console.error('[CheckIn] IDB 错误 (配置):', err);
|
console.error('[CheckIn] IDB 错误 (配置):', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
); // 使用 IndexedDB 持久化存储签到记录
|
|
||||||
const { data: checkInStorage, isFinished: isStorageLoaded } = useIDBKeyval<CheckInStorage>(
|
|
||||||
'autoAction.checkin.storage',
|
|
||||||
{
|
|
||||||
lastCheckIn: {},
|
|
||||||
users: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onError: (err) => {
|
|
||||||
console.error('[CheckIn] IDB 错误 (记录):', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const accountInfo = useAccount();
|
||||||
|
|
||||||
// 处理签到弹幕
|
// 处理签到弹幕 - 调用服务端API
|
||||||
async function processCheckIn(
|
async function processCheckIn(
|
||||||
event: EventModel,
|
event: EventModel,
|
||||||
runtimeState: RuntimeState
|
runtimeState: RuntimeState
|
||||||
) {
|
) {
|
||||||
// 确保配置和存储已加载
|
// 确保配置已加载
|
||||||
if (!isConfigLoaded.value || !isStorageLoaded.value) {
|
if (!isConfigLoaded.value) {
|
||||||
console.log('[CheckIn] 配置或存储尚未加载完成,跳过处理');
|
console.log('[CheckIn] 配置尚未加载完成,跳过处理');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!roomId.value || !checkInConfig.value.enabled) {
|
if (!accountInfo.value.settings.point.enableCheckIn) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否仅在直播时可签到
|
|
||||||
if (checkInConfig.value.onlyDuringLive && !isLive.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,134 +88,49 @@ export function useCheckIn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查弹幕内容是否匹配签到指令
|
// 检查弹幕内容是否匹配签到指令
|
||||||
if (event.msg?.trim() !== checkInConfig.value.command) {
|
if (event.msg?.trim() !== accountInfo.value.settings.point.checkInKeyword.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = event.ouid;
|
const username = event.uname || event.uid || event.open_id || '用户';
|
||||||
const username = event.uname || '用户';
|
|
||||||
const currentTime = Date.now();
|
|
||||||
|
|
||||||
// 检查是否已经在今天签到过
|
|
||||||
const lastCheckInTime = checkInStorage.value.lastCheckIn[userId] || 0;
|
|
||||||
|
|
||||||
// 判断上次签到时间是否为今天
|
|
||||||
const lastCheckInDate = new Date(lastCheckInTime);
|
|
||||||
const currentDate = new Date(currentTime);
|
|
||||||
|
|
||||||
// 比较日期部分是否相同(年、月、日)
|
|
||||||
const isSameDay = lastCheckInDate.getFullYear() === currentDate.getFullYear() &&
|
|
||||||
lastCheckInDate.getMonth() === currentDate.getMonth() &&
|
|
||||||
lastCheckInDate.getDate() === currentDate.getDate();
|
|
||||||
|
|
||||||
// 检查是否发送冷却提示
|
|
||||||
if (lastCheckInTime > 0 && isSameDay) {
|
|
||||||
// 用户今天已经签到过,发送提示
|
|
||||||
if (checkInConfig.value.sendReply) {
|
|
||||||
// 构建上下文
|
|
||||||
const cooldownContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU, {
|
|
||||||
user: { name: username, uid: userId }
|
|
||||||
});
|
|
||||||
// 统一用 executeActions
|
|
||||||
executeActions(
|
|
||||||
[checkInConfig.value.cooldownAction],
|
|
||||||
event,
|
|
||||||
TriggerType.DANMAKU,
|
|
||||||
roomId.value,
|
|
||||||
runtimeState,
|
|
||||||
{ sendLiveDanmaku: sendDanmaku },
|
|
||||||
{
|
|
||||||
customContextBuilder: () => cooldownContext
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
window.$notification.info({
|
|
||||||
title: '签到提示',
|
|
||||||
description: `${username} 重复签到, 已忽略`,
|
|
||||||
duration: 5000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算积分奖励
|
|
||||||
let pointsEarned = checkInConfig.value.points;
|
|
||||||
let bonusPoints = 0;
|
|
||||||
let isEarlyBird = false;
|
|
||||||
|
|
||||||
// 检查是否符合早鸟奖励条件
|
|
||||||
if (checkInConfig.value.earlyBird.enabled && liveStartTime.value) {
|
|
||||||
const earlyBirdWindowMs = checkInConfig.value.earlyBird.windowMinutes * 60 * 1000;
|
|
||||||
const timeSinceLiveStart = currentTime - liveStartTime.value;
|
|
||||||
|
|
||||||
if (timeSinceLiveStart <= earlyBirdWindowMs) {
|
|
||||||
bonusPoints = checkInConfig.value.earlyBird.bonusPoints;
|
|
||||||
pointsEarned += bonusPoints;
|
|
||||||
isEarlyBird = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户积分
|
|
||||||
try {
|
try {
|
||||||
// 调用积分系统添加积分
|
// 调用服务端API进行签到
|
||||||
const point = await pointStore.addPoints(userId, pointsEarned, `签到奖励 (${format(new Date(), 'yyyy-MM-dd')})`, `${username} 完成签到`);
|
const apiUrl = `${CHECKIN_API_URL}check-in-for`;
|
||||||
if (checkInStorage.value) {
|
|
||||||
if (!checkInStorage.value.lastCheckIn) {
|
// 使用query.ts中的QueryGetAPI替代fetch
|
||||||
checkInStorage.value.lastCheckIn = {};
|
const response = await QueryGetAPI<CheckInResult>(apiUrl, event.uid ? {
|
||||||
}
|
uid: event.uid,
|
||||||
if (!checkInStorage.value.users) {
|
name: username
|
||||||
checkInStorage.value.users = {};
|
} : {
|
||||||
}
|
oId: event.ouid,
|
||||||
let userData = checkInStorage.value.users[userId];
|
name: username
|
||||||
if (!userData) {
|
});
|
||||||
userData = {
|
|
||||||
ouid: userId,
|
const checkInResult = response.data;
|
||||||
username: username,
|
|
||||||
totalCheckins: 0,
|
if (checkInResult) {
|
||||||
streakDays: 0,
|
if (checkInResult.success) {
|
||||||
lastCheckInTime: 0,
|
// 签到成功
|
||||||
earlyBirdCount: 0,
|
if (roomId.value && checkInConfig.value.sendReply) {
|
||||||
firstCheckInTime: currentTime
|
const isEarlyBird = liveStartTime.value && (Date.now() - liveStartTime.value < 30 * 60 * 1000);
|
||||||
};
|
|
||||||
}
|
// 构建签到数据上下文
|
||||||
const lastCheckInDate = new Date(userData.lastCheckInTime);
|
|
||||||
const currentDate = new Date(currentTime);
|
|
||||||
const isYesterday =
|
|
||||||
lastCheckInDate.getFullYear() === currentDate.getFullYear() &&
|
|
||||||
lastCheckInDate.getMonth() === currentDate.getMonth() &&
|
|
||||||
lastCheckInDate.getDate() === currentDate.getDate() - 1;
|
|
||||||
if (!isSameDay) {
|
|
||||||
if (isYesterday) {
|
|
||||||
userData.streakDays += 1;
|
|
||||||
} else if (userData.lastCheckInTime > 0) {
|
|
||||||
userData.streakDays = 1;
|
|
||||||
} else {
|
|
||||||
userData.streakDays = 1;
|
|
||||||
}
|
|
||||||
userData.totalCheckins += 1;
|
|
||||||
if (isEarlyBird) {
|
|
||||||
userData.earlyBirdCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userData.lastCheckInTime = currentTime;
|
|
||||||
userData.username = username;
|
|
||||||
checkInStorage.value.users[userId] = userData;
|
|
||||||
checkInStorage.value.lastCheckIn[userId] = currentTime;
|
|
||||||
}
|
|
||||||
if (roomId.value) {
|
|
||||||
const checkInData = {
|
const checkInData = {
|
||||||
checkin: {
|
checkin: {
|
||||||
points: checkInConfig.value.points,
|
points: checkInResult.points,
|
||||||
bonusPoints: isEarlyBird ? bonusPoints : 0,
|
consecutiveDays: checkInResult.consecutiveDays,
|
||||||
totalPoints: pointsEarned,
|
todayRank: checkInResult.todayRank,
|
||||||
userPoints: point,
|
time: new Date()
|
||||||
isEarlyBird: isEarlyBird,
|
|
||||||
time: new Date(currentTime),
|
|
||||||
cooldownSeconds: checkInConfig.value.cooldownSeconds
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (checkInConfig.value.sendReply) {
|
|
||||||
|
// 执行回复动作
|
||||||
const successContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU, checkInData);
|
const successContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU, checkInData);
|
||||||
const action = isEarlyBird ? checkInConfig.value.earlyBird.successAction : checkInConfig.value.successAction;
|
const action = isEarlyBird && checkInConfig.value.earlyBird.enabled
|
||||||
|
? checkInConfig.value.earlyBird.successAction
|
||||||
|
: checkInConfig.value.successAction;
|
||||||
|
|
||||||
executeActions(
|
executeActions(
|
||||||
[action],
|
[action],
|
||||||
event,
|
event,
|
||||||
@@ -277,37 +143,52 @@ export function useCheckIn(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示签到成功通知
|
||||||
window.$notification.success({
|
window.$notification.success({
|
||||||
title: '签到成功',
|
title: '签到成功',
|
||||||
description: `${username} 完成签到, 获得 ${pointsEarned} 积分, 累计签到 ${checkInStorage.value.users[userId].totalCheckins} 次`,
|
description: `${username} 完成签到, 获得 ${checkInResult.points} 积分, 连续签到 ${checkInResult.consecutiveDays} 天`,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 签到失败 - 今天已经签到过
|
||||||
|
if (roomId.value && checkInConfig.value.sendReply) {
|
||||||
|
const cooldownContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU);
|
||||||
|
|
||||||
|
executeActions(
|
||||||
|
[checkInConfig.value.cooldownAction],
|
||||||
|
event,
|
||||||
|
TriggerType.DANMAKU,
|
||||||
|
roomId.value,
|
||||||
|
runtimeState,
|
||||||
|
{ sendLiveDanmaku: sendDanmaku },
|
||||||
|
{
|
||||||
|
customContextBuilder: () => cooldownContext
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示签到失败通知
|
||||||
|
window.$notification.info({
|
||||||
|
title: '签到提示',
|
||||||
|
description: checkInResult.message || `${username} 重复签到, 已忽略`,
|
||||||
duration: 5000
|
duration: 5000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CheckIn] 处理签到失败:', error);
|
console.error('[CheckIn] 处理签到失败:', error);
|
||||||
|
window.$notification.error({
|
||||||
|
title: '签到错误',
|
||||||
|
description: `签到请求失败:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听直播开始事件
|
|
||||||
function onLiveStart() {
|
|
||||||
// 直播开始时记录开始时间,用于早鸟奖励计算
|
|
||||||
if (isLive.value && !liveStartTime.value) {
|
|
||||||
liveStartTime.value = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听直播结束事件
|
|
||||||
function onLiveEnd() {
|
|
||||||
// 直播结束时清空早鸟奖励的时间记录
|
|
||||||
liveStartTime.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkInConfig,
|
checkInConfig,
|
||||||
checkInStorage,
|
processCheckIn
|
||||||
processCheckIn,
|
|
||||||
onLiveStart,
|
|
||||||
onLiveEnd
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +205,7 @@ export function createCheckInAutoActions(): AutoActionItem[] {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
triggerType: TriggerType.DANMAKU,
|
triggerType: TriggerType.DANMAKU,
|
||||||
actionType: ActionType.SEND_DANMAKU,
|
actionType: ActionType.SEND_DANMAKU,
|
||||||
template: '@{{user.name}} 签到成功,获得 {{points}} 积分',
|
template: '@{{user.name}} 签到成功,获得 {{checkin.points}} 积分',
|
||||||
priority: Priority.NORMAL,
|
priority: Priority.NORMAL,
|
||||||
logicalExpression: '',
|
logicalExpression: '',
|
||||||
ignoreCooldown: false,
|
ignoreCooldown: false,
|
||||||
@@ -344,7 +225,7 @@ export function createCheckInAutoActions(): AutoActionItem[] {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
triggerType: TriggerType.DANMAKU,
|
triggerType: TriggerType.DANMAKU,
|
||||||
actionType: ActionType.SEND_DANMAKU,
|
actionType: ActionType.SEND_DANMAKU,
|
||||||
template: '@{{user.name}} 早鸟签到成功,获得 {{totalPoints}} 积分',
|
template: '@{{user.name}} 早鸟签到成功,获得 {{checkin.points}} 积分',
|
||||||
priority: Priority.HIGH,
|
priority: Priority.HIGH,
|
||||||
logicalExpression: '',
|
logicalExpression: '',
|
||||||
ignoreCooldown: false,
|
ignoreCooldown: false,
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
// 导入 Vue 和 Pinia 相关函数
|
|
||||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
// 导入 API 模型和类型
|
|
||||||
import { useAccount } from '@/api/account.js';
|
import { useAccount } from '@/api/account.js';
|
||||||
import { EventDataTypes, EventModel } from '@/api/api-models.js';
|
import { EventDataTypes, EventModel } from '@/api/api-models.js';
|
||||||
import { useDanmakuClient } from '@/store/useDanmakuClient.js';
|
import { useDanmakuClient } from '@/store/useDanmakuClient.js';
|
||||||
import { useBiliFunction } from './useBiliFunction.js';
|
import { useBiliFunction } from './useBiliFunction.js';
|
||||||
// 导入 VueUse 工具库
|
|
||||||
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
|
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
|
||||||
// 导入自动操作相关的类型和工具函数
|
|
||||||
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
|
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
|
||||||
import {
|
import {
|
||||||
ActionType,
|
ActionType,
|
||||||
@@ -24,13 +20,9 @@ import {
|
|||||||
createDefaultRuntimeState,
|
createDefaultRuntimeState,
|
||||||
getRandomTemplate
|
getRandomTemplate
|
||||||
} from './autoAction/utils';
|
} from './autoAction/utils';
|
||||||
// 导入 actionUtils 工具函数
|
|
||||||
// 导入 nanoid 用于生成唯一 ID
|
|
||||||
// 导入开发环境判断标志
|
|
||||||
import { isDev } from '@/data/constants.js';
|
import { isDev } from '@/data/constants.js';
|
||||||
|
|
||||||
// 导入所有自动操作子模块
|
import { usePointStore } from '@/store/usePointStore';
|
||||||
import { usePointStore } from '@/store/usePointStore'; // 修正导入路径
|
|
||||||
import { useAutoReply } from './autoAction/modules/autoReply';
|
import { useAutoReply } from './autoAction/modules/autoReply';
|
||||||
import { useEntryWelcome } from './autoAction/modules/entryWelcome';
|
import { useEntryWelcome } from './autoAction/modules/entryWelcome';
|
||||||
import { useFollowThank } from './autoAction/modules/followThank';
|
import { useFollowThank } from './autoAction/modules/followThank';
|
||||||
@@ -41,31 +33,29 @@ import { useSuperChatThank } from './autoAction/modules/superChatThank';
|
|||||||
import { useCheckIn } from './autoAction/modules/checkin';
|
import { useCheckIn } from './autoAction/modules/checkin';
|
||||||
|
|
||||||
|
|
||||||
// 定义名为 'autoAction' 的 Pinia store
|
|
||||||
export const useAutoAction = defineStore('autoAction', () => {
|
export const useAutoAction = defineStore('autoAction', () => {
|
||||||
// 获取 Pinia store 实例
|
const danmakuClient = useDanmakuClient();
|
||||||
const danmakuClient = useDanmakuClient(); // 弹幕客户端
|
const biliFunc = useBiliFunction();
|
||||||
const biliFunc = useBiliFunction(); // B站相关功能函数
|
const account = useAccount();
|
||||||
const account = useAccount(); // 账户信息,用于获取房间ID和直播状态
|
const pointStore = usePointStore();
|
||||||
const pointStore = usePointStore(); // 积分 Store
|
|
||||||
|
|
||||||
// --- 共享状态 ---
|
// 共享状态
|
||||||
const isLive = computed(() => account.value.streamerInfo?.isStreaming ?? false); // 获取直播状态
|
const isLive = computed(() => account.value.streamerInfo?.isStreaming ?? false);
|
||||||
const roomId = computed(() => isDev ? 1294406 : account.value.streamerInfo?.roomId); // 获取房间ID (开发环境使用固定ID)
|
const roomId = computed(() => isDev ? 1294406 : account.value.streamerInfo?.roomId);
|
||||||
const isTianXuanActive = ref(false); // 天选时刻活动状态
|
const isTianXuanActive = ref(false);
|
||||||
const liveStartTime = ref<number | null>(null); // 直播开始时间戳
|
const liveStartTime = ref<number | null>(null);
|
||||||
|
|
||||||
// --- 存储所有自动操作项 (使用 IndexedDB 持久化) ---
|
// 存储自动操作项
|
||||||
const { data: autoActions, isFinished: isActionsLoaded } = useIDBKeyval<AutoActionItem[]>('autoAction.items', [], {
|
const { data: autoActions, isFinished: isActionsLoaded } = useIDBKeyval<AutoActionItem[]>('autoAction.items', [], {
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('[AutoAction] IDB 错误 (项目):', err); // 报告 IndexedDB 错误
|
console.error('[AutoAction] IDB 错误 (项目):', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 运行时状态 (非持久化) ---
|
// 运行时状态
|
||||||
const runtimeState = ref<RuntimeState>(createDefaultRuntimeState());
|
const runtimeState = ref<RuntimeState>(createDefaultRuntimeState());
|
||||||
|
|
||||||
// --- 添加触发类型启用状态持久化 ---
|
// 触发类型启用状态
|
||||||
const { data: enabledTriggerTypes, isFinished: isTriggersLoaded } = useIDBKeyval<Record<TriggerType, boolean>>('autoAction.enabledTriggers', {
|
const { data: enabledTriggerTypes, isFinished: isTriggersLoaded } = useIDBKeyval<Record<TriggerType, boolean>>('autoAction.enabledTriggers', {
|
||||||
[TriggerType.DANMAKU]: true,
|
[TriggerType.DANMAKU]: true,
|
||||||
[TriggerType.GIFT]: true,
|
[TriggerType.GIFT]: true,
|
||||||
@@ -75,99 +65,77 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
[TriggerType.SCHEDULED]: true,
|
[TriggerType.SCHEDULED]: true,
|
||||||
[TriggerType.SUPER_CHAT]: true
|
[TriggerType.SUPER_CHAT]: true
|
||||||
}, {
|
}, {
|
||||||
onError: (err) => console.error('[AutoAction] IDB 错误 (触发类型):', err) // 报告 IndexedDB 错误
|
onError: (err) => console.error('[AutoAction] IDB 错误 (触发类型):', err)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置触发类型启用状态
|
* 设置触发类型启用状态
|
||||||
* @param triggerType 触发类型
|
|
||||||
* @param enabled 是否启用
|
|
||||||
*/
|
*/
|
||||||
function setTriggerTypeEnabled(triggerType: TriggerType, enabled: boolean) {
|
function setTriggerTypeEnabled(triggerType: TriggerType, enabled: boolean) {
|
||||||
if (enabledTriggerTypes.value) {
|
if (enabledTriggerTypes.value) {
|
||||||
enabledTriggerTypes.value[triggerType] = enabled;
|
enabledTriggerTypes.value[triggerType] = enabled;
|
||||||
|
|
||||||
// 如果是定时任务类型,且状态改变,则需要相应处理定时器
|
|
||||||
if (triggerType === TriggerType.SCHEDULED) {
|
if (triggerType === TriggerType.SCHEDULED) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// 启用时,启动相关定时器
|
|
||||||
startIndividualScheduledActions();
|
startIndividualScheduledActions();
|
||||||
startGlobalTimer();
|
startGlobalTimer();
|
||||||
} else {
|
} else {
|
||||||
// 禁用时,停止相关定时器
|
|
||||||
stopAllIndividualScheduledActions();
|
stopAllIndividualScheduledActions();
|
||||||
// 移除 stopGlobalTimer() 调用,让计时器继续运行,但回调会提前返回
|
|
||||||
// stopGlobalTimer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 全局定时器设置 (使用 IndexedDB 持久化) ---
|
// 全局定时器设置
|
||||||
const { data: globalIntervalSeconds, isFinished: isIntervalLoaded } = useIDBKeyval<number>('autoAction.globalInterval', 300, {
|
const { data: globalIntervalSeconds, isFinished: isIntervalLoaded } = useIDBKeyval<number>('autoAction.globalInterval', 300, {
|
||||||
onError: (err) => console.error('[AutoAction] IDB 错误 (间隔):', err) // 报告 IndexedDB 错误
|
onError: (err) => console.error('[AutoAction] IDB 错误 (间隔):', err)
|
||||||
});
|
});
|
||||||
const { data: globalSchedulingMode, isFinished: isModeLoaded } = useIDBKeyval<'random' | 'sequential'>('autoAction.globalMode', 'random', {
|
const { data: globalSchedulingMode, isFinished: isModeLoaded } = useIDBKeyval<'random' | 'sequential'>('autoAction.globalMode', 'random', {
|
||||||
onError: (err) => console.error('[AutoAction] IDB 错误 (模式):', err) // 报告 IndexedDB 错误
|
onError: (err) => console.error('[AutoAction] IDB 错误 (模式):', err)
|
||||||
});
|
});
|
||||||
// 持久化上次全局顺序执行的索引 (使用 IndexedDB 持久化)
|
|
||||||
const { data: lastGlobalActionIndex, isFinished: isIndexLoaded } = useIDBKeyval<number>('autoAction.lastGlobalIndex', -1, {
|
const { data: lastGlobalActionIndex, isFinished: isIndexLoaded } = useIDBKeyval<number>('autoAction.lastGlobalIndex', -1, {
|
||||||
onError: (err) => console.error('[AutoAction] IDB 错误 (上次索引):', err) // 报告 IndexedDB 错误
|
onError: (err) => console.error('[AutoAction] IDB 错误 (上次索引):', err)
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalTimer = ref<any | null>(null); // 单个全局定时器实例
|
const globalTimer = ref<any | null>(null);
|
||||||
|
|
||||||
// --- 全局定时器逻辑 ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局定时器触发处理函数
|
* 全局定时器触发处理函数
|
||||||
*/
|
*/
|
||||||
function handleGlobalTimerTick() {
|
function handleGlobalTimerTick() {
|
||||||
// 1. 基本状态检查 (如果条件不满足,返回但不停止计时器)
|
|
||||||
if (!roomId.value || !isActionsLoaded.value) {
|
if (!roomId.value || !isActionsLoaded.value) {
|
||||||
console.warn('[AutoAction] 全局定时器触发跳过: 房间ID或操作项未就绪.');
|
console.warn('[AutoAction] 全局定时器触发跳过: 房间ID或操作项未就绪.');
|
||||||
// Schedule next tick? No, rely on restart when ready.
|
return;
|
||||||
return; // <- Changed from stopGlobalTimer()
|
|
||||||
}
|
}
|
||||||
// 检查触发类型是否启用
|
|
||||||
if (!enabledTriggerTypes.value || !enabledTriggerTypes.value[TriggerType.SCHEDULED]) {
|
if (!enabledTriggerTypes.value || !enabledTriggerTypes.value[TriggerType.SCHEDULED]) {
|
||||||
console.log('[AutoAction] 全局定时器触发跳过: 定时任务类型已禁用.');
|
console.log('[AutoAction] 全局定时器触发跳过: 定时任务类型已禁用.');
|
||||||
// Schedule next tick? No.
|
return;
|
||||||
return; // <- Changed from stopGlobalTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 筛选出当前*符合条件*的已启用操作
|
|
||||||
const eligibleActions = autoActions.value.filter(action =>
|
const eligibleActions = autoActions.value.filter(action =>
|
||||||
action.triggerType === TriggerType.SCHEDULED &&
|
action.triggerType === TriggerType.SCHEDULED &&
|
||||||
action.enabled &&
|
action.enabled &&
|
||||||
action.triggerConfig.useGlobalTimer && // 必须选择使用全局定时器
|
action.triggerConfig.useGlobalTimer &&
|
||||||
(!action.triggerConfig.onlyDuringLive || isLive.value) && // 检查是否仅直播时触发
|
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value) // 检查是否忽略天选时刻
|
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. 执行操作 (仅当有符合条件的任务时)
|
|
||||||
if (eligibleActions.length > 0) {
|
if (eligibleActions.length > 0) {
|
||||||
let actionToExecute: AutoActionItem | null = null;
|
let actionToExecute: AutoActionItem | null = null;
|
||||||
if (globalSchedulingMode.value === 'random') {
|
if (globalSchedulingMode.value === 'random') {
|
||||||
// 随机模式:随机选择一个
|
|
||||||
const randomIndex = Math.floor(Math.random() * eligibleActions.length);
|
const randomIndex = Math.floor(Math.random() * eligibleActions.length);
|
||||||
actionToExecute = eligibleActions[randomIndex];
|
actionToExecute = eligibleActions[randomIndex];
|
||||||
} else {
|
} else {
|
||||||
// 顺序模式:按顺序循环选择
|
|
||||||
lastGlobalActionIndex.value = (lastGlobalActionIndex.value + 1) % eligibleActions.length;
|
lastGlobalActionIndex.value = (lastGlobalActionIndex.value + 1) % eligibleActions.length;
|
||||||
actionToExecute = eligibleActions[lastGlobalActionIndex.value];
|
actionToExecute = eligibleActions[lastGlobalActionIndex.value];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionToExecute) {
|
if (actionToExecute) {
|
||||||
// 构建执行上下文并执行选中的操作
|
|
||||||
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
||||||
// 手动执行定时任务
|
|
||||||
const template = getRandomTemplate(actionToExecute.template);
|
const template = getRandomTemplate(actionToExecute.template);
|
||||||
if (template && roomId.value) {
|
if (template && roomId.value) {
|
||||||
const formattedContent = evaluateTemplateExpressions(template, context);
|
const formattedContent = evaluateTemplateExpressions(template, context);
|
||||||
// 更新执行时间
|
|
||||||
runtimeState.value.lastExecutionTime[actionToExecute.id] = Date.now();
|
runtimeState.value.lastExecutionTime[actionToExecute.id] = Date.now();
|
||||||
// 发送弹幕
|
|
||||||
if (actionToExecute.actionConfig.delaySeconds && actionToExecute.actionConfig.delaySeconds > 0) {
|
if (actionToExecute.actionConfig.delaySeconds && actionToExecute.actionConfig.delaySeconds > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
biliFunc.sendLiveDanmaku(roomId.value!, formattedContent).catch(err => console.error("[AutoAction] 发送弹幕失败:", err));
|
biliFunc.sendLiveDanmaku(roomId.value!, formattedContent).catch(err => console.error("[AutoAction] 发送弹幕失败:", err));
|
||||||
@@ -178,57 +146,40 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有符合条件的任务,跳过本次执行,但定时器继续
|
|
||||||
console.log('[AutoAction] 当前没有符合条件的全局定时任务可执行,跳过本次执行。');
|
console.log('[AutoAction] 当前没有符合条件的全局定时任务可执行,跳过本次执行。');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 安排 *下一次* 触发 (只要有任务配置了全局定时器且间隔有效)
|
|
||||||
const intervalMs = globalIntervalSeconds.value * 1000;
|
const intervalMs = globalIntervalSeconds.value * 1000;
|
||||||
if (intervalMs > 0) {
|
if (intervalMs > 0) {
|
||||||
// 确保在设置新的定时器之前清除任何旧的句柄
|
|
||||||
if (globalTimer.value) {
|
if (globalTimer.value) {
|
||||||
clearTimeout(globalTimer.value);
|
clearTimeout(globalTimer.value);
|
||||||
}
|
}
|
||||||
globalTimer.value = setTimeout(handleGlobalTimerTick, intervalMs);
|
globalTimer.value = setTimeout(handleGlobalTimerTick, intervalMs);
|
||||||
runtimeState.value.globalTimerStartTime = Date.now(); // 记录下一次间隔的开始时间
|
runtimeState.value.globalTimerStartTime = Date.now();
|
||||||
} else {
|
} else {
|
||||||
console.warn('[AutoAction] 全局定时器间隔无效,无法安排下一次触发。');
|
console.warn('[AutoAction] 全局定时器间隔无效,无法安排下一次触发。');
|
||||||
// 不停止定时器,等待间隔被修正后由 restartGlobalTimer 恢复
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动全局定时器 (如果尚未运行且有需要)
|
* 启动全局定时器
|
||||||
* 仅安排 *第一次* 触发
|
|
||||||
*/
|
*/
|
||||||
function startGlobalTimer() {
|
function startGlobalTimer() {
|
||||||
// 如果定时器已在运行或操作尚未加载完成,则不执行
|
|
||||||
if (globalTimer.value || !isActionsLoaded.value) return;
|
if (globalTimer.value || !isActionsLoaded.value) return;
|
||||||
|
if (!enabledTriggerTypes.value || !enabledTriggerTypes.value[TriggerType.SCHEDULED]) return;
|
||||||
|
|
||||||
// 如果定时任务类型被禁用,则不启动定时器
|
|
||||||
// (handleGlobalTimerTick 会处理返回,这里不需要停止)
|
|
||||||
if (!enabledTriggerTypes.value || !enabledTriggerTypes.value[TriggerType.SCHEDULED]) return; // <- Added enabledTriggerTypes check
|
|
||||||
|
|
||||||
// 检查是否有任何启用的定时任务需要全局定时器
|
|
||||||
const needsGlobalTimer = autoActions.value.some(action =>
|
const needsGlobalTimer = autoActions.value.some(action =>
|
||||||
action.triggerType === TriggerType.SCHEDULED &&
|
action.triggerType === TriggerType.SCHEDULED &&
|
||||||
// action.enabled && // Don't require enabled here, just configured
|
|
||||||
action.triggerConfig.useGlobalTimer
|
action.triggerConfig.useGlobalTimer
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果需要全局定时器且间隔时间有效
|
|
||||||
if (needsGlobalTimer && globalIntervalSeconds.value > 0) {
|
if (needsGlobalTimer && globalIntervalSeconds.value > 0) {
|
||||||
// 这里只 *安排* 第一次触发,`handleGlobalTimerTick` 会处理后续的触发
|
|
||||||
const intervalMs = globalIntervalSeconds.value * 1000;
|
const intervalMs = globalIntervalSeconds.value * 1000;
|
||||||
// console.log(`[AutoAction] 安排首次全局定时器触发于 ${globalIntervalSeconds.value} 秒后`); // 移除调试日志
|
|
||||||
// 以防万一先清除旧的定时器 (例如,快速切换状态)
|
|
||||||
if (globalTimer.value) clearTimeout(globalTimer.value);
|
if (globalTimer.value) clearTimeout(globalTimer.value);
|
||||||
globalTimer.value = setTimeout(handleGlobalTimerTick, intervalMs);
|
globalTimer.value = setTimeout(handleGlobalTimerTick, intervalMs);
|
||||||
runtimeState.value.globalTimerStartTime = Date.now(); // 记录首次间隔的开始时间
|
runtimeState.value.globalTimerStartTime = Date.now();
|
||||||
} else {
|
} else {
|
||||||
// 如果没有任务配置需要全局定时器,或者间隔无效,则确保停止
|
stopGlobalTimer();
|
||||||
// console.log('[AutoAction] 无操作需要全局定时器或间隔无效.'); // 移除信息日志
|
|
||||||
stopGlobalTimer(); // 保持这个停止调用
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +191,8 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
console.log('[AutoAction] 停止全局定时器.');
|
console.log('[AutoAction] 停止全局定时器.');
|
||||||
clearTimeout(globalTimer.value);
|
clearTimeout(globalTimer.value);
|
||||||
globalTimer.value = null;
|
globalTimer.value = null;
|
||||||
lastGlobalActionIndex.value = -1; // 重置顺序索引
|
lastGlobalActionIndex.value = -1;
|
||||||
runtimeState.value.globalTimerStartTime = null; // 清除启动时间
|
runtimeState.value.globalTimerStartTime = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,68 +201,51 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
*/
|
*/
|
||||||
function restartGlobalTimer() {
|
function restartGlobalTimer() {
|
||||||
stopGlobalTimer();
|
stopGlobalTimer();
|
||||||
// 确保操作加载完成后再启动
|
|
||||||
if (isActionsLoaded.value) {
|
if (isActionsLoaded.value) {
|
||||||
startGlobalTimer();
|
startGlobalTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 独立定时任务管理 ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止指定的独立定时器
|
* 停止指定的独立定时器
|
||||||
* @param actionId 操作项ID
|
|
||||||
*/
|
*/
|
||||||
function stopIndividualTimer(actionId: string) {
|
function stopIndividualTimer(actionId: string) {
|
||||||
const timer = runtimeState.value.scheduledTimers[actionId];
|
const timer = runtimeState.value.scheduledTimers[actionId];
|
||||||
if (timer) {
|
if (timer) {
|
||||||
// console.log(`[AutoAction] 停止独立定时器: ${actionId}`); // 移除调试日志
|
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
delete runtimeState.value.scheduledTimers[actionId];
|
delete runtimeState.value.scheduledTimers[actionId];
|
||||||
delete runtimeState.value.timerStartTimes[actionId]; // 清除启动时间记录
|
delete runtimeState.value.timerStartTimes[actionId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为指定的独立定时任务启动定时器
|
* 为指定的独立定时任务启动定时器
|
||||||
* @param action 操作项配置
|
|
||||||
*/
|
*/
|
||||||
function startIndividualTimer(action: AutoActionItem) {
|
function startIndividualTimer(action: AutoActionItem) {
|
||||||
// 如果定时器已存在、操作未启用或操作使用全局定时器,则不启动
|
|
||||||
if (runtimeState.value.scheduledTimers[action.id] || !action.enabled || action.triggerConfig.useGlobalTimer) return;
|
if (runtimeState.value.scheduledTimers[action.id] || !action.enabled || action.triggerConfig.useGlobalTimer) return;
|
||||||
|
|
||||||
// 获取或设置默认间隔时间
|
|
||||||
const intervalSeconds = action.triggerConfig.intervalSeconds || 300;
|
const intervalSeconds = action.triggerConfig.intervalSeconds || 300;
|
||||||
if (intervalSeconds <= 0) return; // 间隔无效
|
if (intervalSeconds <= 0) return;
|
||||||
|
|
||||||
const intervalMs = intervalSeconds * 1000;
|
const intervalMs = intervalSeconds * 1000;
|
||||||
// console.log(`[AutoAction] 启动独立定时器: ${action.name} (${action.id})`); // 移除调试日志
|
|
||||||
|
|
||||||
// 定义定时器触发时执行的函数
|
|
||||||
const timerFunc = () => {
|
const timerFunc = () => {
|
||||||
// 获取最新的操作状态,以防在此期间发生变化
|
|
||||||
const currentAction = autoActions.value.find(a => a.id === action.id);
|
const currentAction = autoActions.value.find(a => a.id === action.id);
|
||||||
if (!currentAction) {
|
if (!currentAction) {
|
||||||
// 如果操作已被删除,停止定时器
|
|
||||||
stopIndividualTimer(action.id);
|
stopIndividualTimer(action.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 在执行前再次检查条件
|
|
||||||
const shouldExecute = currentAction.enabled &&
|
const shouldExecute = currentAction.enabled &&
|
||||||
!currentAction.triggerConfig.useGlobalTimer && // 确认仍未使用全局定时器
|
!currentAction.triggerConfig.useGlobalTimer &&
|
||||||
(!currentAction.triggerConfig.onlyDuringLive || isLive.value) &&
|
(!currentAction.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||||
(!currentAction.triggerConfig.ignoreTianXuan || !isTianXuanActive.value);
|
(!currentAction.triggerConfig.ignoreTianXuan || !isTianXuanActive.value);
|
||||||
|
|
||||||
if (shouldExecute) {
|
if (shouldExecute) {
|
||||||
// 构建上下文并执行操作
|
|
||||||
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
||||||
// 手动执行定时任务
|
|
||||||
const template = getRandomTemplate(currentAction.template);
|
const template = getRandomTemplate(currentAction.template);
|
||||||
if (template && roomId.value) {
|
if (template && roomId.value) {
|
||||||
const formattedContent = evaluateTemplateExpressions(template, context);
|
const formattedContent = evaluateTemplateExpressions(template, context);
|
||||||
// 更新执行时间
|
|
||||||
runtimeState.value.lastExecutionTime[currentAction.id] = Date.now();
|
runtimeState.value.lastExecutionTime[currentAction.id] = Date.now();
|
||||||
// 发送弹幕
|
|
||||||
if (currentAction.actionConfig.delaySeconds && currentAction.actionConfig.delaySeconds > 0) {
|
if (currentAction.actionConfig.delaySeconds && currentAction.actionConfig.delaySeconds > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
biliFunc.sendLiveDanmaku(roomId.value!, formattedContent).catch(err => console.error("[AutoAction] 发送弹幕失败:", err));
|
biliFunc.sendLiveDanmaku(roomId.value!, formattedContent).catch(err => console.error("[AutoAction] 发送弹幕失败:", err));
|
||||||
@@ -322,19 +256,16 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅当操作仍然启用且未使用全局定时器时,才重新安排下一次触发
|
|
||||||
if (currentAction.enabled && !currentAction.triggerConfig.useGlobalTimer) {
|
if (currentAction.enabled && !currentAction.triggerConfig.useGlobalTimer) {
|
||||||
const rescheduleIntervalMs = (currentAction.triggerConfig.intervalSeconds || 300) * 1000;
|
const rescheduleIntervalMs = (currentAction.triggerConfig.intervalSeconds || 300) * 1000;
|
||||||
runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, rescheduleIntervalMs);
|
runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, rescheduleIntervalMs);
|
||||||
runtimeState.value.timerStartTimes[action.id] = Date.now(); // 重设计时器时更新启动时间
|
runtimeState.value.timerStartTimes[action.id] = Date.now();
|
||||||
} else {
|
} else {
|
||||||
// 如果条件不再满足 (例如,被禁用或切换到全局定时器),停止此独立定时器
|
|
||||||
stopIndividualTimer(action.id);
|
stopIndividualTimer(action.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 首次启动定时器
|
|
||||||
runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, intervalMs);
|
runtimeState.value.scheduledTimers[action.id] = setTimeout(timerFunc, intervalMs);
|
||||||
runtimeState.value.timerStartTimes[action.id] = Date.now(); // 记录首次启动时间
|
runtimeState.value.timerStartTimes[action.id] = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,17 +273,13 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
*/
|
*/
|
||||||
function startIndividualScheduledActions() {
|
function startIndividualScheduledActions() {
|
||||||
if (!roomId.value || !autoActions.value) return;
|
if (!roomId.value || !autoActions.value) return;
|
||||||
|
|
||||||
// 如果定时任务类型被禁用,则不启动任何定时器
|
|
||||||
if (!enabledTriggerTypes.value[TriggerType.SCHEDULED]) return;
|
if (!enabledTriggerTypes.value[TriggerType.SCHEDULED]) return;
|
||||||
|
|
||||||
// 筛选出所有启用且不使用全局定时器的定时任务
|
|
||||||
const individualActions = autoActions.value.filter(action =>
|
const individualActions = autoActions.value.filter(action =>
|
||||||
action.triggerType === TriggerType.SCHEDULED &&
|
action.triggerType === TriggerType.SCHEDULED &&
|
||||||
action.enabled &&
|
action.enabled &&
|
||||||
!action.triggerConfig.useGlobalTimer
|
!action.triggerConfig.useGlobalTimer
|
||||||
);
|
);
|
||||||
// 为每个符合条件的任务启动独立定时器
|
|
||||||
individualActions.forEach(action => startIndividualTimer(action));
|
individualActions.forEach(action => startIndividualTimer(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,11 +288,10 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
*/
|
*/
|
||||||
function stopAllIndividualScheduledActions() {
|
function stopAllIndividualScheduledActions() {
|
||||||
console.log('[AutoAction] 停止所有独立定时器.');
|
console.log('[AutoAction] 停止所有独立定时器.');
|
||||||
// 遍历并停止所有记录在 runtimeState 中的独立定时器
|
|
||||||
Object.keys(runtimeState.value.scheduledTimers).forEach(stopIndividualTimer);
|
Object.keys(runtimeState.value.scheduledTimers).forEach(stopIndividualTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 初始化与事件监听 ---
|
// 初始化与事件监听
|
||||||
let isInited = false
|
let isInited = false
|
||||||
/**
|
/**
|
||||||
* 初始化自动操作系统
|
* 初始化自动操作系统
|
||||||
@@ -375,45 +301,37 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isInited = true;
|
isInited = true;
|
||||||
// 计算属性,判断所有持久化数据是否加载完成
|
|
||||||
const allLoaded = computed(() => isActionsLoaded.value && isIntervalLoaded.value && isModeLoaded.value && isIndexLoaded.value && isTriggersLoaded.value);
|
const allLoaded = computed(() => isActionsLoaded.value && isIntervalLoaded.value && isModeLoaded.value && isIndexLoaded.value && isTriggersLoaded.value);
|
||||||
|
|
||||||
// 监听所有数据加载状态
|
|
||||||
watch(allLoaded, (loaded) => {
|
watch(allLoaded, (loaded) => {
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
console.log('[AutoAction] 所有设置已从 IDB 加载.');
|
console.log('[AutoAction] 所有设置已从 IDB 加载.');
|
||||||
// 确保加载的操作项有默认配置
|
|
||||||
autoActions.value.forEach(action => {
|
autoActions.value.forEach(action => {
|
||||||
if (!action.triggerConfig) action.triggerConfig = {};
|
if (!action.triggerConfig) action.triggerConfig = {};
|
||||||
if (action.triggerType === TriggerType.SCHEDULED) {
|
if (action.triggerType === TriggerType.SCHEDULED) {
|
||||||
// 为定时任务设置默认值(如果缺失)
|
|
||||||
if (action.triggerConfig.useGlobalTimer === undefined) action.triggerConfig.useGlobalTimer = false;
|
if (action.triggerConfig.useGlobalTimer === undefined) action.triggerConfig.useGlobalTimer = false;
|
||||||
if (action.triggerConfig.intervalSeconds === undefined) action.triggerConfig.intervalSeconds = 300;
|
if (action.triggerConfig.intervalSeconds === undefined) action.triggerConfig.intervalSeconds = 300;
|
||||||
if (action.triggerConfig.schedulingMode === undefined) action.triggerConfig.schedulingMode = 'random';
|
if (action.triggerConfig.schedulingMode === undefined) action.triggerConfig.schedulingMode = 'random';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动定时器 (如果有需要)
|
|
||||||
startGlobalTimer();
|
startGlobalTimer();
|
||||||
startIndividualScheduledActions();
|
startIndividualScheduledActions();
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// 监听直播状态变化,处理签到模块的生命周期事件
|
// 监听直播状态变化
|
||||||
watch(isLive, (currentState, prevState) => {
|
watch(isLive, (currentState, prevState) => {
|
||||||
// 直播开始
|
|
||||||
if (currentState && !prevState) {
|
if (currentState && !prevState) {
|
||||||
console.log('[AutoAction] 检测到直播开始,更新签到模块状态');
|
console.log('[AutoAction] 检测到直播开始,更新签到模块状态');
|
||||||
checkInModule.onLiveStart();
|
//checkInModule.onLiveStart();
|
||||||
}
|
}
|
||||||
// 直播结束
|
|
||||||
else if (!currentState && prevState) {
|
else if (!currentState && prevState) {
|
||||||
console.log('[AutoAction] 检测到直播结束,更新签到模块状态');
|
console.log('[AutoAction] 检测到直播结束,更新签到模块状态');
|
||||||
checkInModule.onLiveEnd();
|
//checkInModule.onLiveEnd();
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// 注册事件监听器
|
|
||||||
registerEventListeners();
|
registerEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,14 +353,10 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
* 向弹幕客户端注册事件监听器
|
* 向弹幕客户端注册事件监听器
|
||||||
*/
|
*/
|
||||||
function registerEventListeners() {
|
function registerEventListeners() {
|
||||||
// 检查弹幕客户端连接状态
|
|
||||||
if (danmakuClient.state !== 'connected') {
|
if (danmakuClient.state !== 'connected') {
|
||||||
console.warn('[AutoAction] 弹幕客户端未就绪, 延迟注册监听器.');
|
console.warn('[AutoAction] 弹幕客户端未就绪, 延迟注册监听器.');
|
||||||
// 可选: 等待弹幕客户端发出就绪事件
|
|
||||||
// return;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// 监听各种事件,并交由 processEvent 处理
|
|
||||||
danmakuClient.onEvent('danmaku', (event) => processEvent(event, TriggerType.DANMAKU));
|
danmakuClient.onEvent('danmaku', (event) => processEvent(event, TriggerType.DANMAKU));
|
||||||
danmakuClient.onEvent('gift', (event) => processEvent(event, TriggerType.GIFT));
|
danmakuClient.onEvent('gift', (event) => processEvent(event, TriggerType.GIFT));
|
||||||
danmakuClient.onEvent('guard', (event) => processEvent(event, TriggerType.GUARD));
|
danmakuClient.onEvent('guard', (event) => processEvent(event, TriggerType.GUARD));
|
||||||
@@ -455,50 +369,38 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 操作项管理 (增删改查) ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加一个新的自动操作项
|
* 添加一个新的自动操作项
|
||||||
* @param triggerType 触发器类型
|
|
||||||
* @returns 新创建的操作项
|
|
||||||
*/
|
*/
|
||||||
function addAutoAction(triggerType: TriggerType): AutoActionItem {
|
function addAutoAction(triggerType: TriggerType): AutoActionItem {
|
||||||
const newAction = createDefaultAutoAction(triggerType); // 创建默认操作项
|
const newAction = createDefaultAutoAction(triggerType);
|
||||||
autoActions.value.push(newAction); // 添加到列表
|
autoActions.value.push(newAction);
|
||||||
// 如果是定时任务,根据配置启动相应的定时器
|
|
||||||
if (triggerType === TriggerType.SCHEDULED) {
|
if (triggerType === TriggerType.SCHEDULED) {
|
||||||
if (newAction.triggerConfig.useGlobalTimer) {
|
if (newAction.triggerConfig.useGlobalTimer) {
|
||||||
restartGlobalTimer(); // 可能需要启动或重启全局定时器
|
restartGlobalTimer();
|
||||||
} else {
|
} else {
|
||||||
startIndividualTimer(newAction); // 启动独立的定时器
|
startIndividualTimer(newAction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// console.log('[AutoAction] 已添加:', newAction.name, newAction.id); // 移除调试日志
|
|
||||||
return newAction;
|
return newAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除一个自动操作项
|
* 移除一个自动操作项
|
||||||
* @param id 要移除的操作项 ID
|
|
||||||
*/
|
*/
|
||||||
function removeAutoAction(id: string) {
|
function removeAutoAction(id: string) {
|
||||||
const index = autoActions.value.findIndex(action => action.id === id);
|
const index = autoActions.value.findIndex(action => action.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const removedAction = autoActions.value[index];
|
const removedAction = autoActions.value[index];
|
||||||
// console.log('[AutoAction] 正在移除:', removedAction.name, id); // 移除调试日志
|
|
||||||
|
|
||||||
let needsGlobalTimerAfterRemoval = false;
|
let needsGlobalTimerAfterRemoval = false;
|
||||||
let wasGlobalTimerAction = false;
|
let wasGlobalTimerAction = false;
|
||||||
|
|
||||||
// 如果移除的是定时任务,需要特殊处理定时器
|
|
||||||
if (removedAction.triggerType === TriggerType.SCHEDULED) {
|
if (removedAction.triggerType === TriggerType.SCHEDULED) {
|
||||||
stopIndividualTimer(id); // 停止可能存在的独立定时器
|
stopIndividualTimer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从列表中移除操作项
|
|
||||||
autoActions.value.splice(index, 1);
|
autoActions.value.splice(index, 1);
|
||||||
|
|
||||||
// 如果移除了一个全局定时任务,但还有其他全局任务存在,则重启全局定时器以更新状态
|
|
||||||
if (wasGlobalTimerAction && needsGlobalTimerAfterRemoval) {
|
if (wasGlobalTimerAction && needsGlobalTimerAfterRemoval) {
|
||||||
restartGlobalTimer();
|
restartGlobalTimer();
|
||||||
}
|
}
|
||||||
@@ -507,171 +409,137 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换自动操作项的启用/禁用状态
|
* 切换自动操作项的启用/禁用状态
|
||||||
* @param id 操作项 ID
|
|
||||||
* @param enabled 目标状态 (true 为启用, false 为禁用)
|
|
||||||
*/
|
*/
|
||||||
function toggleAutoAction(id: string, enabled: boolean) {
|
function toggleAutoAction(id: string, enabled: boolean) {
|
||||||
const action = autoActions.value.find(action => action.id === id);
|
const action = autoActions.value.find(action => action.id === id);
|
||||||
if (action) {
|
if (action) {
|
||||||
// console.log(`[AutoAction] 切换 ${action.name} (${id}) 状态为 ${enabled}`); // 移除调试日志
|
|
||||||
action.enabled = enabled;
|
action.enabled = enabled;
|
||||||
|
|
||||||
// 如果是定时任务,需要相应地启动或停止定时器
|
|
||||||
if (action.triggerType === TriggerType.SCHEDULED) {
|
if (action.triggerType === TriggerType.SCHEDULED) {
|
||||||
if (action.triggerConfig.useGlobalTimer) {
|
if (action.triggerConfig.useGlobalTimer) {
|
||||||
// 启用/禁用全局定时任务时,重启全局定时器以确保其根据剩余任务正确运行或停止
|
|
||||||
restartGlobalTimer();
|
restartGlobalTimer();
|
||||||
} else {
|
} else {
|
||||||
// 独立定时任务
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
startIndividualTimer(action); // 尝试启动其独立定时器
|
startIndividualTimer(action);
|
||||||
} else {
|
} else {
|
||||||
stopIndividualTimer(id); // 停止其独立定时器
|
stopIndividualTimer(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 辅助函数与执行逻辑 ---
|
// 天选状态检查
|
||||||
|
|
||||||
// 模拟的天选状态检查函数 (需替换为实际实现)
|
|
||||||
function checkTianXuanStatus() {
|
function checkTianXuanStatus() {
|
||||||
// TODO: 实现检查天选时刻状态的逻辑
|
// TODO: 实现检查天选时刻状态的逻辑
|
||||||
// isTianXuanActive.value = a_real_check();
|
|
||||||
}
|
}
|
||||||
// 定时检查天选状态 (每5分钟)
|
|
||||||
const tianXuanTimer = setInterval(checkTianXuanStatus, 5 * 60 * 1000);
|
const tianXuanTimer = setInterval(checkTianXuanStatus, 5 * 60 * 1000);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理接收到的事件
|
* 处理接收到的事件
|
||||||
* @param event 事件数据
|
|
||||||
* @param triggerType 事件对应的触发器类型
|
|
||||||
*/
|
*/
|
||||||
function processEvent(event: EventModel, triggerType: TriggerType) {
|
function processEvent(event: EventModel, triggerType: TriggerType) {
|
||||||
if (!roomId.value) return; // 房间 ID 无效则跳过
|
if (!roomId.value) return;
|
||||||
// 检查触发类型是否启用
|
|
||||||
|
// 处理签到功能(独立于触发类型启用状态)
|
||||||
|
if (triggerType === TriggerType.DANMAKU) {
|
||||||
|
checkInModule.processCheckIn(event, runtimeState.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他功能依赖触发类型启用状态
|
||||||
if (!enabledTriggerTypes.value[triggerType]) return;
|
if (!enabledTriggerTypes.value[triggerType]) return;
|
||||||
// 根据触发类型调用相应模块的处理函数
|
|
||||||
switch (triggerType) {
|
switch (triggerType) {
|
||||||
case TriggerType.DANMAKU:
|
case TriggerType.DANMAKU:
|
||||||
// 调用弹幕自动回复模块
|
|
||||||
autoReplyModule.onDanmaku(event, autoActions.value, runtimeState.value);
|
autoReplyModule.onDanmaku(event, autoActions.value, runtimeState.value);
|
||||||
// 处理签到功能
|
|
||||||
checkInModule.processCheckIn(event, runtimeState.value);
|
|
||||||
break;
|
break;
|
||||||
case TriggerType.GIFT:
|
case TriggerType.GIFT:
|
||||||
// 调用礼物感谢模块
|
|
||||||
giftThankModule.processGift(event, autoActions.value, runtimeState.value);
|
giftThankModule.processGift(event, autoActions.value, runtimeState.value);
|
||||||
break;
|
break;
|
||||||
case TriggerType.GUARD:
|
case TriggerType.GUARD:
|
||||||
// 调用舰长感谢模块
|
|
||||||
guardPmModule.handleGuardBuy(autoActions.value, event, runtimeState.value);
|
guardPmModule.handleGuardBuy(autoActions.value, event, runtimeState.value);
|
||||||
break;
|
break;
|
||||||
case TriggerType.FOLLOW:
|
case TriggerType.FOLLOW:
|
||||||
// 调用关注感谢模块
|
|
||||||
followThankModule.processFollow(event, autoActions.value, runtimeState.value);
|
followThankModule.processFollow(event, autoActions.value, runtimeState.value);
|
||||||
break;
|
break;
|
||||||
case TriggerType.ENTER:
|
case TriggerType.ENTER:
|
||||||
// 调用入场欢迎模块
|
|
||||||
entryWelcomeModule.processEnter(event, autoActions.value, runtimeState.value);
|
entryWelcomeModule.processEnter(event, autoActions.value, runtimeState.value);
|
||||||
break;
|
break;
|
||||||
case TriggerType.SUPER_CHAT:
|
case TriggerType.SUPER_CHAT:
|
||||||
// 调用醒目留言感谢模块
|
|
||||||
superChatThankModule.processSuperChat(event, autoActions.value, runtimeState.value);
|
superChatThankModule.processSuperChat(event, autoActions.value, runtimeState.value);
|
||||||
break;
|
break;
|
||||||
// 定时任务不在此处理,由定时器调用
|
|
||||||
default:
|
default:
|
||||||
console.warn(`[AutoAction] 未知触发类型: ${triggerType}`);
|
console.warn(`[AutoAction] 未知触发类型: ${triggerType}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取定时任务的计时器信息 (用于显示剩余时间等)
|
* 获取定时任务的计时器信息
|
||||||
* @param actionId 定时任务ID
|
|
||||||
* @returns 计时器信息(包含剩余毫秒数估算),或 null (如果任务不存在、未启用或计时器未运行)
|
|
||||||
*/
|
*/
|
||||||
function getScheduledTimerInfo(actionId: string): { actionId: string; intervalMs: number; remainingMs: number } | null {
|
function getScheduledTimerInfo(actionId: string): { actionId: string; intervalMs: number; remainingMs: number } | null {
|
||||||
// 查找对应的操作项
|
|
||||||
const action = autoActions.value.find(a => a.id === actionId);
|
const action = autoActions.value.find(a => a.id === actionId);
|
||||||
// 必须是已启用的定时任务
|
|
||||||
if (!action || action.triggerType !== TriggerType.SCHEDULED || !action.enabled) {
|
if (!action || action.triggerType !== TriggerType.SCHEDULED || !action.enabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usingGlobal = action.triggerConfig.useGlobalTimer ?? false; // 是否使用全局定时器
|
const usingGlobal = action.triggerConfig.useGlobalTimer ?? false;
|
||||||
let intervalSeconds: number; // 间隔秒数
|
let intervalSeconds: number;
|
||||||
let isActive = false; // 定时器是否在运行
|
let isActive = false;
|
||||||
let startTime: number | null = null; // 定时器本轮启动时间
|
let startTime: number | null = null;
|
||||||
|
|
||||||
if (usingGlobal) {
|
if (usingGlobal) {
|
||||||
// 使用全局定时器
|
|
||||||
intervalSeconds = globalIntervalSeconds.value;
|
intervalSeconds = globalIntervalSeconds.value;
|
||||||
isActive = globalTimer.value !== null; // 全局定时器是否在运行
|
isActive = globalTimer.value !== null;
|
||||||
startTime = runtimeState.value.globalTimerStartTime; // 获取全局定时器的启动时间
|
startTime = runtimeState.value.globalTimerStartTime;
|
||||||
} else {
|
} else {
|
||||||
// 使用独立定时器
|
|
||||||
intervalSeconds = action.triggerConfig.intervalSeconds || 300;
|
intervalSeconds = action.triggerConfig.intervalSeconds || 300;
|
||||||
isActive = !!runtimeState.value.scheduledTimers[actionId]; // 独立定时器是否在运行
|
isActive = !!runtimeState.value.scheduledTimers[actionId];
|
||||||
startTime = runtimeState.value.timerStartTimes[actionId] ?? null; // 获取独立定时器的启动时间
|
startTime = runtimeState.value.timerStartTimes[actionId] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果定时器未激活、间隔无效或没有启动时间,则无法计算剩余时间
|
|
||||||
if (!isActive || intervalSeconds <= 0 || startTime === null) {
|
if (!isActive || intervalSeconds <= 0 || startTime === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalMs = intervalSeconds * 1000; // 间隔毫秒数
|
const intervalMs = intervalSeconds * 1000;
|
||||||
const now = Date.now(); // 当前时间
|
const now = Date.now();
|
||||||
// 计算剩余时间 (确保不为负数)
|
|
||||||
const remainingMs = Math.max(0, startTime + intervalMs - now);
|
const remainingMs = Math.max(0, startTime + intervalMs - now);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionId,
|
actionId,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
remainingMs // 返回计算出的剩余时间
|
remainingMs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在列表中向上或向下移动一个操作项
|
* 在列表中向上或向下移动一个操作项
|
||||||
* 这会影响全局定时器顺序模式下的执行顺序
|
|
||||||
* @param id 要移动的操作项 ID
|
|
||||||
* @param direction 'up' (向上) 或 'down' (向下)
|
|
||||||
*/
|
*/
|
||||||
function moveAction(id: string, direction: 'up' | 'down') {
|
function moveAction(id: string, direction: 'up' | 'down') {
|
||||||
const index = autoActions.value.findIndex(action => action.id === id); // 查找当前索引
|
const index = autoActions.value.findIndex(action => action.id === id);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
console.warn(`[AutoAction] 无法移动操作: 未找到 ID ${id}.`);
|
console.warn(`[AutoAction] 无法移动操作: 未找到 ID ${id}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionToMove = autoActions.value[index]; // 获取要移动的操作项
|
const actionToMove = autoActions.value[index];
|
||||||
|
|
||||||
// 简单的在整个列表中重新排序
|
|
||||||
// 更复杂的逻辑可以只在相同触发类型的操作中排序
|
|
||||||
let newIndex = index;
|
let newIndex = index;
|
||||||
if (direction === 'up' && index > 0) {
|
if (direction === 'up' && index > 0) {
|
||||||
// 向上移动
|
|
||||||
newIndex = index - 1;
|
newIndex = index - 1;
|
||||||
} else if (direction === 'down' && index < autoActions.value.length - 1) {
|
} else if (direction === 'down' && index < autoActions.value.length - 1) {
|
||||||
// 向下移动
|
|
||||||
newIndex = index + 1;
|
newIndex = index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newIndex !== index) { // 如果位置确实改变了
|
if (newIndex !== index) {
|
||||||
// console.log(`[AutoAction] 移动操作 ${actionToMove.name} (${id}) 从 ${index} 到 ${newIndex}`); // 移除调试日志
|
|
||||||
// 从原位置移除,并插入到新位置
|
|
||||||
autoActions.value.splice(index, 1);
|
autoActions.value.splice(index, 1);
|
||||||
autoActions.value.splice(newIndex, 0, actionToMove);
|
autoActions.value.splice(newIndex, 0, actionToMove);
|
||||||
// 如果移动的是使用全局定时器的定时任务,重置全局顺序索引,以便下次重新计算
|
|
||||||
if (actionToMove.triggerType === TriggerType.SCHEDULED && actionToMove.triggerConfig.useGlobalTimer) {
|
if (actionToMove.triggerType === TriggerType.SCHEDULED && actionToMove.triggerConfig.useGlobalTimer) {
|
||||||
lastGlobalActionIndex.value = -1; // 强制在下一次触发时重新计算索引
|
lastGlobalActionIndex.value = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听全局间隔设置的变化,如果变化且有效,则重启全局定时器
|
// 监听全局间隔设置的变化
|
||||||
watch(globalIntervalSeconds, (newInterval, oldInterval) => {
|
watch(globalIntervalSeconds, (newInterval, oldInterval) => {
|
||||||
if (newInterval !== oldInterval && newInterval > 0) {
|
if (newInterval !== oldInterval && newInterval > 0) {
|
||||||
console.log('[AutoAction] 全局间隔已更改, 重启全局定时器.');
|
console.log('[AutoAction] 全局间隔已更改, 重启全局定时器.');
|
||||||
@@ -680,15 +548,13 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算属性:获取下一个将在全局顺序模式下执行的操作
|
* 获取下一个将在全局顺序模式下执行的操作
|
||||||
*/
|
*/
|
||||||
const nextScheduledAction = computed<AutoActionItem | null>(() => {
|
const nextScheduledAction = computed<AutoActionItem | null>(() => {
|
||||||
// 仅在顺序模式下有效,且操作列表存在
|
|
||||||
if (globalSchedulingMode.value !== 'sequential' || !autoActions.value) {
|
if (globalSchedulingMode.value !== 'sequential' || !autoActions.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选出当前符合全局定时器条件的活动操作
|
|
||||||
const eligibleActions = autoActions.value.filter(action =>
|
const eligibleActions = autoActions.value.filter(action =>
|
||||||
action.triggerType === TriggerType.SCHEDULED &&
|
action.triggerType === TriggerType.SCHEDULED &&
|
||||||
action.enabled &&
|
action.enabled &&
|
||||||
@@ -698,19 +564,15 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (eligibleActions.length === 0) {
|
if (eligibleActions.length === 0) {
|
||||||
return null; // 没有符合条件的操作
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算下一个索引
|
|
||||||
// 注意:lastGlobalActionIndex 指向的是 *上次* 执行的操作在当时 eligibleActions 列表中的索引
|
|
||||||
const nextIndex = (lastGlobalActionIndex.value + 1) % eligibleActions.length;
|
const nextIndex = (lastGlobalActionIndex.value + 1) % eligibleActions.length;
|
||||||
|
return eligibleActions[nextIndex];
|
||||||
return eligibleActions[nextIndex]; // 返回下一个将要执行的操作
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动设置下一个在全局顺序模式下执行的操作
|
* 手动设置下一个在全局顺序模式下执行的操作
|
||||||
* @param actionId 要设置为下一个执行的操作ID
|
|
||||||
*/
|
*/
|
||||||
function setNextGlobalAction(actionId: string) {
|
function setNextGlobalAction(actionId: string) {
|
||||||
const targetAction = autoActions.value.find(a => a.id === actionId);
|
const targetAction = autoActions.value.find(a => a.id === actionId);
|
||||||
@@ -725,7 +587,7 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
|
|
||||||
const eligibleActions = autoActions.value.filter(action =>
|
const eligibleActions = autoActions.value.filter(action =>
|
||||||
action.triggerType === TriggerType.SCHEDULED &&
|
action.triggerType === TriggerType.SCHEDULED &&
|
||||||
action.enabled && // 这里需要检查启用状态,只在启用的里面找
|
action.enabled &&
|
||||||
action.triggerConfig.useGlobalTimer &&
|
action.triggerConfig.useGlobalTimer &&
|
||||||
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||||
@@ -738,21 +600,16 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置索引,使其下一次执行 targetIndex
|
|
||||||
lastGlobalActionIndex.value = (targetIndex - 1 + eligibleActions.length) % eligibleActions.length;
|
lastGlobalActionIndex.value = (targetIndex - 1 + eligibleActions.length) % eligibleActions.length;
|
||||||
// 立即重置并重新安排计时器,以便下次tick时执行新指定的任务
|
|
||||||
restartGlobalTimer();
|
restartGlobalTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动触发指定类型的测试逻辑
|
* 手动触发指定类型的测试逻辑
|
||||||
* @param triggerType 要测试的触发类型
|
|
||||||
* @param testUid 测试用的UID(仅用于私信测试)
|
|
||||||
*/
|
*/
|
||||||
function triggerTestActionByType(triggerType: TriggerType, testUid?: number) {
|
function triggerTestActionByType(triggerType: TriggerType, testUid?: number) {
|
||||||
console.log(`[AutoAction Test] 准备测试类型: ${triggerType}`);
|
console.log(`[AutoAction Test] 准备测试类型: ${triggerType}`);
|
||||||
|
|
||||||
// 查找所有属于该类型且已启用的操作 (包括触发器类型本身是否启用)
|
|
||||||
const actionsToTest = autoActions.value.filter(a =>
|
const actionsToTest = autoActions.value.filter(a =>
|
||||||
a.triggerType === triggerType &&
|
a.triggerType === triggerType &&
|
||||||
a.enabled &&
|
a.enabled &&
|
||||||
@@ -767,7 +624,6 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建基础测试事件属性
|
|
||||||
const baseTestEvent: Partial<EventModel> = {
|
const baseTestEvent: Partial<EventModel> = {
|
||||||
uid: testUid || 10000,
|
uid: testUid || 10000,
|
||||||
uname: '测试用户',
|
uname: '测试用户',
|
||||||
@@ -777,13 +633,12 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
time: Math.floor(Date.now() / 1000),
|
time: Math.floor(Date.now() / 1000),
|
||||||
num: 1,
|
num: 1,
|
||||||
price: 0,
|
price: 0,
|
||||||
guard_level: Math.floor(Math.random() * 3) + 1, // 1-3
|
guard_level: Math.floor(Math.random() * 3) + 1,
|
||||||
fans_medal_wearing_status: true,
|
fans_medal_wearing_status: true,
|
||||||
fans_medal_name: '测试牌子',
|
fans_medal_name: '测试牌子',
|
||||||
fans_medal_level: Math.floor(Math.random() * 30) + 1 // 1-10
|
fans_medal_level: Math.floor(Math.random() * 30) + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据不同触发类型创建不同的模拟事件
|
|
||||||
let testEvent: EventModel;
|
let testEvent: EventModel;
|
||||||
|
|
||||||
switch (triggerType) {
|
switch (triggerType) {
|
||||||
@@ -804,13 +659,13 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
} as EventModel;
|
} as EventModel;
|
||||||
break;
|
break;
|
||||||
case TriggerType.GUARD:
|
case TriggerType.GUARD:
|
||||||
const level = Math.floor(Math.random() * 3) + 1; // 1-3
|
const level = Math.floor(Math.random() * 3) + 1;
|
||||||
testEvent = {
|
testEvent = {
|
||||||
...baseTestEvent,
|
...baseTestEvent,
|
||||||
type: EventDataTypes.Guard,
|
type: EventDataTypes.Guard,
|
||||||
msg: '舰长',
|
msg: '舰长',
|
||||||
price: level === 1 ? 19998 : level === 2 ? 1998 : 198,
|
price: level === 1 ? 19998 : level === 2 ? 1998 : 198,
|
||||||
guard_level: level, // 1-3
|
guard_level: level,
|
||||||
} as EventModel;
|
} as EventModel;
|
||||||
break;
|
break;
|
||||||
case TriggerType.FOLLOW:
|
case TriggerType.FOLLOW:
|
||||||
@@ -834,7 +689,6 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
} as EventModel;
|
} as EventModel;
|
||||||
break;
|
break;
|
||||||
case TriggerType.SCHEDULED:
|
case TriggerType.SCHEDULED:
|
||||||
// 对于定时任务,使用特殊的处理方式
|
|
||||||
if (actionsToTest.length > 0) {
|
if (actionsToTest.length > 0) {
|
||||||
const action = actionsToTest[0];
|
const action = actionsToTest[0];
|
||||||
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
||||||
@@ -858,50 +712,46 @@ export const useAutoAction = defineStore('autoAction', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return; // 定时任务不需要继续处理
|
return;
|
||||||
default:
|
default:
|
||||||
console.warn(`[AutoAction Test] 未知的触发类型: ${triggerType}`);
|
console.warn(`[AutoAction Test] 未知的触发类型: ${triggerType}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AutoAction Test] 创建测试事件:`, testEvent);
|
console.log(`[AutoAction Test] 创建测试事件:`, testEvent);
|
||||||
|
|
||||||
// 直接调用processEvent进行测试,将创建的测试事件和触发类型传入
|
|
||||||
processEvent(testEvent, triggerType);
|
processEvent(testEvent, triggerType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 导出 Store 成员 ---
|
|
||||||
return {
|
return {
|
||||||
autoActions, // 所有操作项列表 (ref)
|
autoActions,
|
||||||
runtimeState: computed(() => runtimeState.value), // 运行时状态 (计算属性)
|
runtimeState: computed(() => runtimeState.value),
|
||||||
globalIntervalSeconds, // 全局定时器间隔 (ref from IDB)
|
globalIntervalSeconds,
|
||||||
globalSchedulingMode, // 全局定时器模式 (ref from IDB)
|
globalSchedulingMode,
|
||||||
nextScheduledAction, // 下一个顺序执行的操作 (计算属性)
|
nextScheduledAction,
|
||||||
isLive, // 直播状态 (计算属性)
|
isLive,
|
||||||
isTianXuanActive, // 天选状态 (ref)
|
isTianXuanActive,
|
||||||
enabledTriggerTypes, // 触发类型启用状态
|
enabledTriggerTypes,
|
||||||
checkInModule,
|
checkInModule,
|
||||||
init, // 初始化函数
|
init,
|
||||||
addAutoAction, // 添加操作
|
addAutoAction,
|
||||||
removeAutoAction, // 移除操作
|
removeAutoAction,
|
||||||
toggleAutoAction, // 切换操作启用状态
|
toggleAutoAction,
|
||||||
moveAction, // 移动操作顺序
|
moveAction,
|
||||||
setNextGlobalAction, // 手动设置下一个全局顺序操作
|
setNextGlobalAction,
|
||||||
restartGlobalTimer, // 重启全局定时器
|
restartGlobalTimer,
|
||||||
getScheduledTimerInfo, // 获取定时任务计时器信息
|
getScheduledTimerInfo,
|
||||||
setTriggerTypeEnabled, // 设置触发类型启用状态
|
setTriggerTypeEnabled,
|
||||||
// 暴露独立定时器控制函数 (如果 UI 需要单独控制)
|
|
||||||
startIndividualTimer,
|
startIndividualTimer,
|
||||||
stopIndividualTimer,
|
stopIndividualTimer,
|
||||||
stopAllIndividualScheduledActions,
|
stopAllIndividualScheduledActions,
|
||||||
startIndividualScheduledActions,
|
startIndividualScheduledActions,
|
||||||
triggerTestActionByType, // 新的 action
|
triggerTestActionByType,
|
||||||
};
|
};
|
||||||
});// HMR (热模块替换) 支持
|
});
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useAutoAction, import.meta.hot));
|
import.meta.hot.accept(acceptHMRUpdate(useAutoAction, import.meta.hot));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新导出类型,方便外部使用
|
|
||||||
export { ActionType, AutoActionItem, KeywordMatchType, Priority, TriggerType };
|
export { ActionType, AutoActionItem, KeywordMatchType, Priority, TriggerType };
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useAccount } from "@/api/account";
|
|||||||
import { useBiliCookie } from "./useBiliCookie";
|
import { useBiliCookie } from "./useBiliCookie";
|
||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // 引入 Body
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // 引入 Body
|
||||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||||
import { computed, ref, onUnmounted } from 'vue';
|
import { computed, ref, onUnmounted, h } from 'vue';
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { QueryBiliAPI } from "../data/utils";
|
import { QueryBiliAPI } from "../data/utils";
|
||||||
import { onSendPrivateMessageFailed } from "../data/notification";
|
import { onSendPrivateMessageFailed } from "../data/notification";
|
||||||
import { useSettings } from "./useSettings";
|
import { useSettings } from "./useSettings";
|
||||||
|
import { isDev } from "@/data/constants";
|
||||||
|
|
||||||
// WBI 混合密钥编码表
|
// WBI 混合密钥编码表
|
||||||
const mixinKeyEncTab = [
|
const mixinKeyEncTab = [
|
||||||
@@ -187,7 +188,18 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
|||||||
console.warn("尝试发送空弹幕,已阻止。");
|
console.warn("尝试发送空弹幕,已阻止。");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
roomId = 1294406; // 测试用房间号
|
|
||||||
|
// 开发环境下只显示通知,不实际发送
|
||||||
|
if (isDev) {
|
||||||
|
console.log(`[开发环境] 模拟发送弹幕到房间 ${roomId}: ${message}`);
|
||||||
|
window.$notification.info({
|
||||||
|
title: '开发环境 - 弹幕未实际发送',
|
||||||
|
description: `房间: ${roomId}, 内容: ${message}`,
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const url = "https://api.live.bilibili.com/msg/send";
|
const url = "https://api.live.bilibili.com/msg/send";
|
||||||
const rnd = Math.floor(Date.now() / 1000);
|
const rnd = Math.floor(Date.now() / 1000);
|
||||||
const data = {
|
const data = {
|
||||||
@@ -264,6 +276,17 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开发环境下只显示通知,不实际发送
|
||||||
|
if (isDev) {
|
||||||
|
console.log(`[开发环境] 模拟发送私信到用户 ${receiverId}: ${message}`);
|
||||||
|
window.$notification.info({
|
||||||
|
title: '开发环境 - 私信未实际发送',
|
||||||
|
description: `接收者: ${receiverId}, 内容: ${message}`,
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取WBI密钥(如果还没有)
|
// 获取WBI密钥(如果还没有)
|
||||||
if (!wbiKeys.value) {
|
if (!wbiKeys.value) {
|
||||||
|
|||||||
2
src/components.d.ts
vendored
2
src/components.d.ts
vendored
@@ -25,9 +25,11 @@ declare module 'vue' {
|
|||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
NFlex: typeof import('naive-ui')['NFlex']
|
||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
|
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
|
||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
NImage: typeof import('naive-ui')['NImage']
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NSpace: typeof import('naive-ui')['NSpace']
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function renderContent(content: updateNoteItemContentType): VNode | string | und
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NScrollbar
|
<NScrollbar
|
||||||
style="max-height: 80vh;"
|
style="max-height: 80vh;padding-right: 16px;"
|
||||||
trigger="none"
|
trigger="none"
|
||||||
>
|
>
|
||||||
<NFlex vertical>
|
<NFlex vertical>
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
label: '使用',
|
label: '使用',
|
||||||
value: PointFrom.Use,
|
value: PointFrom.Use,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '签到',
|
||||||
|
value: PointFrom.CheckIn,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
render: (row: ResponsePointHisrotyModel) => {
|
render: (row: ResponsePointHisrotyModel) => {
|
||||||
const get = () => {
|
const get = () => {
|
||||||
@@ -97,6 +101,23 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
)
|
)
|
||||||
case PointFrom.Use:
|
case PointFrom.Use:
|
||||||
return h(NTag, { type: 'warning', bordered: false, size: 'small' }, () => '使用')
|
return h(NTag, { type: 'warning', bordered: false, size: 'small' }, () => '使用')
|
||||||
|
case PointFrom.CheckIn:
|
||||||
|
return h(NFlex, { align: 'center' }, () => [
|
||||||
|
h(NTag, { type: 'success', bordered: false, size: 'small' }, () => '签到'),
|
||||||
|
row.extra?.user
|
||||||
|
? h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
tag: 'a',
|
||||||
|
href: '/@' + row.extra.user?.name,
|
||||||
|
target: '_blank',
|
||||||
|
text: true,
|
||||||
|
type: 'success',
|
||||||
|
},
|
||||||
|
() => row.extra.user?.name,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return h(NFlex, {}, () => get())
|
return h(NFlex, {}, () => get())
|
||||||
@@ -173,40 +194,22 @@ const historyColumn: DataTableColumns<ResponsePointHisrotyModel> = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 无数据时显示提示 -->
|
<!-- 无数据时显示提示 -->
|
||||||
<NEmpty
|
<NEmpty v-if="!histories || histories.length === 0" description="暂无积分历史记录" />
|
||||||
v-if="!histories || histories.length === 0"
|
|
||||||
description="暂无积分历史记录"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 有数据时显示表格 -->
|
<!-- 有数据时显示表格 -->
|
||||||
<NDataTable
|
<NDataTable v-else :columns="historyColumn" :data="histories" :pagination="{
|
||||||
v-else
|
|
||||||
:columns="historyColumn"
|
|
||||||
:data="histories"
|
|
||||||
:pagination="{
|
|
||||||
showSizePicker: true,
|
showSizePicker: true,
|
||||||
pageSizes: [10, 25, 50, 100],
|
pageSizes: [10, 25, 50, 100],
|
||||||
defaultPageSize: 10,
|
defaultPageSize: 10,
|
||||||
size: 'small'
|
size: 'small'
|
||||||
}"
|
}" />
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 商品详情模态框 -->
|
<!-- 商品详情模态框 -->
|
||||||
<NModal
|
<NModal v-model:show="showGoodsModal" preset="card" title="礼物详情 (快照)" style="max-width: 400px; height: auto">
|
||||||
v-model:show="showGoodsModal"
|
|
||||||
preset="card"
|
|
||||||
title="礼物详情 (快照)"
|
|
||||||
style="max-width: 400px; height: auto"
|
|
||||||
>
|
|
||||||
<PointGoodsItem :goods="currentGoods" />
|
<PointGoodsItem :goods="currentGoods" />
|
||||||
<template v-if="currentGoods?.content">
|
<template v-if="currentGoods?.content">
|
||||||
<NDivider>礼物内容</NDivider>
|
<NDivider>礼物内容</NDivider>
|
||||||
<NInput
|
<NInput :value="currentGoods?.content" type="textarea" readonly placeholder="无内容" />
|
||||||
:value="currentGoods?.content"
|
|
||||||
type="textarea"
|
|
||||||
readonly
|
|
||||||
placeholder="无内容"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</NModal>
|
</NModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
import UpdateNoteContainer from "@/components/UpdateNoteContainer.vue";
|
import UpdateNoteContainer from "@/components/UpdateNoteContainer.vue";
|
||||||
import { NButton, NImage, NTag } from "naive-ui";
|
import { NButton, NImage } from "naive-ui";
|
||||||
import { VNode } from "vue";
|
import { VNode } from "vue";
|
||||||
import { FETCH_API } from "./constants";
|
|
||||||
|
|
||||||
export const updateNotes: updateNoteType[] = [
|
export const updateNotes: updateNoteType[] = [
|
||||||
|
{
|
||||||
|
ver: 7,
|
||||||
|
date: '2025.5.1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'optimize',
|
||||||
|
title: '礼物支持置顶和随机key',
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
'积分礼物现在可以对部分进行置顶操作, 置顶的礼物会出现在礼物列表的最前面',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'现在支持为礼物附加key, 可以在兑换礼物之后自动选择一个附加到礼物内容中',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'礼物页面样式优化'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'new',
|
||||||
|
title: '签到功能增加排行榜, 允许仅签到',
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
'签到功能增加排行榜, 可以查看签到天数和签到排名 ',
|
||||||
|
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/25_5_1_1.png', width: 300 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'签到功能增加仅签到功能, 可以只签到不给予积分, 修改设置项',
|
||||||
|
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202025-05-01%20080506.png', width: 300 }),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ver: 6,
|
ver: 6,
|
||||||
date: '2025.4.26',
|
date: '2025.4.26',
|
||||||
@@ -45,7 +79,7 @@ export const updateNotes: updateNoteType[] = [
|
|||||||
content: [
|
content: [
|
||||||
[
|
[
|
||||||
'弹幕姬现在可用,兼容 blivechat 样式',
|
'弹幕姬现在可用,兼容 blivechat 样式',
|
||||||
() => h(NImage, { src: 'https://pan.suki.club/d/vtsuru/imgur/3c5a6f68-1aa4-4b96-a25e-dba2581ac898.png', width: 300 }),
|
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/屏幕截图 2025-05-01 081550.png', width: 300 }),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'大部分功能都和 blivechat 一致, 不过目前还无法提供本地文件访问, 部分css中需要使用图片等本地资源样式的需要等 EventFetcher 更新相关功能后才能使用\r\n',
|
'大部分功能都和 blivechat 一致, 不过目前还无法提供本地文件访问, 部分css中需要使用图片等本地资源样式的需要等 EventFetcher 更新相关功能后才能使用\r\n',
|
||||||
@@ -158,6 +192,10 @@ export function checkUpdateNote() {
|
|||||||
if (savedUpdateNoteVer.value < currentUpdateNoteVer) {
|
if (savedUpdateNoteVer.value < currentUpdateNoteVer) {
|
||||||
window.$dialog.create({
|
window.$dialog.create({
|
||||||
title: '更新日志',
|
title: '更新日志',
|
||||||
|
style: {
|
||||||
|
width: '700px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
},
|
||||||
content: () => h(UpdateNoteContainer),
|
content: () => h(UpdateNoteContainer),
|
||||||
negativeText: '确定',
|
negativeText: '确定',
|
||||||
positiveText: '下次更新前不再提示',
|
positiveText: '下次更新前不再提示',
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const BILI_AUTH_API_URL = BASE_API_URL + 'bili-auth/';
|
|||||||
export const FORUM_API_URL = BASE_API_URL + 'forum/';
|
export const FORUM_API_URL = BASE_API_URL + 'forum/';
|
||||||
export const USER_INDEX_API_URL = BASE_API_URL + 'user-index/';
|
export const USER_INDEX_API_URL = BASE_API_URL + 'user-index/';
|
||||||
export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
|
export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
|
||||||
|
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/';
|
||||||
|
|
||||||
export type TemplateMapType = {
|
export type TemplateMapType = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ export default [
|
|||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'check-in',
|
||||||
|
name: 'user-checkin',
|
||||||
|
component: () => import('@/views/view/CheckInRankingView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '签到排行',
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'video-collect',
|
path: 'video-collect',
|
||||||
name: 'user-video-collect',
|
name: 'user-video-collect',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
Person48Filled,
|
Person48Filled,
|
||||||
VideoAdd20Filled,
|
VideoAdd20Filled,
|
||||||
WindowWrench20Filled,
|
WindowWrench20Filled,
|
||||||
|
CheckmarkCircle24Filled,
|
||||||
} from '@vicons/fluent';
|
} from '@vicons/fluent';
|
||||||
import { BrowsersOutline, 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';
|
||||||
@@ -117,6 +118,11 @@
|
|||||||
key: 'user-goods', icon: renderIcon(BookCoins20Filled),
|
key: 'user-goods', icon: renderIcon(BookCoins20Filled),
|
||||||
show: userInfo.value?.extra?.enableFunctions.includes(FunctionTypes.Point)
|
show: userInfo.value?.extra?.enableFunctions.includes(FunctionTypes.Point)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () => h(RouterLink, { to: { name: 'user-checkin' } }, { default: () => '签到排行' }),
|
||||||
|
key: 'user-checkin', icon: renderIcon(CheckmarkCircle24Filled),
|
||||||
|
show: userInfo.value?.extra?.allowCheckInRanking
|
||||||
|
},
|
||||||
].filter(option => option.show !== false) as MenuOption[]; // 过滤掉 show 为 false 的菜单项
|
].filter(option => option.show !== false) as MenuOption[]; // 过滤掉 show 为 false 的菜单项
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ const defaultSettingPoint: Setting_Point = {
|
|||||||
scPointPercent: 0.1, // SC积分比例 (10%)
|
scPointPercent: 0.1, // SC积分比例 (10%)
|
||||||
giftPointPercent: 0.1, // 礼物积分比例 (10%)
|
giftPointPercent: 0.1, // 礼物积分比例 (10%)
|
||||||
giftAllowType: SettingPointGiftAllowType.All, // 默认允许所有礼物
|
giftAllowType: SettingPointGiftAllowType.All, // 默认允许所有礼物
|
||||||
|
enableCheckIn: false,
|
||||||
|
checkInKeyword: '签到',
|
||||||
|
givePointsForCheckIn: false,
|
||||||
|
baseCheckInPoints: 10,
|
||||||
|
enableConsecutiveBonus: false,
|
||||||
|
bonusPointsPerDay: 2,
|
||||||
|
maxBonusPoints: 0,
|
||||||
|
allowSelfCheckIn: false,
|
||||||
|
requireAuth: false,
|
||||||
|
allowCheckInRanking: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式设置对象
|
// 响应式设置对象
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function getPointHistory() {
|
|||||||
// 根据用户认证状态使用不同的请求参数
|
// 根据用户认证状态使用不同的请求参数
|
||||||
const params = props.user.info.id > 0
|
const params = props.user.info.id > 0
|
||||||
? { authId: props.user.info.id }
|
? { authId: props.user.info.id }
|
||||||
: { id: props.user.info.userId ?? props.user.info.openId }
|
: { id: props.user.info.userId > 0 ? props.user.info.userId : props.user.info.openId }
|
||||||
|
|
||||||
const data = await QueryGetAPI<ResponsePointHisrotyModel[]>(
|
const data = await QueryGetAPI<ResponsePointHisrotyModel[]>(
|
||||||
POINT_API_URL + 'get-user-histories',
|
POINT_API_URL + 'get-user-histories',
|
||||||
|
|||||||
468
src/views/view/CheckInRankingView.vue
Normal file
468
src/views/view/CheckInRankingView.vue
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<template>
|
||||||
|
<div class="checkin-ranking-view">
|
||||||
|
<NSpace vertical>
|
||||||
|
<NCard title="签到排行榜">
|
||||||
|
<template #header-extra>
|
||||||
|
<NSpace>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="timeRange"
|
||||||
|
style="width: 180px"
|
||||||
|
:options="timeRangeOptions"
|
||||||
|
@update:value="loadCheckInRanking"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="userFilter"
|
||||||
|
placeholder="搜索用户"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="loadCheckInRanking"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NSpin :show="isLoading">
|
||||||
|
<div
|
||||||
|
v-if="rankingData.length === 0 && !isLoading"
|
||||||
|
class="empty-data"
|
||||||
|
>
|
||||||
|
<NEmpty description="暂无签到数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义排行榜表格 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="custom-ranking-table"
|
||||||
|
>
|
||||||
|
<!-- 排行榜头部 -->
|
||||||
|
<div class="ranking-header">
|
||||||
|
<div class="ranking-row">
|
||||||
|
<div class="col-rank">
|
||||||
|
排名
|
||||||
|
</div>
|
||||||
|
<div class="col-user">
|
||||||
|
用户
|
||||||
|
</div>
|
||||||
|
<div class="col-days">
|
||||||
|
连续签到
|
||||||
|
</div>
|
||||||
|
<div class="col-monthly">
|
||||||
|
本月签到
|
||||||
|
</div>
|
||||||
|
<div class="col-total">
|
||||||
|
总签到
|
||||||
|
</div>
|
||||||
|
<div class="col-time">
|
||||||
|
最近签到时间
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排行榜内容 -->
|
||||||
|
<div class="ranking-body">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in pagedData"
|
||||||
|
:key="index"
|
||||||
|
class="ranking-row"
|
||||||
|
:class="{'top-three': index < 3}"
|
||||||
|
>
|
||||||
|
<!-- 排名列 -->
|
||||||
|
<div class="col-rank">
|
||||||
|
<div
|
||||||
|
class="rank-number"
|
||||||
|
:class="{
|
||||||
|
'rank-1': index === 0,
|
||||||
|
'rank-2': index === 1,
|
||||||
|
'rank-3': index === 2
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ index + 1 + (pagination.page - 1) * pagination.pageSize }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户列 -->
|
||||||
|
<div class="col-user">
|
||||||
|
<div class="user-name">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.isAuthed"
|
||||||
|
class="user-authed"
|
||||||
|
>
|
||||||
|
已认证
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连续签到列 -->
|
||||||
|
<div class="col-days">
|
||||||
|
<div class="days-count">
|
||||||
|
{{ item.consecutiveDays }}
|
||||||
|
</div>
|
||||||
|
<div class="days-text">
|
||||||
|
天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本月签到列 -->
|
||||||
|
<div class="col-monthly">
|
||||||
|
<div class="count-value">
|
||||||
|
{{ item.monthlyCheckInCount || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="count-text">
|
||||||
|
次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 总签到列 -->
|
||||||
|
<div class="col-total">
|
||||||
|
<div class="count-value">
|
||||||
|
{{ item.totalCheckInCount || 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="count-text">
|
||||||
|
次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 签到时间列 -->
|
||||||
|
<div class="col-time">
|
||||||
|
{{ formatDate(item.lastCheckInTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页控制 -->
|
||||||
|
<div class="ranking-footer">
|
||||||
|
<NPagination
|
||||||
|
v-model:page="pagination.page"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:item-count="filteredRankingData.length"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
show-size-picker
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
|
||||||
|
<div class="ranking-info">
|
||||||
|
<NAlert type="info">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon>
|
||||||
|
<Info24Filled />
|
||||||
|
</NIcon>
|
||||||
|
</template>
|
||||||
|
签到可获得积分,连续签到有额外奖励。排行榜每日更新,发送"{{ checkInKeyword }}"即可参与签到。
|
||||||
|
</NAlert>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckInRankingInfo, UserInfo } from '@/api/api-models';
|
||||||
|
import { QueryGetAPI } from '@/api/query';
|
||||||
|
import { CHECKIN_API_URL } from '@/data/constants';
|
||||||
|
import { Info24Filled } from '@vicons/fluent';
|
||||||
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NEmpty,
|
||||||
|
NIcon,
|
||||||
|
NInput,
|
||||||
|
NPagination,
|
||||||
|
NSelect,
|
||||||
|
NSpace,
|
||||||
|
NSpin,
|
||||||
|
} from 'naive-ui';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
biliInfo: any | undefined
|
||||||
|
userInfo: UserInfo | undefined
|
||||||
|
template?: string | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 状态变量
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const rankingData = ref<CheckInRankingInfo[]>([]);
|
||||||
|
const timeRange = ref<string>('all');
|
||||||
|
const userFilter = ref<string>('');
|
||||||
|
const checkInKeyword = ref('签到'); // 默认签到关键词
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 时间段选项
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ label: '全部时间', value: 'all' },
|
||||||
|
{ label: '今日', value: 'today' },
|
||||||
|
{ label: '本周', value: 'week' },
|
||||||
|
{ label: '本月', value: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 过滤后的排行榜数据
|
||||||
|
const filteredRankingData = computed(() => {
|
||||||
|
let filtered = rankingData.value;
|
||||||
|
|
||||||
|
// 按时间范围筛选
|
||||||
|
if (timeRange.value !== 'all') {
|
||||||
|
const now = new Date();
|
||||||
|
let startTime: Date;
|
||||||
|
|
||||||
|
if (timeRange.value === 'today') {
|
||||||
|
// 今天凌晨
|
||||||
|
startTime = new Date(now);
|
||||||
|
startTime.setHours(0, 0, 0, 0);
|
||||||
|
} else if (timeRange.value === 'week') {
|
||||||
|
// 本周一
|
||||||
|
const dayOfWeek = now.getDay() || 7; // 把周日作为7处理
|
||||||
|
startTime = new Date(now);
|
||||||
|
startTime.setDate(now.getDate() - (dayOfWeek - 1));
|
||||||
|
startTime.setHours(0, 0, 0, 0);
|
||||||
|
} else if (timeRange.value === 'month') {
|
||||||
|
// 本月1号
|
||||||
|
startTime = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = filtered.filter(user => {
|
||||||
|
const checkInTime = new Date(user.lastCheckInTime);
|
||||||
|
return checkInTime >= startTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按用户名筛选
|
||||||
|
if (userFilter.value) {
|
||||||
|
const keyword = userFilter.value.toLowerCase();
|
||||||
|
filtered = filtered.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理分页后的数据
|
||||||
|
const pagedData = computed(() => {
|
||||||
|
const { page, pageSize } = pagination.value;
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
return filteredRankingData.value.slice(startIndex, endIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return `${month}月${day}日 ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载签到排行榜数据
|
||||||
|
async function loadCheckInRanking() {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
// 使用用户视角的签到排行API
|
||||||
|
const response = await QueryGetAPI<CheckInRankingInfo[]>(`${CHECKIN_API_URL}ranking`, {
|
||||||
|
vId: props.userInfo?.id,
|
||||||
|
count: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code === 200) {
|
||||||
|
rankingData.value = response.data;
|
||||||
|
pagination.value.page = 1; // 重置为第一页
|
||||||
|
} else {
|
||||||
|
rankingData.value = [];
|
||||||
|
window.$message?.error?.(`获取签到排行榜失败: ${response.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载签到排行榜失败:', error);
|
||||||
|
rankingData.value = [];
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签到关键词
|
||||||
|
async function fetchCheckInKeyword() {
|
||||||
|
if (!props.userInfo?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取主播的签到关键词设置
|
||||||
|
const response = await QueryGetAPI<{
|
||||||
|
keyword: string
|
||||||
|
isEnabled: boolean
|
||||||
|
requireAuth: boolean
|
||||||
|
}>(`${CHECKIN_API_URL}keyword`, {
|
||||||
|
vId: props.userInfo?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
checkInKeyword.value = response.data.keyword;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取签到关键词失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时获取签到排行和关键词
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCheckInKeyword();
|
||||||
|
loadCheckInRanking();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.checkin-ranking-view {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-data {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义表格样式 */
|
||||||
|
.custom-ranking-table {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 使用官方阴影变量 */
|
||||||
|
box-shadow: var(--n-boxShadow1, 0 1px 2px -2px rgba(0, 0, 0, .24), 0 3px 6px 0 rgba(0, 0, 0, .18), 0 5px 12px 4px rgba(0, 0, 0, .12));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-header {
|
||||||
|
/* 使用官方背景色变量 */
|
||||||
|
background-color: var(--n-tableHeaderColor, rgba(255, 255, 255, 0.06));
|
||||||
|
font-weight: var(--n-fontWeightStrong, 500);
|
||||||
|
color: var(--n-textColor2, rgba(255, 255, 255, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
/* 使用官方分割线变量 */
|
||||||
|
border-bottom: 1px solid var(--n-dividerColor, rgba(255, 255, 255, 0.09));
|
||||||
|
transition: background-color 0.3s var(--n-cubicBezierEaseInOut, cubic-bezier(.4, 0, .2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-body .ranking-row:hover {
|
||||||
|
/* 使用官方悬停背景色变量 */
|
||||||
|
background-color: var(--n-hoverColor, rgba(255, 255, 255, 0.09));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-body .ranking-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-three {
|
||||||
|
/* 使用官方条纹背景色变量 */
|
||||||
|
background-color: var(--n-tableColorStriped, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-rank {
|
||||||
|
width: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-user {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-days,
|
||||||
|
.col-monthly,
|
||||||
|
.col-total {
|
||||||
|
width: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-time {
|
||||||
|
width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: var(--n-fontWeightStrong, 500);
|
||||||
|
/* 使用官方文本和背景色变量 */
|
||||||
|
color: var(--n-textColor2, rgba(255, 255, 255, 0.82));
|
||||||
|
background-color: var(--n-actionColor, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保持奖牌颜色在暗色模式下也清晰可见 */
|
||||||
|
.rank-1 {
|
||||||
|
background: linear-gradient(135deg, #ffe259, #ffa751);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-2 {
|
||||||
|
background: linear-gradient(135deg, #d3d3d3, #a9a9a9);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-3 {
|
||||||
|
background: linear-gradient(135deg, #c79364, #a77347);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: var(--n-fontWeightStrong, 500);
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-authed {
|
||||||
|
background-color: var(--n-successColor, #63e2b7);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: var(--n-fontSizeTiny, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-count,
|
||||||
|
.count-value {
|
||||||
|
font-weight: var(--n-fontWeightStrong, 500);
|
||||||
|
font-size: var(--n-fontSizeLarge, 15px);
|
||||||
|
color: var(--n-infoColor, #70c0e8);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-text,
|
||||||
|
.count-text {
|
||||||
|
color: var(--n-textColor3, rgba(255, 255, 255, 0.52));
|
||||||
|
font-size: var(--n-fontSizeTiny, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-footer {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
/* 使用官方背景色变量 */
|
||||||
|
background-color: var(--n-tableHeaderColor, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user