mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 更新组件和配置,增强功能和用户体验, 添加签到功能
- 在 .editorconfig 中调整文件格式设置,统一代码风格。 - 在 default.d.ts 中为 naive-ui 添加 TabPaneSlots 接口声明,增强类型支持。 - 在多个组件中优化了模板和样式,提升用户交互体验。 - 在 ClientAutoAction.vue 中新增签到设置标签页,丰富功能选项。 - 在 Utils.ts 中增强 GUID 处理逻辑,增加输入验证和错误处理。 - 更新多个组件的逻辑,简化代码结构,提升可读性和维护性。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
[*.{js,jsx,ts,tsx,vue,vine.ts}]
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
|
||||
6
default.d.ts
vendored
6
default.d.ts
vendored
@@ -15,6 +15,12 @@ declare module '*.js' {
|
||||
export = content
|
||||
}
|
||||
|
||||
declare module 'naive-ui' {
|
||||
interface TabPaneSlots {
|
||||
tab?: () => VNode[]
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
$message: MessageProviderInst
|
||||
|
||||
59
src/Utils.ts
59
src/Utils.ts
@@ -156,6 +156,9 @@ export function getOUIdAvatarUrl(ouid: string) {
|
||||
export class GuidUtils {
|
||||
// 将数字转换为GUID
|
||||
public static numToGuid(value: number): string {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('输入必须是非负安全整数');
|
||||
}
|
||||
const buffer = new ArrayBuffer(16);
|
||||
const view = new DataView(buffer);
|
||||
view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节
|
||||
@@ -164,44 +167,60 @@ export class GuidUtils {
|
||||
|
||||
// 检查GUID是否由数字生成
|
||||
public static isGuidFromUserId(guid: string): boolean {
|
||||
const buffer = GuidUtils.guidToBuffer(guid);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0
|
||||
try {
|
||||
const buffer = GuidUtils.guidToBuffer(guid);
|
||||
const view = new DataView(buffer);
|
||||
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转换为数字
|
||||
public static guidToLong(guid: string): number {
|
||||
const buffer = GuidUtils.guidToBuffer(guid);
|
||||
const view = new DataView(buffer);
|
||||
return Number(view.getBigUint64(8));
|
||||
try {
|
||||
const buffer = GuidUtils.guidToBuffer(guid);
|
||||
const view = new DataView(buffer);
|
||||
return Number(view.getBigUint64(8));
|
||||
} catch (e) {
|
||||
throw new Error('无效的GUID格式');
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助方法:将ArrayBuffer转换为GUID字符串
|
||||
private static bufferToGuid(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const guid = bytes.reduce((str, byte, idx) => {
|
||||
const pair = byte.toString(16).padStart(2, '0');
|
||||
return (
|
||||
str +
|
||||
pair +
|
||||
(idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '')
|
||||
);
|
||||
}, '');
|
||||
return guid;
|
||||
let hex = '';
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
// 标准GUID格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
return hex.substring(0, 8) + '-' +
|
||||
hex.substring(8, 12) + '-' +
|
||||
hex.substring(12, 16) + '-' +
|
||||
hex.substring(16, 20) + '-' +
|
||||
hex.substring(20);
|
||||
}
|
||||
|
||||
// 辅助方法:将GUID字符串转换为ArrayBuffer
|
||||
private static guidToBuffer(guid: string): ArrayBuffer {
|
||||
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 view = new DataView(buffer);
|
||||
const view = new Uint8Array(buffer);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import GlobalScheduledSettings from './components/autoaction/settings/GlobalSche
|
||||
import TimerCountdown from './components/autoaction/TimerCountdown.vue';
|
||||
import DataManager from './components/autoaction/DataManager.vue';
|
||||
import ActionHistoryViewer from './components/autoaction/ActionHistoryViewer.vue';
|
||||
import CheckInSettings from './components/autoaction/settings/CheckInSettings.vue';
|
||||
|
||||
const autoActionStore = useAutoAction();
|
||||
const message = useMessage();
|
||||
@@ -699,6 +700,14 @@ onMounted(() => {
|
||||
</NTabs>
|
||||
</NTabPane>
|
||||
|
||||
<!-- 新增:签到设置标签页 -->
|
||||
<NTabPane
|
||||
name="check-in-settings"
|
||||
tab="签到设置"
|
||||
>
|
||||
<CheckInSettings />
|
||||
</NTabPane>
|
||||
|
||||
<!-- 历史记录标签页 -->
|
||||
<NTabPane
|
||||
name="action-history"
|
||||
|
||||
@@ -24,8 +24,6 @@ import { useDanmakuWindow } from './store/useDanmakuWindow';
|
||||
|
||||
// 获取 webfetcher 状态管理的实例
|
||||
const webfetcher = useWebFetcher();
|
||||
// 获取账户信息状态管理的实例 (如果 accountInfo 未使用,可以考虑移除)
|
||||
const accountInfo = useAccount();
|
||||
const danmakuWindow = useDanmakuWindow();
|
||||
// 用于存储用户输入的 Token
|
||||
const token = ref('');
|
||||
|
||||
155
src/client/components/autoaction/CheckInTemplateHelper.vue
Normal file
155
src/client/components/autoaction/CheckInTemplateHelper.vue
Normal 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>{{user.name}}</code> - 用户名称
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{user.uid}}</code> - 用户ID
|
||||
</div>
|
||||
</div>
|
||||
<div class="placeholder-group">
|
||||
<div class="group-title">
|
||||
签到信息
|
||||
</div> <div class="placeholder-item">
|
||||
<code>{{checkin.points}}</code> - 基础签到积分
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{checkin.bonusPoints}}</code> - 早鸟额外积分 (普通签到为0)
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{checkin.totalPoints}}</code> - 总获得积分
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{checkin.isEarlyBird}}</code> - 是否是早鸟签到 (true/false)
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{checkin.cooldownSeconds}}</code> - 签到冷却时间(秒)
|
||||
</div>
|
||||
<div class="placeholder-item">
|
||||
<code>{{checkin.time}}</code> - 签到时间对象
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NDivider style="margin: 6px 0;" />
|
||||
<div class="placeholder-example">
|
||||
<div class="example-title">
|
||||
示例模板:
|
||||
</div> <div class="example-item">
|
||||
普通签到: <code>{{user.name}} 签到成功!获得 {{checkin.totalPoints}} 积分。</code>
|
||||
</div>
|
||||
<div class="example-item">
|
||||
早鸟签到: <code>恭喜 {{user.name}} 完成早鸟签到!额外获得 {{checkin.bonusPoints}} 积分,共获得 {{checkin.totalPoints}} 积分!</code>
|
||||
</div>
|
||||
<div class="example-item">
|
||||
条件表达式: <code>{{js: checkin.isEarlyBird ? `恭喜 ${user.name} 获得早鸟奖励!` : `${user.name} 签到成功!`}} 获得 {{checkin.totalPoints}} 积分。</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>
|
||||
@@ -31,7 +31,7 @@ function handleTemplateUpdate(payload: { index: number, value: string }) {
|
||||
|
||||
<template>
|
||||
<TemplateEditor
|
||||
:action="props.action"
|
||||
:template="props.action"
|
||||
:template-index="0"
|
||||
:title="title"
|
||||
:description="description"
|
||||
|
||||
@@ -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 { computed, ref, watch } from '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 { buildExecutionContext } from '@/client/store/autoAction/utils';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/autoAction/types';
|
||||
@@ -11,7 +10,7 @@ import { EventDataTypes, EventModel } from '@/api/api-models';
|
||||
import GraphemeSplitter from 'grapheme-splitter';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
template: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
},
|
||||
@@ -26,6 +25,10 @@ const props = defineProps({
|
||||
checkLength: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
customTestContext: {
|
||||
type: Object,
|
||||
default: undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +55,7 @@ const mergedPlaceholders = computed(() => {
|
||||
|
||||
const specificPlaceholders: { name: string, description: string }[] = [];
|
||||
|
||||
switch (props.action.triggerType) {
|
||||
switch (props.template.triggerType) {
|
||||
case TriggerType.DANMAKU:
|
||||
specificPlaceholders.push(
|
||||
{ name: '{{message}}', description: '弹幕内容' },
|
||||
@@ -87,8 +90,27 @@ const mergedPlaceholders = computed(() => {
|
||||
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(() => {
|
||||
return buildExecutionContext({
|
||||
// 创建默认上下文
|
||||
const defaultContext = buildExecutionContext({
|
||||
msg: '测试',
|
||||
time: 1713542400,
|
||||
num: 1,
|
||||
@@ -105,8 +127,14 @@ const testContext = computed(() => {
|
||||
fans_medal_wearing_status: true,
|
||||
guard_level_name: '测试舰队',
|
||||
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();
|
||||
@@ -120,13 +148,13 @@ function countGraphemes(value: string) {
|
||||
}
|
||||
|
||||
function convertPlaceholders() {
|
||||
if (!props.action.template) {
|
||||
if (!props.template.template) {
|
||||
message.warning('请先输入模板内容');
|
||||
return;
|
||||
}
|
||||
const converted = convertToJsExpressions(props.action.template, mergedPlaceholders.value);
|
||||
if (converted !== props.action.template) {
|
||||
props.action.template = converted;
|
||||
const converted = convertToJsExpressions(props.template.template, mergedPlaceholders.value);
|
||||
if (converted !== props.template.template) {
|
||||
props.template.template = converted;
|
||||
message.success('已转换占位符为表达式格式');
|
||||
} else {
|
||||
message.info('模板中没有需要转换的占位符');
|
||||
@@ -139,7 +167,7 @@ function hasJsExpression(template: string): boolean {
|
||||
|
||||
const highlightPatterns = computed(() => {
|
||||
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])];
|
||||
return allPatterns;
|
||||
});
|
||||
@@ -148,7 +176,20 @@ const MAX_LENGTH = 20;
|
||||
const WARNING_THRESHOLD = 16;
|
||||
|
||||
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 {
|
||||
return evaluateTemplateExpressions(template, executionContext);
|
||||
} catch (error) {
|
||||
@@ -158,8 +199,8 @@ function evaluateTemplateForUI(template: string): string {
|
||||
}
|
||||
|
||||
const evaluatedTemplateResult = computed(() => {
|
||||
if (!props.action.template || !showLivePreview.value) return '';
|
||||
return evaluateTemplateForUI(props.action.template);
|
||||
if (!props.template.template || !showLivePreview.value) return '';
|
||||
return evaluateTemplateForUI(props.template.template);
|
||||
});
|
||||
|
||||
const previewResult = computed(() => {
|
||||
@@ -167,7 +208,7 @@ const previewResult = 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: '' };
|
||||
}
|
||||
try {
|
||||
@@ -230,7 +271,7 @@ const templateExamples = [
|
||||
];
|
||||
|
||||
function insertExample(template: string) {
|
||||
props.action.template = template;
|
||||
props.template.template = template;
|
||||
message.success('已插入示例模板');
|
||||
}
|
||||
</script>
|
||||
@@ -286,7 +327,7 @@ function insertExample(template: string) {
|
||||
|
||||
<!-- 当前模板预览 -->
|
||||
<NInput
|
||||
v-model:value="action.template"
|
||||
v-model:value="template.template"
|
||||
type="textarea"
|
||||
placeholder="输入模板内容... 使用 {{变量名}} 插入变量, {{js: 表达式}} 执行JS"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
@@ -352,15 +393,6 @@ function insertExample(template: string) {
|
||||
>
|
||||
占位符转表达式
|
||||
</NButton>
|
||||
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="activeTab = 'test'"
|
||||
>
|
||||
测试模板
|
||||
</NButton>
|
||||
</NFlex>
|
||||
|
||||
<!-- 模板示例 -->
|
||||
@@ -403,17 +435,6 @@ function insertExample(template: string) {
|
||||
</NCollapse>
|
||||
</NFlex>
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="test"
|
||||
tab="测试"
|
||||
>
|
||||
<TemplateTester
|
||||
:default-template="action.template"
|
||||
:context="testContext"
|
||||
:placeholders="mergedPlaceholders"
|
||||
/>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
<!-- 新增 Modal 组件 -->
|
||||
|
||||
398
src/client/components/autoaction/settings/CheckInSettings.vue
Normal file
398
src/client/components/autoaction/settings/CheckInSettings.vue
Normal 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>
|
||||
@@ -56,7 +56,7 @@ function handleTemplateUpdate(payload: { index: number, value: string }) {
|
||||
appear
|
||||
>
|
||||
<TemplateEditor
|
||||
:action="props.action"
|
||||
:template="props.action"
|
||||
:template-index="0"
|
||||
:title="templateTitle"
|
||||
:description="templateDescription"
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function initAll(isOnBoot: boolean) {
|
||||
initInfo();
|
||||
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({
|
||||
title: '正在初始化弹幕客户端...',
|
||||
closable: false
|
||||
@@ -196,9 +196,11 @@ export async function initDanmakuClient() {
|
||||
const biliCookie = useBiliCookie();
|
||||
const settings = useSettings();
|
||||
if (isInitedDanmakuClient.value || isInitingDanmakuClient.value) {
|
||||
info('弹幕客户端已初始化, 跳过初始化');
|
||||
return { success: true, message: '' };
|
||||
}
|
||||
isInitingDanmakuClient.value = true;
|
||||
console.log(settings.settings);
|
||||
let result = { success: false, message: '' };
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
@@ -231,6 +233,9 @@ export async function initDanmakuClient() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info('未登录, 跳过弹幕客户端初始化');
|
||||
result = { success: true, message: '' };
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -265,6 +270,7 @@ export async function callStartDanmakuClient() {
|
||||
const settings = useSettings();
|
||||
const webFetcher = useWebFetcher();
|
||||
if (settings.settings.useDanmakuClientType === 'direct') {
|
||||
info('开始初始化弹幕客户端 [direct]');
|
||||
const key = await getRoomKey(
|
||||
accountInfo.value.biliRoomId!, await biliCookie.getBiliCookie() || '');
|
||||
if (!key) {
|
||||
@@ -283,6 +289,7 @@ export async function callStartDanmakuClient() {
|
||||
tokenUserId: biliCookie.uId!,
|
||||
}, true);
|
||||
} else {
|
||||
info('开始初始化弹幕客户端 [openlive]');
|
||||
return await webFetcher.Start('openlive', undefined, true);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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, {
|
||||
method: method,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36',
|
||||
Origin: '',
|
||||
Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
|
||||
'User-Agent': randomUserAgent,
|
||||
Origin: 'https://www.bilibili.com',
|
||||
Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function checkCooldown(action: AutoActionItem, runtimeState: RuntimeState
|
||||
*/
|
||||
export function processTemplate(
|
||||
action: AutoActionItem,
|
||||
context: any,
|
||||
context: ExecutionContext,
|
||||
options?: {
|
||||
useRandomTemplate?: boolean; // 是否随机选择模板,默认true
|
||||
defaultValue?: string; // 如果模板为空或格式化失败时的默认值
|
||||
|
||||
436
src/client/store/autoAction/modules/checkin.ts
Normal file
436
src/client/store/autoAction/modules/checkin.ts
Normal 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: {}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -269,12 +269,14 @@ export function checkUserFilter(config: { userFilterEnabled: boolean; requireMed
|
||||
* @param event 事件对象
|
||||
* @param roomId 房间ID
|
||||
* @param triggerType 触发类型
|
||||
* @param additionalContext 附加的上下文数据,将被合并到上下文中
|
||||
* @returns 标准化的执行上下文
|
||||
*/
|
||||
export function buildExecutionContext(
|
||||
event: any,
|
||||
roomId: number | undefined,
|
||||
triggerType?: TriggerType
|
||||
triggerType?: TriggerType,
|
||||
additionalContext?: Record<string, any>
|
||||
): ExecutionContext {
|
||||
const now = Date.now();
|
||||
const dateObj = new Date(now);
|
||||
@@ -432,5 +434,13 @@ export function buildExecutionContext(
|
||||
}
|
||||
}
|
||||
|
||||
// 合并附加的上下文数据(如果存在)
|
||||
if (additionalContext) {
|
||||
context.variables = {
|
||||
...context.variables,
|
||||
...additionalContext
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
// 导入 Vue 和 Pinia 相关函数
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
// 导入 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 { useBiliFunction } from './useBiliFunction.js';
|
||||
import { useAccount } from '@/api/account.js';
|
||||
// 导入 VueUse 工具库
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval';
|
||||
// 导入自动操作相关的类型和工具函数
|
||||
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
|
||||
import {
|
||||
TriggerType,
|
||||
ActionType,
|
||||
KeywordMatchType,
|
||||
Priority,
|
||||
RuntimeState,
|
||||
type AutoActionItem,
|
||||
ExecutionContext,
|
||||
KeywordMatchType
|
||||
TriggerType,
|
||||
type AutoActionItem
|
||||
} from './autoAction/types.js';
|
||||
import {
|
||||
getRandomTemplate,
|
||||
buildExecutionContext,
|
||||
evaluateExpression,
|
||||
shouldProcess,
|
||||
createDefaultAutoAction,
|
||||
createDefaultRuntimeState
|
||||
createDefaultRuntimeState,
|
||||
getRandomTemplate
|
||||
} from './autoAction/utils';
|
||||
import { evaluateTemplateExpressions } from './autoAction/expressionEvaluator';
|
||||
// 导入 actionUtils 工具函数
|
||||
import { filterValidActions, checkUserFilters, checkCooldown, processTemplate, executeActions } from './autoAction/actionUtils';
|
||||
// 导入 nanoid 用于生成唯一 ID
|
||||
import { nanoid } from 'nanoid';
|
||||
// 导入开发环境判断标志
|
||||
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 { 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 { useSuperChatThank } from './autoAction/modules/superChatThank';
|
||||
import { useCheckIn } from './autoAction/modules/checkin';
|
||||
|
||||
|
||||
// 定义名为 'autoAction' 的 Pinia store
|
||||
export const useAutoAction = defineStore('autoAction', () => {
|
||||
@@ -50,11 +47,13 @@ export const useAutoAction = defineStore('autoAction', () => {
|
||||
const danmakuClient = useDanmakuClient(); // 弹幕客户端
|
||||
const biliFunc = useBiliFunction(); // B站相关功能函数
|
||||
const account = useAccount(); // 账户信息,用于获取房间ID和直播状态
|
||||
const pointStore = usePointStore(); // 积分 Store
|
||||
|
||||
// --- 共享状态 ---
|
||||
const isLive = computed(() => account.value.streamerInfo?.isStreaming ?? false); // 获取直播状态
|
||||
const roomId = computed(() => isDev ? 1294406 : account.value.streamerInfo?.roomId); // 获取房间ID (开发环境使用固定ID)
|
||||
const isTianXuanActive = ref(false); // 天选时刻活动状态
|
||||
const liveStartTime = ref<number | null>(null); // 直播开始时间戳
|
||||
|
||||
// --- 存储所有自动操作项 (使用 IndexedDB 持久化) ---
|
||||
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';
|
||||
}
|
||||
});
|
||||
// 确保全局顺序索引已初始化 (以防 IDB 返回 null/undefined)
|
||||
if (lastGlobalActionIndex.value === null || lastGlobalActionIndex.value === undefined) {
|
||||
lastGlobalActionIndex.value = -1;
|
||||
}
|
||||
// 确保触发类型启用状态已初始化
|
||||
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(); // 注册弹幕事件监听器
|
||||
|
||||
// 启动定时器 (如果有需要)
|
||||
startGlobalTimer();
|
||||
startIndividualScheduledActions();
|
||||
}
|
||||
}, { 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 scheduledDanmakuModule = useScheduledDanmaku(isLive, roomId, 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:
|
||||
// 调用弹幕自动回复模块
|
||||
autoReplyModule.onDanmaku(event, autoActions.value, runtimeState.value);
|
||||
// 处理签到功能
|
||||
checkInModule.processCheckIn(event, runtimeState.value);
|
||||
break;
|
||||
case TriggerType.GIFT:
|
||||
// 调用礼物感谢模块
|
||||
@@ -710,42 +713,34 @@ export const useAutoAction = defineStore('autoAction', () => {
|
||||
* @param actionId 要设置为下一个执行的操作ID
|
||||
*/
|
||||
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') {
|
||||
console.warn('[AutoAction] 只能在顺序模式下手动指定下一个操作。');
|
||||
console.warn(`[AutoAction] setNextGlobalAction: 只有在顺序模式下才能手动指定下一个操作。当前模式: ${globalSchedulingMode.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 筛选出当前符合条件的活动操作 (与 nextScheduledAction 逻辑一致)
|
||||
const eligibleActions = autoActions.value.filter(action =>
|
||||
action.triggerType === TriggerType.SCHEDULED &&
|
||||
action.enabled &&
|
||||
action.enabled && // 这里需要检查启用状态,只在启用的里面找
|
||||
action.triggerConfig.useGlobalTimer &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||
);
|
||||
|
||||
if (eligibleActions.length === 0) {
|
||||
console.warn('[AutoAction] 没有符合条件的活动操作可供指定。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到目标操作在当前合格列表中的索引
|
||||
const targetIndex = eligibleActions.findIndex(action => action.id === actionId);
|
||||
const targetIndex = eligibleActions.findIndex(a => a.id === actionId);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
console.warn(`[AutoAction] 指定的操作ID ${actionId} 不存在或不符合当前执行条件。`);
|
||||
console.warn(`[AutoAction] setNextGlobalAction: 指定的操作 ID ${actionId} 当前不符合执行条件,无法设置为下一个。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 lastGlobalActionIndex 为目标索引的前一个索引
|
||||
// 这样,在下一次 handleGlobalTimerTick 中计算 (lastGlobalActionIndex + 1) % length 时,就会得到 targetIndex
|
||||
// 如果目标是列表中的第一个 (index 0),则将 lastGlobalActionIndex 设置为列表最后一个元素的索引
|
||||
lastGlobalActionIndex.value = (targetIndex === 0) ? eligibleActions.length - 1 : targetIndex - 1;
|
||||
|
||||
console.log(`[AutoAction] 手动指定下一个执行的操作为: ${eligibleActions[targetIndex].name} (ID: ${actionId}), 将在下一个计时周期执行。`);
|
||||
|
||||
// 重启全局计时器,以便立即应用更改并在下一个周期执行指定的操作
|
||||
// (如果不重启,则会在当前周期结束后,按新的索引执行)
|
||||
// 设置索引,使其下一次执行 targetIndex
|
||||
lastGlobalActionIndex.value = (targetIndex - 1 + eligibleActions.length) % eligibleActions.length;
|
||||
// 立即重置并重新安排计时器,以便下次tick时执行新指定的任务
|
||||
restartGlobalTimer();
|
||||
}
|
||||
|
||||
@@ -885,6 +880,7 @@ export const useAutoAction = defineStore('autoAction', () => {
|
||||
isLive, // 直播状态 (计算属性)
|
||||
isTianXuanActive, // 天选状态 (ref)
|
||||
enabledTriggerTypes, // 触发类型启用状态
|
||||
checkInModule,
|
||||
init, // 初始化函数
|
||||
addAutoAction, // 添加操作
|
||||
removeAutoAction, // 移除操作
|
||||
@@ -899,7 +895,7 @@ export const useAutoAction = defineStore('autoAction', () => {
|
||||
stopIndividualTimer,
|
||||
stopAllIndividualScheduledActions,
|
||||
startIndividualScheduledActions,
|
||||
triggerTestActionByType // 新的 action
|
||||
triggerTestActionByType, // 新的 action
|
||||
};
|
||||
});// HMR (热模块替换) 支持
|
||||
if (import.meta.hot) {
|
||||
@@ -907,5 +903,5 @@ if (import.meta.hot) {
|
||||
}
|
||||
|
||||
// 重新导出类型,方便外部使用
|
||||
export { AutoActionItem, TriggerType, ActionType, Priority, KeywordMatchType };
|
||||
export { ActionType, AutoActionItem, KeywordMatchType, Priority, TriggerType };
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
info('[BiliCookie] 检测到已存储的 Bilibili Cookie');
|
||||
// 检查 Cookie 有效性,除非用户信息缓存有效且未过期
|
||||
if (!_cachedUserInfo.value) { // 只有在没有有效缓存时才立即检查
|
||||
debug('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...');
|
||||
info('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...');
|
||||
const { valid } = await _checkCookieValidity(storedCookieData.cookie);
|
||||
_updateCookieState(true, valid); // 更新状态
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
}, () => `错误: ${json.code} - ${json.message || json.msg}`),
|
||||
}, `错误: ${json.code} - ${json.message || json.msg}`),
|
||||
duration: 0,
|
||||
});
|
||||
console.error(`发送弹幕API失败 to: ${roomId} ${uid.value} [${message}] - ${json.code} - ${json.message || json.msg}`);
|
||||
|
||||
3
src/components.d.ts
vendored
3
src/components.d.ts
vendored
@@ -36,6 +36,9 @@ declare module 'vue' {
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
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']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
|
||||
@@ -916,8 +916,12 @@ onMounted(() => {
|
||||
</NSpace>
|
||||
<NSpace align="center">
|
||||
<NCheckbox
|
||||
:checked="updateSongModel.options!.fanMedalMinLevel != null"
|
||||
@update:checked="(checked: boolean) => updateSongModel.options!.fanMedalMinLevel = checked ? 1 : undefined"
|
||||
:checked="updateSongModel.options?.fanMedalMinLevel != null"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (updateSongModel.options) {
|
||||
updateSongModel.options.fanMedalMinLevel = checked ? 1 : undefined;
|
||||
}
|
||||
}"
|
||||
>
|
||||
粉丝牌
|
||||
<NTooltip trigger="hover">
|
||||
@@ -931,14 +935,14 @@ onMounted(() => {
|
||||
</NTooltip>
|
||||
</NCheckbox>
|
||||
<NInputGroup
|
||||
v-if="updateSongModel.options!.fanMedalMinLevel != null"
|
||||
v-if="updateSongModel.options?.fanMedalMinLevel != null"
|
||||
style="width: auto;"
|
||||
>
|
||||
<NInputGroupLabel size="small">
|
||||
最低
|
||||
</NInputGroupLabel>
|
||||
<NInputNumber
|
||||
v-model:value="updateSongModel.options!.fanMedalMinLevel"
|
||||
v-model:value="updateSongModel.options.fanMedalMinLevel"
|
||||
:min="1"
|
||||
size="small"
|
||||
style="width: 80px;"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { TURNSTILE_KEY } from '@/data/constants'
|
||||
import { isDarkMode } from '@/Utils';
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
@@ -9,6 +10,11 @@ const turnstile = ref()
|
||||
const token = defineModel<string>('token', {
|
||||
default: '',
|
||||
})
|
||||
|
||||
// Set theme based on dark mode status
|
||||
const theme = computed(() => {
|
||||
return isDarkMode ? 'dark' : 'light'
|
||||
})
|
||||
onUnmounted(() => {
|
||||
turnstile.value?.remove()
|
||||
})
|
||||
@@ -27,7 +33,7 @@ function reset() {
|
||||
ref="turnstile"
|
||||
v-model="token"
|
||||
:site-key="TURNSTILE_KEY"
|
||||
theme="auto"
|
||||
:theme="theme"
|
||||
style="text-align: center"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { POINT_API_URL } from "@/data/constants";
|
||||
import { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider";
|
||||
import { defineStore } from "pinia";
|
||||
import { useAuthStore } from "./useAuthStore";
|
||||
import { GuidUtils } from "@/Utils";
|
||||
|
||||
export const usePointStore = defineStore('point', () => {
|
||||
const useAuth = useAuthStore()
|
||||
@@ -41,9 +42,54 @@ export const usePointStore = defineStore('point', () => {
|
||||
}
|
||||
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 {
|
||||
GetSpecificPoint,
|
||||
GetGoods
|
||||
GetGoods,
|
||||
addPoints
|
||||
}
|
||||
})
|
||||
@@ -206,197 +206,289 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="question-box-container"
|
||||
title="提问"
|
||||
>
|
||||
<div class="question-box-container">
|
||||
<!-- 提问表单 -->
|
||||
<NCard embedded>
|
||||
<NSpace vertical>
|
||||
<!-- 话题选择区域 -->
|
||||
<NCard
|
||||
v-if="tags.length > 0"
|
||||
title="投稿话题 (可选)"
|
||||
size="small"
|
||||
>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
style="cursor: pointer"
|
||||
:bordered="false"
|
||||
:type="selectedTag === tag ? 'primary' : 'default'"
|
||||
@click="onSelectTag(tag)"
|
||||
<transition
|
||||
name="fade-slide-down"
|
||||
appear
|
||||
>
|
||||
<NCard
|
||||
embedded
|
||||
class="question-form-card"
|
||||
:class="{ 'self-user': isSelf }"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<!-- 话题选择区域 -->
|
||||
<transition
|
||||
name="fade-scale"
|
||||
appear
|
||||
>
|
||||
<NCard
|
||||
v-if="tags.length > 0"
|
||||
title="投稿话题 (可选)"
|
||||
size="small"
|
||||
class="topic-card"
|
||||
>
|
||||
{{ tag }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
<transition-group
|
||||
name="tag-list"
|
||||
tag="div"
|
||||
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
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="questionMessage"
|
||||
:disabled="isSelf"
|
||||
show-count
|
||||
maxlength="5000"
|
||||
type="textarea"
|
||||
:count-graphemes="countGraphemes"
|
||||
style="width: 300px"
|
||||
/>
|
||||
<NUpload
|
||||
v-model:file-list="fileList"
|
||||
:max="1"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
|
||||
list-type="image-card"
|
||||
:disabled="!accountInfo.id || isSelf"
|
||||
:default-upload="false"
|
||||
@update:file-list="OnFileListChange"
|
||||
<!-- 提问内容区域 -->
|
||||
<div class="question-input-area">
|
||||
<NInput
|
||||
v-model:value="questionMessage"
|
||||
:disabled="isSelf"
|
||||
show-count
|
||||
maxlength="5000"
|
||||
type="textarea"
|
||||
:count-graphemes="countGraphemes"
|
||||
class="question-textarea"
|
||||
placeholder="在这里输入您的问题..."
|
||||
/>
|
||||
<transition
|
||||
name="fade-scale"
|
||||
>
|
||||
<NUpload
|
||||
v-model:file-list="fileList"
|
||||
:max="1"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
|
||||
list-type="image-card"
|
||||
:disabled="!accountInfo.id || isSelf"
|
||||
: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
|
||||
>
|
||||
+ 上传图片
|
||||
</NUpload>
|
||||
</NSpace>
|
||||
<NAlert
|
||||
v-if="!accountInfo.id && !isSelf"
|
||||
type="warning"
|
||||
class="login-alert"
|
||||
>
|
||||
只有注册用户才能够上传图片
|
||||
</NAlert>
|
||||
</transition>
|
||||
|
||||
<NDivider style="margin: 10px 0" />
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<NSpace align="center">
|
||||
<NAlert
|
||||
v-if="!accountInfo.id && !isSelf"
|
||||
type="warning"
|
||||
<!-- 匿名选项 -->
|
||||
<transition
|
||||
name="fade"
|
||||
appear
|
||||
>
|
||||
只有注册用户才能够上传图片
|
||||
</NAlert>
|
||||
</NSpace>
|
||||
<NSpace
|
||||
v-if="accountInfo.id"
|
||||
vertical
|
||||
class="anonymous-option"
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="isAnonymous"
|
||||
:disabled="isSelf"
|
||||
label="匿名提问"
|
||||
/>
|
||||
<NDivider class="form-divider" />
|
||||
</NSpace>
|
||||
</transition>
|
||||
|
||||
<!-- 匿名选项 -->
|
||||
<NSpace
|
||||
v-if="accountInfo.id"
|
||||
vertical
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="isAnonymous"
|
||||
:disabled="isSelf"
|
||||
label="匿名提问"
|
||||
/>
|
||||
<NDivider style="margin: 10px 0" />
|
||||
</NSpace>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<NButton
|
||||
:disabled="isSelf"
|
||||
type="primary"
|
||||
:loading="isSending || !token"
|
||||
class="send-button"
|
||||
@click="SendQuestion"
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<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">
|
||||
<NButton
|
||||
:disabled="isSelf"
|
||||
type="primary"
|
||||
:loading="isSending || !token"
|
||||
@click="SendQuestion"
|
||||
<!-- 验证码 -->
|
||||
<div class="turnstile-container">
|
||||
<VueTurnstile
|
||||
ref="turnstile"
|
||||
v-model="token"
|
||||
:site-key="TURNSTILE_KEY"
|
||||
theme="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<transition
|
||||
name="fade-slide-up"
|
||||
appear
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<NButton
|
||||
:disabled="isSelf || !accountInfo.id"
|
||||
type="info"
|
||||
@click="$router.push({ name: 'manage-questionBox', query: { send: '1' } })"
|
||||
>
|
||||
我发送的
|
||||
</NButton>
|
||||
<NAlert
|
||||
v-if="isSelf"
|
||||
type="warning"
|
||||
class="self-alert"
|
||||
>
|
||||
不能给自己提问
|
||||
</NAlert>
|
||||
</transition>
|
||||
</NSpace>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<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>
|
||||
</NCard>
|
||||
</transition>
|
||||
|
||||
<!-- 公开回复列表 -->
|
||||
<NDivider> 公开回复 </NDivider>
|
||||
<NList v-if="publicQuestions.length > 0">
|
||||
<NListItem
|
||||
v-for="item in publicQuestions"
|
||||
:key="item.id"
|
||||
>
|
||||
<NCard
|
||||
:embedded="!item.isReaded"
|
||||
hoverable
|
||||
size="small"
|
||||
<transition
|
||||
name="fade"
|
||||
appear
|
||||
>
|
||||
<div>
|
||||
<NDivider class="public-divider">
|
||||
<div class="divider-content">
|
||||
公开回复
|
||||
</div>
|
||||
</NDivider>
|
||||
<transition-group
|
||||
name="list-fade"
|
||||
tag="div"
|
||||
class="questions-list-container"
|
||||
>
|
||||
<!-- 问题头部 -->
|
||||
<template #header>
|
||||
<NSpace
|
||||
: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
|
||||
<NList
|
||||
v-if="publicQuestions.length > 0"
|
||||
class="questions-list"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
:size="6"
|
||||
:wrap="false"
|
||||
<NListItem
|
||||
v-for="item in publicQuestions"
|
||||
:key="item.id"
|
||||
class="question-list-item"
|
||||
>
|
||||
<NAvatar
|
||||
:src="AVATAR_URL + userInfo?.biliId + '?size=64'"
|
||||
circle
|
||||
:size="45"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
/>
|
||||
<NDivider vertical />
|
||||
<NText style="font-size: 16px">
|
||||
{{ item.answer?.message }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NCard>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty v-else />
|
||||
<NCard
|
||||
:embedded="!item.isReaded"
|
||||
hoverable
|
||||
size="small"
|
||||
class="question-card"
|
||||
:class="{ 'unread': !item.isReaded }"
|
||||
>
|
||||
<!-- 问题头部 -->
|
||||
<template #header>
|
||||
<NSpace
|
||||
:size="0"
|
||||
align="center"
|
||||
class="question-header"
|
||||
>
|
||||
<NText
|
||||
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 />
|
||||
</div>
|
||||
@@ -408,5 +500,380 @@ onUnmounted(() => {
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
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>
|
||||
|
||||
@@ -31,6 +31,8 @@ const selectedLanguage = ref<string | undefined>();
|
||||
const selectedTag = ref<string | undefined>(); // Renamed from activeTab for clarity
|
||||
const searchQuery = ref<string>('');
|
||||
const selectedArtist = ref<string | null>(null);
|
||||
// 添加点歌条件筛选状态
|
||||
const selectedOption = ref<string | undefined>();
|
||||
|
||||
// --- New: Sorting State ---
|
||||
type SortKey = 'name' | 'author' | 'language' | 'tags' | 'options' | 'description' | null;
|
||||
@@ -42,6 +44,10 @@ const sortOrder = ref<'asc' | 'desc'>('asc'); // 当前排序顺序
|
||||
// Extract unique languages
|
||||
const allUniqueLanguages = computed<string[]>(() => {
|
||||
const languages = new Set<string>();
|
||||
|
||||
// 添加"未设定"语言选项
|
||||
languages.add('未设定');
|
||||
|
||||
props.data?.forEach(song => {
|
||||
song.language?.forEach(lang => {
|
||||
if (lang?.trim()) {
|
||||
@@ -60,6 +66,10 @@ const languageButtons = computed<FilterButton[]>(() =>
|
||||
// Extract unique tags (similar to original 'tabs' logic)
|
||||
const allUniqueTags = computed<string[]>(() => {
|
||||
const tags = new Set<string>();
|
||||
|
||||
// 添加"未设定"标签选项
|
||||
tags.add('未设定');
|
||||
|
||||
props.data?.forEach(song => {
|
||||
song.tags?.forEach(tag => {
|
||||
if (tag?.trim()) {
|
||||
@@ -75,6 +85,28 @@ const tagButtons = computed<FilterButton[]>(() =>
|
||||
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 ---
|
||||
|
||||
@@ -105,21 +137,52 @@ const filteredAndSortedSongs = computed(() => {
|
||||
// 1. Filter by Selected Language
|
||||
if (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
|
||||
if (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
|
||||
if (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)
|
||||
if (searchQuery.value.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)
|
||||
const selectArtistFromTable = (artist: string) => {
|
||||
if (selectedArtist.value === artist) {
|
||||
@@ -213,6 +285,7 @@ const clearFilters = () => {
|
||||
selectedLanguage.value = undefined;
|
||||
selectedTag.value = undefined;
|
||||
selectedArtist.value = null; // Reset NSelect value
|
||||
selectedOption.value = undefined; // 清除点歌条件筛选
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
@@ -361,8 +434,8 @@ function GetPlayButton(song: SongsInfo) {
|
||||
// --- New: Helper function for Song Request Options ---
|
||||
function getOptionDisplay(options?: SongRequestOption) {
|
||||
if (!options) {
|
||||
// 为"无特殊要求"添加 'empty-placeholder' 类
|
||||
return h('span', { class: 'empty-placeholder' }, '无特殊要求');
|
||||
// 直接返回空元素,不显示"无特殊要求"
|
||||
return h('span', {});
|
||||
}
|
||||
|
||||
const conditions: VNode[] = [];
|
||||
@@ -384,8 +457,8 @@ function getOptionDisplay(options?: SongRequestOption) {
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
// 为"无特殊要求"添加 'empty-placeholder' 类
|
||||
return h('span', { class: 'empty-placeholder' }, '无特殊要求');
|
||||
// 如果没有条件,直接返回空元素,不显示"无特殊要求"
|
||||
return h('span', {});
|
||||
}
|
||||
|
||||
// Use NFlex for better wrapping
|
||||
@@ -697,6 +770,23 @@ export const Config = defineTemplateConfig([
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="filter-divider" />
|
||||
|
||||
@@ -885,10 +975,11 @@ export const Config = defineTemplateConfig([
|
||||
<span v-if="index < song.language.length - 1">, </span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>未知</span>
|
||||
<!-- 移除了 "未知" 占位文本 -->
|
||||
</td>
|
||||
<td>
|
||||
<n-flex
|
||||
v-if="song.tags && song.tags.length > 0"
|
||||
:size="4"
|
||||
:wrap="true"
|
||||
style="gap: 4px;"
|
||||
@@ -904,12 +995,8 @@ export const Config = defineTemplateConfig([
|
||||
>
|
||||
{{ tag }}
|
||||
</n-tag>
|
||||
<!-- 为"无标签"添加 'empty-placeholder' 类 -->
|
||||
<span
|
||||
v-if="!song.tags || song.tags.length === 0"
|
||||
class="empty-placeholder"
|
||||
>无标签</span>
|
||||
</n-flex>
|
||||
<!-- 移除了 "无标签" 占位文本 -->
|
||||
</td>
|
||||
<td>
|
||||
<component :is="getOptionDisplay(song.options)" />
|
||||
@@ -981,7 +1068,6 @@ html.dark .filter-button {
|
||||
|
||||
html.dark .filter-button:hover:not(.active) {
|
||||
background-color: var(--item-color-hover);
|
||||
border-color: var(--border-color-hover);
|
||||
}
|
||||
|
||||
/* Divider between filters and search bar */
|
||||
|
||||
Reference in New Issue
Block a user