mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 更新依赖项和配置,添加新通知类型
- 在 package.json 中添加了 @types/md5 和 @vueuse/integrations 依赖。 - 更新了 tsconfig.json 中的模块解析方式为 bundler。 - 在组件声明中移除了不再使用的 Naive UI 组件。 - 在弹幕窗口和设置中添加了启用动画的选项,并更新了相关样式。 - 实现了私信发送失败的通知功能,增强了用户体验。
This commit is contained in:
@@ -1,93 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { NTabs, NTabPane, NCard, NSpace, NScrollbar } from 'naive-ui';
|
||||
import { useAutoAction } from '@/client/store/useAutoAction';
|
||||
import GiftThankConfig from './components/autoaction/GiftThankConfig.vue';
|
||||
import GuardPmConfig from './components/autoaction/GuardPmConfig.vue';
|
||||
import FollowThankConfig from './components/autoaction/FollowThankConfig.vue';
|
||||
import EntryWelcomeConfig from './components/autoaction/EntryWelcomeConfig.vue';
|
||||
import ScheduledDanmakuConfig from './components/autoaction/ScheduledDanmakuConfig.vue';
|
||||
import AutoReplyConfig from './components/autoaction/AutoReplyConfig.vue';
|
||||
import { AutoActionItem, TriggerType, useAutoAction } from '@/client/store/useAutoAction';
|
||||
import { useDanmakuClient } from '@/store/useDanmakuClient';
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCountdown,
|
||||
NDropdown,
|
||||
NEmpty,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NTag,
|
||||
useMessage,
|
||||
NDataTable,
|
||||
NSwitch
|
||||
} from 'naive-ui';
|
||||
import { computed, h, onMounted, onUnmounted, provide, reactive, ref, watch } from 'vue';
|
||||
import AutoActionEditor from './components/autoaction/AutoActionEditor.vue';
|
||||
|
||||
const autoActionStore = useAutoAction();
|
||||
const message = useMessage();
|
||||
const danmakuClient = useDanmakuClient();
|
||||
|
||||
// 当前激活的标签页
|
||||
const activeTab = ref('gift-thank');
|
||||
// 分类标签
|
||||
const typeMap = {
|
||||
[TriggerType.DANMAKU]: '自动回复',
|
||||
[TriggerType.GIFT]: '礼物感谢',
|
||||
[TriggerType.GUARD]: '舰长相关',
|
||||
[TriggerType.FOLLOW]: '关注感谢',
|
||||
[TriggerType.ENTER]: '入场欢迎',
|
||||
[TriggerType.SCHEDULED]: '定时发送',
|
||||
[TriggerType.SUPER_CHAT]: 'SC感谢',
|
||||
};
|
||||
|
||||
// 类型总开关状态
|
||||
const typeEnabledStatus = reactive<Record<string, boolean>>({});
|
||||
|
||||
// 初始化每种类型的启用状态(默认启用)
|
||||
Object.values(TriggerType).forEach(type => {
|
||||
typeEnabledStatus[type as string] = true;
|
||||
});
|
||||
|
||||
// 激活的标签页
|
||||
const activeTab = ref(TriggerType.GIFT);
|
||||
|
||||
// 添加自动操作模态框
|
||||
const showAddModal = ref(false);
|
||||
const selectedTriggerType = ref<TriggerType>(TriggerType.GIFT);
|
||||
|
||||
// 正在编辑的自动操作
|
||||
const editingActionId = ref<string | null>(null);
|
||||
|
||||
const triggerTypeOptions = [
|
||||
{ label: '自动回复', value: TriggerType.DANMAKU },
|
||||
{ label: '礼物感谢', value: TriggerType.GIFT },
|
||||
{ label: '舰长相关', value: TriggerType.GUARD },
|
||||
{ label: '关注感谢', value: TriggerType.FOLLOW },
|
||||
{ label: '入场欢迎', value: TriggerType.ENTER },
|
||||
{ label: '定时发送', value: TriggerType.SCHEDULED },
|
||||
{ label: 'SC感谢', value: TriggerType.SUPER_CHAT },
|
||||
];
|
||||
|
||||
// 自定义列存储,按触发类型分组
|
||||
const customColumnsByType = reactive<Record<string, any[]>>({});
|
||||
|
||||
// 基础表格列定义
|
||||
const baseColumns = [
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
render: (row: AutoActionItem) => {
|
||||
return h('div', { style: 'font-weight: 500' }, row.name || '未命名自动操作');
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
render: (row: AutoActionItem) => {
|
||||
const status = getStatusTag(row);
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type: status.type,
|
||||
size: 'small',
|
||||
round: true,
|
||||
style: 'cursor: pointer;',
|
||||
onClick: () => toggleActionStatus(row)
|
||||
},
|
||||
{ default: () => status.text }
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// 定义定时任务的剩余时间列
|
||||
const remainingTimeColumn = {
|
||||
title: '剩余时间',
|
||||
key: 'remainingTime',
|
||||
width: 150,
|
||||
render: (row: AutoActionItem) => {
|
||||
// 从runtimeState中获取该任务的定时器状态
|
||||
const timer = autoActionStore.runtimeState.scheduledTimers[row.id];
|
||||
|
||||
if (timer) {
|
||||
// 从store中获取剩余时间
|
||||
const timerInfo = autoActionStore.getScheduledTimerInfo(row.id);
|
||||
const remainingMs = timerInfo?.remainingMs || 0;
|
||||
|
||||
// 获取状态标记
|
||||
const remainingSeconds = Math.floor(remainingMs / 1000);
|
||||
|
||||
// 根据剩余时间确定状态
|
||||
let statusType: 'success' | 'warning' | 'error' = 'success';
|
||||
let statusText = '等待中';
|
||||
|
||||
if (remainingSeconds <= 10) {
|
||||
statusType = 'error';
|
||||
statusText = '即将发送';
|
||||
} else if (remainingSeconds <= 30) {
|
||||
statusType = 'warning';
|
||||
statusText = '即将发送';
|
||||
}
|
||||
|
||||
return h(
|
||||
NSpace,
|
||||
{ align: 'center' },
|
||||
{
|
||||
default: () => [
|
||||
h(NCountdown, {
|
||||
duration: remainingMs,
|
||||
precision: 0,
|
||||
render: (props) => {
|
||||
const { minutes, seconds } = props;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}),
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: statusType,
|
||||
size: 'small',
|
||||
round: true
|
||||
},
|
||||
{ default: () => statusText }
|
||||
)
|
||||
]
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// 没有定时器,显示未设置状态
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type: 'default',
|
||||
size: 'small',
|
||||
round: true
|
||||
},
|
||||
{ default: () => '未启动' }
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 操作列定义
|
||||
const actionsColumn = {
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (row: AutoActionItem) => {
|
||||
return h(
|
||||
NSpace,
|
||||
{ justify: 'end', align: 'center' },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
ghost: true,
|
||||
onClick: () => editAction(row.id)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
NDropdown,
|
||||
{
|
||||
trigger: 'hover',
|
||||
options: [
|
||||
{
|
||||
label: '复制',
|
||||
key: 'duplicate',
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
}
|
||||
],
|
||||
onSelect: (key: string) => {
|
||||
if (key === 'duplicate') duplicateAutoAction(row);
|
||||
if (key === 'delete') removeAutoAction(row);
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
tertiary: true,
|
||||
style: 'padding: 0 8px;'
|
||||
},
|
||||
{ default: () => '•••' }
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前类型的列,组合基础列、自定义列和操作列
|
||||
const getColumnsForType = (type: TriggerType) => {
|
||||
const customCols = customColumnsByType[type] || [];
|
||||
|
||||
// 如果是定时发送类型,添加剩余时间列
|
||||
if (type === TriggerType.SCHEDULED) {
|
||||
return [...baseColumns, remainingTimeColumn, ...customCols, actionsColumn];
|
||||
}
|
||||
|
||||
return [...baseColumns, ...customCols, actionsColumn];
|
||||
};
|
||||
|
||||
// 按类型分组的自动操作
|
||||
const groupedActions = computed(() => {
|
||||
const grouped: Record<string, AutoActionItem[]> = {};
|
||||
|
||||
// 初始化所有分组
|
||||
Object.values(TriggerType).forEach(type => {
|
||||
grouped[type as string] = [];
|
||||
});
|
||||
|
||||
// 放入对应分组
|
||||
autoActionStore.autoActions.forEach(action => {
|
||||
if (grouped[action.triggerType]) {
|
||||
grouped[action.triggerType].push(action);
|
||||
}
|
||||
});
|
||||
|
||||
// 对每个组内的操作进行排序,启用的排在前面
|
||||
Object.keys(grouped).forEach(type => {
|
||||
grouped[type].sort((a, b) => {
|
||||
if (a.enabled === b.enabled) return 0;
|
||||
return a.enabled ? -1 : 1; // 启用的排在前面
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
// 添加新的自动操作
|
||||
function addAutoAction() {
|
||||
if (!selectedTriggerType.value) {
|
||||
message.error('请选择触发类型');
|
||||
return;
|
||||
}
|
||||
|
||||
const newAction = autoActionStore.addAutoAction(selectedTriggerType.value);
|
||||
showAddModal.value = false;
|
||||
activeTab.value = selectedTriggerType.value; // 切换到新添加的类型的标签页
|
||||
editingActionId.value = newAction.id; // 自动打开编辑界面
|
||||
message.success('已添加新的自动操作');
|
||||
}
|
||||
|
||||
// 删除自动操作
|
||||
function removeAutoAction(action: AutoActionItem) {
|
||||
autoActionStore.removeAutoAction(action.id);
|
||||
if (editingActionId.value === action.id) {
|
||||
editingActionId.value = null;
|
||||
}
|
||||
message.success('已删除自动操作');
|
||||
}
|
||||
|
||||
// 复制自动操作
|
||||
function duplicateAutoAction(action: AutoActionItem) {
|
||||
const newAction = JSON.parse(JSON.stringify(action));
|
||||
newAction.id = `${newAction.id}-copy-${Date.now()}`;
|
||||
newAction.name += ' (复制)';
|
||||
autoActionStore.autoActions.push(newAction);
|
||||
message.success('已复制自动操作');
|
||||
}
|
||||
|
||||
// 切换到编辑模式
|
||||
function editAction(actionId: string) {
|
||||
editingActionId.value = actionId;
|
||||
}
|
||||
|
||||
// 返回到概览
|
||||
function backToOverview() {
|
||||
editingActionId.value = null;
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
function getStatusTag(action: AutoActionItem) {
|
||||
// 如果该类型被禁用,显示为类型禁用状态
|
||||
if (!typeEnabledStatus[action.triggerType]) {
|
||||
return { type: 'warning' as const, text: '类型已禁用' };
|
||||
}
|
||||
|
||||
if (!action.enabled) {
|
||||
return { type: 'error' as const, text: '已禁用' };
|
||||
}
|
||||
return { type: 'success' as const, text: '已启用' };
|
||||
}
|
||||
|
||||
// 切换动作状态
|
||||
function toggleActionStatus(action: AutoActionItem) {
|
||||
action.enabled = !action.enabled;
|
||||
message.success(`已${action.enabled ? '启用' : '禁用'}操作: ${action.name || '未命名自动操作'}`);
|
||||
}
|
||||
|
||||
// 切换类型启用状态
|
||||
function toggleTypeStatus(type: string) {
|
||||
typeEnabledStatus[type] = !typeEnabledStatus[type];
|
||||
message.success(`已${typeEnabledStatus[type] ? '启用' : '禁用'}所有${typeMap[type as TriggerType]}`);
|
||||
}
|
||||
|
||||
// 判断自动操作是否实际启用(考虑类型总开关)
|
||||
function isActionActuallyEnabled(action: AutoActionItem) {
|
||||
return action.enabled && typeEnabledStatus[action.triggerType];
|
||||
}
|
||||
|
||||
// 在组件挂载后初始化自动操作模块
|
||||
onMounted(() => {
|
||||
//autoActionStore.init();
|
||||
// 确保danmakuClient已经初始化
|
||||
if (danmakuClient.connected) {
|
||||
autoActionStore.init();
|
||||
|
||||
// 添加总开关状态检查代码
|
||||
// 监听类型总开关变化,根据开关启用或禁用定时任务
|
||||
watch(typeEnabledStatus, (newStatus) => {
|
||||
// 更新定时任务
|
||||
const scheduledActions = autoActionStore.autoActions.filter(
|
||||
action => action.triggerType === TriggerType.SCHEDULED
|
||||
);
|
||||
|
||||
// 如果定时发送类型被禁用,停止所有定时任务
|
||||
if (!newStatus[TriggerType.SCHEDULED]) {
|
||||
scheduledActions.forEach(action => {
|
||||
if (autoActionStore.runtimeState.scheduledTimers[action.id]) {
|
||||
clearTimeout(autoActionStore.runtimeState.scheduledTimers[action.id]!);
|
||||
autoActionStore.runtimeState.scheduledTimers[action.id] = null;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果定时发送类型被启用,重新初始化自动操作
|
||||
autoActionStore.init();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 修改shouldProcessAction函数,使其考虑类型总开关
|
||||
const originalShouldProcess = autoActionStore.shouldProcessAction;
|
||||
if (originalShouldProcess) {
|
||||
autoActionStore.shouldProcessAction = (action, event) => {
|
||||
// 首先检查类型总开关状态
|
||||
if (!typeEnabledStatus[action.triggerType]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 然后再执行原始检查
|
||||
return originalShouldProcess(action, event);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 如果没有连接,等待连接状态变化后再初始化
|
||||
watch(() => danmakuClient.connected, (isConnected) => {
|
||||
if (isConnected) {
|
||||
autoActionStore.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提供类型启用状态和判断函数给子组件使用
|
||||
provide('typeEnabledStatus', typeEnabledStatus);
|
||||
provide('isActionActuallyEnabled', isActionActuallyEnabled);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
// 清理操作保留在这里,但移除了具体的定时器引用
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
title="自动操作设置"
|
||||
size="small"
|
||||
>
|
||||
<NAlert
|
||||
type="warning"
|
||||
show-icon
|
||||
closable
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<div>
|
||||
<NAlert type="warning">
|
||||
施工中
|
||||
</NAlert>
|
||||
<NTabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
<NCard
|
||||
title="自动操作设置"
|
||||
size="small"
|
||||
>
|
||||
<NTabPane
|
||||
name="gift-thank"
|
||||
tab="礼物感谢"
|
||||
>
|
||||
<GiftThankConfig :config="autoActionStore.giftThankConfig" />
|
||||
</NTabPane>
|
||||
<template #header-extra>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
添加自动操作
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<NTabPane
|
||||
name="guard-pm"
|
||||
tab="上舰私信"
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
>
|
||||
<GuardPmConfig :config="autoActionStore.guardPmConfig" />
|
||||
</NTabPane>
|
||||
<!-- 分类标签页 -->
|
||||
<NTabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
@update:value="editingActionId = null"
|
||||
>
|
||||
<NTabPane
|
||||
v-for="(label, type) in typeMap"
|
||||
:key="type"
|
||||
:name="type"
|
||||
:tab="label"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<!-- 类型总开关 -->
|
||||
<NSpace
|
||||
align="center"
|
||||
style="padding: 8px 0; margin-bottom: 8px"
|
||||
>
|
||||
<NSwitch
|
||||
v-model:value="typeEnabledStatus[type]"
|
||||
@update:value="toggleTypeStatus(type)"
|
||||
/>
|
||||
<span>{{ typeEnabledStatus[type] ? '启用' : '禁用' }}所有{{ label }}</span>
|
||||
</NSpace>
|
||||
|
||||
<NTabPane
|
||||
name="follow-thank"
|
||||
tab="关注感谢"
|
||||
>
|
||||
<FollowThankConfig :config="autoActionStore.followThankConfig" />
|
||||
</NTabPane>
|
||||
<!-- 如果没有此类型的动作,显示空状态 -->
|
||||
<NEmpty
|
||||
v-if="groupedActions[type].length === 0"
|
||||
description="暂无自动操作"
|
||||
>
|
||||
<template #extra>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="() => { selectedTriggerType = type; showAddModal = true; }"
|
||||
>
|
||||
添加{{ typeMap[type] }}
|
||||
</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
|
||||
<NTabPane
|
||||
name="entry-welcome"
|
||||
tab="入场欢迎"
|
||||
>
|
||||
<EntryWelcomeConfig :config="autoActionStore.entryWelcomeConfig" />
|
||||
</NTabPane>
|
||||
<!-- 此类型的所有操作概览 -->
|
||||
<div
|
||||
v-else-if="editingActionId === null"
|
||||
class="overview-container"
|
||||
>
|
||||
<NDataTable
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:columns="getColumnsForType(type)"
|
||||
:data="groupedActions[type]"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty description="暂无数据" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NTabPane
|
||||
name="scheduled-danmaku"
|
||||
tab="定时弹幕"
|
||||
>
|
||||
<ScheduledDanmakuConfig :config="autoActionStore.scheduledDanmakuConfig" />
|
||||
</NTabPane>
|
||||
<!-- 添加按钮 -->
|
||||
<NButton
|
||||
type="default"
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
class="btn-with-transition"
|
||||
@click="() => { selectedTriggerType = type; showAddModal = true; }"
|
||||
>
|
||||
+ 添加{{ typeMap[type] }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<NTabPane
|
||||
name="auto-reply"
|
||||
tab="自动回复"
|
||||
>
|
||||
<AutoReplyConfig :config="autoActionStore.autoReplyConfig" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NCard>
|
||||
<!-- 编辑视图 -->
|
||||
<div
|
||||
v-else
|
||||
class="edit-container"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NButton
|
||||
size="small"
|
||||
style="align-self: flex-start; margin-bottom: 8px"
|
||||
class="back-btn btn-with-transition"
|
||||
@click="backToOverview"
|
||||
>
|
||||
← 返回列表
|
||||
</NButton>
|
||||
|
||||
<transition-group
|
||||
name="fade-slide"
|
||||
tag="div"
|
||||
>
|
||||
<div
|
||||
v-for="action in groupedActions[type]"
|
||||
v-show="action.id === editingActionId"
|
||||
:key="action.id"
|
||||
class="action-item"
|
||||
>
|
||||
<!-- 编辑器组件 -->
|
||||
<AutoActionEditor :action="action" />
|
||||
</div>
|
||||
</transition-group>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 添加新自动操作的模态框 -->
|
||||
<NModal
|
||||
v-model:show="showAddModal"
|
||||
preset="dialog"
|
||||
title="添加新的自动操作"
|
||||
positive-text="确认"
|
||||
negative-text="取消"
|
||||
class="modal-with-transition"
|
||||
@positive-click="addAutoAction"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<div>请选择要添加的自动操作类型:</div>
|
||||
<NSelect
|
||||
v-model:value="selectedTriggerType"
|
||||
:options="triggerTypeOptions"
|
||||
placeholder="选择触发类型"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-item {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-menu-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.config-description {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 淡入淡出过渡 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 列表动画 */
|
||||
.list-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 淡入滑动过渡 */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
/* 按钮过渡 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 返回按钮特殊动画 */
|
||||
.back-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.back-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
.back-btn:hover::after {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* 容器过渡 */
|
||||
.overview-container,
|
||||
.edit-container {
|
||||
transition: all 0.4s ease;
|
||||
transform-origin: center top;
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,13 @@
|
||||
// 动画和阴影
|
||||
root.style.setProperty('--dw-animation-duration', `${setting.value.animationDuration || 300}ms`);
|
||||
root.style.setProperty('--dw-shadow', setting.value.enableShadow ? `0 0 10px ${setting.value.shadowColor}` : 'none');
|
||||
|
||||
// 根据 enableAnimation 设置 data-animation-disabled 属性
|
||||
if (setting.value.enableAnimation) {
|
||||
root.removeAttribute('data-animation-disabled');
|
||||
} else {
|
||||
root.setAttribute('data-animation-disabled', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function addDanmaku(data: EventModel) {
|
||||
@@ -287,6 +294,7 @@
|
||||
transition-duration: 100ms !important;
|
||||
}
|
||||
|
||||
/* 动画相关样式 - 根据 enableAnimation 设置应用 */
|
||||
/* 1. declare transition */
|
||||
.danmaku-list-move,
|
||||
.danmaku-list-enter-active,
|
||||
@@ -294,6 +302,14 @@
|
||||
transition: all var(--dw-animation-duration) cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
|
||||
/* 当禁用动画时应用的样式 */
|
||||
:root[data-animation-disabled="true"] .danmaku-list-move,
|
||||
:root[data-animation-disabled="true"] .danmaku-list-enter-active,
|
||||
:root[data-animation-disabled="true"] .danmaku-list-leave-active {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.danmaku-list-enter-from,
|
||||
.danmaku-list-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
@@ -278,6 +278,24 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
<component :is="renderNotifidactionEnable('danmaku')" />
|
||||
</template>
|
||||
</NCard>
|
||||
<NCard
|
||||
size="small"
|
||||
title="私信失败通知"
|
||||
>
|
||||
<template #header-extra>
|
||||
<component :is="renderNotifidactionEnable('message-failed')" />
|
||||
</template>
|
||||
<p>当B站私信发送失败时通知你</p>
|
||||
</NCard>
|
||||
<NCard
|
||||
size="small"
|
||||
title="弹幕发送失败通知"
|
||||
>
|
||||
<template #header-extra>
|
||||
<component :is="renderNotifidactionEnable('live-danmaku-failed')" />
|
||||
</template>
|
||||
<p>当直播弹幕发送失败时通知你</p>
|
||||
</NCard>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
@@ -402,6 +402,20 @@ const separatorOptions = [
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
|
||||
<NGi>
|
||||
<NFormItem label="启用动画">
|
||||
<NSwitch
|
||||
v-model:value="danmakuWindow.danmakuWindowSetting.enableAnimation"
|
||||
/>
|
||||
<NText
|
||||
depth="3"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
关闭可减少资源占用
|
||||
</NText>
|
||||
</NFormItem>
|
||||
</NGi>
|
||||
|
||||
<NGi>
|
||||
<NFormItem label="最大弹幕数量">
|
||||
<NInputNumber
|
||||
|
||||
95
src/client/components/autoaction/AutoActionEditor.vue
Normal file
95
src/client/components/autoaction/AutoActionEditor.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NSpace, NCollapse, NDivider } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
// 引入拆分的子组件
|
||||
import BasicSettings from './settings/BasicSettings.vue';
|
||||
import AdvancedSettings from './settings/AdvancedSettings.vue';
|
||||
import DanmakuSettings from './settings/DanmakuSettings.vue';
|
||||
import GiftSettings from './settings/GiftSettings.vue';
|
||||
import GuardSettings from './settings/GuardSettings.vue';
|
||||
import ScheduledSettings from './settings/ScheduledSettings.vue';
|
||||
import TemplateSettings from './settings/TemplateSettings.vue';
|
||||
import FollowSettings from './settings/FollowSettings.vue';
|
||||
import EnterSettings from './settings/EnterSettings.vue';
|
||||
import SuperChatSettings from './settings/SuperChatSettings.vue';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 根据触发类型获取对应的设置组件
|
||||
const getTriggerSettings = () => {
|
||||
switch (props.action.triggerType) {
|
||||
case TriggerType.DANMAKU:
|
||||
return DanmakuSettings;
|
||||
case TriggerType.GIFT:
|
||||
return GiftSettings;
|
||||
case TriggerType.GUARD:
|
||||
return GuardSettings;
|
||||
case TriggerType.FOLLOW:
|
||||
return FollowSettings;
|
||||
case TriggerType.ENTER:
|
||||
return EnterSettings;
|
||||
case TriggerType.SCHEDULED:
|
||||
return ScheduledSettings;
|
||||
case TriggerType.SUPER_CHAT:
|
||||
return SuperChatSettings;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TriggerSettings = getTriggerSettings();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auto-action-editor">
|
||||
<NCard
|
||||
:title="action.name"
|
||||
size="small"
|
||||
class="editor-card"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<NDivider style="margin: 10px 0;">
|
||||
高级选项
|
||||
</NDivider>
|
||||
<!-- 通用高级设置 -->
|
||||
<AdvancedSettings
|
||||
:action="action"
|
||||
class="advanced-settings"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
高级选项
|
||||
</template>
|
||||
</NCollapse>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auto-action-editor {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,313 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NDivider, NInputNumber, NCard, NButton, NInput, NCollapse, NCollapseItem, NPopconfirm, NTag } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import { AutoReplyConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => AutoReplyConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 新增规则表单数据
|
||||
const newRule = ref({
|
||||
keywords: [] as string[],
|
||||
replies: [] as string[],
|
||||
blockwords: [] as string[]
|
||||
});
|
||||
|
||||
// 临时输入字段
|
||||
const tempKeyword = ref('');
|
||||
const tempReply = ref('');
|
||||
const tempBlockword = ref('');
|
||||
|
||||
function addKeyword() {
|
||||
if (tempKeyword.value.trim() && !newRule.value.keywords.includes(tempKeyword.value.trim())) {
|
||||
newRule.value.keywords.push(tempKeyword.value.trim());
|
||||
tempKeyword.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addReply() {
|
||||
if (tempReply.value.trim() && !newRule.value.replies.includes(tempReply.value.trim())) {
|
||||
newRule.value.replies.push(tempReply.value.trim());
|
||||
tempReply.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addBlockword() {
|
||||
if (tempBlockword.value.trim() && !newRule.value.blockwords.includes(tempBlockword.value.trim())) {
|
||||
newRule.value.blockwords.push(tempBlockword.value.trim());
|
||||
tempBlockword.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
if (newRule.value.keywords.length > 0 && newRule.value.replies.length > 0) {
|
||||
props.config.rules.push({
|
||||
keywords: [...newRule.value.keywords],
|
||||
replies: [...newRule.value.replies],
|
||||
blockwords: [...newRule.value.blockwords]
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
newRule.value = {
|
||||
keywords: [],
|
||||
replies: [],
|
||||
blockwords: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
props.config.rules.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeKeyword(index: number) {
|
||||
newRule.value.keywords.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeReply(index: number) {
|
||||
newRule.value.replies.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeBlockword(index: number) {
|
||||
newRule.value.blockwords.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeRuleKeyword(ruleIndex: number, keywordIndex: number) {
|
||||
props.config.rules[ruleIndex].keywords.splice(keywordIndex, 1);
|
||||
}
|
||||
|
||||
function removeRuleReply(ruleIndex: number, replyIndex: number) {
|
||||
props.config.rules[ruleIndex].replies.splice(replyIndex, 1);
|
||||
}
|
||||
|
||||
function removeRuleBlockword(ruleIndex: number, blockwordIndex: number) {
|
||||
props.config.rules[ruleIndex].blockwords.splice(blockwordIndex, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auto-reply-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
自动回复设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>冷却时间 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.cooldownSeconds"
|
||||
:min="0"
|
||||
:max="300"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NCard
|
||||
title="规则列表"
|
||||
size="small"
|
||||
>
|
||||
<NCollapse>
|
||||
<NCollapseItem
|
||||
v-for="(rule, ruleIndex) in config.rules"
|
||||
:key="ruleIndex"
|
||||
:title="`规则 ${ruleIndex + 1}: ${rule.keywords.join(', ')}`"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
触发关键词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(keyword, keywordIndex) in rule.keywords"
|
||||
:key="keywordIndex"
|
||||
closable
|
||||
@close="removeRuleKeyword(ruleIndex, keywordIndex)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
回复内容:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(reply, replyIndex) in rule.replies"
|
||||
:key="replyIndex"
|
||||
closable
|
||||
@close="removeRuleReply(ruleIndex, replyIndex)"
|
||||
>
|
||||
{{ reply }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="rule.blockwords.length > 0"
|
||||
vertical
|
||||
>
|
||||
<div class="rule-section-title">
|
||||
屏蔽词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(blockword, blockwordIndex) in rule.blockwords"
|
||||
:key="blockwordIndex"
|
||||
closable
|
||||
type="warning"
|
||||
@close="removeRuleBlockword(ruleIndex, blockwordIndex)"
|
||||
>
|
||||
{{ blockword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NPopconfirm @positive-click="removeRule(ruleIndex)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
>
|
||||
删除规则
|
||||
</NButton>
|
||||
</template>
|
||||
确定要删除此规则吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
|
||||
<NCard
|
||||
title="添加新规则"
|
||||
size="small"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
触发关键词:
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempKeyword"
|
||||
placeholder="输入关键词"
|
||||
@keyup.enter="addKeyword"
|
||||
/>
|
||||
<NButton @click="addKeyword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(keyword, index) in newRule.keywords"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeKeyword(index)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
回复内容: <span class="hint">(可以使用 {{ '\{\{ user.name \}\}' }} 作为用户名变量)</span>
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempReply"
|
||||
placeholder="输入回复内容"
|
||||
@keyup.enter="addReply"
|
||||
/>
|
||||
<NButton @click="addReply">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(reply, index) in newRule.replies"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeReply(index)"
|
||||
>
|
||||
{{ reply }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
屏蔽词: <span class="hint">(可选,当弹幕中包含屏蔽词时不触发)</span>
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempBlockword"
|
||||
placeholder="输入屏蔽词"
|
||||
@keyup.enter="addBlockword"
|
||||
/>
|
||||
<NButton @click="addBlockword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(blockword, index) in newRule.blockwords"
|
||||
:key="index"
|
||||
closable
|
||||
type="warning"
|
||||
@close="removeBlockword(index)"
|
||||
>
|
||||
{{ blockword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NButton
|
||||
type="primary"
|
||||
block
|
||||
:disabled="newRule.keywords.length === 0 || newRule.replies.length === 0"
|
||||
@click="addRule"
|
||||
>
|
||||
保存规则
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rule-section-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { EntryWelcomeConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => EntryWelcomeConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '被欢迎的用户名或用户列表' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-welcome-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
入场欢迎设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次欢迎最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="欢迎模板"
|
||||
description="可以使用变量来个性化欢迎内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { FollowThankConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => FollowThankConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '被感谢的用户名或用户列表' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="follow-thank-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
关注感谢设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次感谢最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="感谢模板"
|
||||
description="可以使用变量来个性化感谢内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NSpace, NDivider, NSelect, NInputNumber, NSwitch, NRadioGroup, NRadio } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { GiftThankConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => GiftThankConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const filterModeOptions = [
|
||||
{ label: '不过滤', value: 'none' },
|
||||
{ label: '礼物黑名单', value: 'blacklist' },
|
||||
{ label: '礼物白名单', value: 'whitelist' },
|
||||
{ label: '最低价值', value: 'value' },
|
||||
{ label: '过滤免费礼物', value: 'free' }
|
||||
];
|
||||
|
||||
const thankModeOptions = [
|
||||
{ label: '单用户单礼物', value: 'singleGift' },
|
||||
{ label: '单用户多礼物', value: 'singleUserMultiGift' },
|
||||
{ label: '多用户多礼物', value: 'multiUserMultiGift' }
|
||||
];
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{gift.summary}}', description: '礼物摘要,包含礼物名称和数量' },
|
||||
{ name: '{{gift.totalPrice}}', description: '礼物总价值' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gift-thank-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
礼物过滤设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="config.filterMode"
|
||||
:options="filterModeOptions"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.filterMode === 'value'"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>最低价值 (元):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.minValue"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.filterGiftNames"
|
||||
title="礼物名称列表"
|
||||
:description="config.filterMode === 'blacklist' ? '以下礼物将被过滤不触发感谢' : config.filterMode === 'whitelist' ? '只有以下礼物会触发感谢' : '请添加礼物名称'"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
感谢设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>感谢模式:</span>
|
||||
<NRadioGroup v-model:value="config.thankMode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in thankModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次感谢最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.thankMode === 'singleUserMultiGift'"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每用户最大礼物数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxGiftsPerUser"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>包含礼物数量:</span>
|
||||
<NSwitch v-model:value="config.includeQuantity" />
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="感谢模板"
|
||||
description="可以使用变量来个性化感谢内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,223 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NCard, NSpace, NDivider, NInput, NSwitch, NButton, NSelect, NPopconfirm } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import { GuardPmConfig, GuardLevel } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => GuardPmConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const newCode = ref('');
|
||||
const selectedLevel = ref(GuardLevel.Jianzhang);
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '通用', value: GuardLevel.None },
|
||||
{ label: '舰长', value: GuardLevel.Jianzhang },
|
||||
{ label: '提督', value: GuardLevel.Tidu },
|
||||
{ label: '总督', value: GuardLevel.Zongdu }
|
||||
];
|
||||
|
||||
function getLevelName(level: GuardLevel): string {
|
||||
const opt = levelOptions.find(o => o.value === level);
|
||||
return opt ? opt.label : '未知';
|
||||
}
|
||||
|
||||
function addGiftCode() {
|
||||
if (!newCode.value.trim()) return;
|
||||
|
||||
const level = selectedLevel.value;
|
||||
const levelCodes = props.config.giftCodes.find(gc => gc.level === level);
|
||||
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.push(newCode.value.trim());
|
||||
} else {
|
||||
props.config.giftCodes.push({
|
||||
level: level,
|
||||
codes: [newCode.value.trim()]
|
||||
});
|
||||
}
|
||||
|
||||
newCode.value = '';
|
||||
}
|
||||
|
||||
function removeCode(level: GuardLevel, index: number) {
|
||||
const levelCodes = props.config.giftCodes.find(gc => gc.level === level);
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{guard.levelName}}', description: '舰长等级名称' },
|
||||
{ name: '{{guard.giftCode}}', description: '礼品码(礼品码模式下可用)' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guard-pm-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
私信设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>私信模板:</span>
|
||||
<NInput
|
||||
v-model:value="config.template"
|
||||
placeholder="例如: 感谢 {{user.name}} 成为 {{guard.levelName}}!"
|
||||
style="width: 350px"
|
||||
>
|
||||
<template #prefix>
|
||||
<NPopconfirm placement="bottom">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
>
|
||||
?
|
||||
</NButton>
|
||||
</template>
|
||||
<div>
|
||||
<div
|
||||
v-for="ph in placeholders"
|
||||
:key="ph.name"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
</div>
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</NInput>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送弹幕确认:</span>
|
||||
<NSwitch v-model:value="config.sendDanmakuConfirm" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.sendDanmakuConfirm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>弹幕确认模板:</span>
|
||||
<NInput
|
||||
v-model:value="config.danmakuTemplate"
|
||||
placeholder="例如: 已私信 {{user.name}} 舰长福利!"
|
||||
style="width: 350px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="config.preventRepeat" />
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
礼品码模式
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>启用礼品码模式:</span>
|
||||
<NSwitch v-model:value="config.giftCodeMode" />
|
||||
</NSpace>
|
||||
|
||||
<div v-if="config.giftCodeMode">
|
||||
<NCard
|
||||
title="添加礼品码"
|
||||
size="small"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace justify="space-between">
|
||||
<NSelect
|
||||
v-model:value="selectedLevel"
|
||||
:options="levelOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="newCode"
|
||||
placeholder="输入礼品码"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="addGiftCode"
|
||||
>
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div
|
||||
v-for="levelData in config.giftCodes"
|
||||
:key="levelData.level"
|
||||
>
|
||||
<NCard
|
||||
v-if="levelData.codes.length > 0"
|
||||
:title="getLevelName(levelData.level) + ' 礼品码'"
|
||||
size="small"
|
||||
>
|
||||
<NSpace
|
||||
v-for="(code, index) in levelData.codes"
|
||||
:key="index"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>{{ code }}</span>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
quaternary
|
||||
@click="removeCode(levelData.level, index)"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber, NRadioGroup, NRadio } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { ScheduledDanmakuConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => ScheduledDanmakuConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const modeOptions = [
|
||||
{ label: '随机模式', value: 'random' },
|
||||
{ label: '顺序模式', value: 'sequential' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scheduled-danmaku-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
定时弹幕设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送间隔 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.intervalSeconds"
|
||||
:min="60"
|
||||
:max="3600"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送模式:</span>
|
||||
<NRadioGroup v-model:value="config.mode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in modeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.messages"
|
||||
title="弹幕内容列表"
|
||||
description="每条消息将按照设定的模式定时发送"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { NInput, NInputNumber, NButton, NSpace, NCard, NDivider, NList, NListItem, NPopconfirm, NTooltip } from 'naive-ui';
|
||||
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 TemplateHelper from './TemplateHelper.vue';
|
||||
import TemplateTester from './TemplateTester.vue';
|
||||
import { containsJsExpression, convertToJsExpressions } from '@/client/store/autoAction/expressionEvaluator';
|
||||
import { Info24Filled } from '@vicons/fluent';
|
||||
|
||||
const props = defineProps({
|
||||
templates: {
|
||||
@@ -18,57 +22,133 @@ const props = defineProps({
|
||||
placeholders: {
|
||||
type: Array as () => { name: string, description: string }[],
|
||||
default: () => []
|
||||
},
|
||||
// 新增:提供测试上下文对象
|
||||
testContext: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
user: { uid: 12345, name: '测试用户' },
|
||||
gift: { name: '测试礼物', count: 1, price: 100 }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// 添加默认的弹幕相关占位符
|
||||
const mergedPlaceholders = computed(() => {
|
||||
const defaultPlaceholders = [
|
||||
{ name: '{{danmaku.type}}', description: '事件类型' },
|
||||
{ name: '{{danmaku.uname}}', description: '用户名称' },
|
||||
{ name: '{{danmaku.uface}}', description: '用户头像URL' },
|
||||
{ name: '{{danmaku.uid}}', description: '用户ID(直接连接)' },
|
||||
{ name: '{{danmaku.open_id}}', description: '用户开放平台ID' },
|
||||
{ name: '{{danmaku.msg}}', description: '消息内容' },
|
||||
{ name: '{{danmaku.time}}', description: '时间戳' },
|
||||
{ name: '{{danmaku.num}}', description: '数量' },
|
||||
{ name: '{{danmaku.price}}', description: '价格' },
|
||||
{ name: '{{danmaku.guard_level}}', description: '大航海等级' },
|
||||
{ name: '{{danmaku.fans_medal_level}}', description: '粉丝牌等级' },
|
||||
{ name: '{{danmaku.fans_medal_name}}', description: '粉丝牌名称' },
|
||||
{ name: '{{danmaku.fans_medal_wearing_status}}', description: '是否佩戴粉丝牌' },
|
||||
{ name: '{{danmaku.emoji}}', description: '表情符号' }
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{user.uid}}', description: '用户ID' },
|
||||
{ name: '{{user.nameLength}}', description: '用户名长度' },
|
||||
{ name: '{{date.formatted}}', description: '当前日期格式化' },
|
||||
{ name: '{{timeOfDay()}}', description: '获取当前时段(早上/下午/晚上)' }
|
||||
];
|
||||
|
||||
// 返回自定义占位符和默认占位符,但不合并它们
|
||||
return { custom: props.placeholders, default: defaultPlaceholders };
|
||||
// 合并自定义占位符和默认占位符
|
||||
return [...props.placeholders, ...defaultPlaceholders];
|
||||
});
|
||||
|
||||
const newTemplate = ref('');
|
||||
const message = useMessage();
|
||||
const activeTab = ref('editor'); // 新增:标签页控制
|
||||
|
||||
// 新增:跟踪编辑状态
|
||||
const isEditing = ref(false);
|
||||
const editIndex = ref(-1);
|
||||
const editTemplate = ref('');
|
||||
|
||||
// 新增:测试选中的模板
|
||||
const selectedTemplateForTest = ref('');
|
||||
|
||||
function addTemplate() {
|
||||
if (newTemplate.value.trim()) {
|
||||
props.templates.push(newTemplate.value.trim());
|
||||
newTemplate.value = '';
|
||||
const val = newTemplate.value.trim();
|
||||
if (!val) return;
|
||||
if (props.templates.includes(val)) {
|
||||
message.warning('模板已存在');
|
||||
return;
|
||||
}
|
||||
props.templates.push(val);
|
||||
newTemplate.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;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否与其他模板重复(排除当前编辑的模板)
|
||||
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) {
|
||||
message.warning('请先输入模板内容');
|
||||
return;
|
||||
}
|
||||
newTemplate.value = convertToJsExpressions(newTemplate.value, mergedPlaceholders.value);
|
||||
message.success('已转换占位符为表达式格式');
|
||||
}
|
||||
|
||||
// 新增:测试模板
|
||||
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:'
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
:title="title"
|
||||
size="small"
|
||||
class="template-editor-card"
|
||||
>
|
||||
<template
|
||||
v-if="mergedPlaceholders.custom.length > 0 || mergedPlaceholders.default.length > 0"
|
||||
v-if="mergedPlaceholders.length > 0"
|
||||
#header-extra
|
||||
>
|
||||
<NTooltip
|
||||
@@ -79,29 +159,46 @@ function removeTemplate(index: number) {
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
class="btn-with-transition"
|
||||
>
|
||||
变量说明
|
||||
</NButton>
|
||||
</template>
|
||||
<div style="max-width: 300px">
|
||||
|
||||
<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.custom"
|
||||
:key="'custom-' + idx"
|
||||
v-for="(ph, idx) in mergedPlaceholders"
|
||||
:key="idx"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
<NDivider
|
||||
v-if="mergedPlaceholders.custom.length > 0 && mergedPlaceholders.default.length > 0"
|
||||
style="margin: 10px 0;">
|
||||
默认变量
|
||||
</NDivider>
|
||||
<div
|
||||
v-for="(ph, idx) in mergedPlaceholders.default"
|
||||
:key="'default-' + idx"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
@@ -112,56 +209,284 @@ function removeTemplate(index: number) {
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<NList bordered>
|
||||
<NListItem
|
||||
v-for="(template, index) in templates"
|
||||
:key="index"
|
||||
<!-- 新增:添加标签页支持 -->
|
||||
<NTabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
class="editor-tabs"
|
||||
>
|
||||
<NTabPane
|
||||
name="editor"
|
||||
tab="编辑模板"
|
||||
>
|
||||
<NSpace
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
<!-- 新增:添加模板帮助组件 -->
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<span>{{ template }}</span>
|
||||
<NPopconfirm @positive-click="removeTemplate(index)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
<TemplateHelper :placeholders="mergedPlaceholders" />
|
||||
</transition>
|
||||
|
||||
<NList
|
||||
bordered
|
||||
class="template-list"
|
||||
>
|
||||
<transition-group
|
||||
name="list-slide"
|
||||
tag="div"
|
||||
appear
|
||||
>
|
||||
<NListItem
|
||||
v-for="(template, index) in templates"
|
||||
:key="index"
|
||||
class="template-list-item"
|
||||
>
|
||||
<NSpace
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
确定要删除此模板吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<!-- 更新:使用自定义高亮规则 -->
|
||||
<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>
|
||||
|
||||
<NDivider />
|
||||
<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>
|
||||
|
||||
<NSpace vertical>
|
||||
<NInput
|
||||
v-model:value="newTemplate"
|
||||
placeholder="输入新模板内容"
|
||||
clearable
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
block
|
||||
@click="addTemplate"
|
||||
<NDivider />
|
||||
|
||||
<transition
|
||||
name="fade-scale"
|
||||
appear
|
||||
>
|
||||
<NSpace
|
||||
vertical
|
||||
style="width: 100%"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="newTemplate"
|
||||
type="textarea"
|
||||
placeholder="输入新模板"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
class="template-input"
|
||||
@keydown.enter.ctrl="isEditing ? saveEditedTemplate() : addTemplate()"
|
||||
/>
|
||||
|
||||
<NSpace justify="space-between">
|
||||
<NSpace>
|
||||
<NButton
|
||||
type="default"
|
||||
class="btn-with-transition"
|
||||
@click="convertPlaceholders"
|
||||
>
|
||||
转换为表达式
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<NButton
|
||||
v-if="isEditing"
|
||||
class="btn-with-transition"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
class="btn-with-transition"
|
||||
@click="isEditing ? saveEditedTemplate() : addTemplate()"
|
||||
>
|
||||
{{ isEditing ? '保存' : '添加' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</transition>
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="test"
|
||||
tab="测试模板"
|
||||
>
|
||||
添加模板
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<TemplateTester
|
||||
:default-template="selectedTemplateForTest"
|
||||
:context="testContext"
|
||||
:placeholders="mergedPlaceholders"
|
||||
/>
|
||||
</transition>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-editor-card {
|
||||
transition: all 0.3s ease;
|
||||
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: 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
margin-top: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-input {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 列表动画 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 淡入缩放 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 淡入淡出 */
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
|
||||
74
src/client/components/autoaction/TemplateHelper.vue
Normal file
74
src/client/components/autoaction/TemplateHelper.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="template-helper">
|
||||
<NSpace vertical>
|
||||
<NSpace align="center">
|
||||
<div style="font-weight: bold">
|
||||
可用变量:
|
||||
</div>
|
||||
<NSpace style="flex-wrap: wrap">
|
||||
<NTooltip
|
||||
v-for="item in props.placeholders"
|
||||
:key="item.name"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #trigger>
|
||||
<NTag
|
||||
:bordered="false"
|
||||
type="info"
|
||||
size="small"
|
||||
style="cursor: pointer"
|
||||
@click="copyToClipboard(item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</NTag>
|
||||
</template>
|
||||
{{ item.description }}
|
||||
</NTooltip>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Info24Filled } from '@vicons/fluent';
|
||||
import { NSpace, NTag, NAlert, NTooltip, NIcon, useMessage, NDivider } from 'naive-ui';
|
||||
|
||||
const props = defineProps({
|
||||
placeholders: {
|
||||
type: Array as () => { name: string; description: string }[],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
message.success('已复制到剪贴板');
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-helper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
107
src/client/components/autoaction/TemplateTester.vue
Normal file
107
src/client/components/autoaction/TemplateTester.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="template-tester">
|
||||
<NSpace vertical>
|
||||
<NInput
|
||||
v-model:value="template"
|
||||
type="textarea"
|
||||
placeholder="输入包含表达式的模板"
|
||||
/>
|
||||
|
||||
<NSpace>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="testTemplate"
|
||||
>
|
||||
测试模板
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click="resetTemplate"
|
||||
>
|
||||
重置
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<template
|
||||
v-if="hasResult"
|
||||
>
|
||||
<NDivider style="margin: 5px;" />
|
||||
<NCard
|
||||
title="结果预览"
|
||||
size="small"
|
||||
>
|
||||
<NInput
|
||||
type="textarea"
|
||||
:value="result"
|
||||
readonly
|
||||
/>
|
||||
</NCard>
|
||||
</template>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { NSpace, NInput, NInputGroup, NInputGroupLabel, NButton, useMessage, NDivider } from 'naive-ui';
|
||||
import { evaluateTemplateExpressions } from '@/client/store/autoAction/expressionEvaluator';
|
||||
|
||||
const props = defineProps({
|
||||
defaultTemplate: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
context: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const template = ref(props.defaultTemplate || '');
|
||||
const result = ref('');
|
||||
const hasResult = computed(() => result.value !== '');
|
||||
const message = useMessage();
|
||||
|
||||
function testTemplate() {
|
||||
try {
|
||||
result.value = evaluateTemplateExpressions(template.value, props.context);
|
||||
} catch (error) {
|
||||
message.error(`表达式求值错误: ${(error as Error).message}`);
|
||||
result.value = `[错误] ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTemplate() {
|
||||
template.value = props.defaultTemplate;
|
||||
result.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-tester {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
padding: 8px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
196
src/client/components/autoaction/settings/AdvancedSettings.vue
Normal file
196
src/client/components/autoaction/settings/AdvancedSettings.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NInput, NCollapseItem } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 根据触发类型判断是否显示用户过滤选项
|
||||
const showUserFilter = computed(() => {
|
||||
return ![TriggerType.SCHEDULED].includes(props.action.triggerType);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapse>
|
||||
<NCollapseItem
|
||||
v-if="showUserFilter"
|
||||
key="user-filter"
|
||||
title="用户过滤"
|
||||
class="settings-section"
|
||||
>
|
||||
<div>
|
||||
<NSpace
|
||||
key="user-filter-enabled"
|
||||
vertical
|
||||
class="settings-subsection"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>启用用户过滤:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.userFilterEnabled" />
|
||||
</NSpace>
|
||||
|
||||
<template v-if="action.triggerConfig.userFilterEnabled">
|
||||
<NSpace
|
||||
key="require-medal"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>要求本房间勋章:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.requireMedal" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="require-captain"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>要求任意舰长:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.requireCaptain" />
|
||||
</NSpace>
|
||||
</template>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<NCollapseItem
|
||||
key="cooldown"
|
||||
title="冷却控制"
|
||||
class="settings-section"
|
||||
>
|
||||
<div>
|
||||
<NSpace
|
||||
key="ignore-cooldown"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>忽略全局冷却:</span>
|
||||
<NSwitch v-model:value="action.ignoreCooldown" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="delay-seconds"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>延迟执行(秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.delaySeconds"
|
||||
:min="0"
|
||||
:max="600"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="cooldown-seconds"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>冷却时间(秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.cooldownSeconds"
|
||||
:min="0"
|
||||
:max="3600"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<NCollapseItem
|
||||
key="logical-expression"
|
||||
title="逻辑条件表达式"
|
||||
class="settings-section"
|
||||
>
|
||||
<div>
|
||||
<NSpace vertical>
|
||||
<p class="description">
|
||||
当表达式为真时才会执行此操作。可使用JS语法,例如: <code>user.guardLevel > 0 || gift.price > 10</code>
|
||||
</p>
|
||||
<NInput
|
||||
v-model:value="action.logicalExpression"
|
||||
type="textarea"
|
||||
placeholder="输入表达式,留空则始终为真"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<NCollapseItem
|
||||
key="custom-js"
|
||||
title="自定义JS执行"
|
||||
class="settings-section"
|
||||
>
|
||||
<div>
|
||||
<NSpace vertical>
|
||||
<p class="description">
|
||||
可访问 context, event, biliFunc, roomId 等变量
|
||||
</p>
|
||||
<NInput
|
||||
v-model:value="action.executeCommand"
|
||||
type="textarea"
|
||||
placeholder="输入要执行的JS代码"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-section {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-subsection {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 已移除所有动画相关样式 */
|
||||
</style>
|
||||
143
src/client/components/autoaction/settings/BasicSettings.vue
Normal file
143
src/client/components/autoaction/settings/BasicSettings.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NInput, NSwitch, NSelect } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType, ActionType, Priority } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 触发类型选项
|
||||
const triggerTypeOptions = [
|
||||
{ label: '弹幕触发', value: TriggerType.DANMAKU },
|
||||
{ label: '礼物感谢', value: TriggerType.GIFT },
|
||||
{ label: '上舰感谢', value: TriggerType.GUARD },
|
||||
{ label: '关注感谢', value: TriggerType.FOLLOW },
|
||||
{ label: '入场欢迎', value: TriggerType.ENTER },
|
||||
{ label: '定时发送', value: TriggerType.SCHEDULED },
|
||||
{ label: 'SC感谢', value: TriggerType.SUPER_CHAT },
|
||||
];
|
||||
|
||||
// 操作类型选项
|
||||
const actionTypeOptions = [
|
||||
{ label: '发送弹幕', value: ActionType.SEND_DANMAKU },
|
||||
{ label: '发送私信', value: ActionType.SEND_PRIVATE_MSG },
|
||||
{ label: '执行命令', value: ActionType.EXECUTE_COMMAND },
|
||||
];
|
||||
|
||||
// 优先级选项
|
||||
const priorityOptions = [
|
||||
{ label: '最高', value: Priority.HIGHEST },
|
||||
{ label: '高', value: Priority.HIGH },
|
||||
{ label: '普通', value: Priority.NORMAL },
|
||||
{ label: '低', value: Priority.LOW },
|
||||
{ label: '最低', value: Priority.LOWEST },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="basic-settings-container">
|
||||
<NSpace
|
||||
vertical
|
||||
class="basic-settings"
|
||||
>
|
||||
<NSpace
|
||||
key="name"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>名称:</span>
|
||||
<NInput
|
||||
v-model:value="action.name"
|
||||
style="width: 300px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="enabled"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>启用:</span>
|
||||
<NSwitch v-model:value="action.enabled" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="only-during-live"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>仅直播中启用:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.onlyDuringLive" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="ignore-tianxuan"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>天选时刻忽略:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.ignoreTianXuan" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="triggerType"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>触发类型:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerType"
|
||||
style="width: 300px"
|
||||
:options="triggerTypeOptions"
|
||||
disabled
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="actionType"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>操作类型:</span>
|
||||
<NSelect
|
||||
v-model:value="action.actionType"
|
||||
style="width: 300px"
|
||||
:options="actionTypeOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
key="priority"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
class="setting-item"
|
||||
>
|
||||
<span>优先级:</span>
|
||||
<NSelect
|
||||
v-model:value="action.priority"
|
||||
style="width: 300px"
|
||||
:options="priorityOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
137
src/client/components/autoaction/settings/DanmakuSettings.vue
Normal file
137
src/client/components/autoaction/settings/DanmakuSettings.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NInput, NButton, NTag, NDivider, NCollapseItem, useMessage } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// 弹幕关键词相关
|
||||
const tempKeyword = ref('');
|
||||
const tempBlockword = ref('');
|
||||
|
||||
// 添加关键词
|
||||
function addKeyword() {
|
||||
if (!tempKeyword.value.trim()) return;
|
||||
|
||||
if (!props.action.triggerConfig.keywords) {
|
||||
props.action.triggerConfig.keywords = [];
|
||||
}
|
||||
|
||||
if (!props.action.triggerConfig.keywords.includes(tempKeyword.value.trim())) {
|
||||
props.action.triggerConfig.keywords.push(tempKeyword.value.trim());
|
||||
tempKeyword.value = '';
|
||||
} else {
|
||||
message.warning('此关键词已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除关键词
|
||||
function removeKeyword(index: number) {
|
||||
if (props.action.triggerConfig.keywords) {
|
||||
props.action.triggerConfig.keywords.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加屏蔽词
|
||||
function addBlockword() {
|
||||
if (!tempBlockword.value.trim()) return;
|
||||
|
||||
if (!props.action.triggerConfig.blockwords) {
|
||||
props.action.triggerConfig.blockwords = [];
|
||||
}
|
||||
|
||||
if (!props.action.triggerConfig.blockwords.includes(tempBlockword.value.trim())) {
|
||||
props.action.triggerConfig.blockwords.push(tempBlockword.value.trim());
|
||||
tempBlockword.value = '';
|
||||
} else {
|
||||
message.warning('此屏蔽词已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除屏蔽词
|
||||
function removeBlockword(index: number) {
|
||||
if (props.action.triggerConfig.blockwords) {
|
||||
props.action.triggerConfig.blockwords.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.DANMAKU"
|
||||
title="自动回复设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<div class="section-title">
|
||||
触发关键词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NInput
|
||||
v-model:value="tempKeyword"
|
||||
placeholder="输入关键词"
|
||||
@keyup.enter="addKeyword"
|
||||
/>
|
||||
<NButton @click="addKeyword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<template v-if="action.triggerConfig.keywords">
|
||||
<NTag
|
||||
v-for="(keyword, index) in action.triggerConfig.keywords"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeKeyword(index)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NSpace>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div class="section-title">
|
||||
屏蔽词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NInput
|
||||
v-model:value="tempBlockword"
|
||||
placeholder="输入屏蔽词"
|
||||
@keyup.enter="addBlockword"
|
||||
/>
|
||||
<NButton @click="addBlockword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<template v-if="action.triggerConfig.blockwords">
|
||||
<NTag
|
||||
v-for="(blockword, index) in action.triggerConfig.blockwords"
|
||||
:key="index"
|
||||
closable
|
||||
type="warning"
|
||||
@close="removeBlockword(index)"
|
||||
>
|
||||
{{ blockword }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
65
src/client/components/autoaction/settings/EnterSettings.vue
Normal file
65
src/client/components/autoaction/settings/EnterSettings.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NSelect, NCollapseItem } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 入场过滤模式选项
|
||||
const enterFilterModeOptions = [
|
||||
{ label: '不过滤', value: 'none' },
|
||||
{ label: '用户黑名单', value: 'blacklist' },
|
||||
{ label: '用户白名单', value: 'whitelist' },
|
||||
{ label: '仅舰长', value: 'guard' },
|
||||
{ label: '仅勋章', value: 'medal' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.ENTER"
|
||||
title="入场触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>入场过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerConfig.enterFilterMode"
|
||||
style="width: 200px"
|
||||
:options="enterFilterModeOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.preventRepeat" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次处理的最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
43
src/client/components/autoaction/settings/FollowSettings.vue
Normal file
43
src/client/components/autoaction/settings/FollowSettings.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NCollapseItem } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.FOLLOW"
|
||||
title="关注触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.preventRepeat" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次处理的最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
149
src/client/components/autoaction/settings/GiftSettings.vue
Normal file
149
src/client/components/autoaction/settings/GiftSettings.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NInput, NButton, NTag, NSelect, NInputNumber, NSwitch, NCollapseItem, useMessage } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// 礼物过滤模式选项
|
||||
const giftFilterModeOptions = [
|
||||
{ label: '不过滤', value: 'none' },
|
||||
{ label: '礼物黑名单', value: 'blacklist' },
|
||||
{ label: '礼物白名单', value: 'whitelist' },
|
||||
{ label: '最低价值', value: 'value' },
|
||||
{ label: '过滤免费礼物', value: 'free' }
|
||||
];
|
||||
|
||||
// 礼物名称相关
|
||||
const tempGiftName = ref('');
|
||||
|
||||
// 添加礼物名称到过滤列表
|
||||
function addGiftName() {
|
||||
if (!tempGiftName.value.trim()) return;
|
||||
|
||||
if (!props.action.triggerConfig.filterGiftNames) {
|
||||
props.action.triggerConfig.filterGiftNames = [];
|
||||
}
|
||||
|
||||
if (!props.action.triggerConfig.filterGiftNames.includes(tempGiftName.value.trim())) {
|
||||
props.action.triggerConfig.filterGiftNames.push(tempGiftName.value.trim());
|
||||
tempGiftName.value = '';
|
||||
} else {
|
||||
message.warning('此礼物名称已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除礼物名称
|
||||
function removeGiftName(index: number) {
|
||||
if (props.action.triggerConfig.filterGiftNames) {
|
||||
props.action.triggerConfig.filterGiftNames.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.GIFT"
|
||||
title="礼物触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>礼物过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerConfig.giftFilterMode"
|
||||
style="width: 200px"
|
||||
:options="giftFilterModeOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<template v-if="action.triggerConfig.giftFilterMode === 'blacklist' || action.triggerConfig.giftFilterMode === 'whitelist'">
|
||||
<NSpace>
|
||||
<NInput
|
||||
v-model:value="tempGiftName"
|
||||
placeholder="输入礼物名称"
|
||||
@keyup.enter="addGiftName"
|
||||
/>
|
||||
<NButton @click="addGiftName">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace>
|
||||
<template v-if="action.triggerConfig.filterGiftNames">
|
||||
<NTag
|
||||
v-for="(giftName, index) in action.triggerConfig.filterGiftNames"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeGiftName(index)"
|
||||
>
|
||||
{{ giftName }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<template v-if="action.triggerConfig.giftFilterMode === 'value'">
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>最低价值 (元):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.triggerConfig.minValue"
|
||||
:min="0"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>包含礼物数量:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.includeQuantity" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次处理的最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每用户最大礼物种类数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.maxItemsPerUser"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
152
src/client/components/autoaction/settings/GuardSettings.vue
Normal file
152
src/client/components/autoaction/settings/GuardSettings.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NInput, NButton, NTag, NSelect, NSwitch, NDivider, NCollapseItem, useMessage } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType, ActionType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// 舰长礼品码相关
|
||||
const tempGiftCodeLevel = ref(3); // 默认为舰长等级
|
||||
const tempGiftCode = ref('');
|
||||
|
||||
// 添加礼品码
|
||||
function addGiftCode() {
|
||||
if (!tempGiftCode.value.trim()) return;
|
||||
|
||||
if (!props.action.triggerConfig.giftCodes) {
|
||||
props.action.triggerConfig.giftCodes = [];
|
||||
}
|
||||
|
||||
// 查找对应等级的礼品码数组
|
||||
let levelCodes = props.action.triggerConfig.giftCodes.find(gc => gc.level === tempGiftCodeLevel.value);
|
||||
|
||||
if (!levelCodes) {
|
||||
// 如果没有此等级的礼品码数组,创建一个
|
||||
levelCodes = { level: tempGiftCodeLevel.value, codes: [] };
|
||||
props.action.triggerConfig.giftCodes.push(levelCodes);
|
||||
}
|
||||
|
||||
// 添加礼品码
|
||||
if (!levelCodes.codes.includes(tempGiftCode.value.trim())) {
|
||||
levelCodes.codes.push(tempGiftCode.value.trim());
|
||||
tempGiftCode.value = '';
|
||||
} else {
|
||||
message.warning('此礼品码已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除礼品码
|
||||
function removeGiftCode(levelIndex: number, codeIndex: number) {
|
||||
if (props.action.triggerConfig.giftCodes &&
|
||||
props.action.triggerConfig.giftCodes[levelIndex] &&
|
||||
props.action.triggerConfig.giftCodes[levelIndex].codes) {
|
||||
props.action.triggerConfig.giftCodes[levelIndex].codes.splice(codeIndex, 1);
|
||||
|
||||
// 如果该等级没有礼品码了,移除这个等级
|
||||
if (props.action.triggerConfig.giftCodes[levelIndex].codes.length === 0) {
|
||||
props.action.triggerConfig.giftCodes.splice(levelIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 舰长等级名称映射
|
||||
function getGuardLevelName(level: number): string {
|
||||
switch (level) {
|
||||
case 1: return '总督';
|
||||
case 2: return '提督';
|
||||
case 3: return '舰长';
|
||||
default: return '通用';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.GUARD"
|
||||
title="上舰触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.preventRepeat" />
|
||||
</NSpace>
|
||||
|
||||
<template v-if="action.actionType === ActionType.SEND_PRIVATE_MSG">
|
||||
<NDivider title-placement="left">
|
||||
礼品码设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace>
|
||||
<NSelect
|
||||
v-model:value="tempGiftCodeLevel"
|
||||
style="width: 120px"
|
||||
:options="[
|
||||
{ label: '总督', value: 1 },
|
||||
{ label: '提督', value: 2 },
|
||||
{ label: '舰长', value: 3 },
|
||||
{ label: '通用', value: 0 }
|
||||
]"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="tempGiftCode"
|
||||
placeholder="输入礼品码"
|
||||
@keyup.enter="addGiftCode"
|
||||
/>
|
||||
<NButton @click="addGiftCode">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<div v-if="action.triggerConfig.giftCodes && action.triggerConfig.giftCodes.length > 0">
|
||||
<div
|
||||
v-for="(levelCodes, levelIndex) in action.triggerConfig.giftCodes"
|
||||
:key="levelIndex"
|
||||
class="gift-code-section"
|
||||
>
|
||||
<div class="gift-code-level">
|
||||
{{ getGuardLevelName(levelCodes.level) }}礼品码:
|
||||
</div>
|
||||
<div class="gift-code-list">
|
||||
<NTag
|
||||
v-for="(code, codeIndex) in levelCodes.codes"
|
||||
:key="codeIndex"
|
||||
closable
|
||||
@close="removeGiftCode(levelIndex, codeIndex)"
|
||||
>
|
||||
{{ code }}
|
||||
</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gift-code-section {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.gift-code-level {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.gift-code-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NInputNumber, NRadioGroup, NRadio, NCollapseItem } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 定时模式选项
|
||||
const schedulingModeOptions = [
|
||||
{ label: '随机模式', value: 'random' },
|
||||
{ label: '顺序模式', value: 'sequential' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.SCHEDULED"
|
||||
title="定时触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送间隔 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.triggerConfig.intervalSeconds"
|
||||
:min="60"
|
||||
:max="3600"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送模式:</span>
|
||||
<NRadioGroup v-model:value="action.triggerConfig.schedulingMode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in schedulingModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NSelect, NCollapseItem } from 'naive-ui';
|
||||
import { AutoActionItem, TriggerType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// SC过滤模式选项
|
||||
const scFilterModeOptions = [
|
||||
{ label: '不过滤', value: 'none' },
|
||||
{ label: '最低价格', value: 'price' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapseItem
|
||||
v-if="action.triggerType === TriggerType.SUPER_CHAT"
|
||||
title="SC触发设置"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>SC过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="action.triggerConfig.scFilterMode"
|
||||
style="width: 200px"
|
||||
:options="scFilterModeOptions"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<template v-if="action.triggerConfig.scFilterMode === 'price'">
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>最低价格 (元):</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.triggerConfig.minPrice"
|
||||
:min="0"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="action.triggerConfig.preventRepeat" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次处理的最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="action.actionConfig.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</template>
|
||||
136
src/client/components/autoaction/settings/TemplateSettings.vue
Normal file
136
src/client/components/autoaction/settings/TemplateSettings.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { NDivider } from 'naive-ui';
|
||||
import TemplateEditor from '../TemplateEditor.vue';
|
||||
import { AutoActionItem, TriggerType, ActionType } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: Object as () => AutoActionItem,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 模板变量占位符选项,根据触发类型动态生成
|
||||
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) {
|
||||
case ActionType.SEND_DANMAKU:
|
||||
return '弹幕模板';
|
||||
case ActionType.SEND_PRIVATE_MSG:
|
||||
return '私信模板';
|
||||
case ActionType.EXECUTE_COMMAND:
|
||||
return '命令模板';
|
||||
default:
|
||||
return '消息模板';
|
||||
}
|
||||
});
|
||||
|
||||
// 根据操作类型获取模板描述
|
||||
const templateDescription = computed(() => {
|
||||
switch (props.action.actionType) {
|
||||
case ActionType.SEND_DANMAKU:
|
||||
return '发送到直播间的弹幕内容';
|
||||
case ActionType.SEND_PRIVATE_MSG:
|
||||
return '发送给用户的私信内容';
|
||||
case ActionType.EXECUTE_COMMAND:
|
||||
return '执行的命令模板';
|
||||
default:
|
||||
return '消息内容模板';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-settings">
|
||||
<transition
|
||||
name="fade-scale"
|
||||
appear
|
||||
>
|
||||
<TemplateEditor
|
||||
:templates="action.templates"
|
||||
:placeholders="placeholders"
|
||||
:title="templateTitle"
|
||||
:description="templateDescription"
|
||||
class="template-editor"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-settings {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.template-editor {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 淡入缩放效果 */
|
||||
.fade-scale-enter-active,
|
||||
.fade-scale-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fade-scale-enter-from,
|
||||
.fade-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -57,12 +57,15 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === EventDataTypes.Gift">
|
||||
<span class="gift-badge">
|
||||
<span
|
||||
class="gift-badge"
|
||||
:isPay="item?.price > 0"
|
||||
>
|
||||
{{ item?.num || 1 }} × {{ item?.msg }}
|
||||
<span
|
||||
v-if="item?.price"
|
||||
class="gift-price"
|
||||
>¥{{ (item.price || 0).toFixed(2) }}</span>
|
||||
>¥{{ (item.price || 0).toFixed(1) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === EventDataTypes.Guard">
|
||||
@@ -333,7 +336,7 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
|
||||
/* 礼物 徽章 */
|
||||
.gift-badge {
|
||||
background: #F56C6C;
|
||||
background: #6aa8a3;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
@@ -345,6 +348,9 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gift-badge[isPay="true"] {
|
||||
background: #F56C6C;
|
||||
}
|
||||
|
||||
.gift-price {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { check } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { useDanmakuWindow } from "../store/useDanmakuWindow";
|
||||
import { getAllWebviewWindows } from "@tauri-apps/api/webviewWindow";
|
||||
import { useAutoAction } from "../store/useAutoAction";
|
||||
|
||||
const accountInfo = useAccount();
|
||||
|
||||
@@ -145,6 +146,8 @@ export async function initAll(isOnBoot: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
useAutoAction().init();
|
||||
|
||||
clientInited.value = true;
|
||||
}
|
||||
export function OnClientUnmounted() {
|
||||
|
||||
@@ -85,4 +85,22 @@ export function onGoodsBuy(info: {
|
||||
extra: { type: 'goods-buy' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 私信发送失败通知
|
||||
export function onSendPrivateMessageFailed(receiverId: number, message: string, error: any) {
|
||||
const setting = useSettings();
|
||||
if (setting.settings.notificationSettings.enableTypes.includes("message-failed")) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
window.$notification.error({
|
||||
title: "私信发送失败",
|
||||
description: `向用户 ${receiverId} 发送私信失败: ${errorMsg}`,
|
||||
duration: 8000,
|
||||
});
|
||||
trySendNotification({
|
||||
title: "私信发送失败",
|
||||
body: `向用户 ${receiverId} 发送私信失败`,
|
||||
extra: { type: 'message-failed' },
|
||||
});
|
||||
}
|
||||
}
|
||||
175
src/client/store/autoAction/expressionEvaluator.ts
Normal file
175
src/client/store/autoAction/expressionEvaluator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 表达式求值工具 - 用于在自动操作模板中支持简单的JavaScript表达式
|
||||
*/
|
||||
|
||||
// 表达式模式匹配
|
||||
// {{js: expression}} - 完整的JavaScript表达式
|
||||
const JS_EXPRESSION_REGEX = /\{\{\s*js:\s*(.*?)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* 处理模板中的表达式
|
||||
* @param template 包含表达式的模板字符串
|
||||
* @param context 上下文对象,包含可在表达式中访问的变量
|
||||
* @returns 处理后的字符串
|
||||
*/
|
||||
export function evaluateTemplateExpressions(template: string, context: Record<string, any>): string {
|
||||
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 result = evalInContext(...Object.values(context));
|
||||
return result !== undefined ? String(result) : "";
|
||||
} catch (error) {
|
||||
console.error("表达式求值错误:", error);
|
||||
return `[表达式错误: ${(error as Error).message}]`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模板中是否包含JavaScript表达式
|
||||
* @param template 要检查的模板字符串
|
||||
* @returns 是否包含表达式
|
||||
*/
|
||||
export function containsJsExpression(template: string): boolean {
|
||||
return JS_EXPRESSION_REGEX.test(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义字符串中的特殊字符,使其可以安全地在正则表达式中使用
|
||||
* @param string 要转义的字符串
|
||||
* @returns 转义后的字符串
|
||||
*/
|
||||
export function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将普通占位符格式转换为JS表达式格式
|
||||
* 例如: {{user.name}} 转换为 {{js: user.name}}
|
||||
* @param template 包含普通占位符的模板
|
||||
* @param placeholders 占位符列表
|
||||
* @returns 转换后的模板
|
||||
*/
|
||||
export function convertToJsExpressions(template: string, placeholders: {name: string, description: string}[]): string {
|
||||
let result = template;
|
||||
|
||||
placeholders.forEach(p => {
|
||||
const placeholder = p.name;
|
||||
const path = placeholder.replace(/\{\{|\}\}/g, '').trim();
|
||||
const regex = new RegExp(escapeRegExp(placeholder), 'g');
|
||||
result = result.replace(regex, `{{js: ${path}}}`);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为礼物感谢模块创建上下文对象
|
||||
* @param user 用户信息
|
||||
* @param gift 礼物信息
|
||||
* @returns 上下文对象
|
||||
*/
|
||||
export function createGiftThankContext(user: { uid: number; name: string },
|
||||
gift: { name: string; count: number; price: number }): Record<string, any> {
|
||||
return {
|
||||
user: {
|
||||
uid: user.uid,
|
||||
name: user.name,
|
||||
// 额外方法和属性
|
||||
nameLength: user.name.length,
|
||||
},
|
||||
gift: {
|
||||
name: gift.name,
|
||||
count: gift.count,
|
||||
price: gift.price,
|
||||
totalPrice: gift.count * gift.price,
|
||||
// 工具方法
|
||||
summary: `${gift.count}个${gift.name}`,
|
||||
isExpensive: gift.price >= 50
|
||||
},
|
||||
// 工具函数
|
||||
format: {
|
||||
currency: (value: number) => `¥${value.toFixed(2)}`,
|
||||
pluralize: (count: number, singular: string, plural: string) => count === 1 ? singular : plural,
|
||||
},
|
||||
// 日期时间
|
||||
date: {
|
||||
now: new Date(),
|
||||
timestamp: Date.now(),
|
||||
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())
|
||||
}
|
||||
};
|
||||
}
|
||||
150
src/client/store/autoAction/modules/autoReply.ts
Normal file
150
src/client/store/autoAction/modules/autoReply.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel } from '@/api/api-models';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ExecutionContext,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
shouldProcess,
|
||||
evaluateExpression
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* 自动回复模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useAutoReply(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 运行时数据 - 记录特定关键词的最后回复时间
|
||||
const lastReplyTimestamps = ref<{ [keyword: string]: number; }>({});
|
||||
|
||||
/**
|
||||
* 处理弹幕事件
|
||||
* @param event 弹幕事件
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function onDanmaku(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 过滤出有效的自动回复操作
|
||||
const replyActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.DANMAKU &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value)
|
||||
);
|
||||
|
||||
if (replyActions.length === 0) return;
|
||||
|
||||
const message = event.msg;
|
||||
const now = Date.now();
|
||||
|
||||
// 准备执行上下文
|
||||
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')
|
||||
}
|
||||
},
|
||||
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; // 匹配到一个规则就停止
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置冷却时间 (用于测试)
|
||||
function resetCooldowns(runtimeState: RuntimeState, actionId?: string) {
|
||||
if (actionId) {
|
||||
delete runtimeState.lastExecutionTime[actionId];
|
||||
} else {
|
||||
Object.keys(runtimeState.lastExecutionTime).forEach(id => {
|
||||
delete runtimeState.lastExecutionTime[id];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onDanmaku,
|
||||
resetCooldowns
|
||||
};
|
||||
}
|
||||
110
src/client/store/autoAction/modules/entryWelcome.ts
Normal file
110
src/client/store/autoAction/modules/entryWelcome.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 入场欢迎模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param isTianXuanActive 是否处于天选时刻
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useEntryWelcome(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
isTianXuanActive: Ref<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 运行时数据
|
||||
const timer = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* 处理入场事件 - 支持新的AutoActionItem结构
|
||||
* @param event 入场事件
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processEnter(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
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)
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
function clearTimer() {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processEnter,
|
||||
clearTimer
|
||||
};
|
||||
}
|
||||
116
src/client/store/autoAction/modules/followThank.ts
Normal file
116
src/client/store/autoAction/modules/followThank.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 关注感谢模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param isTianXuanActive 是否处于天选时刻
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useFollowThank(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
isTianXuanActive: Ref<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 运行时数据
|
||||
const aggregatedFollows = ref<{uid: number, name: string, timestamp: number}[]>([]);
|
||||
const timer = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* 处理关注事件 - 支持新的AutoActionItem结构
|
||||
* @param event 关注事件
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processFollow(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
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)
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理关注事件 - 旧方式实现,用于兼容现有代码
|
||||
*/
|
||||
function onFollow(event: EventModel) {
|
||||
// 将在useAutoAction.ts中进行迁移,此方法保留但不实现具体逻辑
|
||||
console.log('关注事件处理已迁移到新的AutoActionItem结构');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
function clearTimer() {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onFollow,
|
||||
processFollow,
|
||||
clearTimer
|
||||
};
|
||||
}
|
||||
213
src/client/store/autoAction/modules/giftThank.ts
Normal file
213
src/client/store/autoAction/modules/giftThank.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import { EventModel, EventDataTypes } from '@/api/api-models';
|
||||
import {
|
||||
formatTemplate,
|
||||
getRandomTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ExecutionContext,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 礼物感谢模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param isTianXuanActive 是否处于天选时刻
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useGiftThank(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
isTianXuanActive: Ref<boolean>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 测试发送功能状态
|
||||
const lastTestTime = ref(0);
|
||||
const testCooldown = 5000; // 5秒冷却时间
|
||||
const testLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 处理礼物事件
|
||||
* @param event 礼物事件
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processGift(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
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)
|
||||
);
|
||||
|
||||
if (giftActions.length === 0) return;
|
||||
|
||||
// 礼物基本信息
|
||||
const giftName = event.msg;
|
||||
const giftPrice = event.price / 1000;
|
||||
const giftCount = event.num;
|
||||
|
||||
// 创建执行上下文
|
||||
const context = buildExecutionContext(event, roomId.value, TriggerType.GIFT);
|
||||
|
||||
// 处理每个符合条件的操作
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发送礼物感谢弹幕
|
||||
*/
|
||||
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
|
||||
};
|
||||
}
|
||||
171
src/client/store/autoAction/modules/guardPm.ts
Normal file
171
src/client/store/autoAction/modules/guardPm.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Ref } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { GuardLevel, EventModel } from '@/api/api-models';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ActionType,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
import { formatTemplate, 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>
|
||||
) {
|
||||
// 保留旧配置用于兼容
|
||||
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 runtimeState 运行时状态
|
||||
*/
|
||||
function processGuard(
|
||||
event: EventModel,
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
const guardLevel = event.guard_level;
|
||||
if (guardLevel === GuardLevel.None) return; // 不是上舰事件
|
||||
|
||||
// 过滤出有效的舰长私信操作
|
||||
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) return;
|
||||
|
||||
// 创建执行上下文
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上舰事件 - 旧方式实现,用于兼容现有代码
|
||||
*/
|
||||
function onGuard(event: EventModel) {
|
||||
// 将在useAutoAction.ts中进行迁移,此方法保留但不实现具体逻辑
|
||||
console.log('舰长事件处理已迁移到新的AutoActionItem结构');
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
onGuard,
|
||||
processGuard
|
||||
};
|
||||
}
|
||||
121
src/client/store/autoAction/modules/scheduledDanmaku.ts
Normal file
121
src/client/store/autoAction/modules/scheduledDanmaku.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { ref, watch, Ref, computed } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import {
|
||||
getRandomTemplate,
|
||||
formatTemplate,
|
||||
buildExecutionContext
|
||||
} from '../utils';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
RuntimeState
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 定时弹幕模块
|
||||
* @param isLive 是否处于直播状态
|
||||
* @param roomId 房间ID
|
||||
* @param sendLiveDanmaku 发送弹幕函数
|
||||
*/
|
||||
export function useScheduledDanmaku(
|
||||
isLive: Ref<boolean>,
|
||||
roomId: Ref<number | undefined>,
|
||||
sendLiveDanmaku: (roomId: number, message: string) => Promise<boolean>
|
||||
) {
|
||||
// 运行时数据
|
||||
const timer = ref<any | null>(null);
|
||||
const remainingSeconds = ref(0); // 倒计时剩余秒数
|
||||
const countdownTimer = ref<any | null>(null); // 倒计时定时器
|
||||
|
||||
/**
|
||||
* 处理定时任务 - 使用新的AutoActionItem结构
|
||||
* @param actions 自动操作列表
|
||||
* @param runtimeState 运行时状态
|
||||
*/
|
||||
function processScheduledActions(
|
||||
actions: AutoActionItem[],
|
||||
runtimeState: RuntimeState
|
||||
) {
|
||||
if (!roomId.value) return;
|
||||
|
||||
// 获取定时消息操作
|
||||
const scheduledActions = actions.filter(action =>
|
||||
action.triggerType === TriggerType.SCHEDULED &&
|
||||
action.enabled &&
|
||||
(!action.triggerConfig.onlyDuringLive || isLive.value)
|
||||
);
|
||||
|
||||
// 为每个定时操作设置定时器
|
||||
scheduledActions.forEach(action => {
|
||||
// 检查是否已有定时器
|
||||
if (runtimeState.scheduledTimers[action.id]) return;
|
||||
|
||||
const intervalSeconds = action.triggerConfig.intervalSeconds || 300; // 默认5分钟
|
||||
|
||||
// 创建定时器函数
|
||||
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);
|
||||
}
|
||||
|
||||
// 设置下一次定时
|
||||
runtimeState.scheduledTimers[action.id] = setTimeout(timerFn, intervalSeconds * 1000);
|
||||
};
|
||||
|
||||
// 首次启动定时器
|
||||
runtimeState.scheduledTimers[action.id] = setTimeout(timerFn, intervalSeconds * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时弹幕 (旧方式)
|
||||
*/
|
||||
function startScheduledDanmaku() {
|
||||
console.log('定时弹幕已迁移到新的AutoActionItem结构');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时弹幕 (旧方式)
|
||||
*/
|
||||
function stopScheduledDanmaku() {
|
||||
console.log('定时弹幕已迁移到新的AutoActionItem结构');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化剩余时间为分:秒格式
|
||||
*/
|
||||
const formattedRemainingTime = computed(() => {
|
||||
const minutes = Math.floor(remainingSeconds.value / 60);
|
||||
const seconds = remainingSeconds.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
function clearTimer() {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
if (countdownTimer.value) {
|
||||
clearInterval(countdownTimer.value);
|
||||
countdownTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startScheduledDanmaku,
|
||||
stopScheduledDanmaku,
|
||||
processScheduledActions,
|
||||
clearTimer,
|
||||
remainingSeconds,
|
||||
formattedRemainingTime
|
||||
};
|
||||
}
|
||||
99
src/client/store/autoAction/types.ts
Normal file
99
src/client/store/autoAction/types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// 统一的自动操作类型定义
|
||||
|
||||
import { EventModel } from '@/api/api-models';
|
||||
|
||||
// 触发条件类型
|
||||
export enum TriggerType {
|
||||
DANMAKU = 'danmaku', // 弹幕
|
||||
GIFT = 'gift', // 礼物
|
||||
GUARD = 'guard', // 上舰
|
||||
FOLLOW = 'follow', // 关注
|
||||
ENTER = 'enter', // 进入直播间
|
||||
SCHEDULED = 'scheduled', // 定时触发
|
||||
SUPER_CHAT = 'super_chat', // SC
|
||||
}
|
||||
|
||||
// 操作类型
|
||||
export enum ActionType {
|
||||
SEND_DANMAKU = 'send_danmaku', // 发送弹幕
|
||||
SEND_PRIVATE_MSG = 'send_private_msg', // 发送私信
|
||||
EXECUTE_COMMAND = 'execute_command', // 执行命令
|
||||
}
|
||||
|
||||
// 优先级
|
||||
export enum Priority {
|
||||
HIGHEST = 0,
|
||||
HIGH = 1,
|
||||
NORMAL = 2,
|
||||
LOW = 3,
|
||||
LOWEST = 4,
|
||||
}
|
||||
|
||||
// 统一的自动操作定义
|
||||
export type AutoActionItem = {
|
||||
id: string; // 唯一ID
|
||||
name: string; // 操作名称
|
||||
enabled: boolean; // 是否启用
|
||||
triggerType: TriggerType; // 触发类型
|
||||
actionType: ActionType; // 操作类型
|
||||
templates: string[]; // 模板列表
|
||||
priority: Priority; // 优先级
|
||||
|
||||
// 高级配置
|
||||
logicalExpression: string; // 逻辑表达式,为真时才执行此操作
|
||||
ignoreCooldown: boolean; // 是否忽略冷却时间
|
||||
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[]}[]; // 礼品码
|
||||
};
|
||||
|
||||
// 动作特定配置
|
||||
actionConfig: {
|
||||
delaySeconds?: number; // 延迟执行秒数
|
||||
maxUsersPerMsg?: number; // 每条消息最大用户数
|
||||
maxItemsPerUser?: number; // 每用户最大项目数 (礼物等)
|
||||
cooldownSeconds?: number; // 冷却时间(秒)
|
||||
};
|
||||
}
|
||||
|
||||
// 执行上下文,包含事件信息和可用变量
|
||||
export interface ExecutionContext {
|
||||
event?: EventModel; // 触发事件
|
||||
roomId?: number; // 直播间ID
|
||||
variables: Record<string, any>; // 额外变量
|
||||
timestamp: number; // 时间戳
|
||||
}
|
||||
|
||||
// 运行状态接口
|
||||
export interface RuntimeState {
|
||||
lastExecutionTime: Record<string, number>; // 上次执行时间
|
||||
aggregatedEvents: Record<string, any[]>; // 聚合的事件
|
||||
scheduledTimers: Record<string, NodeJS.Timeout | null>; // 定时器
|
||||
sentGuardPms: Set<number>; // 已发送的舰长私信
|
||||
}
|
||||
344
src/client/store/autoAction/utils.ts
Normal file
344
src/client/store/autoAction/utils.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
AutoActionItem,
|
||||
TriggerType,
|
||||
ActionType,
|
||||
Priority,
|
||||
RuntimeState,
|
||||
ExecutionContext
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 创建默认的运行时状态
|
||||
*/
|
||||
export function createDefaultRuntimeState(): RuntimeState {
|
||||
return {
|
||||
lastExecutionTime: {},
|
||||
scheduledTimers: {},
|
||||
sentGuardPms: new Set(),
|
||||
aggregatedEvents: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的自动操作项
|
||||
* @param triggerType 触发类型
|
||||
*/
|
||||
export function createDefaultAutoAction(triggerType: TriggerType): AutoActionItem {
|
||||
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 defaultNames: Record<TriggerType, string> = {
|
||||
[TriggerType.DANMAKU]: '弹幕回复',
|
||||
[TriggerType.GIFT]: '礼物感谢',
|
||||
[TriggerType.GUARD]: '舰长感谢',
|
||||
[TriggerType.FOLLOW]: '关注感谢',
|
||||
[TriggerType.ENTER]: '入场欢迎',
|
||||
[TriggerType.SCHEDULED]: '定时消息',
|
||||
[TriggerType.SUPER_CHAT]: 'SC感谢',
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
name: defaultNames[triggerType] || '新建自动操作',
|
||||
enabled: true,
|
||||
triggerType,
|
||||
actionType: triggerType === TriggerType.GUARD ? ActionType.SEND_PRIVATE_MSG : ActionType.SEND_DANMAKU,
|
||||
priority: Priority.NORMAL,
|
||||
templates: defaultTemplates[triggerType] || ['默认模板'],
|
||||
logicalExpression: '',
|
||||
executeCommand: '',
|
||||
ignoreCooldown: false,
|
||||
triggerConfig: {
|
||||
onlyDuringLive: true,
|
||||
ignoreTianXuan: true,
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
preventRepeat: triggerType === TriggerType.GUARD,
|
||||
intervalSeconds: triggerType === TriggerType.SCHEDULED ? 300 : undefined,
|
||||
},
|
||||
actionConfig: {
|
||||
delaySeconds: 0,
|
||||
cooldownSeconds: 5,
|
||||
maxUsersPerMsg: 5,
|
||||
maxItemsPerUser: 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板数组中随机选择一个
|
||||
* @param templates 模板数组
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化模板,替换变量
|
||||
* @param template 模板字符串
|
||||
* @param context 执行上下文
|
||||
*/
|
||||
export function formatTemplate(template: string, context: ExecutionContext): string {
|
||||
if (!template) return '';
|
||||
|
||||
// 简单的模板替换
|
||||
return template.replace(/{([^}]+)}/g, (match, path) => {
|
||||
try {
|
||||
// 解析路径
|
||||
const parts = path.trim().split('.');
|
||||
let value: any = context;
|
||||
|
||||
// 特殊处理函数类型
|
||||
if (parts[0] === 'timeOfDay' && typeof context.variables.timeOfDay === 'function') {
|
||||
return context.variables.timeOfDay();
|
||||
}
|
||||
|
||||
// 特殊处理event直接访问
|
||||
if (parts[0] === 'event') {
|
||||
value = context.event;
|
||||
parts.shift();
|
||||
} else {
|
||||
// 否则从variables中获取
|
||||
value = context.variables;
|
||||
}
|
||||
|
||||
// 递归获取嵌套属性
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return match;
|
||||
value = value[part];
|
||||
if (typeof value === 'function') value = value();
|
||||
}
|
||||
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
} catch (error) {
|
||||
console.error('模板格式化错误:', error);
|
||||
return match; // 出错时返回原始匹配项
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算逻辑表达式
|
||||
* @param expression 表达式字符串
|
||||
* @param context 执行上下文
|
||||
*/
|
||||
export function evaluateExpression(expression: string, context: ExecutionContext): boolean {
|
||||
if (!expression || expression.trim() === '') return true; // 空表达式默认为true
|
||||
|
||||
try {
|
||||
// 预定义函数和变量
|
||||
const utils = {
|
||||
// 事件相关
|
||||
inDanmaku: (keyword: string) => {
|
||||
if (!context.event?.msg) return false;
|
||||
return context.event.msg.includes(keyword);
|
||||
},
|
||||
|
||||
// 礼物相关
|
||||
giftValue: () => {
|
||||
if (!context.event) return 0;
|
||||
return (context.event.price || 0) * (context.event.num || 1) / 1000;
|
||||
},
|
||||
|
||||
giftName: () => context.event?.msg || '',
|
||||
giftCount: () => context.event?.num || 0,
|
||||
|
||||
// 用户相关
|
||||
hasMedal: () => context.event?.fans_medal_wearing_status || false,
|
||||
medalLevel: () => context.event?.fans_medal_level || 0,
|
||||
isCaptain: () => (context.event?.guard_level || 0) > 0,
|
||||
|
||||
// 时间相关
|
||||
time: {
|
||||
hour: new Date().getHours(),
|
||||
minute: new Date().getMinutes()
|
||||
},
|
||||
|
||||
// 字符串处理
|
||||
str: {
|
||||
includes: (str: string, search: string) => str.includes(search),
|
||||
startsWith: (str: string, search: string) => str.startsWith(search),
|
||||
endsWith: (str: string, search: string) => str.endsWith(search)
|
||||
}
|
||||
};
|
||||
|
||||
// 创建安全的eval环境
|
||||
const evalFunc = new Function(
|
||||
'context',
|
||||
'event',
|
||||
'utils',
|
||||
`try {
|
||||
with(utils) {
|
||||
return (${expression});
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('表达式评估错误:', e);
|
||||
return false;
|
||||
}`
|
||||
);
|
||||
|
||||
// 执行表达式
|
||||
return Boolean(evalFunc(context, context.event, utils));
|
||||
} catch (error) {
|
||||
console.error('表达式评估错误:', error);
|
||||
return false; // 出错时返回false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息模板,替换变量
|
||||
* @param template 模板字符串
|
||||
* @param params 参数对象
|
||||
*/
|
||||
export function formatMessage(template: string, params: Record<string, any>): string {
|
||||
if (!template) return '';
|
||||
|
||||
// 简单的模板替换
|
||||
return template.replace(/{{([^}]+)}}/g, (match, path) => {
|
||||
try {
|
||||
// 解析路径
|
||||
const parts = path.trim().split('.');
|
||||
let value: any = params;
|
||||
|
||||
// 递归获取嵌套属性
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return match;
|
||||
value = value[part];
|
||||
if (typeof value === 'function') value = value();
|
||||
}
|
||||
|
||||
return value !== undefined && value !== null ? String(value) : match;
|
||||
} catch (error) {
|
||||
console.error('模板格式化错误:', error);
|
||||
return match; // 出错时返回原始匹配项
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该处理自动操作
|
||||
* @param config 配置对象,需要包含enabled和onlyDuringLive属性
|
||||
* @param isLive 当前是否为直播状态
|
||||
*/
|
||||
export function shouldProcess(config: { enabled: boolean; onlyDuringLive: boolean }, isLive: boolean): boolean {
|
||||
if (!config.enabled) return false;
|
||||
if (config.onlyDuringLive && !isLive) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否符合过滤条件
|
||||
* @param config 配置对象,需要包含userFilterEnabled、requireMedal和requireCaptain属性
|
||||
* @param event 事件对象
|
||||
*/
|
||||
export function checkUserFilter(config: { userFilterEnabled: boolean; requireMedal: boolean; requireCaptain: boolean }, event: { fans_medal_wearing_status?: boolean; guard_level?: number }): boolean {
|
||||
if (!config.userFilterEnabled) return true;
|
||||
if (config.requireMedal && !event.fans_medal_wearing_status) return false;
|
||||
if (config.requireCaptain && (!event.guard_level || event.guard_level === 0)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建执行上下文对象
|
||||
* @param event 事件对象
|
||||
* @param roomId 房间ID
|
||||
* @param triggerType 触发类型
|
||||
* @returns 标准化的执行上下文
|
||||
*/
|
||||
export function buildExecutionContext(
|
||||
event: any,
|
||||
roomId: number | undefined,
|
||||
triggerType?: TriggerType
|
||||
): ExecutionContext {
|
||||
const now = Date.now();
|
||||
const dateObj = new Date(now);
|
||||
|
||||
// 基础上下文
|
||||
const context: ExecutionContext = {
|
||||
event,
|
||||
roomId,
|
||||
timestamp: now,
|
||||
variables: {
|
||||
// 日期相关变量
|
||||
date: {
|
||||
formatted: dateObj.toLocaleString('zh-CN'),
|
||||
year: dateObj.getFullYear(),
|
||||
month: dateObj.getMonth() + 1,
|
||||
day: dateObj.getDate(),
|
||||
hour: dateObj.getHours(),
|
||||
minute: dateObj.getMinutes(),
|
||||
second: dateObj.getSeconds()
|
||||
},
|
||||
// 时段函数
|
||||
timeOfDay: () => {
|
||||
const hour = dateObj.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 '深夜';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 如果有事件对象,添加用户信息
|
||||
if (event) {
|
||||
context.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
|
||||
};
|
||||
|
||||
context.variables.message = event.msg;
|
||||
|
||||
// 根据不同触发类型添加特定变量
|
||||
if (triggerType === TriggerType.GIFT) {
|
||||
context.variables.gift = {
|
||||
name: event.msg, // 礼物名称通常存在msg字段
|
||||
count: event.num,
|
||||
price: (event.price || 0) / 1000, // B站价格单位通常是 1/1000 元
|
||||
totalPrice: ((event.price || 0) / 1000) * (event.num || 1),
|
||||
summary: `${event.num || 1}个${event.msg || '礼物'}`
|
||||
};
|
||||
} else if (triggerType === TriggerType.GUARD) {
|
||||
const guardLevelMap: Record<number, string> = {
|
||||
1: '总督',
|
||||
2: '提督',
|
||||
3: '舰长',
|
||||
0: '无舰长'
|
||||
};
|
||||
context.variables.guard = {
|
||||
level: event.guard_level || 0,
|
||||
levelName: guardLevelMap[event.guard_level || 0] || '未知舰长等级',
|
||||
giftCode: ''
|
||||
};
|
||||
} else if (triggerType === TriggerType.SUPER_CHAT) {
|
||||
context.variables.sc = {
|
||||
message: event.msg,
|
||||
price: (event.price || 0) / 1000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,87 @@ import { useAccount } from "@/api/account";
|
||||
import { useBiliCookie } from "./useBiliCookie";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // 引入 Body
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import md5 from 'md5';
|
||||
import { QueryBiliAPI } from "../data/utils";
|
||||
import { onSendPrivateMessageFailed } from "../data/notification";
|
||||
|
||||
// WBI 混合密钥编码表
|
||||
const mixinKeyEncTab = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
||||
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
||||
36, 20, 34, 44, 52
|
||||
];
|
||||
|
||||
// 对 imgKey 和 subKey 进行字符顺序打乱编码
|
||||
const getMixinKey = (orig: string): string =>
|
||||
mixinKeyEncTab.map(n => orig[n]).join('').slice(0, 32);
|
||||
|
||||
// 为请求参数进行 wbi 签名
|
||||
function encWbi(
|
||||
params: { [key: string]: string | number },
|
||||
img_key: string,
|
||||
sub_key: string
|
||||
): string {
|
||||
const mixin_key = getMixinKey(img_key + sub_key);
|
||||
const curr_time = Math.round(Date.now() / 1000);
|
||||
const chr_filter = /[!'()*]/g;
|
||||
|
||||
Object.assign(params, { wts: curr_time.toString() }); // 添加 wts 字段
|
||||
|
||||
// 按照 key 重排参数
|
||||
const query = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => {
|
||||
// 过滤 value 中的 "!'()*" 字符
|
||||
const value = params[key].toString().replace(chr_filter, '');
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
})
|
||||
.join('&');
|
||||
|
||||
const wbi_sign = md5(query + mixin_key); // 计算 w_rid
|
||||
return query + '&w_rid=' + wbi_sign;
|
||||
}
|
||||
|
||||
// 获取最新的 img_key 和 sub_key
|
||||
async function getWbiKeys(cookie: string): Promise<{ img_key: string, sub_key: string }> {
|
||||
try {
|
||||
const response = await QueryBiliAPI('https://api.bilibili.com/x/web-interface/nav');
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("获取WBI密钥失败:", response.status);
|
||||
throw new Error("获取WBI密钥失败");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const { wbi_img } = result.data;
|
||||
|
||||
console.log(`获取WBI秘钥: img_key: ${wbi_img.img_url}, sub_key: ${wbi_img.sub_url}`);
|
||||
|
||||
return {
|
||||
img_key: wbi_img.img_url.slice(
|
||||
wbi_img.img_url.lastIndexOf('/') + 1,
|
||||
wbi_img.img_url.lastIndexOf('.')
|
||||
),
|
||||
sub_key: wbi_img.sub_url.slice(
|
||||
wbi_img.sub_url.lastIndexOf('/') + 1,
|
||||
wbi_img.sub_url.lastIndexOf('.')
|
||||
)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取WBI密钥时发生错误:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
const biliCookieStore = useBiliCookie();
|
||||
const account = useAccount();
|
||||
const cookie = computed(() => biliCookieStore.cookie);
|
||||
const uid = computed(() => account.value.biliId);
|
||||
// 存储WBI密钥
|
||||
const wbiKeys = ref<{ img_key: string, sub_key: string } | null>(null);
|
||||
|
||||
const csrf = computed(() => {
|
||||
if (!cookie.value) return null;
|
||||
@@ -34,6 +108,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
console.warn("尝试发送空弹幕,已阻止。");
|
||||
return false;
|
||||
}
|
||||
roomId = 1294406; // 测试用房间号
|
||||
const url = "https://api.live.bilibili.com/msg/send";
|
||||
const rnd = Math.floor(Date.now() / 1000);
|
||||
const data = {
|
||||
@@ -47,7 +122,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
csrf: csrf.value,
|
||||
csrf_token: csrf.value,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(data)
|
||||
try {
|
||||
// 注意: B站网页版发送弹幕是用 application/x-www-form-urlencoded
|
||||
const response = await tauriFetch(url, {
|
||||
@@ -58,7 +133,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/${roomId}`
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
body: params, // 发送 JSON 数据
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -105,6 +180,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(data)
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -113,7 +189,7 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/p/html/live-room-setting/#/room-manager/black-list?room_id=${roomId}` // 模拟来源
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
body: params, // 发送 URLSearchParams 数据
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("封禁用户失败:", response.status, await response.text());
|
||||
@@ -139,61 +215,107 @@ export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
*/
|
||||
async function sendPrivateMessage(receiverId: number, message: string): Promise<boolean> {
|
||||
if (!csrf.value || !cookie.value || !uid.value) {
|
||||
console.error("发送私信失败:缺少 cookie, csrf token 或 uid");
|
||||
const error = "发送私信失败:缺少 cookie, csrf token 或 uid";
|
||||
console.error(error);
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空私信,已阻止。");
|
||||
const error = "尝试发送空私信,已阻止。";
|
||||
console.warn(error);
|
||||
return false;
|
||||
}
|
||||
const url = "https://api.vc.bilibili.com/web_im/v1/web_im/send_msg";
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const content = JSON.stringify({ content: message });
|
||||
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();
|
||||
});
|
||||
const data = {
|
||||
'msg[sender_uid]': uid.value.toString(),
|
||||
'msg[receiver_id]': receiverId.toString(),
|
||||
'msg[receiver_type]': '1',
|
||||
'msg[msg_type]': '1',
|
||||
'msg[msg_status]': '0',
|
||||
'msg[content]': content,
|
||||
'msg[timestamp]': timestamp.toString(),
|
||||
'msg[new_face_version]': '0',
|
||||
'msg[dev_id]': dev_id,
|
||||
'build': '0',
|
||||
'mobi_app': 'web',
|
||||
'csrf': csrf.value,
|
||||
'csrf_token': csrf.value,
|
||||
};
|
||||
|
||||
try {
|
||||
// 获取WBI密钥(如果还没有)
|
||||
if (!wbiKeys.value) {
|
||||
wbiKeys.value = await getWbiKeys(cookie.value);
|
||||
}
|
||||
if (!wbiKeys.value) {
|
||||
const error = "获取WBI密钥失败,无法发送私信";
|
||||
console.error(error);
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const content = JSON.stringify({ content: message });
|
||||
|
||||
// 准备URL参数(需要WBI签名的参数)
|
||||
const urlParams = {
|
||||
w_sender_uid: uid.value.toString(),
|
||||
w_receiver_id: receiverId.toString(),
|
||||
w_dev_id: dev_id,
|
||||
};
|
||||
|
||||
// 生成带WBI签名的URL查询字符串
|
||||
const signedQuery = encWbi(
|
||||
urlParams,
|
||||
wbiKeys.value.img_key,
|
||||
wbiKeys.value.sub_key
|
||||
);
|
||||
|
||||
// 构建最终URL
|
||||
const url = `https://api.vc.bilibili.com/web_im/v1/web_im/send_msg?${signedQuery}`;
|
||||
|
||||
// 准备表单数据
|
||||
const formData = {
|
||||
'msg[sender_uid]': uid.value.toString(),
|
||||
'msg[receiver_id]': receiverId.toString(),
|
||||
'msg[receiver_type]': '1',
|
||||
'msg[msg_type]': '1',
|
||||
'msg[msg_status]': '0',
|
||||
'msg[content]': content,
|
||||
'msg[timestamp]': timestamp.toString(),
|
||||
'msg[new_face_version]': '0',
|
||||
'msg[dev_id]': dev_id,
|
||||
'build': '0',
|
||||
'mobi_app': 'web',
|
||||
'csrf': csrf.value,
|
||||
'csrf_token': csrf.value,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(formData);
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://message.bilibili.com/`,
|
||||
"Origin": '',
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("发送私信网络失败:", response.status, await response.text());
|
||||
const error = `发送私信网络失败: ${response.status}`;
|
||||
console.error(error, await response.text());
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
}
|
||||
// 私信成功码也是 0
|
||||
if (response.data.code !== 0) {
|
||||
console.error("发送私信API失败:", response.data.code, response.data.message);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`发送私信给 ${receiverId} 成功`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("发送私信时发生错误:", error);
|
||||
// 如果是WBI密钥问题,清空密钥以便下次重新获取
|
||||
if (String(error).includes('WBI')) {
|
||||
wbiKeys.value = null;
|
||||
}
|
||||
onSendPrivateMessageFailed(receiverId, message, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export type DanmakuWindowSettings = {
|
||||
reverseOrder: boolean; // 是否倒序显示(从下往上)
|
||||
filterTypes: string[]; // 要显示的弹幕类型
|
||||
animationDuration: number; // 动画持续时间
|
||||
enableAnimation: boolean; // 是否启用动画效果
|
||||
backgroundColor: string; // 背景色
|
||||
textColor: string; // 文字颜色
|
||||
alwaysOnTop: boolean; // 是否总在最前
|
||||
@@ -182,6 +183,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
textStyleCompact: false, // 新增:默认不使用紧凑布局
|
||||
textStyleShowType: true, // 新增:默认显示消息类型标签
|
||||
textStyleNameSeparator: ': ', // 新增:默认用户名和消息之间的分隔符为冒号+空格
|
||||
enableAnimation: true, // 新增:默认启用动画效果
|
||||
});
|
||||
const emojiData = useStorage<{
|
||||
updateAt: number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTauriStore } from './useTauriStore';
|
||||
|
||||
export type NotificationType = 'question-box' | 'danmaku' | 'goods-buy';
|
||||
export type NotificationType = 'question-box' | 'danmaku' | 'goods-buy' | 'message-failed' | 'live-danmaku-failed';
|
||||
export type NotificationSettings = {
|
||||
enableTypes: NotificationType[];
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export const useSettings = defineStore('settings', () => {
|
||||
loginType: 'qrcode',
|
||||
enableNotification: true,
|
||||
notificationSettings: {
|
||||
enableTypes: ['question-box', 'danmaku'],
|
||||
enableTypes: ['question-box', 'danmaku', 'message-failed'],
|
||||
},
|
||||
|
||||
dev_disableDanmakuClient: false,
|
||||
@@ -39,7 +39,7 @@ export const useSettings = defineStore('settings', () => {
|
||||
async function init() {
|
||||
settings.value = (await store.get()) || Object.assign({}, defaultSettings);
|
||||
settings.value.notificationSettings ??= defaultSettings.notificationSettings;
|
||||
settings.value.notificationSettings.enableTypes ??= [ 'question-box', 'danmaku' ];
|
||||
settings.value.notificationSettings.enableTypes ??= [ 'question-box', 'danmaku', 'message-failed' ];
|
||||
}
|
||||
async function save() {
|
||||
await store.set(settings.value);
|
||||
|
||||
13
src/components.d.ts
vendored
13
src/components.d.ts
vendored
@@ -18,29 +18,22 @@ declare module 'vue' {
|
||||
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NBadge: typeof import('naive-ui')['NBadge']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDivider: typeof import('naive-ui')['NDivider']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
NFormItemG: typeof import('naive-ui')['NFormItemG']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NH4: typeof import('naive-ui')['NH4']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSSwitch: typeof import('naive-ui')['NSSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
|
||||
@@ -399,7 +399,7 @@ function renderCell(value: string | number) {
|
||||
}
|
||||
|
||||
async function updateSong() {
|
||||
if (props.songs.some((s) => s.name == updateSongModel.value.name)) {
|
||||
if (props.songs.filter((s) => s.name == updateSongModel.value.name).length > 1) {
|
||||
message.error('已存在相同名称的歌曲')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -47,11 +47,15 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
|
||||
console.warn("[DanmakuClient] 尝试在客户端初始化之前调用 'onEvent'。");
|
||||
return;
|
||||
}
|
||||
if (eventName === 'all') {
|
||||
// 对于 'all' 事件, 直接使用 AllEventListener 类型
|
||||
danmakuClient.value.eventsAsModel[eventName].push(listener as AllEventListener);
|
||||
} else {
|
||||
danmakuClient.value.eventsAsModel[eventName].push(listener);
|
||||
try {
|
||||
if (eventName === 'all') {
|
||||
// 对于 'all' 事件, 直接使用 AllEventListener 类型
|
||||
danmakuClient.value.eventsAsModel[eventName].push(listener as AllEventListener);
|
||||
} else {
|
||||
danmakuClient.value.eventsAsModel[eventName].push(listener);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DanmakuClient] 注册事件监听器: ${eventName} 失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +189,14 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
|
||||
console.log('[DanmakuClient] 开始初始化...');
|
||||
|
||||
|
||||
const oldEventsAsModel = danmakuClient.value?.eventsAsModel;
|
||||
const oldEventsRaw = danmakuClient.value?.eventsRaw;
|
||||
let oldEventsAsModel = danmakuClient.value?.eventsAsModel;
|
||||
let oldEventsRaw = danmakuClient.value?.eventsRaw;
|
||||
if (!oldEventsAsModel || Object.keys(oldEventsAsModel).length === 0) {
|
||||
oldEventsAsModel = client.createEmptyEventModelListeners();
|
||||
}
|
||||
if (!oldEventsRaw || Object.keys(oldEventsRaw).length === 0) {
|
||||
oldEventsRaw = client.createEmptyRawEventlisteners();
|
||||
}
|
||||
|
||||
// 先停止并清理旧客户端 (如果存在)
|
||||
if (danmakuClient.value) {
|
||||
@@ -194,14 +204,13 @@ export const useDanmakuClient = defineStore('DanmakuClient', () => {
|
||||
if (danmakuClient.value.state === 'connected') {
|
||||
await disposeClientInstance(danmakuClient.value);
|
||||
}
|
||||
danmakuClient.value = undefined; // 显式清除旧实例引用
|
||||
}
|
||||
|
||||
// 设置新的客户端实例
|
||||
danmakuClient.value = client;
|
||||
// 确保新客户端有空的监听器容器 (BaseDanmakuClient 应负责初始化)
|
||||
danmakuClient.value.eventsAsModel = oldEventsAsModel || client.createEmptyEventModelListeners();
|
||||
danmakuClient.value.eventsRaw = oldEventsRaw || client.createEmptyRawEventlisteners();
|
||||
danmakuClient.value.eventsAsModel = oldEventsAsModel;
|
||||
danmakuClient.value.eventsRaw = oldEventsRaw;
|
||||
// 通常在 client 实例化或 Start 时处理,或者在 attachListenersToClient 中确保存在
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
NText,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
NCard,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
@@ -644,37 +645,125 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 50px;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: linear-gradient(135deg, rgba(250,250,250,0.8) 0%, rgba(240,240,245,0.9) 100%);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
"
|
||||
:class="isDarkMode ? 'login-dark-bg' : ''"
|
||||
>
|
||||
<template v-if="!isLoadingAccount">
|
||||
<NSpace
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
<NCard
|
||||
style="max-width: 520px; width: 100%; min-width: 350px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
|
||||
:bordered="false"
|
||||
>
|
||||
<NText> 请登录或注册后使用 </NText>
|
||||
<NButton
|
||||
tag="a"
|
||||
href="/"
|
||||
<template #header>
|
||||
<NFlex
|
||||
justify="center"
|
||||
align="center"
|
||||
style="padding: 12px 0;"
|
||||
>
|
||||
<NText
|
||||
strong
|
||||
style="font-size: 1.8rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); background-image: linear-gradient(to right, #36d1dc, #5b86e5); -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
|
||||
>
|
||||
VTSURU CENTER
|
||||
</NText>
|
||||
</NFlex>
|
||||
</template>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
style="padding: 8px 0;"
|
||||
>
|
||||
回到主页
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NDivider />
|
||||
<RegisterAndLogin style="max-width: 500px; min-width: 350px" />
|
||||
<NFlex
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
<NText style="font-size: 16px; text-align: center;">
|
||||
请登录或注册后使用
|
||||
</NText>
|
||||
</NFlex>
|
||||
|
||||
<NAlert
|
||||
type="info"
|
||||
style="border-radius: 8px;"
|
||||
>
|
||||
<NFlex
|
||||
vertical
|
||||
align="center"
|
||||
size="small"
|
||||
>
|
||||
<div style="text-align: center;">
|
||||
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录
|
||||
</div>
|
||||
<NFlex
|
||||
justify="center"
|
||||
style="width: 100%; margin-top: 8px;"
|
||||
>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="$router.push({ name: 'bili-user'})"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="BrowsersOutline" />
|
||||
</template>
|
||||
前往 Bilibili 认证用户主页
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</NAlert>
|
||||
|
||||
<NDivider style="margin: 8px 0;" />
|
||||
|
||||
<RegisterAndLogin />
|
||||
|
||||
<NFlex justify="center">
|
||||
<NButton
|
||||
secondary
|
||||
tag="a"
|
||||
href="/"
|
||||
style="min-width: 100px;"
|
||||
>
|
||||
回到主页
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NSpin
|
||||
:loading="isLoadingAccount"
|
||||
style="overflow: hidden"
|
||||
<NCard
|
||||
:bordered="false"
|
||||
style="min-width: 300px; width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
|
||||
>
|
||||
正在请求账户数据...
|
||||
</NSpin>
|
||||
<NFlex
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
style="padding: 20px 10px;"
|
||||
>
|
||||
<NSpin
|
||||
:loading="isLoadingAccount"
|
||||
size="large"
|
||||
>
|
||||
<NText>正在请求账户数据...</NText>
|
||||
</NSpin>
|
||||
</NFlex>
|
||||
</NCard>
|
||||
</template>
|
||||
</NLayoutContent>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-dark-bg {
|
||||
background: linear-gradient(135deg, rgba(30,30,35,0.9) 0%, rgba(20,20,25,0.95) 100%) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryGetAPI } from '@/api/query'
|
||||
import { POINT_API_URL } from '@/data/constants'
|
||||
import { objectsToCSV } from '@/Utils'
|
||||
import { Info24Filled } from '@vicons/fluent'
|
||||
import { Warning24Regular } from '@vicons/fluent'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { format } from 'date-fns'
|
||||
import { saveAs } from 'file-saver'
|
||||
@@ -63,6 +64,7 @@ const ps = ref(25)
|
||||
// 弹窗控制
|
||||
const showModal = ref(false)
|
||||
const showGivePointModal = ref(false)
|
||||
const showResetAllPointsModal = ref(false)
|
||||
const isLoading = ref(true)
|
||||
|
||||
// 积分调整表单
|
||||
@@ -70,6 +72,10 @@ const addPointCount = ref(0)
|
||||
const addPointReason = ref<string>('')
|
||||
const addPointTarget = ref<number>()
|
||||
|
||||
// 重置所有积分确认
|
||||
const resetConfirmText = ref('')
|
||||
const RESET_CONFIRM_TEXT = '我确认删除'
|
||||
|
||||
// 用户数据
|
||||
const users = ref<ResponsePointUserModel[]>([])
|
||||
// 根据筛选条件过滤后的用户
|
||||
@@ -277,6 +283,37 @@ async function deleteUser(user: ResponsePointUserModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有用户积分
|
||||
async function resetAllPoints() {
|
||||
// 验证确认文本
|
||||
if (resetConfirmText.value !== RESET_CONFIRM_TEXT) {
|
||||
message.error(`请输入"${RESET_CONFIRM_TEXT}"以确认操作`)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await QueryGetAPI(POINT_API_URL + 'reset')
|
||||
|
||||
if (data.code == 200) {
|
||||
message.success('已重置所有用户积分')
|
||||
resetConfirmText.value = ''
|
||||
showResetAllPointsModal.value = false
|
||||
|
||||
// 重新加载用户数据
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, 1500)
|
||||
} else {
|
||||
message.error('重置失败: ' + data.message)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('重置失败: ' + err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出用户积分数据
|
||||
function exportData() {
|
||||
try {
|
||||
@@ -360,6 +397,12 @@ onMounted(async () => {
|
||||
>
|
||||
导出积分数据
|
||||
</NButton>
|
||||
<NButton
|
||||
type="error"
|
||||
@click="showResetAllPointsModal = true"
|
||||
>
|
||||
重置所有积分
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</template>
|
||||
|
||||
@@ -516,6 +559,46 @@ onMounted(async () => {
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NModal>
|
||||
|
||||
<!-- 重置所有用户积分弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showResetAllPointsModal"
|
||||
preset="card"
|
||||
style="max-width: 500px"
|
||||
title="重置所有用户积分"
|
||||
>
|
||||
<NFlex
|
||||
vertical
|
||||
:gap="16"
|
||||
>
|
||||
<NFlex
|
||||
align="center"
|
||||
:gap="8"
|
||||
>
|
||||
<NIcon
|
||||
:component="Warning24Regular"
|
||||
color="red"
|
||||
/>
|
||||
<NText type="error">
|
||||
警告:此操作将删除所有用户积分记录,不可恢复!
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NText>请输入 <b>"{{ RESET_CONFIRM_TEXT }}"</b> 以确认操作</NText>
|
||||
<NInput
|
||||
v-model:value="resetConfirmText"
|
||||
placeholder="请输入确认文本"
|
||||
/>
|
||||
|
||||
<NButton
|
||||
type="error"
|
||||
:loading="isLoading"
|
||||
@click="resetAllPoints"
|
||||
:disabled="resetConfirmText !== RESET_CONFIRM_TEXT"
|
||||
>
|
||||
确认重置所有用户积分
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -115,13 +115,13 @@ const showOBSModal = ref(false)
|
||||
|
||||
const settings = computed({
|
||||
get: () => {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
return accountInfo.value.settings.songRequest
|
||||
}
|
||||
return defaultSettings
|
||||
},
|
||||
set: (value) => {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
accountInfo.value.settings.songRequest = value
|
||||
}
|
||||
},
|
||||
@@ -231,7 +231,7 @@ const configCanEdit = computed(() => {
|
||||
const table = ref()
|
||||
|
||||
async function getAllSong() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
try {
|
||||
const data = await QueryGetAPI<SongRequestInfo[]>(SONG_REQUEST_API_URL + 'get-all', {
|
||||
id: accountInfo.value.id,
|
||||
@@ -263,7 +263,7 @@ async function addSong(danmaku: EventModel) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
await QueryPostAPI<SongRequestInfo>(SONG_REQUEST_API_URL + 'try-add', danmaku).then((data) => {
|
||||
if (data.code == 200) {
|
||||
message.success(`[${danmaku.uname}] 添加曲目: ${data.data.songName}`)
|
||||
@@ -310,7 +310,7 @@ async function addSongManual() {
|
||||
message.error('请输入名称')
|
||||
return
|
||||
}
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
await QueryPostAPIWithParams<SongRequestInfo>(SONG_REQUEST_API_URL + 'add', {
|
||||
name: newSongName.value,
|
||||
}).then((data) => {
|
||||
@@ -409,7 +409,7 @@ function checkMessage(msg: string) {
|
||||
.startsWith(accountInfo.value ? settings.value.orderPrefix.toLowerCase() : defaultPrefix.value)
|
||||
}
|
||||
async function onUpdateFunctionEnable() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
const oldValue = JSON.parse(JSON.stringify(accountInfo.value.settings.enableFunctions))
|
||||
if (accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest)) {
|
||||
accountInfo.value.settings.enableFunctions = accountInfo.value.settings.enableFunctions.filter(
|
||||
@@ -428,7 +428,7 @@ async function onUpdateFunctionEnable() {
|
||||
`已${accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.SongRequest) ? '启用' : '禁用'}点播功能`,
|
||||
)
|
||||
} else {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
accountInfo.value.settings.enableFunctions = oldValue
|
||||
}
|
||||
message.error(
|
||||
@@ -444,7 +444,7 @@ async function onUpdateFunctionEnable() {
|
||||
}
|
||||
}
|
||||
async function updateSettings() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
isLoading.value = true
|
||||
await SaveSetting('SongRequest', settings.value)
|
||||
.then((msg) => {
|
||||
@@ -714,7 +714,7 @@ function GetGuardColor(level: number | null | undefined): string {
|
||||
return ''
|
||||
}
|
||||
async function updateActive() {
|
||||
if (!accountInfo.value) return
|
||||
if (!accountInfo.value.id) return
|
||||
try {
|
||||
const data = await QueryGetAPI<SongRequestInfo[]>(SONG_REQUEST_API_URL + 'get-active', {
|
||||
id: accountInfo.value?.id,
|
||||
@@ -763,7 +763,7 @@ let timer: any
|
||||
let updateActiveTimer: any
|
||||
const updateKey = ref(0)
|
||||
onMounted(() => {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
settings.value = accountInfo.value.settings.songRequest
|
||||
}
|
||||
client.onEvent('danmaku', onGetDanmaku)
|
||||
|
||||
@@ -132,13 +132,13 @@
|
||||
// 队列设置 (登录后使用账户设置, 否则使用默认设置)
|
||||
const settings = computed({
|
||||
get: () => {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
return accountInfo.value.settings.queue;
|
||||
}
|
||||
return defaultSettings;
|
||||
},
|
||||
set: (value) => {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
accountInfo.value.settings.queue = value;
|
||||
}
|
||||
},
|
||||
@@ -215,7 +215,7 @@
|
||||
|
||||
// 获取所有队列数据
|
||||
async function getAll() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const data = await QueryGetAPI<ResponseQueueModel[]>(QUEUE_API_URL + 'get-all', {
|
||||
@@ -258,7 +258,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountInfo.value) { // 已登录,调用 API
|
||||
if (accountInfo.value.id) { // 已登录,调用 API
|
||||
try {
|
||||
const data = await QueryPostAPI<ResponseQueueModel>(QUEUE_API_URL + 'try-add', danmaku);
|
||||
if (data.code == 200) {
|
||||
@@ -321,7 +321,7 @@
|
||||
message.error('请输入用户名');
|
||||
return;
|
||||
}
|
||||
if (accountInfo.value) { // 已登录,调用 API
|
||||
if (accountInfo.value.id) { // 已登录,调用 API
|
||||
try {
|
||||
const data = await QueryPostAPIWithParams<ResponseQueueModel>(QUEUE_API_URL + 'add', {
|
||||
name: newQueueName.value,
|
||||
@@ -484,7 +484,7 @@
|
||||
|
||||
// 更新功能启用状态
|
||||
async function onUpdateFunctionEnable() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
const oldValue = JSON.parse(JSON.stringify(accountInfo.value.settings.enableFunctions));
|
||||
const isEnabling = !accountInfo.value.settings.enableFunctions.includes(FunctionTypes.Queue);
|
||||
|
||||
@@ -508,14 +508,14 @@
|
||||
message.success(`已${isEnabling ? '启用' : '禁用'}队列功能`);
|
||||
} else {
|
||||
// 回滚状态
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
accountInfo.value.settings.enableFunctions = oldValue;
|
||||
}
|
||||
message.error(`队列功能${isEnabling ? '启用' : '禁用'}失败: ${data.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// 回滚状态
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
accountInfo.value.settings.enableFunctions = oldValue;
|
||||
}
|
||||
message.error(`队列功能${isEnabling ? '启用' : '禁用'}失败: ${err.message || err}`);
|
||||
@@ -526,7 +526,7 @@
|
||||
|
||||
// 更新设置
|
||||
async function updateSettings() {
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const success = await SaveSetting('Queue', settings.value);
|
||||
@@ -550,7 +550,7 @@
|
||||
async function deleteQueue(values: ResponseQueueModel[]) {
|
||||
if (!values || values.length === 0) return;
|
||||
|
||||
if (accountInfo.value) { // 已登录,调用 API
|
||||
if (accountInfo.value.id) { // 已登录,调用 API
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const idsToDelete = values.map((s) => s.id);
|
||||
@@ -578,7 +578,7 @@
|
||||
|
||||
// 取消所有活动队列项
|
||||
async function deactiveAllSongs() {
|
||||
if (accountInfo.value) { // 已登录,调用 API
|
||||
if (accountInfo.value.id) { // 已登录,调用 API
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const data = await QueryGetAPI(QUEUE_API_URL + 'deactive');
|
||||
@@ -826,7 +826,7 @@
|
||||
|
||||
// 定时更新活动队列信息 (增量更新)
|
||||
async function updateActive() {
|
||||
if (!accountInfo.value) return; // 未登录则不执行
|
||||
if (!accountInfo.value.id) return; // 未登录则不执行
|
||||
try {
|
||||
const data = await QueryGetAPI<ResponseQueueModel[]>(QUEUE_API_URL + 'get-active', {
|
||||
id: accountInfo.value?.id,
|
||||
@@ -922,7 +922,7 @@
|
||||
async function init() {
|
||||
dispose(); // 先清理旧的计时器
|
||||
// 如果登录了,获取一次全量数据
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
originQueue.value = await getAll();
|
||||
}
|
||||
// 设置定时器
|
||||
@@ -945,7 +945,7 @@
|
||||
// --- 生命周期钩子 ---
|
||||
onMounted(async () => {
|
||||
// 挂载时初始化
|
||||
if (accountInfo.value) {
|
||||
if (accountInfo.value.id) {
|
||||
// 如果已登录,同步一次设置到本地状态 (虽然 computed 会处理,但显式同步更清晰)
|
||||
settings.value = accountInfo.value.settings.queue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user