feat: 更新组件和配置,增强功能和用户体验, 添加签到功能

- 在 .editorconfig 中调整文件格式设置,统一代码风格。
- 在 default.d.ts 中为 naive-ui 添加 TabPaneSlots 接口声明,增强类型支持。
- 在多个组件中优化了模板和样式,提升用户交互体验。
- 在 ClientAutoAction.vue 中新增签到设置标签页,丰富功能选项。
- 在 Utils.ts 中增强 GUID 处理逻辑,增加输入验证和错误处理。
- 更新多个组件的逻辑,简化代码结构,提升可读性和维护性。
This commit is contained in:
2025-04-26 01:35:59 +08:00
parent e48b3df236
commit 8bed5bbc1a
24 changed files with 2004 additions and 328 deletions

View File

@@ -1,4 +1,4 @@
[*.{js,jsx,ts,tsx,vue,vine.ts}] [*]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
end_of_line = lf end_of_line = lf

6
default.d.ts vendored
View File

@@ -15,6 +15,12 @@ declare module '*.js' {
export = content export = content
} }
declare module 'naive-ui' {
interface TabPaneSlots {
tab?: () => VNode[]
}
}
declare global { declare global {
interface Window { interface Window {
$message: MessageProviderInst $message: MessageProviderInst

View File

@@ -156,6 +156,9 @@ export function getOUIdAvatarUrl(ouid: string) {
export class GuidUtils { export class GuidUtils {
// 将数字转换为GUID // 将数字转换为GUID
public static numToGuid(value: number): string { public static numToGuid(value: number): string {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error('输入必须是非负安全整数');
}
const buffer = new ArrayBuffer(16); const buffer = new ArrayBuffer(16);
const view = new DataView(buffer); const view = new DataView(buffer);
view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节 view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节
@@ -164,44 +167,60 @@ export class GuidUtils {
// 检查GUID是否由数字生成 // 检查GUID是否由数字生成
public static isGuidFromUserId(guid: string): boolean { public static isGuidFromUserId(guid: string): boolean {
const buffer = GuidUtils.guidToBuffer(guid); try {
const view = new DataView(buffer); const buffer = GuidUtils.guidToBuffer(guid);
for (let i = 0; i < 8; i++) { const view = new DataView(buffer);
if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0 for (let i = 0; i < 8; i++) {
if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0
}
return true;
} catch (e) {
return false;
} }
return true;
} }
// 将GUID转换为数字 // 将GUID转换为数字
public static guidToLong(guid: string): number { public static guidToLong(guid: string): number {
const buffer = GuidUtils.guidToBuffer(guid); try {
const view = new DataView(buffer); const buffer = GuidUtils.guidToBuffer(guid);
return Number(view.getBigUint64(8)); const view = new DataView(buffer);
return Number(view.getBigUint64(8));
} catch (e) {
throw new Error('无效的GUID格式');
}
} }
// 辅助方法将ArrayBuffer转换为GUID字符串 // 辅助方法将ArrayBuffer转换为GUID字符串
private static bufferToGuid(buffer: ArrayBuffer): string { private static bufferToGuid(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
const guid = bytes.reduce((str, byte, idx) => { let hex = '';
const pair = byte.toString(16).padStart(2, '0');
return ( for (let i = 0; i < 16; i++) {
str + hex += bytes[i].toString(16).padStart(2, '0');
pair + }
(idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '')
); // 标准GUID格式xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
}, ''); return hex.substring(0, 8) + '-' +
return guid; hex.substring(8, 12) + '-' +
hex.substring(12, 16) + '-' +
hex.substring(16, 20) + '-' +
hex.substring(20);
} }
// 辅助方法将GUID字符串转换为ArrayBuffer // 辅助方法将GUID字符串转换为ArrayBuffer
private static guidToBuffer(guid: string): ArrayBuffer { private static guidToBuffer(guid: string): ArrayBuffer {
const hex = guid.replace(/-/g, ''); const hex = guid.replace(/-/g, '');
if (hex.length !== 32) throw new Error('Invalid GUID format.'); if (hex.length !== 32) throw new Error('无效的GUID格式');
const buffer = new ArrayBuffer(16); const buffer = new ArrayBuffer(16);
const view = new DataView(buffer); const view = new Uint8Array(buffer);
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16)); const byteValue = parseInt(hex.substr(i * 2, 2), 16);
if (isNaN(byteValue)) throw new Error('GUID包含非法字符');
view.set([byteValue], i);
} }
return buffer; return buffer;
} }
} }

View File

@@ -33,6 +33,7 @@ import GlobalScheduledSettings from './components/autoaction/settings/GlobalSche
import TimerCountdown from './components/autoaction/TimerCountdown.vue'; import TimerCountdown from './components/autoaction/TimerCountdown.vue';
import DataManager from './components/autoaction/DataManager.vue'; import DataManager from './components/autoaction/DataManager.vue';
import ActionHistoryViewer from './components/autoaction/ActionHistoryViewer.vue'; import ActionHistoryViewer from './components/autoaction/ActionHistoryViewer.vue';
import CheckInSettings from './components/autoaction/settings/CheckInSettings.vue';
const autoActionStore = useAutoAction(); const autoActionStore = useAutoAction();
const message = useMessage(); const message = useMessage();
@@ -699,6 +700,14 @@ onMounted(() => {
</NTabs> </NTabs>
</NTabPane> </NTabPane>
<!-- 新增:签到设置标签页 -->
<NTabPane
name="check-in-settings"
tab="签到设置"
>
<CheckInSettings />
</NTabPane>
<!-- 历史记录标签页 --> <!-- 历史记录标签页 -->
<NTabPane <NTabPane
name="action-history" name="action-history"

View File

@@ -24,8 +24,6 @@ import { useDanmakuWindow } from './store/useDanmakuWindow';
// 获取 webfetcher 状态管理的实例 // 获取 webfetcher 状态管理的实例
const webfetcher = useWebFetcher(); const webfetcher = useWebFetcher();
// 获取账户信息状态管理的实例 (如果 accountInfo 未使用,可以考虑移除)
const accountInfo = useAccount();
const danmakuWindow = useDanmakuWindow(); const danmakuWindow = useDanmakuWindow();
// 用于存储用户输入的 Token // 用于存储用户输入的 Token
const token = ref(''); const token = ref('');

View File

@@ -0,0 +1,155 @@
<template>
<div class="checkin-template-helper">
<TemplateHelper :placeholders="checkInPlaceholders" />
<NAlert
type="info"
:show-icon="false"
style="margin-top: 8px;"
>
<template #header>
<div class="alert-header">
<NIcon
:component="Info24Filled"
style="margin-right: 4px;"
/>
签到模板可用变量列表
</div>
</template>
<NDivider style="margin: 6px 0;" />
<div class="placeholder-groups">
<div class="placeholder-group">
<div class="group-title">
用户信息
</div> <div class="placeholder-item">
<code>&#123;&#123;user.name&#125;&#125;</code> - 用户名称
</div>
<div class="placeholder-item">
<code>&#123;&#123;user.uid&#125;&#125;</code> - 用户ID
</div>
</div>
<div class="placeholder-group">
<div class="group-title">
签到信息
</div> <div class="placeholder-item">
<code>&#123;&#123;checkin.points&#125;&#125;</code> - 基础签到积分
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.bonusPoints&#125;&#125;</code> - 早鸟额外积分 (普通签到为0)
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.totalPoints&#125;&#125;</code> - 总获得积分
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.isEarlyBird&#125;&#125;</code> - 是否是早鸟签到 (true/false)
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.cooldownSeconds&#125;&#125;</code> - 签到冷却时间()
</div>
<div class="placeholder-item">
<code>&#123;&#123;checkin.time&#125;&#125;</code> - 签到时间对象
</div>
</div>
</div>
<NDivider style="margin: 6px 0;" />
<div class="placeholder-example">
<div class="example-title">
示例模板:
</div> <div class="example-item">
普通签到: <code>&#123;&#123;user.name&#125;&#125; 签到成功获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
<div class="example-item">
早鸟签到: <code>恭喜 &#123;&#123;user.name&#125;&#125; 完成早鸟签到额外获得 &#123;&#123;checkin.bonusPoints&#125;&#125; 积分共获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
<div class="example-item">
条件表达式: <code>&#123;&#123;js: checkin.isEarlyBird ? `恭喜 ${user.name} 获得早鸟奖励!` : `${user.name} 签到成功!`&#125;&#125; 获得 &#123;&#123;checkin.totalPoints&#125;&#125; 积分</code>
</div>
</div>
</NAlert>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { NAlert, NDivider, NIcon } from 'naive-ui';
import { Info24Filled } from '@vicons/fluent';
import TemplateHelper from './TemplateHelper.vue';
// 签到模板的特定占位符
const checkInPlaceholders = [
{ name: '{{user.name}}', description: '用户名称' },
{ name: '{{user.uid}}', description: '用户ID' },
{ name: '{{checkin.points}}', description: '基础签到积分' },
{ name: '{{checkin.bonusPoints}}', description: '早鸟额外积分 (普通签到为0)' },
{ name: '{{checkin.totalPoints}}', description: '总获得积分' },
{ name: '{{checkin.isEarlyBird}}', description: '是否是早鸟签到 (true/false)' },
{ name: '{{checkin.cooldownSeconds}}', description: '签到冷却时间(秒)' },
{ name: '{{checkin.time}}', description: '签到时间对象' }
];
</script>
<style scoped>
.checkin-template-helper {
margin-bottom: 12px;
}
.alert-header {
display: flex;
align-items: center;
font-weight: bold;
}
.placeholder-groups {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.placeholder-group {
flex: 1;
min-width: 200px;
}
.group-title {
font-weight: bold;
margin-bottom: 6px;
font-size: 14px;
}
.placeholder-item {
margin-bottom: 4px;
font-size: 13px;
}
.placeholder-item code {
padding: 1px 4px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 3px;
font-size: 12px;
}
.placeholder-example {
margin-top: 8px;
}
.example-title {
font-weight: bold;
margin-bottom: 6px;
font-size: 14px;
}
.example-item {
margin-bottom: 4px;
font-size: 13px;
}
.example-item code {
display: block;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 3px;
margin-top: 2px;
font-size: 12px;
white-space: nowrap;
overflow: auto;
}
</style>

View File

@@ -31,7 +31,7 @@ function handleTemplateUpdate(payload: { index: number, value: string }) {
<template> <template>
<TemplateEditor <TemplateEditor
:action="props.action" :template="props.action"
:template-index="0" :template-index="0"
:title="title" :title="title"
:description="description" :description="description"

View File

@@ -2,7 +2,6 @@
import { NButton, NCard, NDivider, NHighlight, NInput, NScrollbar, NSpace, NModal, useMessage, NTabs, NTabPane, NFlex, NAlert, NIcon, NCollapse, NCollapseItem, NBadge, NText } from 'naive-ui'; import { NButton, NCard, NDivider, NHighlight, NInput, NScrollbar, NSpace, NModal, useMessage, NTabs, NTabPane, NFlex, NAlert, NIcon, NCollapse, NCollapseItem, NBadge, NText } from 'naive-ui';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import TemplateHelper from './TemplateHelper.vue'; import TemplateHelper from './TemplateHelper.vue';
import TemplateTester from './TemplateTester.vue';
import { containsJsExpression, convertToJsExpressions, evaluateTemplateExpressions, extractJsExpressions, JS_EXPRESSION_REGEX } from '@/client/store/autoAction/expressionEvaluator'; import { containsJsExpression, convertToJsExpressions, evaluateTemplateExpressions, extractJsExpressions, JS_EXPRESSION_REGEX } from '@/client/store/autoAction/expressionEvaluator';
import { buildExecutionContext } from '@/client/store/autoAction/utils'; import { buildExecutionContext } from '@/client/store/autoAction/utils';
import { AutoActionItem, TriggerType } from '@/client/store/autoAction/types'; import { AutoActionItem, TriggerType } from '@/client/store/autoAction/types';
@@ -11,7 +10,7 @@ import { EventDataTypes, EventModel } from '@/api/api-models';
import GraphemeSplitter from 'grapheme-splitter'; import GraphemeSplitter from 'grapheme-splitter';
const props = defineProps({ const props = defineProps({
action: { template: {
type: Object as () => AutoActionItem, type: Object as () => AutoActionItem,
required: true required: true
}, },
@@ -26,6 +25,10 @@ const props = defineProps({
checkLength: { checkLength: {
type: Boolean, type: Boolean,
default: true default: true
},
customTestContext: {
type: Object,
default: undefined
} }
}); });
@@ -52,7 +55,7 @@ const mergedPlaceholders = computed(() => {
const specificPlaceholders: { name: string, description: string }[] = []; const specificPlaceholders: { name: string, description: string }[] = [];
switch (props.action.triggerType) { switch (props.template.triggerType) {
case TriggerType.DANMAKU: case TriggerType.DANMAKU:
specificPlaceholders.push( specificPlaceholders.push(
{ name: '{{message}}', description: '弹幕内容' }, { name: '{{message}}', description: '弹幕内容' },
@@ -87,8 +90,27 @@ const mergedPlaceholders = computed(() => {
return Array.from(new Map(finalPlaceholders.map(item => [item.name, item])).values()); return Array.from(new Map(finalPlaceholders.map(item => [item.name, item])).values());
}); });
// 深度合并两个对象的辅助函数
function deepMerge(target: any, source: any): any {
if (!source) return target;
if (!target) return source;
const result = { ...target };
Object.keys(source).forEach(key => {
if (typeof source[key] === 'object' && source[key] !== null && typeof target[key] === 'object' && target[key] !== null) {
result[key] = deepMerge(target[key], source[key]);
} else if (source[key] !== undefined) {
result[key] = source[key];
}
});
return result;
}
const testContext = computed(() => { const testContext = computed(() => {
return buildExecutionContext({ // 创建默认上下文
const defaultContext = buildExecutionContext({
msg: '测试', msg: '测试',
time: 1713542400, time: 1713542400,
num: 1, num: 1,
@@ -105,8 +127,14 @@ const testContext = computed(() => {
fans_medal_wearing_status: true, fans_medal_wearing_status: true,
guard_level_name: '测试舰队', guard_level_name: '测试舰队',
guard_level_price: 100, guard_level_price: 100,
}, undefined, props.template.triggerType);
}, undefined, props.action.triggerType); // 如果有自定义上下文,将其与默认上下文合并
if (props.customTestContext) {
return deepMerge(defaultContext, props.customTestContext);
}
return defaultContext;
}); });
const message = useMessage(); const message = useMessage();
@@ -120,13 +148,13 @@ function countGraphemes(value: string) {
} }
function convertPlaceholders() { function convertPlaceholders() {
if (!props.action.template) { if (!props.template.template) {
message.warning('请先输入模板内容'); message.warning('请先输入模板内容');
return; return;
} }
const converted = convertToJsExpressions(props.action.template, mergedPlaceholders.value); const converted = convertToJsExpressions(props.template.template, mergedPlaceholders.value);
if (converted !== props.action.template) { if (converted !== props.template.template) {
props.action.template = converted; props.template.template = converted;
message.success('已转换占位符为表达式格式'); message.success('已转换占位符为表达式格式');
} else { } else {
message.info('模板中没有需要转换的占位符'); message.info('模板中没有需要转换的占位符');
@@ -139,7 +167,7 @@ function hasJsExpression(template: string): boolean {
const highlightPatterns = computed(() => { const highlightPatterns = computed(() => {
const simplePlaceholders = mergedPlaceholders.value.map(p => p.name); const simplePlaceholders = mergedPlaceholders.value.map(p => p.name);
const jsExpressionsInTemplate = extractJsExpressions(props.action.template || ''); const jsExpressionsInTemplate = extractJsExpressions(props.template.template || '');
const allPatterns = [...new Set([...simplePlaceholders, ...jsExpressionsInTemplate])]; const allPatterns = [...new Set([...simplePlaceholders, ...jsExpressionsInTemplate])];
return allPatterns; return allPatterns;
}); });
@@ -148,7 +176,20 @@ const MAX_LENGTH = 20;
const WARNING_THRESHOLD = 16; const WARNING_THRESHOLD = 16;
function evaluateTemplateForUI(template: string): string { function evaluateTemplateForUI(template: string): string {
const executionContext = buildExecutionContext(testContext.value.event, undefined, props.action.triggerType); // 深度合并默认上下文和自定义上下文
const executionContext = buildExecutionContext(testContext.value.event, undefined, props.template.triggerType);
// 如果有自定义上下文,将其深度合并到执行上下文中
if (props.customTestContext) {
Object.keys(props.customTestContext).forEach(key => {
if (typeof props.customTestContext?.[key] === 'object' && props.customTestContext[key] !== null) {
executionContext.variables[key] = deepMerge(executionContext.variables[key] || {}, props.customTestContext[key]);
} else {
executionContext.variables[key] = props.customTestContext?.[key];
}
});
}
try { try {
return evaluateTemplateExpressions(template, executionContext); return evaluateTemplateExpressions(template, executionContext);
} catch (error) { } catch (error) {
@@ -158,8 +199,8 @@ function evaluateTemplateForUI(template: string): string {
} }
const evaluatedTemplateResult = computed(() => { const evaluatedTemplateResult = computed(() => {
if (!props.action.template || !showLivePreview.value) return ''; if (!props.template.template || !showLivePreview.value) return '';
return evaluateTemplateForUI(props.action.template); return evaluateTemplateForUI(props.template.template);
}); });
const previewResult = computed(() => { const previewResult = computed(() => {
@@ -167,7 +208,7 @@ const previewResult = computed(() => {
}); });
const lengthStatus = computed(() => { const lengthStatus = computed(() => {
if (!props.action.template || !props.checkLength || !showLivePreview.value) { if (!props.template.template || !props.checkLength || !showLivePreview.value) {
return { status: 'normal' as const, message: '' }; return { status: 'normal' as const, message: '' };
} }
try { try {
@@ -230,7 +271,7 @@ const templateExamples = [
]; ];
function insertExample(template: string) { function insertExample(template: string) {
props.action.template = template; props.template.template = template;
message.success('已插入示例模板'); message.success('已插入示例模板');
} }
</script> </script>
@@ -286,7 +327,7 @@ function insertExample(template: string) {
<!-- 当前模板预览 --> <!-- 当前模板预览 -->
<NInput <NInput
v-model:value="action.template" v-model:value="template.template"
type="textarea" type="textarea"
placeholder="输入模板内容... 使用 {{变量名}} 插入变量, {{js: 表达式}} 执行JS" placeholder="输入模板内容... 使用 {{变量名}} 插入变量, {{js: 表达式}} 执行JS"
:autosize="{ minRows: 3, maxRows: 6 }" :autosize="{ minRows: 3, maxRows: 6 }"
@@ -352,15 +393,6 @@ function insertExample(template: string) {
> >
占位符转表达式 占位符转表达式
</NButton> </NButton>
<NButton
type="primary"
size="small"
class="btn-with-transition"
@click="activeTab = 'test'"
>
测试模板
</NButton>
</NFlex> </NFlex>
<!-- 模板示例 --> <!-- 模板示例 -->
@@ -403,17 +435,6 @@ function insertExample(template: string) {
</NCollapse> </NCollapse>
</NFlex> </NFlex>
</NTabPane> </NTabPane>
<NTabPane
name="test"
tab="测试"
>
<TemplateTester
:default-template="action.template"
:context="testContext"
:placeholders="mergedPlaceholders"
/>
</NTabPane>
</NTabs> </NTabs>
<!-- 新增 Modal 组件 --> <!-- 新增 Modal 组件 -->

View File

@@ -0,0 +1,398 @@
<template>
<NCard
v-if="config"
title="弹幕签到设置"
size="small"
>
<NTabs
type="line"
animated
>
<NTabPane
name="settings"
tab="签到设置"
>
<NForm
label-placement="left"
label-width="auto"
>
<NFormItem label="启用签到功能">
<NSwitch v-model:value="config.enabled" />
</NFormItem>
<template v-if="config.enabled">
<NFormItem label="签到指令">
<NInput
v-model:value="config.command"
placeholder="例如:签到"
/>
<template #feedback>
观众发送此指令触发签到
</template>
</NFormItem>
<NFormItem label="仅在直播时可签到">
<NSwitch v-model:value="config.onlyDuringLive" />
<template #feedback>
启用后仅在直播进行中才能签到否则任何时候都可以签到
</template>
</NFormItem>
<NFormItem label="发送签到回复">
<NSwitch v-model:value="config.sendReply" />
<template #feedback>
启用后签到成功或重复签到时会发送弹幕回复关闭则只显示通知不发送弹幕
</template>
</NFormItem>
<NFormItem label="签到成功获得积分">
<NInputNumber
v-model:value="config.points"
:min="0"
style="width: 100%"
/>
</NFormItem>
<NFormItem label="用户签到冷却时间 (秒)">
<NInputNumber
v-model:value="config.cooldownSeconds"
:min="0"
style="width: 100%"
/>
<template #feedback>
每个用户在指定秒数内只能签到一次
</template>
</NFormItem>
<NDivider title-placement="left">
回复消息设置
</NDivider>
<!-- 签到模板帮助信息组件 -->
<div style="margin-bottom: 12px">
<TemplateHelper :placeholders="checkInPlaceholders" />
<NAlert
type="info"
:show-icon="false"
style="margin-top: 8px"
>
<template #header>
<div
style="display: flex; align-items: center; font-weight: bold"
>
<NIcon
:component="Info24Filled"
style="margin-right: 4px"
/>
签到模板可用变量列表
</div>
</template>
</NAlert>
</div>
<TemplateEditor
v-model:template="config.successAction"
title="签到成功回复模板"
:custom-test-context="checkInTestContext"
/>
<TemplateEditor
v-model:template="config.cooldownAction"
title="冷却中回复模板"
:custom-test-context="checkInTestContext"
/>
<NDivider title-placement="left">
早鸟奖励设置
</NDivider>
<NFormItem label="启用早鸟奖励">
<NSwitch v-model:value="config.earlyBird.enabled" />
<template #feedback>
在直播开始后的一段时间内签到可获得额外奖励
</template>
</NFormItem>
<template v-if="config.earlyBird.enabled">
<NFormItem label="早鸟时间窗口 (分钟)">
<NInputNumber
v-model:value="config.earlyBird.windowMinutes"
:min="1"
style="width: 100%"
/>
<template #feedback>
直播开始后多少分钟内视为早鸟
</template>
</NFormItem>
<NFormItem label="早鸟额外奖励积分">
<NInputNumber
v-model:value="config.earlyBird.bonusPoints"
:min="0"
style="width: 100%"
/>
<template #feedback>
成功触发早鸟签到的用户额外获得的积分
</template>
</NFormItem> <TemplateEditor
v-model:template="config.earlyBird.successAction"
title="早鸟成功回复模板"
description="用户成功触发早鸟奖励时发送的回复消息,可用变量: {{user.name}}, {{checkin.bonusPoints}}, {{checkin.totalPoints}}, {{checkin.userPoints}}"
:custom-test-context="checkInTestContext"
/>
</template>
</template>
</NForm>
</NTabPane>
<NTabPane
name="userStats"
tab="用户签到情况"
>
<div class="checkin-stats">
<NSpace vertical>
<NAlert type="info">
以下显示用户的签到统计信息包括累计签到次数连续签到天数和早鸟签到次数等
</NAlert>
<NDataTable
:columns="userStatsColumns"
:data="userStatsData"
:pagination="{ pageSize: 10 }"
:bordered="false"
striped
/>
<NEmpty
v-if="!userStatsData.length"
description="暂无用户签到数据"
/>
</NSpace>
</div>
</NTabPane>
<NTabPane
name="testCheckIn"
tab="测试签到"
>
<div class="test-checkin">
<NSpace vertical>
<NAlert type="info">
在此可以模拟用户签到测试签到功能是否正常工作
</NAlert>
<NForm>
<NFormItem label="用户UID">
<NInputNumber
v-model:value="testUid"
:min="1"
style="width: 100%"
placeholder="输入用户数字ID"
/>
</NFormItem>
<NFormItem label="用户名">
<NInput
v-model:value="testUsername"
placeholder="输入用户名,默认为'测试用户'"
/>
</NFormItem>
<NFormItem>
<NButton
type="primary"
:disabled="!testUid || !config?.enabled"
@click="handleTestCheckIn"
>
模拟签到
</NButton>
</NFormItem>
</NForm>
<NDivider title-placement="left">
测试结果
</NDivider>
<NCard
v-if="testResult"
size="small"
:title="testResult.success ? '签到成功' : '签到失败'"
>
<NText>{{ testResult.message }}</NText>
</NCard>
</NSpace>
</div>
</NTabPane>
</NTabs>
<NText
:depth="3"
style="font-size: 12px; margin-top: 15px; display: block"
>
提示签到成功发送的回复消息会遵循全局的弹幕发送设置如频率限制弹幕长度等
</NText>
</NCard>
<NCard
v-else
title="加载中..."
size="small"
>
<NText>正在加载签到设置...</NText>
</NCard>
</template>
<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 { useAutoAction } from '@/client/store/useAutoAction';
import TemplateEditor from '../TemplateEditor.vue';
import TemplateHelper from '../TemplateHelper.vue';
import { TriggerType, ActionType, Priority, RuntimeState } from '@/client/store/autoAction/types';
import { EventModel, EventDataTypes } from '@/api/api-models';
import { Info24Filled } from '@vicons/fluent';
import { computed, h, ref } from 'vue';
import type { UserCheckInData } from '@/client/store/autoAction/modules/checkin';
const autoActionStore = useAutoAction();
const config = autoActionStore.checkInModule.checkInConfig;
const checkInStorage = autoActionStore.checkInModule.checkInStorage;
// 签到模板的特定占位符
const checkInPlaceholders = [
{ name: '{{checkin.points}}', description: '基础签到积分' },
{ name: '{{checkin.bonusPoints}}', description: '早鸟额外积分 (普通签到为0)' },
{ name: '{{checkin.totalPoints}}', description: '本次总获得积分' },
{ name: '{{checkin.userPoints}}', description: '用户当前积分' },
{ name: '{{checkin.isEarlyBird}}', description: '是否是早鸟签到 (true/false)' },
{ name: '{{checkin.cooldownSeconds}}', description: '签到冷却时间(秒)' },
{ name: '{{checkin.time}}', description: '签到时间对象' }
];
// 为签到模板自定义的测试上下文
const checkInTestContext = computed(() => {
if (!config) return undefined;
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 = [
{
title: '用户ID',
key: 'uid'
},
{
title: '用户名',
key: 'username'
},
{
title: '首次签到时间',
key: 'firstCheckInTime',
render(row: UserCheckInData) {
return h('span', {}, new Date(row.firstCheckInTime).toLocaleString());
}
},
{
title: '最近签到时间',
key: 'lastCheckInTime',
sorter: true,
defaultSortOrder: 'descend' as const,
render(row: UserCheckInData) {
return h('span', {}, new Date(row.lastCheckInTime).toLocaleString());
}
},
{
title: '累计签到',
key: 'totalCheckins',
sorter: true
},
{
title: '连续签到',
key: 'streakDays',
sorter: true
},
{
title: '早鸟签到次数',
key: 'earlyBirdCount',
sorter: true
}
];
// 转换用户签到数据为表格可用格式
const userStatsData = computed<UserCheckInData[]>(() => {
if (!checkInStorage?.users) {
return [];
}
// 将对象转换为数组
return Object.values(checkInStorage.users);
});
// 测试签到功能
const testUid = ref<number>();
const testUsername = ref<string>('测试用户');
const testResult = ref<{ success: boolean; message: string }>();
// 处理测试签到
async function handleTestCheckIn() {
if (!testUid.value || !config?.enabled) {
testResult.value = {
success: false,
message: '请输入有效的UID或确保签到功能已启用'
};
return;
}
try {
// 创建唯一标识符ouid基于用户输入的uid
const userOuid = testUid.value.toString();
// 创建模拟的事件对象
const mockEvent: EventModel = {
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 = {
success: true,
message: `已为用户 ${testUsername.value || '测试用户'}(UID: ${testUid.value}) 模拟签到操作,请查看用户签到情况选项卡确认结果`
};
} catch (error) {
testResult.value = {
success: false,
message: `签到操作失败: ${error instanceof Error ? error.message : String(error)}`
};
}
}
</script>

View File

@@ -56,7 +56,7 @@ function handleTemplateUpdate(payload: { index: number, value: string }) {
appear appear
> >
<TemplateEditor <TemplateEditor
:action="props.action" :template="props.action"
:template-index="0" :template-index="0"
:title="templateTitle" :title="templateTitle"
:description="templateDescription" :description="templateDescription"

View File

@@ -65,7 +65,7 @@ export async function initAll(isOnBoot: boolean) {
initInfo(); initInfo();
info('[init] 开始更新数据'); info('[init] 开始更新数据');
if (isLoggedIn && accountInfo.value.isBiliVerified && !setting.settings.dev_disableDanmakuClient) { if (isLoggedIn.value && accountInfo.value.isBiliVerified && !setting.settings.dev_disableDanmakuClient) {
const danmakuInitNoticeRef = window.$notification.info({ const danmakuInitNoticeRef = window.$notification.info({
title: '正在初始化弹幕客户端...', title: '正在初始化弹幕客户端...',
closable: false closable: false
@@ -196,9 +196,11 @@ export async function initDanmakuClient() {
const biliCookie = useBiliCookie(); const biliCookie = useBiliCookie();
const settings = useSettings(); const settings = useSettings();
if (isInitedDanmakuClient.value || isInitingDanmakuClient.value) { if (isInitedDanmakuClient.value || isInitingDanmakuClient.value) {
info('弹幕客户端已初始化, 跳过初始化');
return { success: true, message: '' }; return { success: true, message: '' };
} }
isInitingDanmakuClient.value = true; isInitingDanmakuClient.value = true;
console.log(settings.settings);
let result = { success: false, message: '' }; let result = { success: false, message: '' };
try { try {
if (isLoggedIn) { if (isLoggedIn) {
@@ -231,6 +233,9 @@ export async function initDanmakuClient() {
} }
} }
} }
} else {
info('未登录, 跳过弹幕客户端初始化');
result = { success: true, message: '' };
} }
return result; return result;
} catch (err) { } catch (err) {
@@ -265,6 +270,7 @@ export async function callStartDanmakuClient() {
const settings = useSettings(); const settings = useSettings();
const webFetcher = useWebFetcher(); const webFetcher = useWebFetcher();
if (settings.settings.useDanmakuClientType === 'direct') { if (settings.settings.useDanmakuClientType === 'direct') {
info('开始初始化弹幕客户端 [direct]');
const key = await getRoomKey( const key = await getRoomKey(
accountInfo.value.biliRoomId!, await biliCookie.getBiliCookie() || ''); accountInfo.value.biliRoomId!, await biliCookie.getBiliCookie() || '');
if (!key) { if (!key) {
@@ -283,6 +289,7 @@ export async function callStartDanmakuClient() {
tokenUserId: biliCookie.uId!, tokenUserId: biliCookie.uId!,
}, true); }, true);
} else { } else {
info('开始初始化弹幕客户端 [openlive]');
return await webFetcher.Start('openlive', undefined, true); return await webFetcher.Start('openlive', undefined, true);
} }
} }

View File

@@ -6,13 +6,22 @@ import { error } from '@tauri-apps/plugin-log';
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) { export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) {
const u = new URL(url); const u = new URL(url);
console.log(`调用bilibili api: ${url}`);
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
return fetch(url, { return fetch(url, {
method: method, method: method,
headers: { headers: {
'User-Agent': 'User-Agent': randomUserAgent,
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', Origin: 'https://www.bilibili.com',
Origin: '', Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
}, },
}); });
} }

View File

@@ -117,7 +117,7 @@ export function checkCooldown(action: AutoActionItem, runtimeState: RuntimeState
*/ */
export function processTemplate( export function processTemplate(
action: AutoActionItem, action: AutoActionItem,
context: any, context: ExecutionContext,
options?: { options?: {
useRandomTemplate?: boolean; // 是否随机选择模板默认true useRandomTemplate?: boolean; // 是否随机选择模板默认true
defaultValue?: string; // 如果模板为空或格式化失败时的默认值 defaultValue?: string; // 如果模板为空或格式化失败时的默认值

View File

@@ -0,0 +1,436 @@
import { ref, Ref, computed } from 'vue';
import { EventModel, EventDataTypes } from '@/api/api-models';
import { ActionType, AutoActionItem, RuntimeState, TriggerType, Priority, KeywordMatchType } from '../types';
import { usePointStore } from '@/store/usePointStore';
import { processTemplate } from '../actionUtils';
import { buildExecutionContext } from '../utils';
import { v4 as uuidv4 } from 'uuid';
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
import { GuidUtils } from '@/Utils';
// 签到配置接口
export interface CheckInConfig {
enabled: boolean;
command: string;
points: number;
cooldownSeconds: number;
onlyDuringLive: boolean; // 仅在直播时可签到
sendReply: boolean; // 是否发送签到回复消息
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
cooldownAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
earlyBird: {
enabled: boolean;
windowMinutes: number;
bonusPoints: number;
successAction: AutoActionItem; // 使用 AutoActionItem 替代字符串
};
}
// 创建默认配置
function createDefaultCheckInConfig(): CheckInConfig { return {
enabled: false,
command: '签到',
points: 10,
cooldownSeconds: 3600, // 1小时
onlyDuringLive: true, // 默认仅在直播时可签到
sendReply: true, // 默认发送回复消息
successAction: {
id: uuidv4(),
name: '签到成功回复',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '@{{user.name}} 签到成功,获得 {{checkin.totalPoints}} 积分。',
priority: Priority.NORMAL,
logicalExpression: '',
ignoreCooldown: false,
executeCommand: '',
triggerConfig: {},
actionConfig: {}
},
cooldownAction: {
id: uuidv4(),
name: '签到冷却回复',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '{{user.name}} 你今天已经签到过了,明天再来吧~',
priority: Priority.NORMAL,
logicalExpression: '',
ignoreCooldown: false,
executeCommand: '',
triggerConfig: {},
actionConfig: {}
},
earlyBird: {
enabled: false,
windowMinutes: 30,
bonusPoints: 5,
successAction: {
id: uuidv4(),
name: '早鸟签到回复',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '恭喜 {{user.name}} 完成早鸟签到!额外获得 {{bonusPoints}} 积分,共获得 {{totalPoints}} 积分!',
priority: Priority.NORMAL,
logicalExpression: '',
ignoreCooldown: false,
executeCommand: '',
triggerConfig: {},
actionConfig: {}
}
}
};
}
// 签到记录存储
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; // 首次签到时间
}
/**
* 签到功能核心逻辑
*/
export function useCheckIn(
isLive: Ref<boolean>,
roomId: Ref<number | undefined>,
liveStartTime: Ref<number | null>,
isTianXuanActive: Ref<boolean>,
sendDanmaku: (roomId: number, message: string) => Promise<boolean>
) {
const pointStore = usePointStore();
// 使用 IndexedDB 持久化存储签到配置
const { data: checkInConfig, isFinished: isConfigLoaded } = useIDBKeyval<CheckInConfig>(
'autoAction.checkin.config',
createDefaultCheckInConfig(),
{
onError: (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);
}
}
);
// 处理签到弹幕
async function processCheckIn(
event: EventModel,
runtimeState: RuntimeState
) {
// 确保配置和存储已加载
if (!isConfigLoaded.value || !isStorageLoaded.value) {
console.log('[CheckIn] 配置或存储尚未加载完成,跳过处理');
return;
}
if (!roomId.value || !checkInConfig.value.enabled) {
return;
}
// 检查是否仅在直播时可签到
if (checkInConfig.value.onlyDuringLive && !isLive.value) {
return;
}
// 跳过非弹幕事件
if (event.type !== EventDataTypes.Message) {
return;
}
// 检查弹幕内容是否匹配签到指令
if (event.msg?.trim() !== checkInConfig.value.command) {
return;
}
const userId = event.ouid;
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) {
// 使用buildExecutionContext构建上下文
const cooldownContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU, {
user: { name: username, uid: userId }
});
const message = processTemplate(checkInConfig.value.cooldownAction, cooldownContext);
if (roomId.value && message) {
sendDanmaku(roomId.value, message).catch(err =>
console.error('[CheckIn] 发送已签到提示失败:', err)
);
}
}
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 {
// 调用积分系统添加积分
const point = await pointStore.addPoints(userId, pointsEarned, `签到奖励 (${format(new Date(), 'yyyy-MM-dd')})`, `${username} 完成签到`); // 更新签到记录
if (checkInStorage.value) {
// 确保 lastCheckIn 对象存在
if (!checkInStorage.value.lastCheckIn) {
checkInStorage.value.lastCheckIn = {};
}
// 确保 users 对象存在
if (!checkInStorage.value.users) {
checkInStorage.value.users = {};
}
// 获取用户当前的签到数据
let userData = checkInStorage.value.users[userId];
// 如果是新用户,创建用户数据
if (!userData) {
userData = {
ouid: userId,
username: username,
totalCheckins: 0,
streakDays: 0,
lastCheckInTime: 0,
earlyBirdCount: 0,
firstCheckInTime: currentTime
};
}
// 计算连续签到天数
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) {
// 不是昨天签到且不是首次签到重置连续签到天数为1
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;
// 更新lastCheckIn记录
checkInStorage.value.lastCheckIn[userId] = currentTime;
}
// 发送成功消息
if (roomId.value) {
// 构建签到上下文数据
const checkInData = {
checkin: {
points: checkInConfig.value.points,
bonusPoints: isEarlyBird ? bonusPoints : 0,
totalPoints: pointsEarned,
userPoints: point,
isEarlyBird: isEarlyBird,
time: new Date(currentTime),
cooldownSeconds: checkInConfig.value.cooldownSeconds
}
};
// 根据配置决定是否发送回复消息
if (checkInConfig.value.sendReply) {
// 使用buildExecutionContext构建完整上下文
const successContext = buildExecutionContext(event, roomId.value, TriggerType.DANMAKU, checkInData);
let message;
if (isEarlyBird) {
// 使用早鸟签到模板
message = processTemplate(checkInConfig.value.earlyBird.successAction, successContext);
} else {
// 使用普通签到模板
message = processTemplate(checkInConfig.value.successAction, successContext);
}
if (message) {
sendDanmaku(roomId.value, message).catch(err =>
console.error('[CheckIn] 发送签到成功消息失败:', err)
);
}
}
window.$notification.success({
title: '签到成功',
description: `${username} 完成签到, 获得 ${pointsEarned} 积分, 累计签到 ${checkInStorage.value.users[userId].totalCheckins}`,
duration: 5000
});
}
} catch (error) {
console.error('[CheckIn] 处理签到失败:', error);
}
}
// 监听直播开始事件
function onLiveStart() {
// 直播开始时记录开始时间,用于早鸟奖励计算
if (isLive.value && !liveStartTime.value) {
liveStartTime.value = Date.now();
}
}
// 监听直播结束事件
function onLiveEnd() {
// 直播结束时清空早鸟奖励的时间记录
liveStartTime.value = null;
}
return {
checkInConfig,
checkInStorage,
processCheckIn,
onLiveStart,
onLiveEnd
};
}
/**
* 创建默认的签到相关 AutoActionItem 配置
* 这些配置可以在管理界面中显示和编辑
*/
export function createCheckInAutoActions(): AutoActionItem[] {
return [
// 普通签到成功响应
{
id: uuidv4(),
name: '签到成功响应',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '@{{user.name}} 签到成功,获得 {{points}} 积分',
priority: Priority.NORMAL,
logicalExpression: '',
ignoreCooldown: false,
executeCommand: '',
triggerConfig: {
keywords: ['签到'],
keywordMatchType: KeywordMatchType.Full
},
actionConfig: {
cooldownSeconds: 86400 // 24小时确保每天只能签到一次
}
},
// 早鸟签到成功响应
{
id: uuidv4(),
name: '早鸟签到成功响应',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '@{{user.name}} 早鸟签到成功,获得 {{totalPoints}} 积分',
priority: Priority.HIGH,
logicalExpression: '',
ignoreCooldown: false,
executeCommand: '',
triggerConfig: {
keywords: ['签到'],
keywordMatchType: KeywordMatchType.Full
},
actionConfig: {
cooldownSeconds: 86400 // 24小时
}
},
// 签到冷却期提示
{
id: uuidv4(),
name: '签到冷却提示',
enabled: true,
triggerType: TriggerType.DANMAKU,
actionType: ActionType.SEND_DANMAKU,
template: '@{{user.name}} 你今天已经签到过了,明天再来吧~',
priority: Priority.LOW,
logicalExpression: '',
ignoreCooldown: true,
executeCommand: '',
triggerConfig: {
keywords: ['签到'],
keywordMatchType: KeywordMatchType.Full
},
actionConfig: {}
}
];
}

View File

@@ -269,12 +269,14 @@ export function checkUserFilter(config: { userFilterEnabled: boolean; requireMed
* @param event 事件对象 * @param event 事件对象
* @param roomId 房间ID * @param roomId 房间ID
* @param triggerType 触发类型 * @param triggerType 触发类型
* @param additionalContext 附加的上下文数据,将被合并到上下文中
* @returns 标准化的执行上下文 * @returns 标准化的执行上下文
*/ */
export function buildExecutionContext( export function buildExecutionContext(
event: any, event: any,
roomId: number | undefined, roomId: number | undefined,
triggerType?: TriggerType triggerType?: TriggerType,
additionalContext?: Record<string, any>
): ExecutionContext { ): ExecutionContext {
const now = Date.now(); const now = Date.now();
const dateObj = new Date(now); const dateObj = new Date(now);
@@ -432,5 +434,13 @@ export function buildExecutionContext(
} }
} }
// 合并附加的上下文数据(如果存在)
if (additionalContext) {
context.variables = {
...context.variables,
...additionalContext
};
}
return context; return context;
} }

View File

@@ -1,48 +1,45 @@
// 导入 Vue 和 Pinia 相关函数 // 导入 Vue 和 Pinia 相关函数
import { ref, computed, watch } from 'vue'; import { acceptHMRUpdate, defineStore } from 'pinia';
import { defineStore, acceptHMRUpdate } from 'pinia'; import { computed, ref, watch } from 'vue';
// 导入 API 模型和类型 // 导入 API 模型和类型
import { EventModel, GuardLevel, EventDataTypes } from '@/api/api-models.js'; import { useAccount } from '@/api/account.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';
import { useAccount } from '@/api/account.js';
// 导入 VueUse 工具库 // 导入 VueUse 工具库
import { useStorage } from '@vueuse/core';
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval'; import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
// 导入自动操作相关的类型和工具函数 // 导入自动操作相关的类型和工具函数
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
import { import {
TriggerType,
ActionType, ActionType,
KeywordMatchType,
Priority, Priority,
RuntimeState, RuntimeState,
type AutoActionItem, TriggerType,
ExecutionContext, type AutoActionItem
KeywordMatchType
} from './autoAction/types.js'; } from './autoAction/types.js';
import { import {
getRandomTemplate,
buildExecutionContext, buildExecutionContext,
evaluateExpression,
shouldProcess,
createDefaultAutoAction, createDefaultAutoAction,
createDefaultRuntimeState createDefaultRuntimeState,
getRandomTemplate
} from './autoAction/utils'; } from './autoAction/utils';
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
// 导入 actionUtils 工具函数 // 导入 actionUtils 工具函数
import { filterValidActions, checkUserFilters, checkCooldown, processTemplate, executeActions } from './autoAction/actionUtils';
// 导入 nanoid 用于生成唯一 ID // 导入 nanoid 用于生成唯一 ID
import { nanoid } from 'nanoid';
// 导入开发环境判断标志 // 导入开发环境判断标志
import { isDev } from '@/data/constants.js'; import { isDev } from '@/data/constants.js';
// 导入所有自动操作子模块 // 导入所有自动操作子模块
import { usePointStore } from '@/store/usePointStore'; // 修正导入路径
import { useAutoReply } from './autoAction/modules/autoReply';
import { useEntryWelcome } from './autoAction/modules/entryWelcome';
import { useFollowThank } from './autoAction/modules/followThank';
import { useGiftThank } from './autoAction/modules/giftThank'; import { useGiftThank } from './autoAction/modules/giftThank';
import { useGuardPm } from './autoAction/modules/guardPm'; import { useGuardPm } from './autoAction/modules/guardPm';
import { useFollowThank } from './autoAction/modules/followThank';
import { useEntryWelcome } from './autoAction/modules/entryWelcome';
import { useAutoReply } from './autoAction/modules/autoReply';
import { useScheduledDanmaku } from './autoAction/modules/scheduledDanmaku'; import { useScheduledDanmaku } from './autoAction/modules/scheduledDanmaku';
import { useSuperChatThank } from './autoAction/modules/superChatThank'; import { useSuperChatThank } from './autoAction/modules/superChatThank';
import { useCheckIn } from './autoAction/modules/checkin';
// 定义名为 'autoAction' 的 Pinia store // 定义名为 'autoAction' 的 Pinia store
export const useAutoAction = defineStore('autoAction', () => { export const useAutoAction = defineStore('autoAction', () => {
@@ -50,11 +47,13 @@ export const useAutoAction = defineStore('autoAction', () => {
const danmakuClient = useDanmakuClient(); // 弹幕客户端 const danmakuClient = useDanmakuClient(); // 弹幕客户端
const biliFunc = useBiliFunction(); // B站相关功能函数 const biliFunc = useBiliFunction(); // B站相关功能函数
const account = useAccount(); // 账户信息用于获取房间ID和直播状态 const account = useAccount(); // 账户信息用于获取房间ID和直播状态
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); // 获取房间ID (开发环境使用固定ID)
const isTianXuanActive = ref(false); // 天选时刻活动状态 const isTianXuanActive = ref(false); // 天选时刻活动状态
const liveStartTime = ref<number | null>(null); // 直播开始时间戳
// --- 存储所有自动操作项 (使用 IndexedDB 持久化) --- // --- 存储所有自动操作项 (使用 IndexedDB 持久化) ---
const { data: autoActions, isFinished: isActionsLoaded } = useIDBKeyval<AutoActionItem[]>('autoAction.items', [], { const { data: autoActions, isFinished: isActionsLoaded } = useIDBKeyval<AutoActionItem[]>('autoAction.items', [], {
@@ -393,28 +392,29 @@ export const useAutoAction = defineStore('autoAction', () => {
if (action.triggerConfig.schedulingMode === undefined) action.triggerConfig.schedulingMode = 'random'; if (action.triggerConfig.schedulingMode === undefined) action.triggerConfig.schedulingMode = 'random';
} }
}); });
// 确保全局顺序索引已初始化 (以防 IDB 返回 null/undefined)
if (lastGlobalActionIndex.value === null || lastGlobalActionIndex.value === undefined) { // 启动定时器 (如果有需要)
lastGlobalActionIndex.value = -1; startGlobalTimer();
} startIndividualScheduledActions();
// 确保触发类型启用状态已初始化
if (!enabledTriggerTypes.value) {
enabledTriggerTypes.value = {
[TriggerType.DANMAKU]: true,
[TriggerType.GIFT]: true,
[TriggerType.GUARD]: true,
[TriggerType.FOLLOW]: true,
[TriggerType.ENTER]: true,
[TriggerType.SCHEDULED]: true,
[TriggerType.SUPER_CHAT]: true
};
}
checkTianXuanStatus(); // 检查天选状态
startIndividualScheduledActions(); // 启动独立定时任务
startGlobalTimer(); // 启动全局定时器 (如果需要)
registerEventListeners(); // 注册弹幕事件监听器
} }
}, { immediate: true }); // 立即执行一次检查 }, { immediate: true });
// 监听直播状态变化,处理签到模块的生命周期事件
watch(isLive, (currentState, prevState) => {
// 直播开始
if (currentState && !prevState) {
console.log('[AutoAction] 检测到直播开始,更新签到模块状态');
checkInModule.onLiveStart();
}
// 直播结束
else if (!currentState && prevState) {
console.log('[AutoAction] 检测到直播结束,更新签到模块状态');
checkInModule.onLiveEnd();
}
}, { immediate: true });
// 注册事件监听器
registerEventListeners();
} }
// 初始化模块 // 初始化模块
@@ -429,6 +429,7 @@ export const useAutoAction = defineStore('autoAction', () => {
const autoReplyModule = useAutoReply(isLive, roomId, biliFunc.sendLiveDanmaku); const autoReplyModule = useAutoReply(isLive, roomId, biliFunc.sendLiveDanmaku);
const scheduledDanmakuModule = useScheduledDanmaku(isLive, roomId, biliFunc.sendLiveDanmaku); const scheduledDanmakuModule = useScheduledDanmaku(isLive, roomId, biliFunc.sendLiveDanmaku);
const superChatThankModule = useSuperChatThank(isLive, roomId, isTianXuanActive, biliFunc.sendLiveDanmaku); const superChatThankModule = useSuperChatThank(isLive, roomId, isTianXuanActive, biliFunc.sendLiveDanmaku);
const checkInModule = useCheckIn(isLive, roomId, liveStartTime, isTianXuanActive, biliFunc.sendLiveDanmaku);
/** /**
* 向弹幕客户端注册事件监听器 * 向弹幕客户端注册事件监听器
@@ -556,6 +557,8 @@ export const useAutoAction = defineStore('autoAction', () => {
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:
// 调用礼物感谢模块 // 调用礼物感谢模块
@@ -710,42 +713,34 @@ export const useAutoAction = defineStore('autoAction', () => {
* @param actionId 要设置为下一个执行的操作ID * @param actionId 要设置为下一个执行的操作ID
*/ */
function setNextGlobalAction(actionId: string) { function setNextGlobalAction(actionId: string) {
const targetAction = autoActions.value.find(a => a.id === actionId);
if (!targetAction || targetAction.triggerType !== TriggerType.SCHEDULED || !targetAction.triggerConfig.useGlobalTimer) {
console.warn(`[AutoAction] setNextGlobalAction: 无法设置 ID 为 ${actionId} 的操作为下一个全局操作 (不存在、类型错误或未使用全局定时器)`);
return;
}
if (globalSchedulingMode.value !== 'sequential') { if (globalSchedulingMode.value !== 'sequential') {
console.warn('[AutoAction] 只能在顺序模式下手动指定下一个操作。'); console.warn(`[AutoAction] setNextGlobalAction: 只有在顺序模式下才能手动指定下一个操作。当前模式: ${globalSchedulingMode.value}`);
return; return;
} }
// 筛选出当前符合条件的活动操作 (与 nextScheduledAction 逻辑一致)
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)
); );
if (eligibleActions.length === 0) { const targetIndex = eligibleActions.findIndex(a => a.id === actionId);
console.warn('[AutoAction] 没有符合条件的活动操作可供指定。');
return;
}
// 找到目标操作在当前合格列表中的索引
const targetIndex = eligibleActions.findIndex(action => action.id === actionId);
if (targetIndex === -1) { if (targetIndex === -1) {
console.warn(`[AutoAction] 指定的操作ID ${actionId} 不存在或不符合当前执行条件。`); console.warn(`[AutoAction] setNextGlobalAction: 指定的操作 ID ${actionId} 当前不符合执行条件,无法设置为下一个`);
return; return;
} }
// 设置 lastGlobalActionIndex 为目标索引的前一个索引 // 设置索引,使其下一次执行 targetIndex
// 这样,在下一次 handleGlobalTimerTick 中计算 (lastGlobalActionIndex + 1) % length 时,就会得到 targetIndex lastGlobalActionIndex.value = (targetIndex - 1 + eligibleActions.length) % eligibleActions.length;
// 如果目标是列表中的第一个 (index 0),则将 lastGlobalActionIndex 设置为列表最后一个元素的索引 // 立即重置并重新安排计时器以便下次tick时执行新指定的任务
lastGlobalActionIndex.value = (targetIndex === 0) ? eligibleActions.length - 1 : targetIndex - 1;
console.log(`[AutoAction] 手动指定下一个执行的操作为: ${eligibleActions[targetIndex].name} (ID: ${actionId}), 将在下一个计时周期执行。`);
// 重启全局计时器,以便立即应用更改并在下一个周期执行指定的操作
// (如果不重启,则会在当前周期结束后,按新的索引执行)
restartGlobalTimer(); restartGlobalTimer();
} }
@@ -885,6 +880,7 @@ export const useAutoAction = defineStore('autoAction', () => {
isLive, // 直播状态 (计算属性) isLive, // 直播状态 (计算属性)
isTianXuanActive, // 天选状态 (ref) isTianXuanActive, // 天选状态 (ref)
enabledTriggerTypes, // 触发类型启用状态 enabledTriggerTypes, // 触发类型启用状态
checkInModule,
init, // 初始化函数 init, // 初始化函数
addAutoAction, // 添加操作 addAutoAction, // 添加操作
removeAutoAction, // 移除操作 removeAutoAction, // 移除操作
@@ -899,7 +895,7 @@ export const useAutoAction = defineStore('autoAction', () => {
stopIndividualTimer, stopIndividualTimer,
stopAllIndividualScheduledActions, stopAllIndividualScheduledActions,
startIndividualScheduledActions, startIndividualScheduledActions,
triggerTestActionByType // 新的 action triggerTestActionByType, // 新的 action
}; };
});// HMR (热模块替换) 支持 });// HMR (热模块替换) 支持
if (import.meta.hot) { if (import.meta.hot) {
@@ -907,5 +903,5 @@ if (import.meta.hot) {
} }
// 重新导出类型,方便外部使用 // 重新导出类型,方便外部使用
export { AutoActionItem, TriggerType, ActionType, Priority, KeywordMatchType }; export { ActionType, AutoActionItem, KeywordMatchType, Priority, TriggerType };

View File

@@ -371,7 +371,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
info('[BiliCookie] 检测到已存储的 Bilibili Cookie'); info('[BiliCookie] 检测到已存储的 Bilibili Cookie');
// 检查 Cookie 有效性,除非用户信息缓存有效且未过期 // 检查 Cookie 有效性,除非用户信息缓存有效且未过期
if (!_cachedUserInfo.value) { // 只有在没有有效缓存时才立即检查 if (!_cachedUserInfo.value) { // 只有在没有有效缓存时才立即检查
debug('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...'); info('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...');
const { valid } = await _checkCookieValidity(storedCookieData.cookie); const { valid } = await _checkCookieValidity(storedCookieData.cookie);
_updateCookieState(true, valid); // 更新状态 _updateCookieState(true, valid); // 更新状态
} }

View File

@@ -153,7 +153,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
justifyContent: 'space-between', justifyContent: 'space-between',
width: '100%', width: '100%',
}, },
}, () => `错误: ${json.code} - ${json.message || json.msg}`), }, `错误: ${json.code} - ${json.message || json.msg}`),
duration: 0, duration: 0,
}); });
console.error(`发送弹幕API失败 to: ${roomId} ${uid.value} [${message}] - ${json.code} - ${json.message || json.msg}`); console.error(`发送弹幕API失败 to: ${roomId} ${uid.value} [${message}] - ${json.code} - ${json.message || json.msg}`);

3
src/components.d.ts vendored
View File

@@ -36,6 +36,9 @@ declare module 'vue' {
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']
NSpin: typeof import('naive-ui')['NSpin']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip'] NTooltip: typeof import('naive-ui')['NTooltip']

View File

@@ -916,8 +916,12 @@ onMounted(() => {
</NSpace> </NSpace>
<NSpace align="center"> <NSpace align="center">
<NCheckbox <NCheckbox
:checked="updateSongModel.options!.fanMedalMinLevel != null" :checked="updateSongModel.options?.fanMedalMinLevel != null"
@update:checked="(checked: boolean) => updateSongModel.options!.fanMedalMinLevel = checked ? 1 : undefined" @update:checked="(checked: boolean) => {
if (updateSongModel.options) {
updateSongModel.options.fanMedalMinLevel = checked ? 1 : undefined;
}
}"
> >
粉丝牌 粉丝牌
<NTooltip trigger="hover"> <NTooltip trigger="hover">
@@ -931,14 +935,14 @@ onMounted(() => {
</NTooltip> </NTooltip>
</NCheckbox> </NCheckbox>
<NInputGroup <NInputGroup
v-if="updateSongModel.options!.fanMedalMinLevel != null" v-if="updateSongModel.options?.fanMedalMinLevel != null"
style="width: auto;" style="width: auto;"
> >
<NInputGroupLabel size="small"> <NInputGroupLabel size="small">
最低 最低
</NInputGroupLabel> </NInputGroupLabel>
<NInputNumber <NInputNumber
v-model:value="updateSongModel.options!.fanMedalMinLevel" v-model:value="updateSongModel.options.fanMedalMinLevel"
:min="1" :min="1"
size="small" size="small"
style="width: 80px;" style="width: 80px;"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { TURNSTILE_KEY } from '@/data/constants' import { TURNSTILE_KEY } from '@/data/constants'
import { isDarkMode } from '@/Utils';
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { onMounted } from 'vue' import { onMounted } from 'vue'
@@ -9,6 +10,11 @@ const turnstile = ref()
const token = defineModel<string>('token', { const token = defineModel<string>('token', {
default: '', default: '',
}) })
// Set theme based on dark mode status
const theme = computed(() => {
return isDarkMode ? 'dark' : 'light'
})
onUnmounted(() => { onUnmounted(() => {
turnstile.value?.remove() turnstile.value?.remove()
}) })
@@ -27,7 +33,7 @@ function reset() {
ref="turnstile" ref="turnstile"
v-model="token" v-model="token"
:site-key="TURNSTILE_KEY" :site-key="TURNSTILE_KEY"
theme="auto" :theme="theme"
style="text-align: center" style="text-align: center"
/> />
</template> </template>

View File

@@ -4,6 +4,7 @@ import { POINT_API_URL } from "@/data/constants";
import { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider"; import { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useAuthStore } from "./useAuthStore"; import { useAuthStore } from "./useAuthStore";
import { GuidUtils } from "@/Utils";
export const usePointStore = defineStore('point', () => { export const usePointStore = defineStore('point', () => {
const useAuth = useAuthStore() const useAuth = useAuthStore()
@@ -41,9 +42,54 @@ export const usePointStore = defineStore('point', () => {
} }
return [] return []
} }
/**
* 给用户添加或扣除积分
* @param userId 用户ID
* @param count 积分数量(正数为增加,负数为减少)
* @param reason 积分变动原因
* @param remark 备注信息
* @returns 成功时返回修改后的积分值失败时返回null
*/
async function addPoints(userId: string, count: number, reason: string, remark?: string) {
if (count === 0) {
console.warn('[point] 积分变动数量不能为0');
return null;
}
try {
// 根据用户ID构建参数
const params: Record<string, any> = GuidUtils.isGuidFromUserId(userId) ? {
uId: GuidUtils.guidToLong(userId),
count: count,
reason: reason || '',
} : {
oid: userId,
count: count,
reason: reason || '',
};
if (remark) {
params.remark = remark;
}
const data = await QueryGetAPI<number>(POINT_API_URL + 'give-point', params);
if (data.code === 200) {
console.log(`[point] 用户 ${userId} 积分${count > 0 ? '增加' : '减少'} ${Math.abs(count)} 成功,当前积分:${data.data}`);
return data.data; // 返回修改后的积分值
} else {
console.error('[point] 积分操作失败:', data.message);
return null;
}
} catch (err) {
console.error('[point] 积分操作出错:', err);
return null;
}
}
return { return {
GetSpecificPoint, GetSpecificPoint,
GetGoods GetGoods,
addPoints
} }
}) })

View File

@@ -206,197 +206,289 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div <div class="question-box-container">
class="question-box-container"
title="提问"
>
<!-- 提问表单 --> <!-- 提问表单 -->
<NCard embedded> <transition
<NSpace vertical> name="fade-slide-down"
<!-- 话题选择区域 --> appear
<NCard >
v-if="tags.length > 0" <NCard
title="投稿话题 (可选)" embedded
size="small" class="question-form-card"
> :class="{ 'self-user': isSelf }"
<NSpace> >
<NTag <NSpace vertical>
v-for="tag in tags" <!-- 话题选择区域 -->
:key="tag" <transition
style="cursor: pointer" name="fade-scale"
:bordered="false" appear
:type="selectedTag === tag ? 'primary' : 'default'" >
@click="onSelectTag(tag)" <NCard
v-if="tags.length > 0"
title="投稿话题 (可选)"
size="small"
class="topic-card"
> >
{{ tag }} <transition-group
</NTag> name="tag-list"
</NSpace> tag="div"
</NCard> class="tag-container"
>
<NTag
v-for="tag in tags"
:key="tag"
class="tag-item"
:bordered="false"
:type="selectedTag === tag ? 'primary' : 'default'"
@click="onSelectTag(tag)"
>
{{ tag }}
</NTag>
</transition-group>
</NCard>
</transition>
<!-- 提问内容区域 --> <!-- 提问内容区域 -->
<NSpace <div class="question-input-area">
align="center" <NInput
justify="center" v-model:value="questionMessage"
> :disabled="isSelf"
<NInput show-count
v-model:value="questionMessage" maxlength="5000"
:disabled="isSelf" type="textarea"
show-count :count-graphemes="countGraphemes"
maxlength="5000" class="question-textarea"
type="textarea" placeholder="在这里输入您的问题..."
:count-graphemes="countGraphemes" />
style="width: 300px" <transition
/> name="fade-scale"
<NUpload >
v-model:file-list="fileList" <NUpload
:max="1" v-model:file-list="fileList"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico" :max="1"
list-type="image-card" accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
:disabled="!accountInfo.id || isSelf" list-type="image-card"
:default-upload="false" :disabled="!accountInfo.id || isSelf"
@update:file-list="OnFileListChange" :default-upload="false"
class="image-upload"
@update:file-list="OnFileListChange"
>
<div class="upload-trigger">
<div class="upload-icon">
+
</div>
<span>上传图片</span>
</div>
</NUpload>
</transition>
</div>
<NDivider class="form-divider" />
<!-- 提示信息 -->
<transition
name="fade"
appear
> >
+ 上传图片 <NAlert
</NUpload> v-if="!accountInfo.id && !isSelf"
</NSpace> type="warning"
class="login-alert"
>
只有注册用户才能够上传图片
</NAlert>
</transition>
<NDivider style="margin: 10px 0" /> <!-- 匿名选项 -->
<transition
<!-- 提示信息 --> name="fade"
<NSpace align="center"> appear
<NAlert
v-if="!accountInfo.id && !isSelf"
type="warning"
> >
只有注册用户才能够上传图片 <NSpace
</NAlert> v-if="accountInfo.id"
</NSpace> vertical
class="anonymous-option"
>
<NCheckbox
v-model:checked="isAnonymous"
:disabled="isSelf"
label="匿名提问"
/>
<NDivider class="form-divider" />
</NSpace>
</transition>
<!-- 匿名选项 --> <!-- 操作按钮 -->
<NSpace <div class="action-buttons">
v-if="accountInfo.id" <NButton
vertical :disabled="isSelf"
> type="primary"
<NCheckbox :loading="isSending || !token"
v-model:checked="isAnonymous" class="send-button"
:disabled="isSelf" @click="SendQuestion"
label="匿名提问" >
/> 发送
<NDivider style="margin: 10px 0" /> </NButton>
</NSpace> <NButton
:disabled="isSelf || !accountInfo.id"
type="info"
class="my-questions-button"
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })"
>
我发送的
</NButton>
</div>
<!-- 操作按钮 --> <!-- 验证码 -->
<NSpace justify="center"> <div class="turnstile-container">
<NButton <VueTurnstile
:disabled="isSelf" ref="turnstile"
type="primary" v-model="token"
:loading="isSending || !token" :site-key="TURNSTILE_KEY"
@click="SendQuestion" theme="auto"
/>
</div>
<!-- 错误提示 -->
<transition
name="fade-slide-up"
appear
> >
发送 <NAlert
</NButton> v-if="isSelf"
<NButton type="warning"
:disabled="isSelf || !accountInfo.id" class="self-alert"
type="info" >
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })" 不能给自己提问
> </NAlert>
我发送的 </transition>
</NButton>
</NSpace> </NSpace>
</NCard>
<!-- 验证码 --> </transition>
<VueTurnstile
ref="turnstile"
v-model="token"
:site-key="TURNSTILE_KEY"
theme="auto"
style="text-align: center"
/>
<!-- 错误提示 -->
<NAlert
v-if="isSelf"
type="warning"
>
不能给自己提问
</NAlert>
</NSpace>
</NCard>
<!-- 公开回复列表 --> <!-- 公开回复列表 -->
<NDivider> 公开回复 </NDivider> <transition
<NList v-if="publicQuestions.length > 0"> name="fade"
<NListItem appear
v-for="item in publicQuestions" >
:key="item.id" <div>
> <NDivider class="public-divider">
<NCard <div class="divider-content">
:embedded="!item.isReaded" 公开回复
hoverable </div>
size="small" </NDivider>
<transition-group
name="list-fade"
tag="div"
class="questions-list-container"
> >
<!-- 问题头部 --> <NList
<template #header> v-if="publicQuestions.length > 0"
<NSpace class="questions-list"
:size="0"
align="center"
>
<NText
depth="3"
style="font-size: small"
>
<NTooltip>
<template #trigger>
<NTime
:time="item.sendAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
</NSpace>
</template>
<!-- 问题内容 -->
<NCard style="text-align: center">
{{ item.question.message }}
<br>
<NImage
v-if="item.question.image"
:src="item.question.image"
height="100"
lazy
/>
</NCard>
<!-- 回答内容 -->
<template
v-if="item.answer"
#footer
> >
<NSpace <NListItem
align="center" v-for="item in publicQuestions"
:size="6" :key="item.id"
:wrap="false" class="question-list-item"
> >
<NAvatar <NCard
:src="AVATAR_URL + userInfo?.biliId + '?size=64'" :embedded="!item.isReaded"
circle hoverable
:size="45" size="small"
:img-props="{ referrerpolicy: 'no-referrer' }" class="question-card"
/> :class="{ 'unread': !item.isReaded }"
<NDivider vertical /> >
<NText style="font-size: 16px"> <!-- 问题头部 -->
{{ item.answer?.message }} <template #header>
</NText> <NSpace
</NSpace> :size="0"
</template> align="center"
</NCard> class="question-header"
</NListItem> >
</NList> <NText
<NEmpty v-else /> depth="3"
class="time-text"
>
<NTooltip>
<template #trigger>
<NTime
:time="item.sendAt"
:to="Date.now()"
type="relative"
/>
</template>
<NTime :time="item.sendAt" />
</NTooltip>
</NText>
<div
v-if="item.tag"
class="question-tag"
>
<NTag
size="small"
type="info"
>
{{ item.tag }}
</NTag>
</div>
</NSpace>
</template>
<!-- 问题内容 -->
<NCard class="question-content">
<div class="question-message">
{{ item.question.message }}
</div>
<div
v-if="item.question.image"
class="question-image-container"
>
<NImage
:src="item.question.image"
class="question-image"
lazy
object-fit="contain"
/>
</div>
</NCard>
<!-- 回答内容 -->
<template
v-if="item.answer"
#footer
>
<div class="answer-container">
<NSpace
align="center"
:wrap="false"
class="answer-content"
>
<NAvatar
:src="AVATAR_URL + userInfo?.biliId + '?size=64'"
circle
class="answer-avatar"
:img-props="{ referrerpolicy: 'no-referrer' }"
/>
<NDivider
vertical
class="answer-divider"
/>
<NText class="answer-text">
{{ item.answer?.message }}
</NText>
</NSpace>
</div>
</template>
</NCard>
</NListItem>
</NList>
<NEmpty
v-else
class="empty-state"
/>
</transition-group>
</div>
</transition>
<NDivider /> <NDivider />
</div> </div>
@@ -408,5 +500,380 @@ onUnmounted(() => {
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
width: 100%; width: 100%;
padding: 0 16px;
}
/* 卡片样式 */
.question-form-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
overflow: hidden;
}
.question-form-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.self-user {
border-left: 4px solid #f5222d;
}
/* 话题选择卡片 */
.topic-card {
border-radius: 8px;
overflow: hidden;
}
.tag-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
border-radius: 16px;
}
.tag-item:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 提问输入区域 */
.question-textarea {
flex: 1;
border-radius: 8px;
transition: all 0.3s ease;
width: 100% !important; /* 使用 !important 确保不被其他样式覆盖 */
min-height: 100px; /* 设置最小高度 */
resize: vertical; /* 允许垂直调整大小 */
}
/* 设置 naive-ui 内部元素样式 */
.question-textarea :deep(.n-input__textarea) {
min-width: 100% !important;
width: 100% !important;
}
.question-textarea :deep(.n-input__textarea-el) {
min-width: 100% !important;
width: 100% !important;
}
/* 确保输入框容器占满可用空间 */
.question-input-area {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
width: 100%;
}
@media (min-width: 640px) {
.question-input-area {
flex-direction: row;
align-items: flex-start;
}
/* 在水平布局中设置输入框区域的最小宽度 */
.question-textarea {
min-width: 75%; /* 占据至少75%的宽度 */
}
}
.image-upload {
min-width: 112px;
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
opacity: 0.8;
transition: all 0.3s ease;
}
.upload-trigger:hover {
opacity: 1;
transform: scale(1.05);
}
.upload-icon {
font-size: 24px;
margin-bottom: 4px;
}
/* 分隔线 */
.form-divider {
margin: 10px 0;
opacity: 0.6;
}
/* 警告提示 */
.login-alert,
.self-alert {
border-radius: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(255, 213, 79, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 213, 79, 0);
}
}
/* 匿名选项 */
.anonymous-option {
margin: 8px 0;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin: 8px 0 16px 0;
}
.send-button,
.my-questions-button {
min-width: 100px;
transition: all 0.3s ease;
border-radius: 20px;
}
.send-button:not(:disabled):hover,
.my-questions-button:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 验证码容器 */
.turnstile-container {
display: flex;
justify-content: center;
margin: 8px 0;
}
/* 公开回复部分 */
.public-divider {
margin: 32px 0 24px;
position: relative;
}
.divider-content {
font-weight: bold;
color: #36ad6a;
background-image: linear-gradient(90deg, #36ad6a, #18a058);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
display: inline-block;
padding: 0 8px;
position: relative;
}
.questions-list-container {
position: relative;
}
.questions-list {
border-radius: 12px;
overflow: hidden;
}
.question-list-item {
margin-bottom: 16px;
}
.question-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.question-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.unread {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
}
.question-header {
padding: 8px 0;
}
.time-text {
font-size: 12px;
opacity: 0.8;
}
.question-tag {
margin-left: 8px;
}
.question-content {
text-align: center;
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 6px;
}
.question-message {
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 12px;
}
.question-image-container {
display: flex;
justify-content: center;
margin-top: 12px;
}
.question-image {
max-height: 200px;
border-radius: 8px;
transition: all 0.3s ease;
}
.question-image:hover {
transform: scale(1.02);
}
.answer-container {
padding: 12px;
background-color: rgba(24, 160, 88, 0.06);
border-radius: 8px;
margin-top: 8px;
}
.answer-content {
gap: 12px;
}
.answer-avatar {
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 2px solid #fff;
transition: all 0.3s ease;
}
.answer-avatar:hover {
transform: scale(1.05);
}
.answer-divider {
height: 24px;
margin: 0 4px;
}
.answer-text {
font-size: 16px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.empty-state {
padding: 24px;
opacity: 0.7;
}
/* 过渡动效 */
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 下滑淡入 */
.fade-slide-down-enter-active,
.fade-slide-down-leave-active {
transition: all 0.5s ease;
}
.fade-slide-down-enter-from,
.fade-slide-down-leave-to {
opacity: 0;
transform: translateY(-20px);
}
/* 上滑淡入 */
.fade-slide-up-enter-active,
.fade-slide-up-leave-active {
transition: all 0.5s ease;
}
.fade-slide-up-enter-from,
.fade-slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 缩放淡入 */
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.5s ease;
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.9);
}
/* 标签列表过渡 */
.tag-list-move {
transition: all 0.5s ease;
}
.tag-list-enter-active,
.tag-list-leave-active {
transition: all 0.5s ease;
}
.tag-list-enter-from,
.tag-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 问题列表过渡 */
.list-fade-move {
transition: transform 0.5s ease;
}
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.5s ease;
}
.list-fade-enter-from,
.list-fade-leave-to {
opacity: 0;
transform: translateY(30px);
} }
</style> </style>

View File

@@ -31,6 +31,8 @@ const selectedLanguage = ref<string | undefined>();
const selectedTag = ref<string | undefined>(); // Renamed from activeTab for clarity const selectedTag = ref<string | undefined>(); // Renamed from activeTab for clarity
const searchQuery = ref<string>(''); const searchQuery = ref<string>('');
const selectedArtist = ref<string | null>(null); const selectedArtist = ref<string | null>(null);
// 添加点歌条件筛选状态
const selectedOption = ref<string | undefined>();
// --- New: Sorting State --- // --- New: Sorting State ---
type SortKey = 'name' | 'author' | 'language' | 'tags' | 'options' | 'description' | null; type SortKey = 'name' | 'author' | 'language' | 'tags' | 'options' | 'description' | null;
@@ -42,6 +44,10 @@ const sortOrder = ref<'asc' | 'desc'>('asc'); // 当前排序顺序
// Extract unique languages // Extract unique languages
const allUniqueLanguages = computed<string[]>(() => { const allUniqueLanguages = computed<string[]>(() => {
const languages = new Set<string>(); const languages = new Set<string>();
// 添加"未设定"语言选项
languages.add('未设定');
props.data?.forEach(song => { props.data?.forEach(song => {
song.language?.forEach(lang => { song.language?.forEach(lang => {
if (lang?.trim()) { if (lang?.trim()) {
@@ -60,6 +66,10 @@ const languageButtons = computed<FilterButton[]>(() =>
// Extract unique tags (similar to original 'tabs' logic) // Extract unique tags (similar to original 'tabs' logic)
const allUniqueTags = computed<string[]>(() => { const allUniqueTags = computed<string[]>(() => {
const tags = new Set<string>(); const tags = new Set<string>();
// 添加"未设定"标签选项
tags.add('未设定');
props.data?.forEach(song => { props.data?.forEach(song => {
song.tags?.forEach(tag => { song.tags?.forEach(tag => {
if (tag?.trim()) { if (tag?.trim()) {
@@ -75,6 +85,28 @@ const tagButtons = computed<FilterButton[]>(() =>
allUniqueTags.value.map((tag, index) => ({ id: index, name: tag })) allUniqueTags.value.map((tag, index) => ({ id: index, name: tag }))
); );
// --- 添加点歌条件筛选按钮 ---
// 提取所有唯一的点歌条件类型
const allOptionTypes = computed<string[]>(() => {
const optionTypes = new Set<string>();
// 添加"未设定"选项
optionTypes.add('未设定');
// 添加基本选项类型
optionTypes.add('舰长');
optionTypes.add('提督');
optionTypes.add('总督');
optionTypes.add('粉丝牌');
optionTypes.add('SC');
return Array.from(optionTypes);
});
// 创建点歌条件筛选按钮
const optionButtons = computed<FilterButton[]>(() =>
allOptionTypes.value.map((option, index) => ({ id: index, name: option }))
);
// --- Computed Properties for Data --- // --- Computed Properties for Data ---
@@ -105,21 +137,52 @@ const filteredAndSortedSongs = computed(() => {
// 1. Filter by Selected Language // 1. Filter by Selected Language
if (selectedLanguage.value) { if (selectedLanguage.value) {
const lang = selectedLanguage.value; const lang = selectedLanguage.value;
query = query.Where(song => song.language?.includes(lang)); if (lang === '未设定') {
// 筛选没有设置语言或语言数组为空的歌曲
query = query.Where(song => !song.language || song.language.length === 0);
} else {
query = query.Where(song => song.language?.includes(lang));
}
} }
// 2. Filter by Selected Tag // 2. Filter by Selected Tag
if (selectedTag.value) { if (selectedTag.value) {
const tag = selectedTag.value; const tag = selectedTag.value;
query = query.Where(song => song.tags?.includes(tag) ?? false); if (tag === '未设定') {
// 筛选没有设置标签或标签数组为空的歌曲
query = query.Where(song => !song.tags || song.tags.length === 0);
} else {
query = query.Where(song => song.tags?.includes(tag) ?? false);
}
} }
// 3. Filter by Selected Artist // 3. Filter by Selected Artist
if (selectedArtist.value) { if (selectedArtist.value) {
const artist = selectedArtist.value; const artist = selectedArtist.value;
query = query.Where(song => song.author?.includes(artist)); query = query.Where(song => song.author?.includes(artist) ?? false);
} }
// 新增: 4. 根据点歌条件筛选
if (selectedOption.value) {
const option = selectedOption.value;
if (option === '未设定') {
// 筛选没有设置点歌条件的歌曲
query = query.Where(song => !song.options);
} else if (option === '舰长') {
query = query.Where(song => song.options?.needJianzhang === true);
} else if (option === '提督') {
query = query.Where(song => song.options?.needTidu === true);
} else if (option === '总督') {
query = query.Where(song => song.options?.needZongdu === true);
} else if (option === '粉丝牌') {
query = query.Where(song => (song.options?.fanMedalMinLevel ?? 0) > 0);
} else if (option === 'SC') {
query = query.Where(song => (song.options?.scMinPrice ?? 0) > 0);
}
}
// 原有的搜索逻辑
// 4. Filter by Search Query (case-insensitive, including tags) // 4. Filter by Search Query (case-insensitive, including tags)
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const lowerSearch = searchQuery.value.toLowerCase().trim(); const lowerSearch = searchQuery.value.toLowerCase().trim();
@@ -190,6 +253,15 @@ const selectTag = (tagName: string) => {
} }
}; };
// 新增: 选择/取消选择点歌条件
const selectOption = (optionName: string) => {
if (optionName === selectedOption.value) {
selectedOption.value = undefined; // 点击已激活的按钮则取消筛选
} else {
selectedOption.value = optionName;
}
};
// Select Artist (from table click, updated to allow deselect) // Select Artist (from table click, updated to allow deselect)
const selectArtistFromTable = (artist: string) => { const selectArtistFromTable = (artist: string) => {
if (selectedArtist.value === artist) { if (selectedArtist.value === artist) {
@@ -213,6 +285,7 @@ const clearFilters = () => {
selectedLanguage.value = undefined; selectedLanguage.value = undefined;
selectedTag.value = undefined; selectedTag.value = undefined;
selectedArtist.value = null; // Reset NSelect value selectedArtist.value = null; // Reset NSelect value
selectedOption.value = undefined; // 清除点歌条件筛选
searchQuery.value = ''; searchQuery.value = '';
}; };
@@ -361,8 +434,8 @@ function GetPlayButton(song: SongsInfo) {
// --- New: Helper function for Song Request Options --- // --- New: Helper function for Song Request Options ---
function getOptionDisplay(options?: SongRequestOption) { function getOptionDisplay(options?: SongRequestOption) {
if (!options) { if (!options) {
// 为"无特殊要求"添加 'empty-placeholder' 类 // 直接返回空元素,不显示"无特殊要求"
return h('span', { class: 'empty-placeholder' }, '无特殊要求'); return h('span', {});
} }
const conditions: VNode[] = []; const conditions: VNode[] = [];
@@ -384,8 +457,8 @@ function getOptionDisplay(options?: SongRequestOption) {
} }
if (conditions.length === 0) { if (conditions.length === 0) {
// 为"无特殊要求"添加 'empty-placeholder' 类 // 如果没有条件,直接返回空元素,不显示"无特殊要求"
return h('span', { class: 'empty-placeholder' }, '无特殊要求'); return h('span', {});
} }
// Use NFlex for better wrapping // Use NFlex for better wrapping
@@ -697,6 +770,23 @@ export const Config = defineTemplateConfig([
</button> </button>
</div> </div>
<!-- 新增: 点歌条件筛选按钮 -->
<div
v-if="optionButtons.length > 0"
class="filter-button-group option-filters"
>
<span class="filter-label">点歌条件:</span>
<button
v-for="option in optionButtons"
:key="option.id"
:class="{ active: selectedOption === option.name }"
class="filter-button"
@click="selectOption(option.name)"
>
{{ option.name }}
</button>
</div>
<!-- Divider --> <!-- Divider -->
<div class="filter-divider" /> <div class="filter-divider" />
@@ -885,10 +975,11 @@ export const Config = defineTemplateConfig([
<span v-if="index < song.language.length - 1">, </span> <span v-if="index < song.language.length - 1">, </span>
</span> </span>
</span> </span>
<span v-else>未知</span> <!-- 移除了 "未知" 占位文本 -->
</td> </td>
<td> <td>
<n-flex <n-flex
v-if="song.tags && song.tags.length > 0"
:size="4" :size="4"
:wrap="true" :wrap="true"
style="gap: 4px;" style="gap: 4px;"
@@ -904,12 +995,8 @@ export const Config = defineTemplateConfig([
> >
{{ tag }} {{ tag }}
</n-tag> </n-tag>
<!-- "无标签"添加 'empty-placeholder' -->
<span
v-if="!song.tags || song.tags.length === 0"
class="empty-placeholder"
>无标签</span>
</n-flex> </n-flex>
<!-- 移除了 "无标签" 占位文本 -->
</td> </td>
<td> <td>
<component :is="getOptionDisplay(song.options)" /> <component :is="getOptionDisplay(song.options)" />
@@ -981,7 +1068,6 @@ html.dark .filter-button {
html.dark .filter-button:hover:not(.active) { html.dark .filter-button:hover:not(.active) {
background-color: var(--item-color-hover); background-color: var(--item-color-hover);
border-color: var(--border-color-hover);
} }
/* Divider between filters and search bar */ /* Divider between filters and search bar */