feat: 更新项目配置和组件,增强功能和用户体验

- 在 .gitignore 中添加了 .specstory 文件的忽略规则。
- 更新 tsconfig.json,修正了 vue-vine/types/macros 的引用路径。
- 在组件声明中新增了 NInput 组件的类型支持。
- 优化了 EventModel 接口,调整了 guard_level 的类型为 GuardLevel。
- 增加了 Follow 事件类型到 EventDataTypes 枚举中。
- 在 ClientAutoAction.vue 中引入了新的 store 和组件,增强了功能。
- 更新了多个设置组件,添加了关键词匹配类型和过滤模式的支持。
- 改进了模板编辑器和测试器的功能,支持更灵活的模板管理。
- 在弹幕客户端中新增了关注事件的处理逻辑,提升了事件响应能力。
This commit is contained in:
2025-04-22 02:30:09 +08:00
parent 2fc8f7fcf8
commit 77cf0c5edc
39 changed files with 3955 additions and 1959 deletions

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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}`;

View 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>

View File

@@ -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;

View File

@@ -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

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>