mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 更新项目配置和组件,增强功能和用户体验
- 在 .gitignore 中添加了 .specstory 文件的忽略规则。 - 更新 tsconfig.json,修正了 vue-vine/types/macros 的引用路径。 - 在组件声明中新增了 NInput 组件的类型支持。 - 优化了 EventModel 接口,调整了 guard_level 的类型为 GuardLevel。 - 增加了 Follow 事件类型到 EventDataTypes 枚举中。 - 在 ClientAutoAction.vue 中引入了新的 store 和组件,增强了功能。 - 更新了多个设置组件,添加了关键词匹配类型和过滤模式的支持。 - 改进了模板编辑器和测试器的功能,支持更灵活的模板管理。 - 在弹幕客户端中新增了关注事件的处理逻辑,提升了事件响应能力。
This commit is contained in:
@@ -558,7 +558,7 @@ export interface EventModel {
|
||||
time: number
|
||||
num: number
|
||||
price: number
|
||||
guard_level: number
|
||||
guard_level: GuardLevel
|
||||
fans_medal_level: number
|
||||
fans_medal_name: string
|
||||
fans_medal_wearing_status: boolean
|
||||
@@ -572,7 +572,8 @@ export enum EventDataTypes {
|
||||
Message,
|
||||
Like,
|
||||
SCDel,
|
||||
Enter
|
||||
Enter,
|
||||
Follow
|
||||
}
|
||||
export interface ResponseQueueModel {
|
||||
id: number
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,48 +48,48 @@ const TriggerSettings = getTriggerSettings();
|
||||
|
||||
<template>
|
||||
<div class="auto-action-editor">
|
||||
<NCard
|
||||
:title="action.name"
|
||||
size="small"
|
||||
class="editor-card"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<!-- 模板设置 - 移到最上面 -->
|
||||
<TemplateSettings :action="action" />
|
||||
<NSpace vertical>
|
||||
<!-- 模板设置 - 移到最上面 -->
|
||||
<TemplateSettings :action="action" />
|
||||
|
||||
<!-- 基本设置 -->
|
||||
<BasicSettings :action="action" />
|
||||
<!-- 高级选项 - 所有高级设置放在一个折叠面板中 -->
|
||||
<NCollapse class="settings-collapse">
|
||||
<template #default>
|
||||
<!-- 触发类型特定设置 -->
|
||||
<component
|
||||
:is="TriggerSettings"
|
||||
v-if="TriggerSettings"
|
||||
:action="action"
|
||||
class="trigger-settings"
|
||||
/>
|
||||
<!-- 基本设置 -->
|
||||
<BasicSettings :action="action" />
|
||||
<!-- 高级选项 - 所有高级设置放在一个折叠面板中 -->
|
||||
<NCollapse class="settings-collapse">
|
||||
<template #default>
|
||||
<br>
|
||||
<!-- 触发类型特定设置 -->
|
||||
<component
|
||||
:is="TriggerSettings"
|
||||
v-if="TriggerSettings"
|
||||
:action="action"
|
||||
class="trigger-settings"
|
||||
/>
|
||||
|
||||
<NDivider style="margin: 10px 0;">
|
||||
高级选项
|
||||
</NDivider>
|
||||
<!-- 通用高级设置 -->
|
||||
<AdvancedSettings
|
||||
:action="action"
|
||||
class="advanced-settings"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<NDivider style="margin: 10px 0;">
|
||||
高级选项
|
||||
</template>
|
||||
</NCollapse>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NDivider>
|
||||
<!-- 通用高级设置 -->
|
||||
<AdvancedSettings
|
||||
:action="action"
|
||||
class="advanced-settings"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
高级选项
|
||||
</template>
|
||||
</NCollapse>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auto-action-editor {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom
|
||||
: 20px;
|
||||
}
|
||||
.trigger-settings {
|
||||
color: var(--n-color-info);
|
||||
font-size: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
352
src/client/components/autoaction/DataManager.vue
Normal file
352
src/client/components/autoaction/DataManager.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h, reactive } from 'vue';
|
||||
import { keys as idbKeys, get as idbGet, del as idbDel, clear as idbClear, createStore } from 'idb-keyval';
|
||||
import { NCard, NDataTable, NButton, NSpace, NPopconfirm, NEmpty, NAlert, NSpin, NTag, useMessage, NText, DataTableColumns, NDivider } from 'naive-ui';
|
||||
|
||||
// --- 定义用户持久化数据的自定义存储区 (与 utils.ts 中保持一致) ---
|
||||
const USER_DATA_DB_NAME = 'AutoActionUserDataDB';
|
||||
const USER_DATA_STORE_NAME = 'userData';
|
||||
const userDataStore = createStore(USER_DATA_DB_NAME, USER_DATA_STORE_NAME);
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// --- 运行时数据配置 (SessionStorage) ---
|
||||
const RUNTIME_STORAGE_PREFIX = 'autoaction_runtime_';
|
||||
// ------------------------------------
|
||||
|
||||
interface DataItem {
|
||||
key: string; // Key 统一为 string
|
||||
value: any;
|
||||
valueDisplay: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// --- 持久化数据 (IndexedDB) 相关状态和函数 ---
|
||||
const persistentData = ref<DataItem[]>([]);
|
||||
const persistentLoading = ref(true);
|
||||
const message = useMessage();
|
||||
|
||||
async function fetchPersistentData() {
|
||||
persistentLoading.value = true;
|
||||
try {
|
||||
const keys = await idbKeys(userDataStore);
|
||||
const fetchedData: DataItem[] = [];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const value = await idbGet(key, userDataStore);
|
||||
let valueDisplay = '';
|
||||
let type: string = typeof value;
|
||||
|
||||
if (value === null) {
|
||||
valueDisplay = 'null';
|
||||
type = 'null';
|
||||
} else if (value === undefined) {
|
||||
valueDisplay = 'undefined';
|
||||
type = 'undefined';
|
||||
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
valueDisplay = String(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
valueDisplay = `[Array (${value.length})]`;
|
||||
type = 'array';
|
||||
} else if (typeof value === 'object') {
|
||||
try {
|
||||
valueDisplay = JSON.stringify(value, null, 2); //尝试格式化
|
||||
if (valueDisplay.length > 200) {
|
||||
valueDisplay = `[Object]`; // 太长则简化
|
||||
}
|
||||
} catch (e) {
|
||||
valueDisplay = '[Object]'; // 不可序列化对象
|
||||
}
|
||||
type = 'object';
|
||||
} else {
|
||||
valueDisplay = `[${typeof value}]`; // 其他类型
|
||||
}
|
||||
|
||||
fetchedData.push({ key: String(key), value, valueDisplay, type });
|
||||
} catch (getValueError) {
|
||||
console.error(`[UserData IDB Manager] Error getting value for key ${String(key)}:`, getValueError);
|
||||
fetchedData.push({ key: String(key), value: undefined, valueDisplay: '[Error Reading Value]', type: 'error' });
|
||||
}
|
||||
}
|
||||
persistentData.value = fetchedData;
|
||||
} catch (error) {
|
||||
console.error('[UserData IDB Manager] Error fetching keys:', error);
|
||||
message.error('无法加载用户持久化数据');
|
||||
persistentData.value = [];
|
||||
} finally {
|
||||
persistentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePersistentItem(key: string) {
|
||||
try {
|
||||
await idbDel(key, userDataStore);
|
||||
message.success(`已删除持久化键: ${key}`);
|
||||
await fetchPersistentData();
|
||||
} catch (error) {
|
||||
console.error(`[UserData IDB Manager] Error deleting key ${String(key)}:`, error);
|
||||
message.error(`删除键 ${String(key)} 时出错`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPersistentData() {
|
||||
try {
|
||||
await idbClear(userDataStore);
|
||||
message.success('已清除所有用户持久化数据');
|
||||
await fetchPersistentData();
|
||||
} catch (error) {
|
||||
console.error('[UserData IDB Manager] Error clearing data:', error);
|
||||
message.error('清除用户数据时出错');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 运行时数据 (SessionStorage) 相关状态和函数 ---
|
||||
const runtimeData = ref<DataItem[]>([]);
|
||||
const runtimeLoading = ref(true);
|
||||
|
||||
function fetchRuntimeData() {
|
||||
runtimeLoading.value = true;
|
||||
try {
|
||||
const fetchedData: DataItem[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const prefixedKey = sessionStorage.key(i);
|
||||
if (prefixedKey && prefixedKey.startsWith(RUNTIME_STORAGE_PREFIX)) {
|
||||
const key = prefixedKey.substring(RUNTIME_STORAGE_PREFIX.length);
|
||||
try {
|
||||
const storedValue = sessionStorage.getItem(prefixedKey);
|
||||
let value: any;
|
||||
let valueDisplay = '';
|
||||
let type: string = 'unknown';
|
||||
|
||||
if (storedValue !== null) {
|
||||
try {
|
||||
value = JSON.parse(storedValue);
|
||||
type = typeof value;
|
||||
if (value === null) { valueDisplay = 'null'; type = 'null'; }
|
||||
else if (value === undefined) { valueDisplay = 'undefined'; type = 'undefined'; }
|
||||
else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { valueDisplay = String(value); }
|
||||
else if (Array.isArray(value)) { valueDisplay = `[Array (${value.length})]`; type = 'array'; }
|
||||
else if (typeof value === 'object') {
|
||||
try { valueDisplay = JSON.stringify(value, null, 2); if (valueDisplay.length > 200) { valueDisplay = `[Object]`; } } catch (e) { valueDisplay = '[Object]'; }
|
||||
type = 'object';
|
||||
} else { valueDisplay = `[${typeof value}]`; }
|
||||
} catch (parseError) {
|
||||
console.error(`[Runtime SessionStorage Manager] Error parsing key '${key}':`, parseError);
|
||||
value = storedValue; // 解析失败则显示原始字符串
|
||||
valueDisplay = `[Parse Error] ${storedValue}`;
|
||||
type = 'parse-error';
|
||||
}
|
||||
} else {
|
||||
// 理论上不应该发生,因为 getItem(key(i)) 应该有值
|
||||
valueDisplay = '[Error Reading Value]';
|
||||
type = 'error';
|
||||
}
|
||||
fetchedData.push({ key, value, valueDisplay, type });
|
||||
} catch (error) {
|
||||
console.error(`[Runtime SessionStorage Manager] Error processing key '${key}':`, error);
|
||||
fetchedData.push({ key, value: undefined, valueDisplay: '[Error Processing Key]', type: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
runtimeData.value = fetchedData;
|
||||
} catch (error) {
|
||||
console.error('[Runtime SessionStorage Manager] Error fetching keys:', error);
|
||||
message.error('无法加载运行时数据');
|
||||
runtimeData.value = [];
|
||||
} finally {
|
||||
runtimeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRuntimeItem(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(RUNTIME_STORAGE_PREFIX + key);
|
||||
message.success(`已删除运行时键: ${key}`);
|
||||
fetchRuntimeData();
|
||||
} catch (error) {
|
||||
console.error(`[Runtime SessionStorage Manager] Error deleting key ${String(key)}:`, error);
|
||||
message.error(`删除键 ${String(key)} 时出错`);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuntimeData() {
|
||||
try {
|
||||
let keysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && key.startsWith(RUNTIME_STORAGE_PREFIX)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
||||
message.success('已清除所有运行时数据');
|
||||
fetchRuntimeData();
|
||||
} catch (error) {
|
||||
console.error('[Runtime SessionStorage Manager] Error clearing data:', error);
|
||||
message.error('清除运行时数据时出错');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 表格列定义 (复用) ---
|
||||
const commonColumns: DataTableColumns<DataItem> = [
|
||||
{ title: '键 (Key)', key: 'key', resizable: true, render: (row) => h(NText, { code: true }, { default: () => row.key }) },
|
||||
{ title: '类型 (Type)', key: 'type', width: 120, render: (row) => h(NTag, { size: 'small', type: (row.type === 'error' || row.type === 'parse-error') ? 'error' : 'default', bordered: false }, { default: () => row.type }) },
|
||||
{ title: '值 (Value)', key: 'valueDisplay', resizable: true, ellipsis: { tooltip: true }, render: (row) => h('pre', { style: 'white-space: pre-wrap; word-break: break-all; margin: 0; font-family: inherit; font-size: inherit;' }, row.valueDisplay) },
|
||||
];
|
||||
|
||||
const persistentColumns: DataTableColumns<DataItem> = [
|
||||
...commonColumns,
|
||||
{ title: '操作', key: 'actions', width: 100, render: (row) => h(NPopconfirm, { onPositiveClick: () => deletePersistentItem(row.key), positiveText: '确认删除', negativeText: '取消' }, { trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => '删除' }), default: () => `删除持久化键 "${row.key}"?` }) },
|
||||
];
|
||||
|
||||
const runtimeColumns: DataTableColumns<DataItem> = [
|
||||
...commonColumns,
|
||||
{ title: '操作', key: 'actions', width: 100, render: (row) => h(NPopconfirm, { onPositiveClick: () => deleteRuntimeItem(row.key), positiveText: '确认删除', negativeText: '取消' }, { trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, { default: () => '删除' }), default: () => `删除运行时键 "${row.key}"?` }) },
|
||||
];
|
||||
|
||||
// --- 组件挂载时加载数据 ---
|
||||
onMounted(() => {
|
||||
fetchPersistentData();
|
||||
fetchRuntimeData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
>
|
||||
<!-- 运行时数据 (SessionStorage) -->
|
||||
<NCard
|
||||
title="运行时数据"
|
||||
size="small"
|
||||
>
|
||||
<NAlert
|
||||
type="warning"
|
||||
:bordered="false"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
这里显示的是脚本通过 <code>getData</code>, <code>setData</code> 管理的数据。
|
||||
这些数据仅在程序运行期间保留,程序关闭后将丢失。
|
||||
</NAlert>
|
||||
<NSpace
|
||||
justify="end"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<NButton
|
||||
:loading="runtimeLoading"
|
||||
size="small"
|
||||
@click="fetchRuntimeData"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确认清除"
|
||||
negative-text="取消"
|
||||
@positive-click="clearRuntimeData"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
type="error"
|
||||
size="small"
|
||||
:disabled="runtimeData.length === 0"
|
||||
>
|
||||
清除所有运行时数据
|
||||
</NButton>
|
||||
</template>
|
||||
确定要清除所有当前会话的运行时数据吗?此操作不可逆!
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
<NSpin :show="runtimeLoading">
|
||||
<NDataTable
|
||||
:columns="runtimeColumns"
|
||||
:data="runtimeData"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
max-height="35vh"
|
||||
virtual-scroll
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty description="当前会话没有运行时数据" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<!-- 用户持久化数据 (IndexedDB) -->
|
||||
<NCard
|
||||
title="持久化数据"
|
||||
size="small"
|
||||
>
|
||||
<NAlert
|
||||
type="info"
|
||||
:bordered="false"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
这是持久化数据,程序关闭后不会丢失。
|
||||
</NAlert>
|
||||
<NSpace
|
||||
justify="end"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<NButton
|
||||
:loading="persistentLoading"
|
||||
size="small"
|
||||
@click="fetchPersistentData"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确认清除"
|
||||
negative-text="取消"
|
||||
@positive-click="clearPersistentData"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
type="error"
|
||||
size="small"
|
||||
:disabled="persistentData.length === 0"
|
||||
>
|
||||
清除所有用户数据
|
||||
</NButton>
|
||||
</template>
|
||||
确定要清除所有由自动操作脚本存储的用户数据吗?应用配置不会被清除。此操作不可逆!
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
<NSpin :show="persistentLoading">
|
||||
<NDataTable
|
||||
:columns="persistentColumns"
|
||||
:data="persistentData"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
max-height="35vh"
|
||||
virtual-scroll
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty description="脚本尚未存储任何持久化数据" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
font-family: inherit; /* 继承表格字体 */
|
||||
font-size: inherit; /* 继承表格字体大小 */
|
||||
}
|
||||
code {
|
||||
background-color: var(--n-code-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--n-border-radius);
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
40
src/client/components/autoaction/SingleTemplateEditor.vue
Normal file
40
src/client/components/autoaction/SingleTemplateEditor.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { AutoActionItem } from '@/client/store/autoAction/types';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '回复模板'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '在这里编辑自动回复的模板,支持变量和JavaScript表达式'
|
||||
}
|
||||
});
|
||||
|
||||
// Handle the update event from TemplateEditor
|
||||
function handleTemplateUpdate(payload: { index: number, value: string }) {
|
||||
// This component edits the first template (index 0)
|
||||
// Assuming props.action.templates should be a single string based on type error
|
||||
if (payload.index === 0) {
|
||||
props.action.template = payload.value;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TemplateEditor
|
||||
:action="props.action"
|
||||
:template-index="0"
|
||||
:title="title"
|
||||
:description="description"
|
||||
@update:template="handleTemplateUpdate"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCard, NDivider, NHighlight, NInput, NList, NListItem, NPopconfirm, NScrollbar, NSpace, NTooltip, useMessage, NTabs, NTabPane, NFlex, NAlert, NIcon } from 'naive-ui';
|
||||
import { computed, ref } from 'vue';
|
||||
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 } from '@/client/store/autoAction/expressionEvaluator';
|
||||
import { Info24Filled } from '@vicons/fluent';
|
||||
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';
|
||||
import { Info24Filled, Code24Regular, LiveOff24Regular } from '@vicons/fluent';
|
||||
import { EventDataTypes, EventModel } from '@/api/api-models';
|
||||
import GraphemeSplitter from 'grapheme-splitter';
|
||||
|
||||
const props = defineProps({
|
||||
templates: {
|
||||
type: Array as () => string[],
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
@@ -19,126 +23,216 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholders: {
|
||||
type: Array as () => { name: string, description: string }[],
|
||||
default: () => []
|
||||
},
|
||||
// 新增:提供测试上下文对象
|
||||
testContext: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
user: { uid: 12345, name: '测试用户' },
|
||||
gift: { name: '测试礼物', count: 1, price: 100 }
|
||||
})
|
||||
checkLength: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
// 添加默认的弹幕相关占位符
|
||||
const emit = defineEmits(['update:template']);
|
||||
|
||||
const mergedPlaceholders = computed(() => {
|
||||
const defaultPlaceholders = [
|
||||
const basePlaceholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{user.uid}}', description: '用户ID' },
|
||||
{ name: '{{user.nameLength}}', description: '用户名长度' },
|
||||
{ name: '{{date.formatted}}', description: '当前日期格式化' },
|
||||
{ name: '{{timeOfDay()}}', description: '获取当前时段(早上/下午/晚上)' }
|
||||
{ name: '{{user.guardLevel}}', description: '用户舰队等级 (0:无, 1:总督, 2:提督, 3:舰长)' },
|
||||
{ name: '{{user.hasMedal}}', description: '用户是否佩戴粉丝勋章 (true/false)' },
|
||||
{ name: '{{user.medalLevel}}', description: '用户佩戴的粉丝勋章等级' },
|
||||
{ name: '{{user.medalName}}', description: '用户佩戴的粉丝勋章名称' },
|
||||
{ name: '{{date.formatted}}', description: '当前日期时间 (格式化)' },
|
||||
{ name: '{{date.year}}', description: '当前年份' },
|
||||
{ name: '{{date.month}}', description: '当前月份' },
|
||||
{ name: '{{date.day}}', description: '当前日期' },
|
||||
{ name: '{{date.hour}}', description: '当前小时 (0-23)' },
|
||||
{ name: '{{date.minute}}', description: '当前分钟' },
|
||||
{ name: '{{date.second}}', description: '当前秒数' },
|
||||
{ name: '{{timeOfDay}}', description: '当前时段 (凌晨/早上/上午/中午/下午/晚上/深夜)' },
|
||||
{ name: '{{event}}', description: '原始事件对象 (高级用法)' }
|
||||
];
|
||||
|
||||
// 合并自定义占位符和默认占位符
|
||||
return [...props.placeholders, ...defaultPlaceholders];
|
||||
const specificPlaceholders: { name: string, description: string }[] = [];
|
||||
|
||||
switch (props.action.triggerType) {
|
||||
case TriggerType.DANMAKU:
|
||||
specificPlaceholders.push(
|
||||
{ name: '{{message}}', description: '弹幕内容' },
|
||||
{ name: '{{danmaku}}', description: '弹幕事件对象' }
|
||||
);
|
||||
break;
|
||||
case TriggerType.GIFT:
|
||||
specificPlaceholders.push(
|
||||
{ name: '{{gift.name}}', description: '礼物名称' },
|
||||
{ name: '{{gift.count}}', description: '礼物数量' },
|
||||
{ name: '{{gift.price}}', description: '礼物单价(元)' },
|
||||
{ name: '{{gift.totalPrice}}', description: '礼物总价值(元)' },
|
||||
{ name: '{{gift.summary}}', description: '礼物概要 (例如: 5个小心心)' }
|
||||
);
|
||||
break;
|
||||
case TriggerType.GUARD:
|
||||
specificPlaceholders.push(
|
||||
{ name: '{{guard.level}}', description: '开通的舰队等级 (1:总督, 2:提督, 3:舰长)' },
|
||||
{ name: '{{guard.levelName}}', description: '开通的舰队等级名称' },
|
||||
{ name: '{{guard.giftCode}}', description: '舰长礼物代码 (预留字段)' }
|
||||
);
|
||||
break;
|
||||
case TriggerType.SUPER_CHAT:
|
||||
specificPlaceholders.push(
|
||||
{ name: '{{sc.message}}', description: 'SC留言内容' },
|
||||
{ name: '{{sc.price}}', description: 'SC金额(元)' }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalPlaceholders = [...specificPlaceholders, ...basePlaceholders];
|
||||
return Array.from(new Map(finalPlaceholders.map(item => [item.name, item])).values());
|
||||
});
|
||||
|
||||
const testContext = computed(() => {
|
||||
return buildExecutionContext({
|
||||
msg: '测试',
|
||||
time: 1713542400,
|
||||
num: 1,
|
||||
price: 100,
|
||||
guard_level: 1,
|
||||
uname: '测试用户',
|
||||
uface: 'https://example.com/test.jpg',
|
||||
uid: 12345,
|
||||
ouid: '1234567890',
|
||||
type: EventDataTypes.Message,
|
||||
open_id: '1234567890',
|
||||
fans_medal_level: 1,
|
||||
fans_medal_name: '测试粉丝勋章',
|
||||
fans_medal_wearing_status: true,
|
||||
guard_level_name: '测试舰队',
|
||||
guard_level_price: 100,
|
||||
|
||||
}, undefined, props.action.triggerType);
|
||||
});
|
||||
|
||||
const newTemplate = ref('');
|
||||
const message = useMessage();
|
||||
const activeTab = ref('editor'); // 新增:标签页控制
|
||||
const activeTab = ref('editor');
|
||||
const showLivePreview = ref(true);
|
||||
const splitter = new GraphemeSplitter();
|
||||
const showSyntaxModal = ref(false);
|
||||
|
||||
// 新增:跟踪编辑状态
|
||||
const isEditing = ref(false);
|
||||
const editIndex = ref(-1);
|
||||
const editTemplate = ref('');
|
||||
|
||||
// 新增:测试选中的模板
|
||||
const selectedTemplateForTest = ref('');
|
||||
|
||||
function addTemplate() {
|
||||
const val = newTemplate.value.trim();
|
||||
if (!val) return;
|
||||
if (props.templates.includes(val)) {
|
||||
message.warning('模板已存在');
|
||||
return;
|
||||
}
|
||||
props.templates.push(val);
|
||||
newTemplate.value = '';
|
||||
function countGraphemes(value: string) {
|
||||
return splitter.countGraphemes(value);
|
||||
}
|
||||
|
||||
function removeTemplate(index: number) {
|
||||
props.templates.splice(index, 1);
|
||||
}
|
||||
|
||||
// 新增:开始编辑模板
|
||||
function startEditTemplate(index: number) {
|
||||
editIndex.value = index;
|
||||
editTemplate.value = props.templates[index];
|
||||
isEditing.value = true;
|
||||
newTemplate.value = editTemplate.value;
|
||||
}
|
||||
|
||||
// 新增:取消编辑
|
||||
function cancelEdit() {
|
||||
isEditing.value = false;
|
||||
editIndex.value = -1;
|
||||
newTemplate.value = '';
|
||||
}
|
||||
|
||||
// 新增:保存编辑后的模板
|
||||
function saveEditedTemplate() {
|
||||
const val = newTemplate.value.trim();
|
||||
if (!val) {
|
||||
message.warning('模板内容不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否与其他模板重复(排除当前编辑的模板)
|
||||
const otherTemplates = props.templates.filter((_, idx) => idx !== editIndex.value);
|
||||
if (otherTemplates.includes(val)) {
|
||||
message.warning('模板已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
props.templates[editIndex.value] = val;
|
||||
message.success('模板更新成功');
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
// 新增:转换为表达式
|
||||
function convertPlaceholders() {
|
||||
if (!newTemplate.value) {
|
||||
if (!props.action.template) {
|
||||
message.warning('请先输入模板内容');
|
||||
return;
|
||||
}
|
||||
newTemplate.value = convertToJsExpressions(newTemplate.value, mergedPlaceholders.value);
|
||||
message.success('已转换占位符为表达式格式');
|
||||
const converted = convertToJsExpressions(props.action.template, mergedPlaceholders.value);
|
||||
if (converted !== props.action.template) {
|
||||
props.action.template = converted;
|
||||
message.success('已转换占位符为表达式格式');
|
||||
} else {
|
||||
message.info('模板中没有需要转换的占位符');
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:测试模板
|
||||
function testTemplate(template: string) {
|
||||
selectedTemplateForTest.value = template;
|
||||
activeTab.value = 'test';
|
||||
}
|
||||
|
||||
// 新增:高亮JavaScript表达式
|
||||
function hasJsExpression(template: string): boolean {
|
||||
return containsJsExpression(template);
|
||||
}
|
||||
|
||||
// 新增:高亮规则
|
||||
const highlightPatterns = computed(() => {
|
||||
return [
|
||||
// 普通占位符高亮
|
||||
...mergedPlaceholders.value.map(p => p.name),
|
||||
// JS表达式高亮
|
||||
'{{js:'
|
||||
];
|
||||
const simplePlaceholders = mergedPlaceholders.value.map(p => p.name);
|
||||
const jsExpressionsInTemplate = extractJsExpressions(props.action.template || '');
|
||||
const allPatterns = [...new Set([...simplePlaceholders, ...jsExpressionsInTemplate])];
|
||||
return allPatterns;
|
||||
});
|
||||
|
||||
const MAX_LENGTH = 20;
|
||||
const WARNING_THRESHOLD = 16;
|
||||
|
||||
function evaluateTemplateForUI(template: string): string {
|
||||
const executionContext = buildExecutionContext(testContext.value.event, undefined, props.action.triggerType);
|
||||
try {
|
||||
return evaluateTemplateExpressions(template, executionContext);
|
||||
} catch (error) {
|
||||
console.error("Preview evaluation error:", error);
|
||||
return `[预览错误: ${(error as Error).message}]`;
|
||||
}
|
||||
}
|
||||
|
||||
const evaluatedTemplateResult = computed(() => {
|
||||
if (!props.action.template || !showLivePreview.value) return '';
|
||||
return evaluateTemplateForUI(props.action.template);
|
||||
});
|
||||
|
||||
const previewResult = computed(() => {
|
||||
return evaluatedTemplateResult.value;
|
||||
});
|
||||
|
||||
const lengthStatus = computed(() => {
|
||||
if (!props.action.template || !props.checkLength || !showLivePreview.value) {
|
||||
return { status: 'normal' as const, message: '' };
|
||||
}
|
||||
try {
|
||||
const formattedText = evaluatedTemplateResult.value;
|
||||
if (formattedText.startsWith('[预览错误:')) {
|
||||
return { status: 'normal' as const, message: '' };
|
||||
}
|
||||
|
||||
const formattedLength = countGraphemes(formattedText);
|
||||
if (formattedLength > MAX_LENGTH) {
|
||||
return { status: 'error' as const, message: `格式化后长度超出限制(${formattedLength}/${MAX_LENGTH}字)` };
|
||||
} else if (formattedLength >= WARNING_THRESHOLD) {
|
||||
return { status: 'warning' as const, message: `格式化后长度接近限制(${formattedLength}/${MAX_LENGTH}字)` };
|
||||
}
|
||||
return { status: 'normal' as const, message: '' };
|
||||
} catch (error) {
|
||||
return { status: 'normal' as const, message: '' };
|
||||
}
|
||||
});
|
||||
|
||||
const templateExamples = [
|
||||
{
|
||||
title: '基础变量',
|
||||
examples: [
|
||||
{ label: '用户名', template: '你好 {{user.name}}!' },
|
||||
{ label: '条件回复', template: '{{js: user.guardLevel > 0 ? "欢迎舰长" : "欢迎"}} {{user.name}}!' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '高级用法',
|
||||
examples: [
|
||||
{ label: '字符串操作', template: '{{js: user.name.toUpperCase()}} 有 {{js: user.name.length}} 个字' },
|
||||
{ label: '随机回复', template: '{{js: ["谢谢", "感谢", "收到"][Math.floor(Math.random() * 3)]}}!' },
|
||||
{ label: '日期时间', template: '{{js: new Date().toLocaleTimeString()}},{{timeOfDay}}好!' },
|
||||
{
|
||||
label: '运行时计数',
|
||||
template: '{{js+: const count = (getData(\'messageCount\') || 0) + 1; setData(\'messageCount\', count); return `这是你本次对话的第 ${count} 条消息。`; }}'
|
||||
},
|
||||
{
|
||||
label: '运行时频率检查',
|
||||
template: '{{js+: const warns = (getData(\'warnings\') || 0) + 1; setData(\'warnings\', warns); return warns > 3 ? "发言太频繁啦!" : "收到你的消息~"; }}'
|
||||
},
|
||||
{
|
||||
label: '触发持久化计数 (异步)',
|
||||
template: '{{js+: const key = `user:${user.uid}:totalMessages`; getStorageData(key, 0).then(c => setStorageData(key, (c || 0) + 1)); return `正在为你累计总发言数...`; }}'
|
||||
},
|
||||
{
|
||||
label: '问候一次 (持久化)',
|
||||
template: '{{js+: const key = `greeted:${user.uid}`; hasStorageData(key).then(exists => { if (!exists) { setStorageData(key, true); /* 这里可以接发送欢迎消息的逻辑 */ } }); return \'检查问候状态...\'; }}'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '弹幕功能',
|
||||
examples: [
|
||||
{ label: '提取内容', template: '你说的是 "{{js: message.substring(0, 5)}}{{js: message.length > 5 ? "..." : ""}}" 吗?' },
|
||||
{ label: '回复问候', template: '{{js: message.includes("早上好") ? "早安" : "你好"}},{{user.name}}!' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function insertExample(template: string) {
|
||||
props.action.template = template;
|
||||
message.success('已插入示例模板');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,55 +245,18 @@ const highlightPatterns = computed(() => {
|
||||
v-if="mergedPlaceholders.length > 0"
|
||||
#header-extra
|
||||
>
|
||||
<NTooltip
|
||||
trigger="hover"
|
||||
placement="top"
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="showSyntaxModal = true"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
>
|
||||
变量说明
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<NAlert
|
||||
type="info"
|
||||
closable
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<template #header>
|
||||
<div class="alert-header">
|
||||
<NIcon
|
||||
:component="Info24Filled"
|
||||
size="18"
|
||||
style="margin-right: 8px"
|
||||
/>
|
||||
模板支持简单的JavaScript表达式
|
||||
</div>
|
||||
</template>
|
||||
在模板中使用 <code>{{ '\{\{js:\}\}' }}</code> 语法可以执行简单的JavaScript表达式
|
||||
|
||||
<NFlex vertical>
|
||||
<span>
|
||||
<code>{{ '\{\{js: user.name.toUpperCase()\}\}' }}</code> → 将用户名转为大写
|
||||
</span>
|
||||
<span>
|
||||
<code>{{ '\{\{js: gift.count > 10 ? "大量" : "少量"\}\}' }}</code> → 根据数量显示不同文本
|
||||
</span>
|
||||
</NFlex>
|
||||
</NAlert>
|
||||
<NScrollbar style="max-height: 200px; max-width: 300px">
|
||||
<div
|
||||
v-for="(ph, idx) in mergedPlaceholders"
|
||||
:key="idx"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</NTooltip>
|
||||
<NIcon
|
||||
:component="Info24Filled"
|
||||
style="margin-right: 4px;"
|
||||
/>
|
||||
变量与语法说明
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<p
|
||||
@@ -209,7 +266,7 @@ const highlightPatterns = computed(() => {
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- 新增:添加标签页支持 -->
|
||||
<!-- 标签页支持 -->
|
||||
<NTabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
@@ -218,161 +275,244 @@ const highlightPatterns = computed(() => {
|
||||
>
|
||||
<NTabPane
|
||||
name="editor"
|
||||
tab="编辑模板"
|
||||
tab="编辑"
|
||||
>
|
||||
<!-- 新增:添加模板帮助组件 -->
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
appear
|
||||
<NFlex
|
||||
vertical
|
||||
:size="12"
|
||||
>
|
||||
<!-- 模板帮助组件 -->
|
||||
<TemplateHelper :placeholders="mergedPlaceholders" />
|
||||
</transition>
|
||||
|
||||
<NList
|
||||
bordered
|
||||
class="template-list"
|
||||
>
|
||||
<transition-group
|
||||
name="list-slide"
|
||||
tag="div"
|
||||
appear
|
||||
<!-- 当前模板预览 -->
|
||||
<NInput
|
||||
v-model:value="action.template"
|
||||
type="textarea"
|
||||
placeholder="输入模板内容... 使用 {{变量名}} 插入变量, {{js: 表达式}} 执行JS"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
:show-count="checkLength"
|
||||
:count-graphemes="countGraphemes"
|
||||
:status="checkLength && lengthStatus.status !== 'normal' ? (lengthStatus.status === 'error' ? 'error' : 'warning') : undefined"
|
||||
class="template-input"
|
||||
/>
|
||||
|
||||
<!-- 长度检查警告 -->
|
||||
<NAlert
|
||||
v-if="checkLength && lengthStatus.message && lengthStatus.status !== 'normal'"
|
||||
:type="lengthStatus.status === 'error' ? 'error' : 'warning'"
|
||||
class="length-alert"
|
||||
>
|
||||
<NListItem
|
||||
v-for="(template, index) in templates"
|
||||
:key="index"
|
||||
class="template-list-item"
|
||||
{{ lengthStatus.message }}
|
||||
</NAlert>
|
||||
|
||||
<!-- 实时预览 -->
|
||||
<NFlex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
class="preview-toggle"
|
||||
>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showLivePreview = !showLivePreview"
|
||||
>
|
||||
<NSpace
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
<template #icon>
|
||||
<NIcon :component="showLivePreview ? LiveOff24Regular : Code24Regular" />
|
||||
</template>
|
||||
{{ showLivePreview ? '隐藏预览' : '显示预览' }}
|
||||
</NButton>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showLivePreview && previewResult"
|
||||
class="live-preview"
|
||||
>
|
||||
<!-- 更新:使用自定义高亮规则 -->
|
||||
<div
|
||||
class="template-content"
|
||||
:class="{ 'has-js-expr': hasJsExpression(template) }"
|
||||
>
|
||||
<NHighlight
|
||||
:patterns="highlightPatterns"
|
||||
:text="template"
|
||||
/>
|
||||
<div
|
||||
v-if="hasJsExpression(template)"
|
||||
class="js-expr-badge"
|
||||
>
|
||||
JS
|
||||
</div>
|
||||
</div>
|
||||
<NBadge
|
||||
dot
|
||||
type="info"
|
||||
/> 实时预览:
|
||||
<NHighlight
|
||||
:text="previewResult"
|
||||
:patterns="highlightPatterns"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</NFlex>
|
||||
|
||||
<NSpace>
|
||||
<NButton
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="testTemplate(template)"
|
||||
>
|
||||
测试
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="startEditTemplate(index)"
|
||||
>
|
||||
编辑
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
@positive-click="removeTemplate(index)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
确定要删除这个模板吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NListItem>
|
||||
</transition-group>
|
||||
</NList>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<transition
|
||||
name="fade-scale"
|
||||
appear
|
||||
>
|
||||
<NSpace
|
||||
vertical
|
||||
style="width: 100%"
|
||||
<!-- 操作按钮 -->
|
||||
<NFlex
|
||||
justify="end"
|
||||
:size="12"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="newTemplate"
|
||||
type="textarea"
|
||||
placeholder="输入新模板"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
class="template-input"
|
||||
@keydown.enter.ctrl="isEditing ? saveEditedTemplate() : addTemplate()"
|
||||
/>
|
||||
<NButton
|
||||
type="default"
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="convertPlaceholders"
|
||||
>
|
||||
占位符转表达式
|
||||
</NButton>
|
||||
|
||||
<NSpace justify="space-between">
|
||||
<NSpace>
|
||||
<NButton
|
||||
type="default"
|
||||
class="btn-with-transition"
|
||||
@click="convertPlaceholders"
|
||||
>
|
||||
转换为表达式
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
@click="activeTab = 'test'"
|
||||
>
|
||||
测试模板
|
||||
</NButton>
|
||||
</NFlex>
|
||||
|
||||
<NSpace>
|
||||
<NButton
|
||||
v-if="isEditing"
|
||||
class="btn-with-transition"
|
||||
@click="cancelEdit"
|
||||
<!-- 模板示例 -->
|
||||
<NCollapse
|
||||
class="template-examples"
|
||||
:default-expanded-names="['examples']"
|
||||
>
|
||||
<NCollapseItem
|
||||
name="examples"
|
||||
title="模板示例 (点击展开)"
|
||||
>
|
||||
<NFlex
|
||||
vertical
|
||||
:size="8"
|
||||
>
|
||||
<div
|
||||
v-for="(category, idx) in templateExamples"
|
||||
:key="idx"
|
||||
class="example-category"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
class="btn-with-transition"
|
||||
@click="isEditing ? saveEditedTemplate() : addTemplate()"
|
||||
>
|
||||
{{ isEditing ? '保存' : '添加' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</transition>
|
||||
<h4>{{ category.title }}</h4>
|
||||
<NFlex
|
||||
wrap
|
||||
:size="8"
|
||||
>
|
||||
<NButton
|
||||
v-for="(example, i) in category.examples"
|
||||
:key="i"
|
||||
size="small"
|
||||
tertiary
|
||||
class="example-button"
|
||||
@click="insertExample(example.template)"
|
||||
>
|
||||
{{ example.label }}
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</div>
|
||||
</NFlex>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NFlex>
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="test"
|
||||
tab="测试模板"
|
||||
tab="测试"
|
||||
>
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<TemplateTester
|
||||
:default-template="selectedTemplateForTest"
|
||||
:context="testContext"
|
||||
:placeholders="mergedPlaceholders"
|
||||
/>
|
||||
</transition>
|
||||
<TemplateTester
|
||||
:default-template="action.template"
|
||||
:context="testContext"
|
||||
:placeholders="mergedPlaceholders"
|
||||
/>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
|
||||
<!-- 新增 Modal 组件 -->
|
||||
<NModal
|
||||
v-model:show="showSyntaxModal"
|
||||
preset="card"
|
||||
title="模板语法与变量说明"
|
||||
:bordered="false"
|
||||
size="huge"
|
||||
style="width: 600px; max-width: 90vw;"
|
||||
:close-on-esc="true"
|
||||
:mask-closable="true"
|
||||
>
|
||||
<NScrollbar style="max-height: 80vh;">
|
||||
<NAlert
|
||||
title="模板语法说明"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
模板支持插入变量和执行 JavaScript。
|
||||
<NDivider style="margin: 8px 0;" />
|
||||
<strong>1. 简单变量替换:</strong><br>
|
||||
直接使用 <code>{{ '\{\{变量名.属性\}\}' }}</code> 插入值。<br>
|
||||
示例: <code>{{ '\{\{user.name\}\}' }}</code> → 显示用户名
|
||||
<NDivider style="margin: 8px 0;" />
|
||||
<strong>2. JS 表达式求值 (<code>js:</code>):</strong><br>
|
||||
使用 <code>{{ '\{\{js: 表达式\}\}' }}</code> 执行单个 JS 表达式并插入结果 (隐式返回)。<br>
|
||||
适合简单计算、字符串操作、三元运算等。<br>
|
||||
示例: <code>{{ '\{\{js: user.guardLevel > 0 ? "舰长" : "非舰长\}\}' }}</code><br>
|
||||
示例: <code>{{ '\{\{js: gift.price * gift.count\}\}' }}</code>
|
||||
<NDivider style="margin: 8px 0;" />
|
||||
<strong>3. JS 代码块执行 (<code>js+:</code> 或 <code>js-run:</code>):</strong><br>
|
||||
使用 <code>{{ '\{\{js+: 代码...\}\}' }}</code> 或 <code>{{ '\{\{js-run: 代码...\}\}' }}</code> 执行多行 JS 代码。<br>
|
||||
<strong style="color: var(--warning-color);">需要显式使用 <code>return</code> 语句来指定输出到模板的值。</strong><br>
|
||||
适合需要临时变量、多步逻辑或调用 <code>getData/setData</code> 等函数的场景。<br>
|
||||
<pre><code>{{ '\{\{js+:\n const count = (getData(\'greetCount\') || 0) + 1;\n setData(\'greetCount\', count);\n return \`这是第 ${count} 次问候!\`;\n\}\}' }}</code></pre>
|
||||
</NAlert>
|
||||
|
||||
<NCollapse arrow-placement="right">
|
||||
<NCollapseItem
|
||||
title="数据存储函数说明 (在 js+ 或 js-run 中使用)"
|
||||
name="data-functions"
|
||||
>
|
||||
<NAlert
|
||||
type="warning"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<strong>运行时数据</strong>仅在本次运行有效, 重启后就没了,且操作是<strong>同步</strong>的。
|
||||
</NAlert>
|
||||
<ul class="function-list">
|
||||
<li><code>getData(key, defaultValue?)</code>: 获取运行时数据。</li>
|
||||
<li><code>setData(key, value)</code>: 设置运行时数据。</li>
|
||||
<li><code>containsData(key)</code>: 检查运行时数据是否存在。</li>
|
||||
<li><code>removeData(key)</code>: 移除运行时数据。</li>
|
||||
</ul>
|
||||
|
||||
<NDivider style="margin: 12px 0;" />
|
||||
|
||||
<NAlert
|
||||
type="info"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
style="margin-bottom: 8px;"
|
||||
>
|
||||
<strong>持久化数据</strong>会长期保留,但操作是<strong>异步</strong>的 (返回 Promise)。<br>
|
||||
在 <code>js+</code> 或 <code>js-run</code> 中使用 <code>await</code> 处理或使用 <code>.then()</code>。
|
||||
</NAlert>
|
||||
<ul class="function-list">
|
||||
<li><code>getStorageData(key, defaultValue?)</code>: 获取持久化数据 (异步)。</li>
|
||||
<li><code>setStorageData(key, value)</code>: 设置持久化数据 (异步)。</li>
|
||||
<li><code>hasStorageData(key)</code>: 检查持久化数据是否存在 (异步)。</li>
|
||||
<li><code>removeStorageData(key)</code>: 移除持久化数据 (异步)。</li>
|
||||
<li><code>clearStorageData()</code>: 清除所有用户持久化数据 (异步)。</li>
|
||||
</ul>
|
||||
<pre><code>{{ '\{\{js+:\n // 异步获取并设置持久化数据\n const key = \`user:${user.uid}:visitCount\`;\n const count = (await getStorageData(key, 0)) + 1;\n await setStorageData(key, count);\n return \`你是第 ${count} 次访问!\`;\n\}\}' }}</code></pre>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
<br>
|
||||
<strong>可用变量 (基础):</strong>
|
||||
|
||||
<div
|
||||
v-for="(ph, idx) in mergedPlaceholders"
|
||||
:key="idx"
|
||||
class="placeholder-item"
|
||||
>
|
||||
<NText code>
|
||||
{{ ph.name }}
|
||||
</NText>: {{ ph.description }}
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-editor-card {
|
||||
transition: all 0.3s ease;
|
||||
animation: card-appear 0.4s ease-out;
|
||||
}
|
||||
|
||||
@@ -388,105 +528,132 @@ const highlightPatterns = computed(() => {
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 12px;
|
||||
color: var(--n-text-color-disabled);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
transition: all 0.3s ease;
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
margin-top: 16px;
|
||||
transition: all 0.3s ease;
|
||||
.placeholder-item {
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.template-list-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-list-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.template-content {
|
||||
position: relative;
|
||||
padding-right: 30px;
|
||||
word-break: break-all;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.has-js-expr {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.js-expr-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background-color: #409EFF;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
.placeholder-item code {
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-input {
|
||||
margin-top: 8px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.preview-toggle {
|
||||
margin-top: 4px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.length-alert {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-preview {
|
||||
background-color: var(--n-color-target);
|
||||
border-radius: var(--n-border-radius);
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid var(--n-color-target);
|
||||
word-break: break-all;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.template-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
.live-preview .n-badge {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 列表动画 */
|
||||
.list-slide-enter-active,
|
||||
.list-slide-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.list-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
.list-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
.list-slide-move {
|
||||
transition: transform 0.4s ease;
|
||||
.template-examples {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 淡入缩放 */
|
||||
.fade-scale-enter-active,
|
||||
.fade-scale-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fade-scale-enter-from,
|
||||
.fade-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
.example-category h4 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color-2);
|
||||
}
|
||||
|
||||
.example-button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.example-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 淡入淡出 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 按钮过渡 */
|
||||
.btn-with-transition {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-with-transition:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.function-list {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.function-list li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.function-list code {
|
||||
background-color: var(--n-code-color);
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--n-border-radius);
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.n-collapse {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.n-alert pre {
|
||||
margin: 4px 0 0 0;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--n-code-color);
|
||||
border-radius: var(--n-border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.n-alert code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
font-family: monospace;
|
||||
font-size: inherit;
|
||||
}
|
||||
.n-alert pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { NSpace, NInput, NInputGroup, NInputGroupLabel, NButton, useMessage, NDivider } from 'naive-ui';
|
||||
import { evaluateTemplateExpressions } from '@/client/store/autoAction/expressionEvaluator';
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import { TriggerType } from '@/client/store/autoAction/types';
|
||||
import { buildExecutionContext } from '@/client/store/autoAction/utils';
|
||||
|
||||
const props = defineProps({
|
||||
defaultTemplate: {
|
||||
@@ -63,9 +66,14 @@ const result = ref('');
|
||||
const hasResult = computed(() => result.value !== '');
|
||||
const message = useMessage();
|
||||
|
||||
function evaluateTemplateForUI(template: string, contextObj: Record<string, any>): string {
|
||||
const tempContext = buildExecutionContext(contextObj, undefined, TriggerType.DANMAKU);
|
||||
return evaluateTemplateExpressions(template, tempContext);
|
||||
}
|
||||
|
||||
function testTemplate() {
|
||||
try {
|
||||
result.value = evaluateTemplateExpressions(template.value, props.context);
|
||||
result.value = evaluateTemplateForUI(template.value, props.context);
|
||||
} catch (error) {
|
||||
message.error(`表达式求值错误: ${(error as Error).message}`);
|
||||
result.value = `[错误] ${(error as Error).message}`;
|
||||
|
||||
151
src/client/components/autoaction/TimerCountdown.vue
Normal file
151
src/client/components/autoaction/TimerCountdown.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||
import { useAutoAction, TriggerType } from '@/client/store/useAutoAction'; // 确保导入 TriggerType
|
||||
import { NText } from 'naive-ui';
|
||||
// import { formatDuration } from '@/utils/time'; // 移除不存在的导入
|
||||
|
||||
// 在组件内部实现简单的格式化函数
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
if (isNaN(totalSeconds) || totalSeconds < 0) {
|
||||
return '00:00';
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const paddedMinutes = String(minutes).padStart(2, '0');
|
||||
const paddedSeconds = String(seconds).padStart(2, '0');
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
actionId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const autoActionStore = useAutoAction();
|
||||
|
||||
const remainingSecondsDisplay = ref<string>('...'); // 用于显示格式化后的剩余时间
|
||||
const intervalId = ref<any | null>(null);
|
||||
|
||||
// 获取目标 action 的基本信息,用于判断是否是有效的定时任务
|
||||
const targetAction = computed(() => autoActionStore.autoActions.find(a => a.id === props.actionId));
|
||||
|
||||
// 获取当前计时器信息
|
||||
const getTimerInfo = () => {
|
||||
return autoActionStore.getScheduledTimerInfo(props.actionId);
|
||||
};
|
||||
|
||||
// 更新显示的函数
|
||||
const updateDisplay = () => {
|
||||
const timerInfo = getTimerInfo();
|
||||
if (timerInfo && timerInfo.remainingMs > 0) {
|
||||
remainingSecondsDisplay.value = formatDuration(timerInfo.remainingMs / 1000); // 使用内部的 formatDuration
|
||||
} else {
|
||||
// 如果没有计时器信息或时间已到,显示默认文本并停止更新
|
||||
remainingSecondsDisplay.value = targetAction.value?.triggerConfig.useGlobalTimer ? '全局' : '独立';
|
||||
if (intervalId.value) {
|
||||
clearInterval(intervalId.value);
|
||||
intervalId.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 启动定时更新
|
||||
const startInterval = () => {
|
||||
stopInterval(); // 先清除旧的定时器
|
||||
const timerInfo = getTimerInfo();
|
||||
// 只有在获取到有效的计时器信息时才启动
|
||||
if (timerInfo && timerInfo.remainingMs > 0) {
|
||||
updateDisplay(); // 先立即更新一次
|
||||
intervalId.value = setInterval(updateDisplay, 1000); // 每秒更新
|
||||
} else {
|
||||
// 如果初始状态就无效,直接显示默认文本
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
|
||||
// 停止定时更新
|
||||
const stopInterval = () => {
|
||||
if (intervalId.value) {
|
||||
clearInterval(intervalId.value);
|
||||
intervalId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 仅当 action 是 SCHEDULED 类型时才尝试启动计时器
|
||||
if (targetAction.value?.triggerType === TriggerType.SCHEDULED) {
|
||||
startInterval();
|
||||
} else {
|
||||
remainingSecondsDisplay.value = '非定时';
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopInterval(); // 组件卸载时清除定时器
|
||||
});
|
||||
|
||||
// 监听计时器状态变化(例如从独立切换到全局,或者反之,以及全局定时器的启动/停止)
|
||||
// 一个简化的方法是监听 getScheduledTimerInfo 的返回值,但这可能频繁触发
|
||||
// 更精确的方法是监听相关的响应式状态
|
||||
watch(
|
||||
() => [
|
||||
targetAction.value?.triggerConfig.useGlobalTimer,
|
||||
targetAction.value?.enabled,
|
||||
// autoActionStore.runtimeState.globalTimerStartTime, // 监听全局启动时间
|
||||
// autoActionStore.runtimeState.timerStartTimes[props.actionId] // 监听独立启动时间
|
||||
// 监听 getTimerInfo 返回的对象的引用可能更稳定,仅当对象本身改变时触发
|
||||
autoActionStore.getScheduledTimerInfo(props.actionId)
|
||||
],
|
||||
(newValues, oldValues) => {
|
||||
// 只有 action 是 SCHEDULED 类型时才处理
|
||||
if (targetAction.value?.triggerType !== TriggerType.SCHEDULED) {
|
||||
stopInterval();
|
||||
remainingSecondsDisplay.value = '非定时';
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimerInfo = newValues[2] as ReturnType<typeof getTimerInfo>; // 获取新的 timerInfo
|
||||
const oldTimerInfo = oldValues ? oldValues[2] as ReturnType<typeof getTimerInfo> : null;
|
||||
|
||||
// 检查计时器状态是否实际改变 (从有到无,从无到有,或者时间重置)
|
||||
const isActiveNow = newTimerInfo !== null && newTimerInfo.remainingMs > 0;
|
||||
const wasActiveBefore = oldTimerInfo !== null && oldTimerInfo.remainingMs > 0;
|
||||
|
||||
if (isActiveNow && !wasActiveBefore) {
|
||||
// 从不活动变为活动 -> 启动计时器
|
||||
startInterval();
|
||||
} else if (!isActiveNow && wasActiveBefore) {
|
||||
// 从活动变为不活动 -> 停止计时器并更新显示
|
||||
stopInterval();
|
||||
updateDisplay(); // 显示 "全局" 或 "独立"
|
||||
} else if (isActiveNow && wasActiveBefore) {
|
||||
// 如果一直活动,但 timerInfo 对象改变了 (可能意味着时间重置了),也重启一下 interval 确保时间准确
|
||||
// 简单的判断:如果 startTime 变了 (需要 getScheduledTimerInfo 返回 startTime)
|
||||
// 或者简单粗暴点:只要 timerInfo 变了就重启
|
||||
// 但由于 getScheduledTimerInfo 每次计算 remainingMs,可能导致不必要的重启
|
||||
// 优化:如果 remainingMs 突然大幅增加 (意味着重置),可以重启
|
||||
if (newTimerInfo && oldTimerInfo && newTimerInfo.remainingMs > oldTimerInfo.remainingMs + 1500) { // 增加超过1.5秒,认为是重置
|
||||
startInterval();
|
||||
} else {
|
||||
// 否则,让现有的 interval 继续运行更新就好
|
||||
// 但为了保险,可以考虑在启用状态或定时器类型切换时强制重启
|
||||
if (newValues[0] !== oldValues?.[0] || newValues[1] !== oldValues?.[1]) {
|
||||
startInterval();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 都不是活动状态,确保停止
|
||||
stopInterval();
|
||||
updateDisplay();
|
||||
}
|
||||
},
|
||||
{ deep: true } // 使用 deep watch 可能性能消耗稍大,但能更好地捕捉嵌套变化
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NText>{{ remainingSecondsDisplay }}</NText>
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NInput, NCollapseItem } from 'naive-ui';
|
||||
import { NSpace, NSwitch, NInputNumber, NInput, NCollapseItem, NCollapse } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
import { computed } from 'vue';
|
||||
import { createDefaultAutoAction } from '@/client/store/autoAction/utils';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
@@ -14,6 +15,54 @@ const props = defineProps({
|
||||
const showUserFilter = computed(() => {
|
||||
return ![TriggerType.SCHEDULED].includes(props.action.triggerType);
|
||||
});
|
||||
|
||||
// 获取默认配置作为比较基准
|
||||
const defaultAction = computed(() => createDefaultAutoAction(props.action.triggerType));
|
||||
|
||||
// 检查设置项是否被修改
|
||||
const isModified = (path: string, value: any) => {
|
||||
const pathParts = path.split('.');
|
||||
let defaultValue: any = defaultAction.value;
|
||||
let currentValue: any = props.action;
|
||||
|
||||
// 遍历路径获取值
|
||||
for (const part of pathParts) {
|
||||
defaultValue = defaultValue && typeof defaultValue === 'object' ? defaultValue[part as keyof typeof defaultValue] : undefined;
|
||||
currentValue = currentValue && typeof currentValue === 'object' ? currentValue[part as keyof typeof currentValue] : undefined;
|
||||
}
|
||||
|
||||
// 处理特殊情况,如果指定了具体值进行比较
|
||||
if (value !== undefined) {
|
||||
return value !== defaultValue;
|
||||
}
|
||||
|
||||
return currentValue !== defaultValue;
|
||||
};
|
||||
|
||||
// 检查用户过滤区域是否有修改
|
||||
const userFilterModified = computed(() => {
|
||||
if (!showUserFilter.value) return false;
|
||||
return isModified('triggerConfig.userFilterEnabled', props.action.triggerConfig.userFilterEnabled) ||
|
||||
isModified('triggerConfig.requireMedal', props.action.triggerConfig.requireMedal) ||
|
||||
isModified('triggerConfig.requireCaptain', props.action.triggerConfig.requireCaptain);
|
||||
});
|
||||
|
||||
// 检查冷却控制区域是否有修改
|
||||
const cooldownModified = computed(() => {
|
||||
return isModified('ignoreCooldown', props.action.ignoreCooldown) ||
|
||||
isModified('actionConfig.delaySeconds', props.action.actionConfig.delaySeconds) ||
|
||||
isModified('actionConfig.cooldownSeconds', props.action.actionConfig.cooldownSeconds);
|
||||
});
|
||||
|
||||
// 检查逻辑表达式是否有修改
|
||||
const logicalExpressionModified = computed(() => {
|
||||
return isModified('logicalExpression', props.action.logicalExpression);
|
||||
});
|
||||
|
||||
// 检查自定义JS是否有修改
|
||||
const customJsModified = computed(() => {
|
||||
return isModified('executeCommand', props.action.executeCommand);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,8 +70,10 @@ const showUserFilter = computed(() => {
|
||||
<NCollapseItem
|
||||
v-if="showUserFilter"
|
||||
key="user-filter"
|
||||
title="用户过滤"
|
||||
:title="userFilterModified ? '用户过滤 *' : '用户过滤'"
|
||||
:title-extra="userFilterModified ? '已修改' : ''"
|
||||
class="settings-section"
|
||||
:class="{'section-modified': userFilterModified}"
|
||||
>
|
||||
<div>
|
||||
<NSpace
|
||||
@@ -34,7 +85,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('triggerConfig.userFilterEnabled', action.triggerConfig.userFilterEnabled)}]"
|
||||
>
|
||||
<span>启用用户过滤:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.userFilterEnabled" />
|
||||
@@ -46,7 +97,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('triggerConfig.requireMedal', action.triggerConfig.requireMedal)}]"
|
||||
>
|
||||
<span>要求本房间勋章:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.requireMedal" />
|
||||
@@ -57,7 +108,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('triggerConfig.requireCaptain', action.triggerConfig.requireCaptain)}]"
|
||||
>
|
||||
<span>要求任意舰长:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.requireCaptain" />
|
||||
@@ -69,8 +120,10 @@ const showUserFilter = computed(() => {
|
||||
|
||||
<NCollapseItem
|
||||
key="cooldown"
|
||||
title="冷却控制"
|
||||
:title="cooldownModified ? '冷却控制 *' : '冷却控制'"
|
||||
:title-extra="cooldownModified ? '已修改' : ''"
|
||||
class="settings-section"
|
||||
:class="{'section-modified': cooldownModified}"
|
||||
>
|
||||
<div>
|
||||
<NSpace
|
||||
@@ -78,7 +131,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('ignoreCooldown', action.ignoreCooldown)}]"
|
||||
>
|
||||
<span>忽略全局冷却:</span>
|
||||
<NSwitch v-model:value="action.ignoreCooldown" />
|
||||
@@ -89,7 +142,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('actionConfig.delaySeconds', action.actionConfig.delaySeconds)}]"
|
||||
>
|
||||
<span>延迟执行(秒):</span>
|
||||
<NInputNumber
|
||||
@@ -105,7 +158,7 @@ const showUserFilter = computed(() => {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
:class="['setting-item', {'setting-modified': isModified('actionConfig.cooldownSeconds', action.actionConfig.cooldownSeconds)}]"
|
||||
>
|
||||
<span>冷却时间(秒):</span>
|
||||
<NInputNumber
|
||||
@@ -120,8 +173,10 @@ const showUserFilter = computed(() => {
|
||||
|
||||
<NCollapseItem
|
||||
key="logical-expression"
|
||||
title="逻辑条件表达式"
|
||||
:title="logicalExpressionModified ? '逻辑条件表达式 *' : '逻辑条件表达式'"
|
||||
:title-extra="logicalExpressionModified ? '已修改' : ''"
|
||||
class="settings-section"
|
||||
:class="{'section-modified': logicalExpressionModified}"
|
||||
>
|
||||
<div>
|
||||
<NSpace vertical>
|
||||
@@ -133,6 +188,7 @@ const showUserFilter = computed(() => {
|
||||
type="textarea"
|
||||
placeholder="输入表达式,留空则始终为真"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:class="{'input-modified': isModified('logicalExpression', action.logicalExpression)}"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
@@ -140,8 +196,10 @@ const showUserFilter = computed(() => {
|
||||
|
||||
<NCollapseItem
|
||||
key="custom-js"
|
||||
title="自定义JS执行"
|
||||
:title="customJsModified ? '自定义JS执行 *' : '自定义JS执行'"
|
||||
:title-extra="customJsModified ? '已修改' : ''"
|
||||
class="settings-section"
|
||||
:class="{'section-modified': customJsModified}"
|
||||
>
|
||||
<div>
|
||||
<NSpace vertical>
|
||||
@@ -153,6 +211,7 @@ const showUserFilter = computed(() => {
|
||||
type="textarea"
|
||||
placeholder="输入要执行的JS代码"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
:class="{'input-modified': isModified('executeCommand', action.executeCommand)}"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
@@ -179,6 +238,31 @@ const showUserFilter = computed(() => {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.setting-modified {
|
||||
font-weight: bold;
|
||||
background-color: rgba(255, 230, 186, 0.2);
|
||||
}
|
||||
|
||||
.setting-modified:hover {
|
||||
background-color: rgba(255, 230, 186, 0.3);
|
||||
}
|
||||
|
||||
.input-modified {
|
||||
border-color: #faad14;
|
||||
background-color: rgba(255, 230, 186, 0.1);
|
||||
}
|
||||
|
||||
.section-modified :deep(.n-collapse-item__header-main) {
|
||||
font-weight: bold;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.section-modified :deep(.n-collapse-item__header-extra) {
|
||||
font-size: 12px;
|
||||
color: #fa8c16;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NInput, NButton, NTag, NDivider, NCollapseItem, useMessage } from 'naive-ui';
|
||||
import { NSpace, NInput, NButton, NTag, NDivider, NCollapseItem, useMessage, NRadioGroup, NRadioButton } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
import { KeywordMatchType } from '@/client/store/autoAction/types';
|
||||
import SingleTemplateEditor from '../SingleTemplateEditor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
@@ -16,6 +18,15 @@ const message = useMessage();
|
||||
const tempKeyword = ref('');
|
||||
const tempBlockword = ref('');
|
||||
|
||||
// 初始化匹配类型配置
|
||||
if (!props.action.triggerConfig.keywordMatchType) {
|
||||
props.action.triggerConfig.keywordMatchType = KeywordMatchType.Contains;
|
||||
}
|
||||
|
||||
if (!props.action.triggerConfig.blockwordMatchType) {
|
||||
props.action.triggerConfig.blockwordMatchType = KeywordMatchType.Contains;
|
||||
}
|
||||
|
||||
// 添加关键词
|
||||
function addKeyword() {
|
||||
if (!tempKeyword.value.trim()) return;
|
||||
@@ -61,6 +72,17 @@ function removeBlockword(index: number) {
|
||||
props.action.triggerConfig.blockwords.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 定义弹幕相关的模板占位符
|
||||
const danmakuPlaceholders = [
|
||||
{ name: '{{user.name}}', description: '发送弹幕的用户名' },
|
||||
{ name: '{{user.uid}}', description: '用户UID' },
|
||||
{ name: '{{user.guardLevel}}', description: '用户舰长等级(0-3)' },
|
||||
{ name: '{{user.medalLevel}}', description: '粉丝勋章等级' },
|
||||
{ name: '{{user.medalName}}', description: '粉丝勋章名称' },
|
||||
{ name: '{{message}}', description: '弹幕内容' },
|
||||
{ name: '{{js: message.substring(0, 10)}}', description: '弹幕内容的前10个字符' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,6 +105,24 @@ function removeBlockword(index: number) {
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace align="center">
|
||||
<span>匹配方式:</span>
|
||||
<NRadioGroup
|
||||
v-model:value="action.triggerConfig.keywordMatchType"
|
||||
size="small"
|
||||
>
|
||||
<NRadioButton :value="KeywordMatchType.Full">
|
||||
完全
|
||||
</NRadioButton>
|
||||
<NRadioButton :value="KeywordMatchType.Contains">
|
||||
包含
|
||||
</NRadioButton>
|
||||
<NRadioButton :value="KeywordMatchType.Regex">
|
||||
正则
|
||||
</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<template v-if="action.triggerConfig.keywords">
|
||||
<NTag
|
||||
@@ -112,6 +152,24 @@ function removeBlockword(index: number) {
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace align="center">
|
||||
<span>匹配方式:</span>
|
||||
<NRadioGroup
|
||||
v-model:value="action.triggerConfig.blockwordMatchType"
|
||||
size="small"
|
||||
>
|
||||
<NRadioButton :value="KeywordMatchType.Full">
|
||||
完全
|
||||
</NRadioButton>
|
||||
<NRadioButton :value="KeywordMatchType.Contains">
|
||||
包含
|
||||
</NRadioButton>
|
||||
<NRadioButton :value="KeywordMatchType.Regex">
|
||||
正则
|
||||
</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<template v-if="action.triggerConfig.blockwords">
|
||||
<NTag
|
||||
|
||||
@@ -32,7 +32,7 @@ const enterFilterModeOptions = [
|
||||
>
|
||||
<span>入场过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerConfig.enterFilterMode"
|
||||
v-model:value="action.triggerConfig.filterMode"
|
||||
style="width: 200px"
|
||||
:options="enterFilterModeOptions"
|
||||
/>
|
||||
|
||||
@@ -61,13 +61,13 @@ function removeGiftName(index: number) {
|
||||
>
|
||||
<span>礼物过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerConfig.giftFilterMode"
|
||||
v-model:value="action.triggerConfig.filterMode"
|
||||
style="width: 200px"
|
||||
:options="giftFilterModeOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<template v-if="action.triggerConfig.giftFilterMode === 'blacklist' || action.triggerConfig.giftFilterMode === 'whitelist'">
|
||||
<template v-if="action.triggerConfig.filterMode === 'blacklist' || action.triggerConfig.filterMode === 'whitelist'">
|
||||
<NSpace>
|
||||
<NInput
|
||||
v-model:value="tempGiftName"
|
||||
@@ -93,7 +93,7 @@ function removeGiftName(index: number) {
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<template v-if="action.triggerConfig.giftFilterMode === 'value'">
|
||||
<template v-if="action.triggerConfig.filterMode === 'value'">
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NCard,
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NRadioGroup,
|
||||
NRadio,
|
||||
NText,
|
||||
NDivider
|
||||
} from 'naive-ui';
|
||||
import { useAutoAction } from '@/client/store/useAutoAction';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const autoActionStore = useAutoAction();
|
||||
|
||||
// 定时模式选项
|
||||
const schedulingModeOptions = [
|
||||
{ label: '随机模式', value: 'random' },
|
||||
{ label: '顺序模式', value: 'sequential' }
|
||||
];
|
||||
|
||||
// 监听变化,触发定时器重启(如果间隔改变)
|
||||
watch(() => autoActionStore.globalIntervalSeconds, () => {
|
||||
autoActionStore.restartGlobalTimer(); // 确保间隔改变时定时器更新
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
title="全局定时设置"
|
||||
size="small"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<NSpace
|
||||
vertical
|
||||
:size="16"
|
||||
>
|
||||
<NText
|
||||
type="info"
|
||||
:depth="3"
|
||||
style="font-size: 12px;"
|
||||
>
|
||||
这里的设置将应用于所有启用了 "使用全局定时器" 选项的定时触发操作。
|
||||
</NText>
|
||||
<NDivider style="margin: 4px 0;" />
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<NText>全局发送间隔 (秒):</NText>
|
||||
<NInputNumber
|
||||
v-model:value="autoActionStore.globalIntervalSeconds"
|
||||
:min="10"
|
||||
:max="7200"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<NText>全局发送模式:</NText>
|
||||
<NRadioGroup v-model:value="autoActionStore.globalSchedulingMode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in schedulingModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加一些特定样式 */
|
||||
</style>
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NInputNumber, NRadioGroup, NRadio, NCollapseItem } from 'naive-ui';
|
||||
import {
|
||||
NSpace,
|
||||
NInputNumber,
|
||||
NRadioGroup,
|
||||
NRadio,
|
||||
NCollapseItem,
|
||||
NSwitch,
|
||||
NDivider,
|
||||
NText
|
||||
} from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
@@ -10,11 +19,30 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化配置项
|
||||
if (props.action.triggerConfig.useGlobalTimer === undefined) {
|
||||
props.action.triggerConfig.useGlobalTimer = false; // 默认不使用全局定时器
|
||||
}
|
||||
if (props.action.triggerConfig.schedulingMode === undefined) {
|
||||
props.action.triggerConfig.schedulingMode = 'random'; // 默认随机模式
|
||||
}
|
||||
if (props.action.triggerConfig.intervalSeconds === undefined) {
|
||||
props.action.triggerConfig.intervalSeconds = 300; // 默认5分钟
|
||||
}
|
||||
|
||||
const useGlobalTimer = ref(props.action.triggerConfig.useGlobalTimer);
|
||||
|
||||
// 同步到 action
|
||||
watch(useGlobalTimer, (value) => {
|
||||
props.action.triggerConfig.useGlobalTimer = value;
|
||||
});
|
||||
|
||||
// 定时模式选项
|
||||
const schedulingModeOptions = [
|
||||
{ label: '随机模式', value: 'random' },
|
||||
{ label: '顺序模式', value: 'sequential' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,11 +50,47 @@ const schedulingModeOptions = [
|
||||
v-if="action.triggerType === TriggerType.SCHEDULED"
|
||||
title="定时触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
vertical
|
||||
:size="16"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<NText>使用全局定时器设置:</NText>
|
||||
<NSwitch v-model:value="useGlobalTimer">
|
||||
<template #checked>
|
||||
是
|
||||
</template>
|
||||
<template #unchecked>
|
||||
否
|
||||
</template>
|
||||
</NSwitch>
|
||||
</NSpace>
|
||||
|
||||
<NText
|
||||
type="info"
|
||||
:depth="3"
|
||||
style="font-size: 12px; margin-top: -8px;"
|
||||
>
|
||||
启用后,此操作将遵循全局定时设置(间隔、模式),忽略下方独立设置。
|
||||
全局设置需在【自动化 -> 全局配置】中修改。
|
||||
</NText>
|
||||
|
||||
<NDivider
|
||||
title-placement="left"
|
||||
style="margin-top: 8px; margin-bottom: 8px;"
|
||||
>
|
||||
独立设置 (仅在不使用全局定时器时生效)
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
:class="{ 'disabled-setting': useGlobalTimer }"
|
||||
>
|
||||
<span>发送间隔 (秒):</span>
|
||||
<NInputNumber
|
||||
@@ -34,6 +98,7 @@ const schedulingModeOptions = [
|
||||
:min="60"
|
||||
:max="3600"
|
||||
style="width: 120px"
|
||||
:disabled="useGlobalTimer"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
@@ -41,9 +106,13 @@ const schedulingModeOptions = [
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
:class="{ 'disabled-setting': useGlobalTimer }"
|
||||
>
|
||||
<span>发送模式:</span>
|
||||
<NRadioGroup v-model:value="action.triggerConfig.schedulingMode">
|
||||
<NRadioGroup
|
||||
v-model:value="action.triggerConfig.schedulingMode"
|
||||
:disabled="useGlobalTimer"
|
||||
>
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in schedulingModeOptions"
|
||||
@@ -58,3 +127,10 @@ const schedulingModeOptions = [
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.disabled-setting {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,7 +43,7 @@ const scFilterModeOptions = [
|
||||
>
|
||||
<span>最低价格 (元):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.triggerConfig.minPrice"
|
||||
v-model:value="action.triggerConfig.scMinPrice"
|
||||
:min="0"
|
||||
style="width: 120px"
|
||||
/>
|
||||
|
||||
@@ -11,59 +11,6 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// 模板变量占位符选项,根据触发类型动态生成
|
||||
const placeholders = computed(() => {
|
||||
const commonPlaceholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{user.uid}}', description: '用户ID' },
|
||||
{ name: '{{date.formatted}}', description: '当前日期时间' },
|
||||
{ name: '{{timeOfDay()}}', description: '当前时段(早上/下午/晚上)' },
|
||||
];
|
||||
|
||||
let specificPlaceholders: { name: string, description: string }[] = [];
|
||||
|
||||
switch (props.action.triggerType) {
|
||||
case TriggerType.GIFT:
|
||||
specificPlaceholders = [
|
||||
{ name: '{{gift.name}}', description: '礼物名称' },
|
||||
{ name: '{{gift.count}}', description: '礼物数量' },
|
||||
{ name: '{{gift.price}}', description: '礼物单价' },
|
||||
{ name: '{{gift.totalPrice}}', description: '礼物总价值' },
|
||||
{ name: '{{gift.summary}}', description: '礼物摘要(如:5个辣条)' },
|
||||
];
|
||||
break;
|
||||
case TriggerType.GUARD:
|
||||
specificPlaceholders = [
|
||||
{ name: '{{guard.level}}', description: '舰长等级' },
|
||||
{ name: '{{guard.levelName}}', description: '舰长等级名称' },
|
||||
{ name: '{{guard.giftCode}}', description: '礼品码(如已配置)' },
|
||||
];
|
||||
break;
|
||||
case TriggerType.SUPER_CHAT:
|
||||
specificPlaceholders = [
|
||||
{ name: '{{sc.message}}', description: 'SC消息内容' },
|
||||
{ name: '{{sc.price}}', description: 'SC价格' },
|
||||
];
|
||||
break;
|
||||
case TriggerType.FOLLOW:
|
||||
specificPlaceholders = [
|
||||
{ name: '{{follow.time}}', description: '关注时间' },
|
||||
{ name: '{{follow.isNew}}', description: '是否新关注' },
|
||||
];
|
||||
break;
|
||||
case TriggerType.ENTER:
|
||||
specificPlaceholders = [
|
||||
{ name: '{{enter.time}}', description: '入场时间' },
|
||||
{ name: '{{enter.guardLevel}}', description: '舰长等级' },
|
||||
{ name: '{{enter.medalName}}', description: '勋章名称' },
|
||||
{ name: '{{enter.medalLevel}}', description: '勋章等级' },
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return [...commonPlaceholders, ...specificPlaceholders];
|
||||
});
|
||||
|
||||
// 根据操作类型获取模板标题
|
||||
const templateTitle = computed(() => {
|
||||
switch (props.action.actionType) {
|
||||
@@ -91,6 +38,15 @@ const templateDescription = computed(() => {
|
||||
return '消息内容模板';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle template updates from TemplateEditor
|
||||
function handleTemplateUpdate(payload: { index: number, value: string }) {
|
||||
// Assuming index will always be 0 here as we only render one editor
|
||||
// And assuming action.templates is a string based on previous findings
|
||||
if (payload.index === 0) {
|
||||
props.action.template = payload.value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,11 +56,13 @@ const templateDescription = computed(() => {
|
||||
appear
|
||||
>
|
||||
<TemplateEditor
|
||||
:templates="action.templates"
|
||||
:placeholders="placeholders"
|
||||
:action="props.action"
|
||||
:template-index="0"
|
||||
:title="templateTitle"
|
||||
:description="templateDescription"
|
||||
:check-length="action.actionType === ActionType.SEND_DANMAKU"
|
||||
class="template-editor"
|
||||
@update:template="handleTemplateUpdate"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
287
src/client/store/autoAction/actionUtils.ts
Normal file
287
src/client/store/autoAction/actionUtils.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Ref } from 'vue';
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState,
|
||||
ActionType,
|
||||
ExecutionContext
|
||||
} from './types';
|
||||
import { buildExecutionContext, getRandomTemplate } from './utils';
|
||||
import { evaluateTemplateExpressions } from './expressionEvaluator';
|
||||
import { evaluateExpression } from './utils';
|
||||
import { useBiliCookie } from '../useBiliCookie';
|
||||
|
||||
/**
|
||||
* 过滤有效的自动操作项
|
||||
* @param actions 所有操作项列表
|
||||
* @param triggerType 触发类型
|
||||
* @param isLive 是否直播中
|
||||
* @param isTianXuanActive 是否天选时刻激活
|
||||
* @param options 额外过滤选项
|
||||
* @returns 过滤后的操作项
|
||||
*/
|
||||
export function filterValidActions(
|
||||
actions: AutoActionItem[],
|
||||
triggerType: TriggerType,
|
||||
isLive: Ref<boolean>,
|
||||
isTianXuanActive?: Ref<boolean>,
|
||||
options?: {
|
||||
actionType?: ActionType; // 特定操作类型
|
||||
customFilter?: (action: AutoActionItem) => boolean; // 自定义过滤器
|
||||
}
|
||||
): AutoActionItem[] {
|
||||
return actions.filter(action => {
|
||||
// 基本过滤条件
|
||||
if (action.triggerType !== triggerType || !action.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 直播状态过滤
|
||||
if (action.triggerConfig.onlyDuringLive && !isLive.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 天选时刻过滤
|
||||
if (isTianXuanActive && action.triggerConfig.ignoreTianXuan && isTianXuanActive.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 操作类型过滤
|
||||
if (options?.actionType && action.actionType !== options.actionType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 自定义过滤器
|
||||
if (options?.customFilter && !options.customFilter(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否满足过滤条件
|
||||
* @param action 操作项
|
||||
* @param event 事件数据
|
||||
* @returns 是否满足条件
|
||||
*/
|
||||
export function checkUserFilters(action: AutoActionItem, event: EventModel): boolean {
|
||||
if (!action.triggerConfig.userFilterEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action.triggerConfig.requireCaptain && !event.guard_level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查冷却时间
|
||||
* @param action 操作项
|
||||
* @param runtimeState 运行时状态
|
||||
* @returns 是否可以执行(已过冷却期)
|
||||
*/
|
||||
export function checkCooldown(action: AutoActionItem, runtimeState: RuntimeState): boolean {
|
||||
if (action.ignoreCooldown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastExecTime = runtimeState.lastExecutionTime[action.id] || 0;
|
||||
const cooldownMs = (action.actionConfig.cooldownSeconds || 0) * 1000;
|
||||
|
||||
return now - lastExecTime >= cooldownMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理模板并返回格式化后的内容
|
||||
* @param action 操作项
|
||||
* @param context 执行上下文
|
||||
* @param options 可选配置
|
||||
* @returns 格式化后的内容,如果没有有效模板则返回null
|
||||
*/
|
||||
export function processTemplate(
|
||||
action: AutoActionItem,
|
||||
context: any,
|
||||
options?: {
|
||||
useRandomTemplate?: boolean; // 是否随机选择模板,默认true
|
||||
defaultValue?: string; // 如果模板为空或格式化失败时的默认值
|
||||
}
|
||||
): string | null {
|
||||
if (!action.template || action.template.trim() === '') {
|
||||
console.warn(`跳过操作 "${action.name || '未命名'}":未设置有效模板`);
|
||||
return options?.defaultValue || null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取模板内容
|
||||
let template: string;
|
||||
if (options?.useRandomTemplate !== false) {
|
||||
// 使用随机模板 (默认行为)
|
||||
const randomTemplate = getRandomTemplate(action.template);
|
||||
if (!randomTemplate) {
|
||||
return options?.defaultValue || null;
|
||||
}
|
||||
template = randomTemplate;
|
||||
} else {
|
||||
// 使用整个模板字符串
|
||||
template = action.template;
|
||||
}
|
||||
|
||||
// 格式化模板
|
||||
const formattedContent = evaluateTemplateExpressions(template, context);
|
||||
return formattedContent;
|
||||
} catch (error) {
|
||||
console.error(`模板处理错误 (${action.name || action.id}):`, error);
|
||||
return options?.defaultValue || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行操作的通用函数
|
||||
* @param actions 过滤后的操作列表
|
||||
* @param event 触发事件
|
||||
* @param triggerType 触发类型
|
||||
* @param roomId 房间ID
|
||||
* @param runtimeState 运行时状态
|
||||
* @param handlers 操作处理器
|
||||
* @param options 额外选项
|
||||
*/
|
||||
export function executeActions(
|
||||
actions: AutoActionItem[],
|
||||
event: EventModel | null,
|
||||
triggerType: TriggerType,
|
||||
roomId: number,
|
||||
runtimeState: RuntimeState,
|
||||
handlers: {
|
||||
sendLiveDanmaku?: (roomId: number, message: string) => Promise<boolean>;
|
||||
sendPrivateMessage?: (userId: number, message: string) => Promise<boolean>;
|
||||
// 可以扩展其他类型的发送处理器
|
||||
},
|
||||
options?: {
|
||||
customContextBuilder?: (event: EventModel | null, roomId: number, triggerType: TriggerType) => ExecutionContext;
|
||||
customFilters?: Array<(action: AutoActionItem, context: ExecutionContext) => boolean>;
|
||||
skipUserFilters?: boolean;
|
||||
skipCooldownCheck?: boolean;
|
||||
onSuccess?: (action: AutoActionItem, context: ExecutionContext) => void;
|
||||
}
|
||||
) {
|
||||
if (!roomId || actions.length === 0) return;
|
||||
const biliCookie = useBiliCookie()
|
||||
// 对每个操作进行处理
|
||||
for (const action of actions) {
|
||||
// 构建执行上下文
|
||||
const context = options?.customContextBuilder
|
||||
? options.customContextBuilder(event, roomId, triggerType)
|
||||
: buildExecutionContext(event, roomId, triggerType);
|
||||
|
||||
// 应用自定义过滤器
|
||||
if (options?.customFilters) {
|
||||
const passesAllFilters = options.customFilters.every(filter => filter(action, context));
|
||||
if (!passesAllFilters) continue;
|
||||
}
|
||||
|
||||
// 检查用户过滤条件
|
||||
if (!options?.skipUserFilters && event && !checkUserFilters(action, event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查逻辑表达式
|
||||
if (action.logicalExpression && event) {
|
||||
if (!evaluateExpression(action.logicalExpression, context)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查冷却时间
|
||||
if (!options?.skipCooldownCheck && !checkCooldown(action, runtimeState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据操作类型执行不同的处理逻辑
|
||||
switch (action.actionType) {
|
||||
case ActionType.SEND_DANMAKU:
|
||||
if (!biliCookie.isCookieValid) {
|
||||
continue; // 如果未登录,则跳过
|
||||
}
|
||||
if (handlers.sendLiveDanmaku) {
|
||||
// 处理弹幕发送
|
||||
const message = processTemplate(action, context);
|
||||
if (message) {
|
||||
// 更新冷却时间
|
||||
runtimeState.lastExecutionTime[action.id] = Date.now();
|
||||
|
||||
// 延迟发送
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
handlers.sendLiveDanmaku!(roomId, message)
|
||||
.catch(err => console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err));
|
||||
}, action.actionConfig.delaySeconds * 1000);
|
||||
} else {
|
||||
handlers.sendLiveDanmaku(roomId, message)
|
||||
.catch(err => console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[AutoAction] 未提供弹幕发送处理器,无法执行操作: ${action.name || action.id}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case ActionType.SEND_PRIVATE_MSG:
|
||||
if (!biliCookie.isCookieValid) {
|
||||
continue; // 如果未登录,则跳过
|
||||
}
|
||||
if (handlers.sendPrivateMessage && event && event.uid) {
|
||||
// 处理私信发送
|
||||
const message = processTemplate(action, context);
|
||||
if (message) {
|
||||
// 更新冷却时间(私信也可以有冷却时间)
|
||||
runtimeState.lastExecutionTime[action.id] = Date.now();
|
||||
|
||||
const sendPmPromise = (uid: number, msg: string) => {
|
||||
return handlers.sendPrivateMessage!(uid, msg)
|
||||
.then(success => {
|
||||
if (success && options?.onSuccess) {
|
||||
// 发送成功后调用 onSuccess 回调
|
||||
options.onSuccess(action, context);
|
||||
}
|
||||
return success;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`[AutoAction] 发送私信失败 (${action.name || action.id}):`, err);
|
||||
return false; // 明确返回 false 表示失败
|
||||
});
|
||||
};
|
||||
|
||||
// 私信通常不需要延迟,但我们也可以支持
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
sendPmPromise(event.uid, message);
|
||||
}, action.actionConfig.delaySeconds * 1000);
|
||||
} else {
|
||||
sendPmPromise(event.uid, message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[AutoAction] 未提供私信发送处理器或事件缺少UID,无法执行操作: ${action.name || action.id}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case ActionType.EXECUTE_COMMAND:
|
||||
// 执行自定义命令(未实现)
|
||||
console.warn(`[AutoAction] 暂不支持执行自定义命令: ${action.name || action.id}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[AutoAction] 未知的操作类型: ${action.actionType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,36 +2,106 @@
|
||||
* 表达式求值工具 - 用于在自动操作模板中支持简单的JavaScript表达式
|
||||
*/
|
||||
|
||||
// 导入ExecutionContext类型
|
||||
import { ExecutionContext } from './types';
|
||||
|
||||
// 表达式模式匹配
|
||||
// {{js: expression}} - 完整的JavaScript表达式
|
||||
const JS_EXPRESSION_REGEX = /\{\{\s*js:\s*(.*?)\s*\}\}/g;
|
||||
// {{js: expression}} - 简单的JavaScript表达式 (隐式return)
|
||||
// {{js+: code block}} - JavaScript代码块 (需要显式return)
|
||||
// {{js-run: code block}} - JavaScript代码块 (需要显式return)
|
||||
export const JS_EXPRESSION_REGEX = /\{\{\s*(js(?:\+|\-run)?):\s*(.*?)\s*\}\}/gs; // 使用 s 标志允许多行匹配
|
||||
|
||||
/**
|
||||
* 处理模板中的表达式
|
||||
* @param template 包含表达式的模板字符串
|
||||
* @param context 上下文对象,包含可在表达式中访问的变量
|
||||
* @param context 执行上下文对象
|
||||
* @returns 处理后的字符串
|
||||
*/
|
||||
export function evaluateTemplateExpressions(template: string, context: Record<string, any>): string {
|
||||
export function evaluateTemplateExpressions(template: string, context: ExecutionContext): string {
|
||||
// 增加严格的类型检查
|
||||
if (typeof template !== 'string') {
|
||||
console.error('[evaluateTemplateExpressions] Error: Expected template to be a string, but received:', typeof template, template);
|
||||
return ""; // 或者抛出错误,或者返回一个默认值
|
||||
}
|
||||
|
||||
if (!template) return "";
|
||||
|
||||
return template.replace(JS_EXPRESSION_REGEX, (match, expression) => {
|
||||
try {
|
||||
// 创建一个安全的求值函数
|
||||
const evalInContext = new Function(...Object.keys(context), `
|
||||
try {
|
||||
return ${expression};
|
||||
} catch (e) {
|
||||
return "[表达式错误: " + e.message + "]";
|
||||
}
|
||||
`);
|
||||
// 获取基础变量和数据管理函数
|
||||
const variables = context.variables;
|
||||
const dataFunctions = {
|
||||
getData: context.getData,
|
||||
setData: context.setData,
|
||||
containsData: context.containsData,
|
||||
removeData: context.removeData,
|
||||
getStorageData: context.getStorageData,
|
||||
setStorageData: context.setStorageData,
|
||||
hasStorageData: context.hasStorageData,
|
||||
removeStorageData: context.removeStorageData,
|
||||
clearStorageData: context.clearStorageData,
|
||||
};
|
||||
|
||||
// 执行表达式并返回结果
|
||||
const result = evalInContext(...Object.values(context));
|
||||
return result !== undefined ? String(result) : "";
|
||||
// 合并基础变量和数据管理函数的作用域
|
||||
const scopeVariables = { ...variables, ...dataFunctions };
|
||||
const scopeKeys = Object.keys(scopeVariables);
|
||||
const scopeValues = Object.values(scopeVariables);
|
||||
|
||||
// 第一步:处理简单的文本替换 {{variable.path}}
|
||||
let result = template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
|
||||
if (path.trim().startsWith('js:') || path.trim().startsWith('js+:') || path.trim().startsWith('js-run:')) {
|
||||
return match; // 跳过所有JS变体,留给下一步
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析路径
|
||||
const parts = path.trim().split('.');
|
||||
let value: any = scopeVariables;
|
||||
|
||||
// 递归获取嵌套属性
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return match;
|
||||
if (dataFunctions.hasOwnProperty(part) && parts.length === 1) {
|
||||
value = value[part]; // 不要调用顶层函数
|
||||
} else if (typeof value[part] === 'function') {
|
||||
value = value[part]();
|
||||
} else {
|
||||
value = value[part];
|
||||
}
|
||||
if (typeof value === 'function' && !dataFunctions.hasOwnProperty(part)) value = value();
|
||||
}
|
||||
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
} catch (error) {
|
||||
console.error("表达式求值错误:", error);
|
||||
return `[表达式错误: ${(error as Error).message}]`;
|
||||
console.error('模板格式化错误:', error);
|
||||
return match; // 出错时返回原始匹配项
|
||||
}
|
||||
});
|
||||
|
||||
// 第二步:处理 JS 表达式和代码块 {{js: ...}}, {{js+: ...}}, {{js-run: ...}}
|
||||
return result.replace(JS_EXPRESSION_REGEX, (match, type, code) => {
|
||||
try {
|
||||
let functionBody: string;
|
||||
|
||||
if (type === 'js') {
|
||||
// 简单表达式: 隐式 return
|
||||
functionBody = `try { return (${code}); } catch (e) { console.error("表达式[js:]执行错误:", e, "代码:", ${JSON.stringify(code)}); return \"[表达式错误: \" + e.message + \"]\"; }`;
|
||||
} else { // js+ 或 js-run
|
||||
// 代码块: 需要显式 return
|
||||
functionBody = `try { ${code} } catch (e) { console.error("代码块[js+/js-run:]执行错误:", e, "代码:", ${JSON.stringify(code)}); return \"[代码块错误: \" + e.message + \"]\"; }`;
|
||||
}
|
||||
|
||||
const evalInContext = new Function(...scopeKeys, functionBody);
|
||||
|
||||
const evalResult = evalInContext(...scopeValues);
|
||||
|
||||
// 对结果进行处理,将 undefined/null 转换为空字符串,除非是错误消息
|
||||
return typeof evalResult === 'string' && (evalResult.startsWith('[表达式错误:') || evalResult.startsWith('[代码块错误:'))
|
||||
? evalResult
|
||||
: String(evalResult ?? '');
|
||||
|
||||
} catch (error) {
|
||||
// 捕获 Function 构造或顶层执行错误
|
||||
console.error("JS占位符处理错误:", error, "类型:", type, "代码:", code);
|
||||
return `[处理错误: ${(error as Error).message}]`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -61,7 +131,7 @@ export function escapeRegExp(string: string): string {
|
||||
* @param placeholders 占位符列表
|
||||
* @returns 转换后的模板
|
||||
*/
|
||||
export function convertToJsExpressions(template: string, placeholders: {name: string, description: string}[]): string {
|
||||
export function convertToJsExpressions(template: string, placeholders: { name: string, description: string }[]): string {
|
||||
let result = template;
|
||||
|
||||
placeholders.forEach(p => {
|
||||
@@ -74,6 +144,22 @@ export function convertToJsExpressions(template: string, placeholders: {name: st
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板字符串中提取所有 JS 表达式占位符。
|
||||
* 例如,从 'Hello {{js: user.name}}, time: {{js: Date.now()}}' 提取出
|
||||
* ['{{js: user.name}}', '{{js: Date.now()}}']
|
||||
* @param template 模板字符串
|
||||
* @returns 包含所有匹配的 JS 表达式字符串的数组
|
||||
*/
|
||||
export function extractJsExpressions(template: string): string[] {
|
||||
if (!template) {
|
||||
return [];
|
||||
}
|
||||
// 使用全局匹配来查找所有出现
|
||||
const matches = template.match(JS_EXPRESSION_REGEX);
|
||||
return matches || []; // match 返回 null 或字符串数组
|
||||
}
|
||||
|
||||
/**
|
||||
* 为礼物感谢模块创建上下文对象
|
||||
* @param user 用户信息
|
||||
@@ -81,7 +167,7 @@ export function convertToJsExpressions(template: string, placeholders: {name: st
|
||||
* @returns 上下文对象
|
||||
*/
|
||||
export function createGiftThankContext(user: { uid: number; name: string },
|
||||
gift: { name: string; count: number; price: number }): Record<string, any> {
|
||||
gift: { name: string; count: number; price: number }): Record<string, any> {
|
||||
return {
|
||||
user: {
|
||||
uid: user.uid,
|
||||
@@ -110,66 +196,4 @@ export function createGiftThankContext(user: { uid: number; name: string },
|
||||
formatted: new Intl.DateTimeFormat('zh-CN').format(new Date())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为入场欢迎模块创建上下文对象
|
||||
* @param user 用户信息
|
||||
* @returns 上下文对象
|
||||
*/
|
||||
export function createEntryWelcomeContext(user: { uid: number; name: string; medal?: { level: number; name: string } }): Record<string, any> {
|
||||
return {
|
||||
user: {
|
||||
uid: user.uid,
|
||||
name: user.name,
|
||||
nameLength: user.name.length,
|
||||
medal: user.medal || { level: 0, name: '' },
|
||||
hasMedal: !!user.medal
|
||||
},
|
||||
date: {
|
||||
now: new Date(),
|
||||
timestamp: Date.now(),
|
||||
formatted: new Intl.DateTimeFormat('zh-CN').format(new Date()),
|
||||
hour: new Date().getHours()
|
||||
},
|
||||
// 时间相关的便捷函数
|
||||
timeOfDay: () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return '凌晨';
|
||||
if (hour < 12) return '上午';
|
||||
if (hour < 14) return '中午';
|
||||
if (hour < 18) return '下午';
|
||||
return '晚上';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为自动回复模块创建上下文对象
|
||||
* @param user 用户信息
|
||||
* @param message 消息内容
|
||||
* @returns 上下文对象
|
||||
*/
|
||||
export function createAutoReplyContext(user: { uid: number; name: string; medal?: { level: number; name: string } },
|
||||
message: string): Record<string, any> {
|
||||
return {
|
||||
user: {
|
||||
uid: user.uid,
|
||||
name: user.name,
|
||||
nameLength: user.name.length,
|
||||
medal: user.medal || { level: 0, name: '' },
|
||||
hasMedal: !!user.medal
|
||||
},
|
||||
message: {
|
||||
content: message,
|
||||
length: message.length,
|
||||
containsQuestion: message.includes('?') || message.includes('?'),
|
||||
words: message.split(/\s+/).filter(Boolean)
|
||||
},
|
||||
date: {
|
||||
now: new Date(),
|
||||
timestamp: Date.now(),
|
||||
formatted: new Intl.DateTimeFormat('zh-CN').format(new Date())
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,15 +3,16 @@ import { EventModel } from '@/api/api-models';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ExecutionContext,
|
||||
RuntimeState
|
||||
RuntimeState,
|
||||
KeywordMatchType
|
||||
} from '../types';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
shouldProcess,
|
||||
evaluateExpression
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
filterValidActions,
|
||||
executeActions
|
||||
} from '../actionUtils';
|
||||
|
||||
/**
|
||||
* 自动回复模块
|
||||
@@ -27,6 +28,32 @@ export function useAutoReply(
|
||||
// 运行时数据 - 记录特定关键词的最后回复时间
|
||||
const lastReplyTimestamps = ref<{ [keyword: string]: number; }>({});
|
||||
|
||||
/**
|
||||
* 检查关键词匹配
|
||||
* @param text 要检查的文本
|
||||
* @param keyword 关键词
|
||||
* @param matchType 匹配类型
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
function isKeywordMatch(text: string, keyword: string, matchType: KeywordMatchType = KeywordMatchType.Contains): boolean {
|
||||
switch (matchType) {
|
||||
case KeywordMatchType.Full:
|
||||
return text === keyword;
|
||||
case KeywordMatchType.Contains:
|
||||
return text.includes(keyword);
|
||||
case KeywordMatchType.Regex:
|
||||
try {
|
||||
const regex = new RegExp(keyword);
|
||||
return regex.test(text);
|
||||
} catch (e) {
|
||||
console.warn('无效的正则表达式:', keyword, e);
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return text.includes(keyword); // 默认使用包含匹配
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理弹幕事件
|
||||
* @param event 弹幕事件
|
||||
@@ -40,95 +67,57 @@ export function useAutoReply(
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 过滤出有效的自动回复操作
|
||||
const replyActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.DANMAKU &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value)
|
||||
);
|
||||
// 使用通用函数过滤有效的自动回复操作
|
||||
const replyActions = filterValidActions(actions, TriggerType.DANMAKU, isLive);
|
||||
|
||||
if (replyActions.length === 0) return;
|
||||
if (replyActions.length > 0 && roomId.value) {
|
||||
const message = event.msg;
|
||||
|
||||
const message = event.msg;
|
||||
const now = Date.now();
|
||||
executeActions(
|
||||
replyActions,
|
||||
event,
|
||||
TriggerType.DANMAKU,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku },
|
||||
{
|
||||
customFilters: [
|
||||
// 关键词和屏蔽词检查
|
||||
(action, context) => {
|
||||
const keywordMatchType = action.triggerConfig.keywordMatchType || KeywordMatchType.Contains;
|
||||
const keywordMatch = action.triggerConfig.keywords?.some(kw =>
|
||||
isKeywordMatch(message, kw, keywordMatchType)
|
||||
);
|
||||
if (!keywordMatch) return false;
|
||||
|
||||
// 准备执行上下文
|
||||
const context: ExecutionContext = {
|
||||
event,
|
||||
roomId: roomId.value,
|
||||
variables: {
|
||||
user: {
|
||||
name: event.uname,
|
||||
uid: event.uid,
|
||||
guardLevel: event.guard_level,
|
||||
hasMedal: event.fans_medal_wearing_status,
|
||||
medalLevel: event.fans_medal_level,
|
||||
medalName: event.fans_medal_name
|
||||
},
|
||||
message: event.msg,
|
||||
timeOfDay: () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return '凌晨';
|
||||
if (hour < 9) return '早上';
|
||||
if (hour < 12) return '上午';
|
||||
if (hour < 14) return '中午';
|
||||
if (hour < 18) return '下午';
|
||||
if (hour < 22) return '晚上';
|
||||
return '深夜';
|
||||
},
|
||||
date: {
|
||||
formatted: new Date().toLocaleString('zh-CN')
|
||||
const blockwordMatchType = action.triggerConfig.blockwordMatchType || KeywordMatchType.Contains;
|
||||
const blockwordMatch = action.triggerConfig.blockwords?.some(bw =>
|
||||
isKeywordMatch(message, bw, blockwordMatchType)
|
||||
);
|
||||
return !blockwordMatch; // 如果匹配屏蔽词返回false,否则返回true
|
||||
}
|
||||
],
|
||||
// 附加选项:只处理第一个匹配的自动回复
|
||||
customContextBuilder: (event, roomId, triggerType) => {
|
||||
const now = Date.now();
|
||||
const context = buildExecutionContext(event, roomId, triggerType);
|
||||
|
||||
// 添加时间段判断变量
|
||||
context.variables.timeOfDay = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return '凌晨';
|
||||
if (hour < 9) return '早上';
|
||||
if (hour < 12) return '上午';
|
||||
if (hour < 14) return '中午';
|
||||
if (hour < 18) return '下午';
|
||||
if (hour < 22) return '晚上';
|
||||
return '深夜';
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
},
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
// 检查每个操作
|
||||
for (const action of replyActions) {
|
||||
// 检查用户过滤条件
|
||||
if (action.triggerConfig.userFilterEnabled) {
|
||||
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) continue;
|
||||
if (action.triggerConfig.requireCaptain && !event.guard_level) continue;
|
||||
}
|
||||
|
||||
// 关键词和屏蔽词检查
|
||||
const keywordMatch = action.triggerConfig.keywords?.some(kw => message.includes(kw));
|
||||
if (!keywordMatch) continue;
|
||||
|
||||
const blockwordMatch = action.triggerConfig.blockwords?.some(bw => message.includes(bw));
|
||||
if (blockwordMatch) continue; // 包含屏蔽词,不回复
|
||||
|
||||
// 评估逻辑表达式
|
||||
if (action.logicalExpression && !evaluateExpression(action.logicalExpression, context)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查冷却
|
||||
const lastExecTime = runtimeState.lastExecutionTime[action.id] || 0;
|
||||
if (!action.ignoreCooldown && now - lastExecTime < (action.actionConfig.cooldownSeconds || 0) * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择回复并发送
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (template) {
|
||||
// 格式化并发送
|
||||
const formattedReply = formatTemplate(template, context);
|
||||
|
||||
// 更新冷却时间
|
||||
runtimeState.lastExecutionTime[action.id] = now;
|
||||
|
||||
// 执行延迟处理
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}, action.actionConfig.delaySeconds * 1000);
|
||||
} else {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}
|
||||
|
||||
break; // 匹配到一个规则就停止
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
executeActions,
|
||||
filterValidActions
|
||||
} from '../actionUtils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
RuntimeState,
|
||||
TriggerType
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,7 @@ export function useEntryWelcome(
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 运行时数据
|
||||
const timer = ref<NodeJS.Timeout | null>(null);
|
||||
const timer = ref<any | null>(null);
|
||||
|
||||
/**
|
||||
* 处理入场事件 - 支持新的AutoActionItem结构
|
||||
@@ -40,56 +39,31 @@ export function useEntryWelcome(
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 过滤出有效的入场欢迎操作
|
||||
const enterActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.ENTER &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||
);
|
||||
// 使用通用函数过滤有效的入场欢迎操作
|
||||
const enterActions = filterValidActions(actions, TriggerType.ENTER, isLive, isTianXuanActive);
|
||||
|
||||
if (enterActions.length === 0) return;
|
||||
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(event, roomId.value, TriggerType.ENTER);
|
||||
|
||||
// 处理每个符合条件的操作
|
||||
for (const action of enterActions) {
|
||||
// 跳过不符合用户过滤条件的
|
||||
if (action.triggerConfig.userFilterEnabled) {
|
||||
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) continue;
|
||||
if (action.triggerConfig.requireCaptain && !event.guard_level) continue;
|
||||
}
|
||||
|
||||
// 检查入场过滤条件 (可以在未来扩展更多条件)
|
||||
if (action.triggerConfig.filterMode === 'blacklist' &&
|
||||
action.triggerConfig.filterGiftNames?.includes(event.uname)) continue;
|
||||
|
||||
// 检查冷却时间
|
||||
const lastExecTime = runtimeState.lastExecutionTime[action.id] || 0;
|
||||
if (!action.ignoreCooldown &&
|
||||
Date.now() - lastExecTime < (action.actionConfig.cooldownSeconds || 0) * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择并发送回复
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (template) {
|
||||
// 更新冷却时间
|
||||
runtimeState.lastExecutionTime[action.id] = Date.now();
|
||||
|
||||
// 格式化并发送
|
||||
const formattedReply = formatTemplate(template, context);
|
||||
|
||||
// 延迟发送
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}, action.actionConfig.delaySeconds * 1000);
|
||||
} else {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
// 使用通用执行函数处理入场事件
|
||||
if (enterActions.length > 0 && roomId.value) {
|
||||
executeActions(
|
||||
enterActions,
|
||||
event,
|
||||
TriggerType.ENTER,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku },
|
||||
{
|
||||
customFilters: [
|
||||
// 检查入场过滤条件
|
||||
(action, context) => {
|
||||
if (action.triggerConfig.filterMode === 'blacklist' &&
|
||||
action.triggerConfig.filterGiftNames?.includes(event.uname)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
@@ -10,6 +8,10 @@ import {
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
import {
|
||||
filterValidActions,
|
||||
executeActions
|
||||
} from '../actionUtils';
|
||||
|
||||
/**
|
||||
* 关注感谢模块
|
||||
@@ -26,7 +28,7 @@ export function useFollowThank(
|
||||
) {
|
||||
// 运行时数据
|
||||
const aggregatedFollows = ref<{uid: number, name: string, timestamp: number}[]>([]);
|
||||
const timer = ref<NodeJS.Timeout | null>(null);
|
||||
const timer = ref<any | null>(null);
|
||||
|
||||
/**
|
||||
* 处理关注事件 - 支持新的AutoActionItem结构
|
||||
@@ -41,52 +43,19 @@ export function useFollowThank(
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 过滤出有效的关注感谢操作
|
||||
const followActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.FOLLOW &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||
);
|
||||
// 使用通用函数过滤有效的关注感谢操作
|
||||
const followActions = filterValidActions(actions, TriggerType.FOLLOW, isLive, isTianXuanActive);
|
||||
|
||||
if (followActions.length === 0) return;
|
||||
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(event, roomId.value, TriggerType.FOLLOW);
|
||||
|
||||
// 处理每个符合条件的操作
|
||||
for (const action of followActions) {
|
||||
// 跳过不符合用户过滤条件的
|
||||
if (action.triggerConfig.userFilterEnabled) {
|
||||
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) continue;
|
||||
if (action.triggerConfig.requireCaptain && !event.guard_level) continue;
|
||||
}
|
||||
|
||||
// 检查冷却时间
|
||||
const lastExecTime = runtimeState.lastExecutionTime[action.id] || 0;
|
||||
if (!action.ignoreCooldown &&
|
||||
Date.now() - lastExecTime < (action.actionConfig.cooldownSeconds || 0) * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择并发送回复
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (template) {
|
||||
// 更新冷却时间
|
||||
runtimeState.lastExecutionTime[action.id] = Date.now();
|
||||
|
||||
// 格式化并发送
|
||||
const formattedReply = formatTemplate(template, context);
|
||||
|
||||
// 延迟发送
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}, action.actionConfig.delaySeconds * 1000);
|
||||
} else {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}
|
||||
}
|
||||
// 使用通用执行函数处理关注事件
|
||||
if (followActions.length > 0 && roomId.value) {
|
||||
executeActions(
|
||||
followActions,
|
||||
event,
|
||||
TriggerType.FOLLOW,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import { evaluateTemplateExpressions } from '../expressionEvaluator';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ExecutionContext,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
import {
|
||||
filterValidActions,
|
||||
executeActions
|
||||
} from '../actionUtils';
|
||||
|
||||
/**
|
||||
* 礼物感谢模块
|
||||
@@ -25,10 +28,6 @@ export function useGiftThank(
|
||||
isTianXuanActive: Ref<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 测试发送功能状态
|
||||
const lastTestTime = ref(0);
|
||||
const testCooldown = 5000; // 5秒冷却时间
|
||||
const testLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 处理礼物事件
|
||||
@@ -43,171 +42,52 @@ export function useGiftThank(
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 过滤出有效的礼物感谢操作
|
||||
const giftActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.GIFT &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value) &&
|
||||
(!action.triggerConfig.ignoreTianXuan || !isTianXuanActive.value)
|
||||
);
|
||||
// 使用通用函数过滤有效的礼物感谢操作
|
||||
const giftActions = filterValidActions(actions, TriggerType.GIFT, isLive, isTianXuanActive);
|
||||
|
||||
if (giftActions.length === 0) return;
|
||||
// 使用通用执行函数处理礼物事件
|
||||
if (giftActions.length > 0 && roomId.value) {
|
||||
// 礼物基本信息
|
||||
const giftName = event.msg;
|
||||
const giftPrice = event.price / 1000;
|
||||
|
||||
// 礼物基本信息
|
||||
const giftName = event.msg;
|
||||
const giftPrice = event.price / 1000;
|
||||
const giftCount = event.num;
|
||||
executeActions(
|
||||
giftActions,
|
||||
event,
|
||||
TriggerType.GIFT,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku },
|
||||
{
|
||||
customFilters: [
|
||||
// 礼物过滤逻辑
|
||||
(action, context) => {
|
||||
// 黑名单模式
|
||||
if (action.triggerConfig.filterMode === 'blacklist' &&
|
||||
action.triggerConfig.filterGiftNames?.includes(giftName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(event, roomId.value, TriggerType.GIFT);
|
||||
// 白名单模式
|
||||
if (action.triggerConfig.filterMode === 'whitelist' &&
|
||||
!action.triggerConfig.filterGiftNames?.includes(giftName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 处理每个符合条件的操作
|
||||
for (const action of giftActions) {
|
||||
// 跳过不符合用户过滤条件的
|
||||
if (action.triggerConfig.userFilterEnabled) {
|
||||
if (action.triggerConfig.requireMedal && !event.fans_medal_wearing_status) continue;
|
||||
if (action.triggerConfig.requireCaptain && !event.guard_level) continue;
|
||||
}
|
||||
// 礼物价值过滤
|
||||
if (action.triggerConfig.minValue && giftPrice < action.triggerConfig.minValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 礼物过滤逻辑
|
||||
if (action.triggerConfig.filterMode === 'blacklist' &&
|
||||
action.triggerConfig.filterGiftNames?.includes(giftName)) continue;
|
||||
|
||||
if (action.triggerConfig.filterMode === 'whitelist' &&
|
||||
!action.triggerConfig.filterGiftNames?.includes(giftName)) continue;
|
||||
|
||||
if (action.triggerConfig.minValue && giftPrice < action.triggerConfig.minValue) continue;
|
||||
|
||||
// 检查冷却时间
|
||||
const lastExecTime = runtimeState.lastExecutionTime[action.id] || 0;
|
||||
if (!action.ignoreCooldown &&
|
||||
Date.now() - lastExecTime < (action.actionConfig.cooldownSeconds || 0) * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择并发送回复
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (template) {
|
||||
// 更新冷却时间
|
||||
runtimeState.lastExecutionTime[action.id] = Date.now();
|
||||
|
||||
// 格式化并发送
|
||||
const formattedReply = formatTemplate(template, context);
|
||||
|
||||
// 延迟发送
|
||||
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
|
||||
setTimeout(() => {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
}, (action.actionConfig.delaySeconds || 0) * 1000);
|
||||
} else {
|
||||
sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
return true;
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发送礼物感谢弹幕
|
||||
*/
|
||||
async function testSendThankMessage(
|
||||
action?: AutoActionItem
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// 检查是否在冷却期
|
||||
const now = Date.now();
|
||||
if (now - lastTestTime.value < testCooldown) {
|
||||
return {
|
||||
success: false,
|
||||
message: `请等待${Math.ceil((testCooldown - (now - lastTestTime.value)) / 1000)}秒后再次测试发送`
|
||||
};
|
||||
}
|
||||
|
||||
if (!roomId.value) {
|
||||
return {
|
||||
success: false,
|
||||
message: '未设置房间号'
|
||||
};
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
message: '未指定要测试的操作'
|
||||
};
|
||||
}
|
||||
|
||||
if (!action.templates || action.templates.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: '请至少添加一条模板'
|
||||
};
|
||||
}
|
||||
|
||||
testLoading.value = true;
|
||||
lastTestTime.value = now;
|
||||
|
||||
try {
|
||||
// 构建测试事件对象
|
||||
const testEvent: EventModel = {
|
||||
type: EventDataTypes.Gift,
|
||||
uname: '测试用户',
|
||||
uface: 'https://i0.hdslb.com/bfs/face/member/noface.jpg',
|
||||
uid: 123456,
|
||||
open_id: '123456',
|
||||
msg: '测试礼物',
|
||||
time: Date.now(),
|
||||
num: 1,
|
||||
price: 100000, // 100元
|
||||
guard_level: 0,
|
||||
fans_medal_level: 0,
|
||||
fans_medal_name: '',
|
||||
fans_medal_wearing_status: false,
|
||||
ouid: '123456'
|
||||
};
|
||||
|
||||
// 创建测试上下文
|
||||
const context = buildExecutionContext(testEvent, roomId.value, TriggerType.GIFT);
|
||||
|
||||
// 获取模板并格式化
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (!template) {
|
||||
return {
|
||||
success: false,
|
||||
message: '无法获取模板'
|
||||
};
|
||||
}
|
||||
|
||||
const testMessage = formatTemplate(template, context);
|
||||
|
||||
// 发送测试弹幕
|
||||
const success = await sendLiveDanmaku(roomId.value, testMessage);
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
message: '测试弹幕发送成功!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '测试弹幕发送失败,请检查B站登录状态和网络连接'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('测试发送出错:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '发送过程出错'
|
||||
};
|
||||
} finally {
|
||||
testLoading.value = false;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processGift,
|
||||
testSendThankMessage,
|
||||
testLoading,
|
||||
lastTestTime,
|
||||
testCooldown
|
||||
};
|
||||
}
|
||||
@@ -1,162 +1,172 @@
|
||||
import { Ref } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { computed, Ref } from 'vue';
|
||||
import { GuardLevel, EventModel } from '@/api/api-models';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ActionType,
|
||||
RuntimeState
|
||||
RuntimeState,
|
||||
ExecutionContext,
|
||||
ActionType
|
||||
} from '../types';
|
||||
import { formatTemplate, buildExecutionContext } from '../utils';
|
||||
import {
|
||||
filterValidActions,
|
||||
executeActions
|
||||
} from '../actionUtils';
|
||||
import { buildExecutionContext } from '../utils';
|
||||
|
||||
/**
|
||||
* 舰长私信模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param sendPrivateMessage 发送私信函数
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useGuardPm(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
sendPrivateMessage: (userId: number, message: string) => Promise<boolean>,
|
||||
sendLiveDanmaku?: (roomId: number, message: string) => Promise<boolean>
|
||||
sendPrivateMessage: (uid: number, message: string) => Promise<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 保留旧配置用于兼容
|
||||
const config = useStorage<{
|
||||
enabled: boolean;
|
||||
template: string;
|
||||
sendDanmakuConfirm: boolean;
|
||||
danmakuTemplate: string;
|
||||
preventRepeat: boolean;
|
||||
giftCodeMode: boolean;
|
||||
giftCodes: { level: number; codes: string[] }[];
|
||||
onlyDuringLive: boolean;
|
||||
}>(
|
||||
'autoAction.guardPmConfig',
|
||||
{
|
||||
enabled: false,
|
||||
template: '感谢 {{user.name}} 成为 {{guard.levelName}}!欢迎加入!',
|
||||
sendDanmakuConfirm: false,
|
||||
danmakuTemplate: '已私信 {{user.name}} 舰长福利!',
|
||||
preventRepeat: true,
|
||||
giftCodeMode: false,
|
||||
giftCodes: [],
|
||||
onlyDuringLive: true
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理舰长事件 - 支持新的AutoActionItem结构
|
||||
* @param event 舰长事件
|
||||
* 处理舰长购买事件
|
||||
* @param actions 自动操作列表
|
||||
* @param event 舰长购买事件
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processGuard(
|
||||
event: EventModel,
|
||||
function handleGuardBuy(
|
||||
actions: AutoActionItem[],
|
||||
event: any,
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
const guardLevel = event.guard_level;
|
||||
if (guardLevel === GuardLevel.None) return; // 不是上舰事件
|
||||
// 使用通用函数过滤舰长事件的操作
|
||||
const isLiveRef = computed(() => true);
|
||||
const guardActions = filterValidActions(actions, TriggerType.GUARD, isLiveRef);
|
||||
|
||||
// 过滤出有效的舰长私信操作
|
||||
const guardActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.GUARD &&
|
||||
action.enabled &&
|
||||
action.actionType === ActionType.SEND_PRIVATE_MSG &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value)
|
||||
);
|
||||
// 使用通用执行函数处理舰长事件
|
||||
if (guardActions.length > 0 && roomId.value) {
|
||||
executeActions(
|
||||
guardActions,
|
||||
event,
|
||||
TriggerType.GUARD,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendPrivateMessage, sendLiveDanmaku },
|
||||
{
|
||||
customFilters: [
|
||||
// 防止重复发送检查
|
||||
(action, context) => {
|
||||
if (action.triggerConfig.preventRepeat && event && event.uid) {
|
||||
// 确保 uid 是数字类型
|
||||
const uid = typeof event.uid === 'number' ? event.uid : parseInt(event.uid, 10);
|
||||
|
||||
if (guardActions.length === 0) return;
|
||||
// 检查是否已经发送过
|
||||
if (runtimeState.sentGuardPms.has(uid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(event, roomId.value, TriggerType.GUARD);
|
||||
|
||||
// 处理礼品码
|
||||
for (const action of guardActions) {
|
||||
// 防止重复发送
|
||||
if (action.triggerConfig.preventRepeat) {
|
||||
if (runtimeState.sentGuardPms.has(event.uid)) {
|
||||
console.log(`用户 ${event.uname} (${event.uid}) 已发送过上舰私信,跳过。`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 特定舰长等级过滤
|
||||
if (action.triggerConfig.guardLevels && !action.triggerConfig.guardLevels.includes(guardLevel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取礼品码
|
||||
let giftCode = '';
|
||||
if (action.triggerConfig.giftCodes && action.triggerConfig.giftCodes.length > 0) {
|
||||
// 查找匹配等级的礼品码
|
||||
const levelCodes = action.triggerConfig.giftCodes.find(gc => gc.level === guardLevel);
|
||||
if (levelCodes && levelCodes.codes.length > 0) {
|
||||
giftCode = levelCodes.codes.shift() || '';
|
||||
} else {
|
||||
// 查找通用码 (level 0)
|
||||
const commonCodes = action.triggerConfig.giftCodes.find(gc => gc.level === GuardLevel.None);
|
||||
if (commonCodes && commonCodes.codes.length > 0) {
|
||||
giftCode = commonCodes.codes.shift() || '';
|
||||
} else {
|
||||
console.warn(`等级 ${guardLevel} 或通用礼品码已用完,无法发送给 ${event.uname}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新上下文中的礼品码
|
||||
if (context.variables.guard) {
|
||||
context.variables.guard.giftCode = giftCode;
|
||||
}
|
||||
|
||||
// 选择模板并格式化
|
||||
if (action.templates.length > 0) {
|
||||
const template = action.templates[0]; // 对于私信,使用第一个模板
|
||||
const formattedMessage = formatTemplate(template, context);
|
||||
|
||||
// 发送私信
|
||||
sendPrivateMessage(event.uid, formattedMessage).then(success => {
|
||||
if (success) {
|
||||
console.log(`成功发送上舰私信给 ${event.uname} (${event.uid})`);
|
||||
if (action.triggerConfig.preventRepeat) {
|
||||
runtimeState.sentGuardPms.add(event.uid);
|
||||
}
|
||||
|
||||
// 发送弹幕确认
|
||||
if (roomId.value && sendLiveDanmaku) {
|
||||
// 查找确认弹幕的设置
|
||||
const confirmActions = actions.filter(a =>
|
||||
a.triggerType === TriggerType.GUARD &&
|
||||
a.enabled &&
|
||||
a.actionType === ActionType.SEND_DANMAKU
|
||||
);
|
||||
|
||||
if (confirmActions.length > 0 && confirmActions[0].templates.length > 0) {
|
||||
const confirmMsg = formatTemplate(confirmActions[0].templates[0], context);
|
||||
sendLiveDanmaku(roomId.value, confirmMsg);
|
||||
// 添加到已发送集合
|
||||
runtimeState.sentGuardPms.add(uid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
console.error(`发送上舰私信给 ${event.uname} (${event.uid}) 失败`);
|
||||
// 失败时归还礼品码
|
||||
if (giftCode && action.triggerConfig.giftCodes) {
|
||||
const levelCodes = action.triggerConfig.giftCodes.find(gc => gc.level === guardLevel);
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.push(giftCode);
|
||||
],
|
||||
customContextBuilder: (eventData, roomId, triggerType): ExecutionContext => {
|
||||
// 使用标准上下文构建方法
|
||||
const context = buildExecutionContext(eventData, roomId, triggerType);
|
||||
|
||||
// 如果是舰长事件且有事件数据,处理礼品码
|
||||
if (triggerType === TriggerType.GUARD && eventData && eventData.guard_level !== undefined) {
|
||||
const guardLevel = eventData.guard_level;
|
||||
|
||||
// 查找包含礼品码的操作
|
||||
guardActions.forEach(action => {
|
||||
// 找到对应等级的礼品码
|
||||
if (action.triggerConfig.giftCodes && action.triggerConfig.giftCodes.length > 0) {
|
||||
// 优先查找特定等级的礼品码
|
||||
let levelCodesEntry = action.triggerConfig.giftCodes.find(gc => gc.level === guardLevel);
|
||||
|
||||
// 如果没有找到特定等级的礼品码,尝试查找通用礼品码(level为0)
|
||||
if (!levelCodesEntry) {
|
||||
levelCodesEntry = action.triggerConfig.giftCodes.find(gc => gc.level === 0);
|
||||
}
|
||||
|
||||
if (levelCodesEntry && levelCodesEntry.codes && levelCodesEntry.codes.length > 0) {
|
||||
// 随机选择一个礼品码
|
||||
const randomIndex = Math.floor(Math.random() * levelCodesEntry.codes.length);
|
||||
const randomCode = levelCodesEntry.codes[randomIndex];
|
||||
// 确保guard变量存在并设置礼品码
|
||||
if (context.variables.guard) {
|
||||
context.variables.guard.giftCode = randomCode;
|
||||
// 在上下文中存储选中的礼品码信息以供后续消耗
|
||||
context.variables.guard.selectedGiftCode = {
|
||||
code: randomCode,
|
||||
level: levelCodesEntry.level
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
onSuccess: (action: AutoActionItem, context: ExecutionContext) => {
|
||||
// 检查是否需要消耗礼品码
|
||||
if (
|
||||
action.actionType === ActionType.SEND_PRIVATE_MSG &&
|
||||
action.triggerConfig.consumeGiftCode &&
|
||||
context.variables.guard?.selectedGiftCode
|
||||
) {
|
||||
const { code: selectedCode, level: selectedLevel } = context.variables.guard.selectedGiftCode;
|
||||
|
||||
console.log(`[AutoAction] 尝试消耗礼品码: ActionID=${action.id}, Level=${selectedLevel}, Code=${selectedCode}`);
|
||||
|
||||
// 确保 giftCodes 存在且为数组
|
||||
if (Array.isArray(action.triggerConfig.giftCodes)) {
|
||||
// 找到对应等级的礼品码条目
|
||||
const levelCodesEntry = action.triggerConfig.giftCodes.find(gc => gc.level === selectedLevel);
|
||||
|
||||
if (levelCodesEntry && Array.isArray(levelCodesEntry.codes)) {
|
||||
// 找到要删除的礼品码的索引
|
||||
const codeIndex = levelCodesEntry.codes.indexOf(selectedCode);
|
||||
|
||||
if (codeIndex > -1) {
|
||||
// 从数组中移除礼品码
|
||||
levelCodesEntry.codes.splice(codeIndex, 1);
|
||||
console.log(`[AutoAction] 成功消耗礼品码: ActionID=${action.id}, Level=${selectedLevel}, Code=${selectedCode}. 剩余 ${levelCodesEntry.codes.length} 个。`);
|
||||
// !!! 重要提示: 此处直接修改了 action 对象。
|
||||
// !!! 请确保你的状态管理允许这种修改,或者调用 store action 来持久化更新。
|
||||
// 例如: store.updateActionGiftCodes(action.id, selectedLevel, levelCodesEntry.codes);
|
||||
} else {
|
||||
console.warn(`[AutoAction] 未能在等级 ${selectedLevel} 中找到要消耗的礼品码: ${selectedCode}, ActionID=${action.id}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[AutoAction] 未找到等级 ${selectedLevel} 的礼品码列表或列表格式不正确, ActionID=${action.id}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[AutoAction] Action ${action.id} 的 giftCodes 配置不存在或不是数组。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取舰长等级名称
|
||||
* @param level 舰长等级
|
||||
* @returns 舰长等级名称
|
||||
*/
|
||||
function getGuardLevelName(level: number): string {
|
||||
switch (level) {
|
||||
case 1: return '总督';
|
||||
case 2: return '提督';
|
||||
case 3: return '舰长';
|
||||
default: return '未知等级';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
processGuard
|
||||
handleGuardBuy
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { ref, watch, Ref, computed } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import {
|
||||
getRandomTemplate,
|
||||
formatTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
RuntimeState,
|
||||
ExecutionContext
|
||||
} from '../types';
|
||||
import {
|
||||
filterValidActions,
|
||||
executeActions
|
||||
} from '../actionUtils';
|
||||
|
||||
/**
|
||||
* 定时弹幕模块
|
||||
@@ -38,12 +41,8 @@ export function useScheduledDanmaku(
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 获取定时消息操作
|
||||
const scheduledActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.SCHEDULED &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value)
|
||||
);
|
||||
// 使用通用函数过滤有效的定时弹幕操作
|
||||
const scheduledActions = filterValidActions(actions, TriggerType.SCHEDULED, isLive);
|
||||
|
||||
// 为每个定时操作设置定时器
|
||||
scheduledActions.forEach(action => {
|
||||
@@ -54,22 +53,30 @@ export function useScheduledDanmaku(
|
||||
|
||||
// 创建定时器函数
|
||||
const timerFn = () => {
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(null, roomId.value, TriggerType.SCHEDULED);
|
||||
|
||||
// 选择并发送消息
|
||||
const template = getRandomTemplate(action.templates);
|
||||
if (template && roomId.value) {
|
||||
const formattedMessage = formatTemplate(template, context);
|
||||
sendLiveDanmaku(roomId.value, formattedMessage);
|
||||
// 使用通用执行函数处理定时操作
|
||||
if (roomId.value) {
|
||||
executeActions(
|
||||
[action], // 只处理单个操作
|
||||
null, // 定时操作没有触发事件
|
||||
TriggerType.SCHEDULED,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku },
|
||||
{
|
||||
skipUserFilters: true, // 定时任务不需要用户过滤
|
||||
skipCooldownCheck: false // 可以保留冷却检查
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 设置下一次定时
|
||||
runtimeState.scheduledTimers[action.id] = setTimeout(timerFn, intervalSeconds * 1000);
|
||||
runtimeState.timerStartTimes[action.id] = Date.now(); // 更新定时器启动时间
|
||||
};
|
||||
|
||||
// 首次启动定时器
|
||||
runtimeState.scheduledTimers[action.id] = setTimeout(timerFn, intervalSeconds * 1000);
|
||||
runtimeState.timerStartTimes[action.id] = Date.now(); // 记录定时器启动时间
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
79
src/client/store/autoAction/modules/superChatThank.ts
Normal file
79
src/client/store/autoAction/modules/superChatThank.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import { Ref } from 'vue';
|
||||
import {
|
||||
executeActions,
|
||||
filterValidActions
|
||||
} from '../actionUtils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
RuntimeState,
|
||||
TriggerType
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 醒目留言感谢模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param isTianXuanActive 是否处于天选时刻
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useSuperChatThank(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
isTianXuanActive: Ref<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
|
||||
/**
|
||||
* 处理醒目留言事件
|
||||
* @param event 醒目留言事件
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processSuperChat(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 使用通用函数过滤有效的SC感谢操作
|
||||
const scActions = filterValidActions(actions, TriggerType.SUPER_CHAT, isLive, isTianXuanActive);
|
||||
|
||||
// 使用通用执行函数处理SC事件
|
||||
if (scActions.length > 0 && roomId.value) {
|
||||
executeActions(
|
||||
scActions,
|
||||
event,
|
||||
TriggerType.SUPER_CHAT,
|
||||
roomId.value,
|
||||
runtimeState,
|
||||
{ sendLiveDanmaku },
|
||||
{
|
||||
customFilters: [
|
||||
// SC价格过滤
|
||||
(action, context) => {
|
||||
// 如果未设置SC过滤或选择了不过滤模式
|
||||
if (!action.triggerConfig.scFilterMode || action.triggerConfig.scFilterMode === 'none') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 价格过滤模式
|
||||
if (action.triggerConfig.scFilterMode === 'price' &&
|
||||
action.triggerConfig.scMinPrice &&
|
||||
event.price < action.triggerConfig.scMinPrice * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processSuperChat,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 统一的自动操作类型定义
|
||||
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import { EventModel, GuardLevel } from '@/api/api-models';
|
||||
|
||||
// 触发条件类型
|
||||
export enum TriggerType {
|
||||
@@ -20,6 +20,13 @@ export enum ActionType {
|
||||
EXECUTE_COMMAND = 'execute_command', // 执行命令
|
||||
}
|
||||
|
||||
// 关键词匹配类型
|
||||
export enum KeywordMatchType {
|
||||
Full = 'full', // 完全匹配
|
||||
Contains = 'contains', // 包含匹配
|
||||
Regex = 'regex', // 正则匹配
|
||||
}
|
||||
|
||||
// 优先级
|
||||
export enum Priority {
|
||||
HIGHEST = 0,
|
||||
@@ -36,7 +43,7 @@ export type AutoActionItem = {
|
||||
enabled: boolean; // 是否启用
|
||||
triggerType: TriggerType; // 触发类型
|
||||
actionType: ActionType; // 操作类型
|
||||
templates: string[]; // 模板列表
|
||||
template: string; // 模板
|
||||
priority: Priority; // 优先级
|
||||
|
||||
// 高级配置
|
||||
@@ -45,33 +52,7 @@ export type AutoActionItem = {
|
||||
executeCommand: string; // 要执行的JS代码
|
||||
|
||||
// 触发器特定配置
|
||||
triggerConfig: {
|
||||
// 通用
|
||||
userFilterEnabled?: boolean; // 是否启用用户过滤
|
||||
requireMedal?: boolean; // 要求本房间勋章
|
||||
requireCaptain?: boolean; // 要求任意舰长
|
||||
onlyDuringLive?: boolean; // 仅直播中启用
|
||||
ignoreTianXuan?: boolean; // 天选时刻忽略
|
||||
|
||||
// 弹幕触发特定
|
||||
keywords?: string[]; // 触发关键词
|
||||
blockwords?: string[]; // 屏蔽词
|
||||
|
||||
// 礼物触发特定
|
||||
filterMode?: 'none' | 'blacklist' | 'whitelist' | 'value' | 'free'; // 礼物过滤模式
|
||||
filterGiftNames?: string[]; // 礼物黑/白名单
|
||||
minValue?: number; // 最低礼物价值
|
||||
includeQuantity?: boolean; // 是否包含礼物数量
|
||||
|
||||
// 定时触发特定
|
||||
intervalSeconds?: number; // 间隔秒数
|
||||
schedulingMode?: 'random' | 'sequential'; // 定时模式
|
||||
|
||||
// 上舰特定
|
||||
guardLevels?: number[]; // 舰长等级过滤
|
||||
preventRepeat?: boolean; // 防止重复发送
|
||||
giftCodes?: {level: number, codes: string[]}[]; // 礼品码
|
||||
};
|
||||
triggerConfig: TriggerConfig;
|
||||
|
||||
// 动作特定配置
|
||||
actionConfig: {
|
||||
@@ -88,12 +69,78 @@ export interface ExecutionContext {
|
||||
roomId?: number; // 直播间ID
|
||||
variables: Record<string, any>; // 额外变量
|
||||
timestamp: number; // 时间戳
|
||||
|
||||
// --- 新增运行时数据管理函数 ---
|
||||
/** 获取运行时数据 */
|
||||
getData: <T>(key: string, defaultValue?: T) => T | undefined;
|
||||
/** 设置运行时数据 */
|
||||
setData: <T>(key: string, value: T) => void;
|
||||
/** 检查运行时数据是否存在 */
|
||||
containsData: (key: string) => boolean;
|
||||
/** 移除运行时数据 */
|
||||
removeData: (key: string) => void;
|
||||
|
||||
// --- 新增持久化数据管理函数 ---
|
||||
/** 获取持久化存储的数据 */
|
||||
getStorageData: <T>(key: string, defaultValue?: T) => Promise<T | undefined>;
|
||||
/** 设置持久化存储的数据 */
|
||||
setStorageData: <T>(key: string, value: T) => Promise<void>;
|
||||
/** 检查持久化存储中是否存在指定的键 */
|
||||
hasStorageData: (key: string) => Promise<boolean>;
|
||||
/** 从持久化存储中删除数据 */
|
||||
removeStorageData: (key: string) => Promise<void>;
|
||||
/** 清除所有持久化存储的数据 */
|
||||
clearStorageData: () => Promise<void>;
|
||||
}
|
||||
|
||||
// 运行状态接口
|
||||
export interface RuntimeState {
|
||||
lastExecutionTime: Record<string, number>; // 上次执行时间
|
||||
aggregatedEvents: Record<string, any[]>; // 聚合的事件
|
||||
scheduledTimers: Record<string, NodeJS.Timeout | null>; // 定时器
|
||||
scheduledTimers: Record<string, any | null>; // 定时器 ID
|
||||
timerStartTimes: Record<string, number>; // <--- 新增:独立定时器启动时间戳
|
||||
globalTimerStartTime: number | null; // <--- 新增:全局定时器启动时间戳
|
||||
sentGuardPms: Set<number>; // 已发送的舰长私信
|
||||
}
|
||||
|
||||
export interface TriggerConfig {
|
||||
// User filters
|
||||
userFilterEnabled?: boolean;
|
||||
requireMedal?: boolean;
|
||||
requireCaptain?: boolean;
|
||||
|
||||
// Common conditions
|
||||
onlyDuringLive?: boolean;
|
||||
ignoreTianXuan?: boolean;
|
||||
|
||||
// Keywords for autoReply
|
||||
keywords?: string[];
|
||||
keywordMatchType?: KeywordMatchType;
|
||||
blockwords?: string[];
|
||||
blockwordMatchType?: KeywordMatchType;
|
||||
|
||||
// Gift filters
|
||||
filterMode?: 'blacklist' | 'whitelist' | 'value' | 'none' | 'free';
|
||||
filterGiftNames?: string[];
|
||||
minValue?: number; // For gift and SC minimum value (元)
|
||||
includeQuantity?: boolean; // 是否包含礼物数量
|
||||
|
||||
// SC相关配置
|
||||
scFilterMode?: 'none' | 'price'; // SC过滤模式
|
||||
scMinPrice?: number; // SC最低价格(元)
|
||||
|
||||
// Scheduled options
|
||||
useGlobalTimer?: boolean;
|
||||
intervalSeconds?: number;
|
||||
schedulingMode?: 'random' | 'sequential';
|
||||
|
||||
// Guard related
|
||||
guardLevels?: GuardLevel[];
|
||||
preventRepeat?: boolean;
|
||||
giftCodes?: { level: number; codes: string[] }[];
|
||||
consumeGiftCode?: boolean; // 是否消耗礼品码
|
||||
|
||||
// Confirm message options
|
||||
sendDanmakuConfirm?: boolean; // 是否发送弹幕确认
|
||||
isConfirmMessage?: boolean; // 标记这是一个确认消息
|
||||
}
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
RuntimeState,
|
||||
ExecutionContext
|
||||
} from './types';
|
||||
import { get, set, del, clear, keys as idbKeys, createStore } from 'idb-keyval'; // 导入 useIDBKeyval
|
||||
|
||||
// --- 定义用户持久化数据的自定义存储区 ---
|
||||
const USER_DATA_DB_NAME = 'AutoActionUserDataDB';
|
||||
const USER_DATA_STORE_NAME = 'userData';
|
||||
const userDataStore = createStore(USER_DATA_DB_NAME, USER_DATA_STORE_NAME);
|
||||
// ----------------------------------------
|
||||
|
||||
// --- 定义运行时数据的前缀 (避免与页面其他 sessionStorage 冲突) ---
|
||||
const RUNTIME_STORAGE_PREFIX = 'autoaction_runtime_';
|
||||
|
||||
/**
|
||||
* 创建默认的运行时状态
|
||||
@@ -15,6 +25,8 @@ export function createDefaultRuntimeState(): RuntimeState {
|
||||
return {
|
||||
lastExecutionTime: {},
|
||||
scheduledTimers: {},
|
||||
timerStartTimes: {},
|
||||
globalTimerStartTime: null,
|
||||
sentGuardPms: new Set(),
|
||||
aggregatedEvents: {}
|
||||
};
|
||||
@@ -28,14 +40,14 @@ export function createDefaultAutoAction(triggerType: TriggerType): AutoActionIte
|
||||
const id = `auto-action-${nanoid(8)}`;
|
||||
|
||||
// 根据不同触发类型设置默认模板
|
||||
const defaultTemplates: Record<TriggerType, string[]> = {
|
||||
[TriggerType.DANMAKU]: ['收到 @{user.name} 的弹幕: {event.msg}'],
|
||||
[TriggerType.GIFT]: ['感谢 @{user.name} 赠送的 {gift.summary}'],
|
||||
[TriggerType.GUARD]: ['感谢 @{user.name} 开通了{guard.levelName}!'],
|
||||
[TriggerType.FOLLOW]: ['感谢 @{user.name} 的关注!'],
|
||||
[TriggerType.ENTER]: ['欢迎 @{user.name} 进入直播间'],
|
||||
[TriggerType.SCHEDULED]: ['这是一条定时消息,当前时间: {date.formatted}'],
|
||||
[TriggerType.SUPER_CHAT]: ['感谢 @{user.name} 的SC: {sc.message}'],
|
||||
const defaultTemplates: Record<TriggerType, string> = {
|
||||
[TriggerType.DANMAKU]: '收到 {{user.name}} 的弹幕: {{danmaku.msg}}',
|
||||
[TriggerType.GIFT]: '感谢 {{user.name}} 赠送的 {{gift.summary}}',
|
||||
[TriggerType.GUARD]: '感谢 {{user.name}} 开通了{{danmaku.msg}}!',
|
||||
[TriggerType.FOLLOW]: '感谢 {{user.name}} 的关注!',
|
||||
[TriggerType.ENTER]: '欢迎 {{user.name}} 进入直播间',
|
||||
[TriggerType.SCHEDULED]: '这是一条定时消息,当前时间: {{date.formatted}}',
|
||||
[TriggerType.SUPER_CHAT]: '感谢 {{user.name}} 的SC!',
|
||||
};
|
||||
|
||||
// 根据不同触发类型设置默认名称
|
||||
@@ -56,7 +68,7 @@ export function createDefaultAutoAction(triggerType: TriggerType): AutoActionIte
|
||||
triggerType,
|
||||
actionType: triggerType === TriggerType.GUARD ? ActionType.SEND_PRIVATE_MSG : ActionType.SEND_DANMAKU,
|
||||
priority: Priority.NORMAL,
|
||||
templates: defaultTemplates[triggerType] || ['默认模板'],
|
||||
template: defaultTemplates[triggerType] || '默认模板',
|
||||
logicalExpression: '',
|
||||
executeCommand: '',
|
||||
ignoreCooldown: false,
|
||||
@@ -79,13 +91,12 @@ export function createDefaultAutoAction(triggerType: TriggerType): AutoActionIte
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板数组中随机选择一个
|
||||
* @param templates 模板数组
|
||||
* 处理模板字符串
|
||||
* @param template 模板字符串
|
||||
*/
|
||||
export function getRandomTemplate(templates: string[]): string | null {
|
||||
if (!templates || templates.length === 0) return null;
|
||||
const index = Math.floor(Math.random() * templates.length);
|
||||
return templates[index];
|
||||
export function getRandomTemplate(template: string): string | null {
|
||||
if (!template) return null;
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,7 +279,6 @@ export function buildExecutionContext(
|
||||
const now = Date.now();
|
||||
const dateObj = new Date(now);
|
||||
|
||||
// 基础上下文
|
||||
const context: ExecutionContext = {
|
||||
event,
|
||||
roomId,
|
||||
@@ -295,6 +305,88 @@ export function buildExecutionContext(
|
||||
if (hour < 22) return '晚上';
|
||||
return '深夜';
|
||||
}
|
||||
},
|
||||
// --- 实现运行时数据管理函数 (使用 sessionStorage) ---
|
||||
getData: <T>(key: string, defaultValue?: T): T | undefined => {
|
||||
const prefixedKey = RUNTIME_STORAGE_PREFIX + key;
|
||||
try {
|
||||
const storedValue = sessionStorage.getItem(prefixedKey);
|
||||
if (storedValue === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return JSON.parse(storedValue) as T;
|
||||
} catch (error) {
|
||||
console.error(`[Runtime SessionStorage] Error getting/parsing key '${key}':`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
setData: <T>(key: string, value: T): void => {
|
||||
const prefixedKey = RUNTIME_STORAGE_PREFIX + key;
|
||||
try {
|
||||
// 不存储 undefined
|
||||
if (value === undefined) {
|
||||
sessionStorage.removeItem(prefixedKey);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`[Runtime SessionStorage] Error setting key '${key}':`, error);
|
||||
// 如果序列化失败,可以选择移除旧键或保留
|
||||
sessionStorage.removeItem(prefixedKey);
|
||||
}
|
||||
},
|
||||
containsData: (key: string): boolean => {
|
||||
const prefixedKey = RUNTIME_STORAGE_PREFIX + key;
|
||||
return sessionStorage.getItem(prefixedKey) !== null;
|
||||
},
|
||||
removeData: (key: string): void => {
|
||||
const prefixedKey = RUNTIME_STORAGE_PREFIX + key;
|
||||
sessionStorage.removeItem(prefixedKey);
|
||||
},
|
||||
// --- 持久化数据管理函数 (不变,继续使用 userDataStore) ---
|
||||
getStorageData: async <T>(key: string, defaultValue?: T): Promise<T | undefined> => {
|
||||
try {
|
||||
// 使用 userDataStore
|
||||
const value = await get<T>(key, userDataStore);
|
||||
return value === undefined ? defaultValue : value;
|
||||
} catch (error) {
|
||||
console.error(`[UserData IDB] getStorageData error for key '${key}':`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
setStorageData: async <T>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
// 使用 userDataStore
|
||||
await set(key, value, userDataStore);
|
||||
} catch (error) {
|
||||
console.error(`[UserData IDB] setStorageData error for key '${key}':`, error);
|
||||
}
|
||||
},
|
||||
hasStorageData: async (key: string): Promise<boolean> => {
|
||||
try {
|
||||
// 使用 userDataStore
|
||||
const value = await get(key, userDataStore);
|
||||
return value !== undefined;
|
||||
} catch (error) {
|
||||
console.error(`[UserData IDB] hasStorageData error for key '${key}':`, error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
removeStorageData: async (key: string): Promise<void> => {
|
||||
try {
|
||||
// 使用 userDataStore
|
||||
await del(key, userDataStore);
|
||||
} catch (error) {
|
||||
console.error(`[UserData IDB] removeStorageData error for key '${key}':`, error);
|
||||
}
|
||||
},
|
||||
clearStorageData: async (): Promise<void> => {
|
||||
try {
|
||||
// 使用 userDataStore
|
||||
await clear(userDataStore);
|
||||
} catch (error) {
|
||||
console.error('[UserData IDB] clearStorageData error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -308,7 +400,7 @@ export function buildExecutionContext(
|
||||
medalLevel: event.fans_medal_level,
|
||||
medalName: event.fans_medal_name
|
||||
};
|
||||
|
||||
context.variables.danmaku = event;
|
||||
context.variables.message = event.msg;
|
||||
|
||||
// 根据不同触发类型添加特定变量
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,8 +105,8 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空弹幕,已阻止。");
|
||||
return false;
|
||||
console.warn("尝试发送空弹幕,已阻止。");
|
||||
return false;
|
||||
}
|
||||
roomId = 1294406; // 测试用房间号
|
||||
const url = "https://api.live.bilibili.com/msg/send";
|
||||
@@ -143,8 +143,21 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
const json = await response.json();
|
||||
// B站成功码通常是 0
|
||||
if (json.code !== 0) {
|
||||
console.error("发送弹幕API失败:", json.code, json.message || json.msg);
|
||||
return false;
|
||||
window.$notification.error({
|
||||
title: '发送弹幕失败',
|
||||
description: `内容: ${message}`,
|
||||
meta: () => h('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
}, () => `错误: ${json.code} - ${json.message || json.msg}`),
|
||||
duration: 0,
|
||||
});
|
||||
console.error(`发送弹幕API失败 to: ${roomId} ${uid.value} [${message}] - ${json.code} - ${json.message || json.msg}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("发送弹幕成功:", message);
|
||||
@@ -221,9 +234,14 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
const error = "尝试发送空私信,已阻止。";
|
||||
console.warn(error);
|
||||
return false;
|
||||
const error = "尝试发送空私信,已阻止。";
|
||||
console.warn(error);
|
||||
window.$notification.error({
|
||||
title: '发送私信失败',
|
||||
description: `尝试发送空私信给 ${receiverId}, 已阻止`,
|
||||
duration: 0,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -239,8 +257,8 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
}
|
||||
|
||||
const dev_id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16).toUpperCase();
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16).toUpperCase();
|
||||
});
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
@@ -301,10 +319,10 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
|
||||
const json = await response.json();
|
||||
if (json.code !== 0) {
|
||||
const error = `发送私信API失败: ${json.code} - ${json.message}`;
|
||||
console.error(error);
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
const error = `发送私信API失败: ${json.code} - ${json.message}`;
|
||||
console.error(error);
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`发送私信给 ${receiverId} 成功`);
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -32,6 +32,7 @@ declare module 'vue' {
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
|
||||
@@ -33,6 +33,7 @@ export default abstract class BaseDanmakuClient {
|
||||
enter: ((arg1: EventModel, arg2?: any) => void)[]; // 新增: 用户进入事件
|
||||
scDel: ((arg1: EventModel, arg2?: any) => void)[]; // 新增: SC 删除事件
|
||||
all: ((arg1: any) => void)[]; // 'all' 事件监听器接收原始消息或特定事件包
|
||||
follow: ((arg1: EventModel, arg2?: any) => void)[]; // 新增: 关注事件
|
||||
};
|
||||
|
||||
// --- 事件系统 2: 使用原始数据类型 ---
|
||||
@@ -45,6 +46,7 @@ export default abstract class BaseDanmakuClient {
|
||||
enter: ((arg1: any, arg2?: any) => void)[]; // 新增: 用户进入事件
|
||||
scDel: ((arg1: any, arg2?: any) => void)[]; // 新增: SC 删除事件
|
||||
all: ((arg1: any) => void)[]; // 'all' 事件监听器接收原始消息或特定事件包
|
||||
follow: ((arg1: any, arg2?: any) => void)[]; // 新增: 关注事件
|
||||
};
|
||||
|
||||
// 创建空的 EventModel 监听器对象
|
||||
@@ -57,6 +59,7 @@ export default abstract class BaseDanmakuClient {
|
||||
enter: [],
|
||||
scDel: [],
|
||||
all: [],
|
||||
follow: [], // 初始化 follow 事件
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ export default abstract class BaseDanmakuClient {
|
||||
enter: [],
|
||||
scDel: [],
|
||||
all: [],
|
||||
follow: [], // 初始化 follow 事件
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,6 +300,7 @@ export default abstract class BaseDanmakuClient {
|
||||
public onEvent(eventName: 'enter', listener: (arg1: EventModel, arg2?: any) => void): this; // 新增
|
||||
public onEvent(eventName: 'scDel', listener: (arg1: EventModel, arg2?: any) => void): this; // 新增
|
||||
public onEvent(eventName: 'all', listener: (arg1: any) => void): this;
|
||||
public onEvent(eventName: 'follow', listener: (arg1: EventModel, arg2?: any) => void): this; // 新增
|
||||
public onEvent(eventName: keyof BaseDanmakuClient['eventsAsModel'], listener: (...args: any[]) => void): this {
|
||||
if (!this.eventsAsModel[eventName]) {
|
||||
// @ts-ignore
|
||||
@@ -327,6 +332,7 @@ export default abstract class BaseDanmakuClient {
|
||||
public on(eventName: 'enter', listener: (arg1: any, arg2?: any) => void): this; // 新增
|
||||
public on(eventName: 'scDel', listener: (arg1: any, arg2?: any) => void): this; // 新增
|
||||
public on(eventName: 'all', listener: (arg1: any) => void): this;
|
||||
public on(eventName: 'follow', listener: (arg1: any, arg2?: any) => void): this; // 新增
|
||||
public on(eventName: keyof BaseDanmakuClient['eventsRaw'], listener: (...args: any[]) => void): this {
|
||||
if (!this.eventsRaw[eventName]) {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KeepLiveWS } from 'bilibili-live-ws/browser';
|
||||
import BaseDanmakuClient from './BaseDanmakuClient';
|
||||
import { EventDataTypes } from '@/api/api-models';
|
||||
import { EventDataTypes, GuardLevel } from '@/api/api-models';
|
||||
import { getUserAvatarUrl, GuidUtils } from '@/Utils';
|
||||
import { AVATAR_URL } from '../constants';
|
||||
export type DirectClientAuthInfo = {
|
||||
@@ -155,28 +155,56 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
}
|
||||
public onEnter(command: any): void {
|
||||
const data = command.data;
|
||||
this.eventsRaw?.enter?.forEach((d) => { d(data, command); });
|
||||
this.eventsAsModel.enter?.forEach((d) => {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Enter,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: '',
|
||||
price: 0,
|
||||
num: 1,
|
||||
time: Date.now(),
|
||||
guard_level: 0,
|
||||
fans_medal_level: data.fans_medal?.medal_level || 0,
|
||||
fans_medal_name: data.fans_medal?.medal_name || '',
|
||||
fans_medal_wearing_status: false,
|
||||
uface: AVATAR_URL + data.uid,
|
||||
open_id: '',
|
||||
ouid: GuidUtils.numToGuid(data.uid)
|
||||
},
|
||||
command
|
||||
);
|
||||
});
|
||||
const msgType = data.msg_type;
|
||||
|
||||
if (msgType === 1) {
|
||||
this.eventsRaw?.enter?.forEach((d) => { d(data, command); });
|
||||
this.eventsAsModel.enter?.forEach((d) => {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Enter,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: '',
|
||||
price: 0,
|
||||
num: 1,
|
||||
time: data.timestamp ? data.timestamp * 1000 : Date.now(),
|
||||
guard_level: data.privilege_type || GuardLevel.None,
|
||||
fans_medal_level: data.fans_medal?.medal_level || 0,
|
||||
fans_medal_name: data.fans_medal?.medal_name || '',
|
||||
fans_medal_wearing_status: data.fans_medal?.is_lighted === 1,
|
||||
uface: data.face?.replace("http://", "https://") || (AVATAR_URL + data.uid),
|
||||
open_id: '',
|
||||
ouid: GuidUtils.numToGuid(data.uid)
|
||||
},
|
||||
command
|
||||
);
|
||||
});
|
||||
}
|
||||
else if (msgType === 2) {
|
||||
this.eventsRaw?.follow?.forEach((d) => { d(data, command); });
|
||||
this.eventsAsModel.follow?.forEach((d) => {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Follow,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: '关注了主播',
|
||||
price: 0,
|
||||
num: 1,
|
||||
time: data.timestamp ? data.timestamp * 1000 : Date.now(),
|
||||
guard_level: data.privilege_type || GuardLevel.None,
|
||||
fans_medal_level: data.fans_medal?.medal_level || 0,
|
||||
fans_medal_name: data.fans_medal?.medal_name || '',
|
||||
fans_medal_wearing_status: data.fans_medal?.is_lighted === 1,
|
||||
uface: data.face?.replace("http://", "https://") || (AVATAR_URL + data.uid),
|
||||
open_id: '',
|
||||
ouid: GuidUtils.numToGuid(data.uid)
|
||||
},
|
||||
command
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
public onScDel(command: any): void {
|
||||
const data = command.data;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { defineStore } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue'; // 引入 shallowRef
|
||||
|
||||
// 定义支持的事件名称类型
|
||||
type EventName = 'danmaku' | 'gift' | 'sc' | 'guard' | 'enter' | 'scDel';
|
||||
type EventName = 'danmaku' | 'gift' | 'sc' | 'guard' | 'enter' | 'scDel' | 'follow';
|
||||
type EventNameWithAll = EventName | 'all';
|
||||
// 定义监听器函数类型
|
||||
type Listener = (arg1: any, arg2: any) => void;
|
||||
|
||||
@@ -42,7 +42,6 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
|
||||
反馈页面
|
||||
</NButton>
|
||||
<NDivider vertical />
|
||||
邮箱:
|
||||
<NButton
|
||||
tag="a"
|
||||
type="info"
|
||||
@@ -87,6 +86,18 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
|
||||
</NIcon>
|
||||
<span>源代码仓库</span>
|
||||
</NButton>
|
||||
<NButton
|
||||
tag="a"
|
||||
href="https://stats.uptimerobot.com/vGKZv8uhVC"
|
||||
target="_blank"
|
||||
text
|
||||
style="display: flex; align-items: center; gap: 4px; color: #666;"
|
||||
>
|
||||
<NIcon size="16">
|
||||
<ServerOutline />
|
||||
</NIcon>
|
||||
<span>服务状态</span>
|
||||
</NButton>
|
||||
</div>
|
||||
</NSpace>
|
||||
<NDivider
|
||||
|
||||
@@ -374,8 +374,6 @@ onMounted(() => {
|
||||
canResendEmail.value = true
|
||||
}
|
||||
}
|
||||
// 当进入管理页时检查更新日志
|
||||
checkUpdateNote();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import { BiliAuthCodeStatusType, BiliAuthModel } from '@/api/api-models'
|
||||
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||
import EventFetcherStatusCard from '@/components/EventFetcherStatusCard.vue'
|
||||
import { ACCOUNT_API_URL, CN_HOST, TURNSTILE_KEY } from '@/data/constants'
|
||||
import { useAuthStore } from '@/store/useAuthStore'
|
||||
import { Info24Filled, Mic24Filled, Question24Regular } from '@vicons/fluent'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
@@ -32,8 +30,9 @@ import {
|
||||
} from 'naive-ui'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import VueTurnstile from 'vue-turnstile'
|
||||
import SettingsManageView from './SettingsManageView.vue'
|
||||
import SettingPaymentView from './Setting_PaymentView.vue'
|
||||
import SettingsManageView from './SettingsManageView.vue'
|
||||
import { checkUpdateNote } from '@/data/UpdateNote'
|
||||
|
||||
|
||||
const token = ref('')
|
||||
@@ -281,6 +280,8 @@ async function ChangeBili() {
|
||||
}
|
||||
onUnmounted(() => {
|
||||
turnstile.value?.remove()
|
||||
// 当进入管理页时检查更新日志
|
||||
checkUpdateNote();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user