Files
vtsuru.live/src/client/components/autoaction/TemplateEditor.vue
Megghy d8aed495d9 feat: 新增消息队列设置功能,优化自动操作体验
- 在 ClientAutoAction.vue 中新增消息队列设置标签页,允许用户配置弹幕和私信发送间隔。
- 更新多个组件以支持新的设置选项,提升用户交互体验。
- 在 useBiliFunction 中实现队列处理逻辑,确保消息按设定间隔发送。
- 优化 CheckInSettings 组件,整合 AutoActionEditor 以简化配置管理。
2025-04-26 04:15:37 +08:00

680 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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 { 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({
template: {
type: Object as () => AutoActionItem,
required: true
},
title: {
type: String,
default: '模板编辑'
},
description: {
type: String,
default: ''
},
checkLength: {
type: Boolean,
default: true
},
customTestContext: {
type: Object,
default: undefined
}
});
const emit = defineEmits(['update:template']);
const mergedPlaceholders = computed(() => {
const basePlaceholders = [
{ name: '{{user.name}}', description: '用户名称' },
{ name: '{{user.uid}}', description: '用户ID' },
{ 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: '原始事件对象 (高级用法)' }
];
const specificPlaceholders: { name: string, description: string }[] = [];
switch (props.template.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());
});
// 深度合并两个对象的辅助函数
function deepMerge(target: any, source: any): any {
if (!source) return target;
if (!target) return source;
const result = { ...target };
Object.keys(source).forEach(key => {
if (typeof source[key] === 'object' && source[key] !== null && typeof target[key] === 'object' && target[key] !== null) {
result[key] = deepMerge(target[key], source[key]);
} else if (source[key] !== undefined) {
result[key] = source[key];
}
});
return result;
}
const testContext = computed(() => {
// 创建默认上下文
const defaultContext = 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.template.triggerType);
// 如果有自定义上下文,将其与默认上下文合并
if (props.customTestContext) {
return deepMerge(defaultContext, props.customTestContext);
}
return defaultContext;
});
const message = useMessage();
const activeTab = ref('editor');
const showLivePreview = ref(true);
const splitter = new GraphemeSplitter();
const showSyntaxModal = ref(false);
function countGraphemes(value: string) {
return splitter.countGraphemes(value);
}
function convertPlaceholders() {
if (!props.template.template) {
message.warning('请先输入模板内容');
return;
}
const converted = convertToJsExpressions(props.template.template, mergedPlaceholders.value);
if (converted !== props.template.template) {
props.template.template = converted;
message.success('已转换占位符为表达式格式');
} else {
message.info('模板中没有需要转换的占位符');
}
}
function hasJsExpression(template: string): boolean {
return containsJsExpression(template);
}
const highlightPatterns = computed(() => {
const simplePlaceholders = mergedPlaceholders.value.map(p => p.name);
const jsExpressionsInTemplate = extractJsExpressions(props.template.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.template.triggerType);
// 如果有自定义上下文,将其深度合并到执行上下文中
if (props.customTestContext) {
Object.keys(props.customTestContext).forEach(key => {
if (typeof props.customTestContext?.[key] === 'object' && props.customTestContext[key] !== null) {
executionContext.variables[key] = deepMerge(executionContext.variables[key] || {}, props.customTestContext[key]);
} else {
executionContext.variables[key] = props.customTestContext?.[key];
}
});
}
try {
return evaluateTemplateExpressions(template, executionContext);
} catch (error) {
console.error("Preview evaluation error:", error);
return `[预览错误: ${(error as Error).message}]`;
}
}
const evaluatedTemplateResult = computed(() => {
if (!props.template.template || !showLivePreview.value) return '';
return evaluateTemplateForUI(props.template.template);
});
const previewResult = computed(() => {
return evaluatedTemplateResult.value;
});
const lengthStatus = computed(() => {
if (!props.template.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.template.template = template;
message.success('已插入示例模板');
}
</script>
<template>
<NCard
:title="title"
size="small"
class="template-editor-card"
>
<template
v-if="mergedPlaceholders.length > 0"
#header-extra
>
<NButton
quaternary
size="small"
class="btn-with-transition"
@click="showSyntaxModal = true"
>
<NIcon
:component="Info24Filled"
style="margin-right: 4px;"
/>
变量与语法说明
</NButton>
</template>
<p
v-if="description"
class="template-description"
>
{{ description }}
</p>
<!-- 标签页支持 -->
<NTabs
v-model:value="activeTab"
type="line"
animated
class="editor-tabs"
>
<NTabPane
name="editor"
tab="编辑"
>
<NFlex
vertical
:size="12"
>
<!-- 模板帮助组件 -->
<TemplateHelper :placeholders="mergedPlaceholders" />
<!-- 当前模板预览 -->
<NInput
v-model:value="template.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"
>
{{ lengthStatus.message }}
</NAlert>
<!-- 实时预览 -->
<NFlex
align="center"
justify="space-between"
class="preview-toggle"
>
<NButton
quaternary
size="small"
@click="showLivePreview = !showLivePreview"
>
<template #icon>
<NIcon :component="showLivePreview ? LiveOff24Regular : Code24Regular" />
</template>
{{ showLivePreview ? '隐藏预览' : '显示预览' }}
</NButton>
<transition name="fade">
<div
v-if="showLivePreview && previewResult"
class="live-preview"
>
<NBadge
dot
type="info"
/> 实时预览:
<NHighlight
:text="previewResult"
:patterns="highlightPatterns"
/>
</div>
</transition>
</NFlex>
<!-- 操作按钮 -->
<NFlex
justify="end"
:size="12"
>
<NButton
type="default"
size="small"
class="btn-with-transition"
@click="convertPlaceholders"
>
占位符转表达式
</NButton>
</NFlex>
<!-- 模板示例 -->
<NCollapse
class="template-examples"
>
<NCollapseItem
name="examples"
title="模板示例 (点击展开)"
>
<NFlex
vertical
:size="8"
>
<div
v-for="(category, idx) in templateExamples"
:key="idx"
class="example-category"
>
<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>
</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 {
animation: card-appear 0.4s ease-out;
}
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.template-description {
margin-bottom: 12px;
color: var(--n-text-color-disabled);
font-size: 13px;
}
.alert-header {
display: flex;
align-items: center;
font-weight: bold;
}
.placeholder-item {
margin-bottom: 4px;
font-size: 13px;
}
.placeholder-item code {
font-size: 13px;
padding: 1px 4px;
}
.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;
}
.live-preview .n-badge {
margin-right: 6px;
}
.template-examples {
margin-top: 16px;
}
.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(-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>