Files
vtsuru.live/src/client/ClientAutoAction.vue
Megghy dd29a141de feat: 优化自动操作逻辑,简化组件初始化和事件处理
- 移除了 ClientAutoAction 组件中的状态监视逻辑,直接在挂载时初始化自动操作存储。
- 在 useAutoAction 中增加初始化标志,避免重复初始化。
- 统一了测试事件的基础属性,简化了事件创建逻辑,提升代码可读性。
2025-04-22 19:56:47 +08:00

897 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { AutoActionItem, TriggerType, useAutoAction } from '@/client/store/useAutoAction';
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { useBiliCookie } from '@/client/store/useBiliCookie';
import { useWebFetcher } from '@/store/useWebFetcher';
import {
NAlert,
NButton,
NCard,
NCountdown,
NDropdown,
NEmpty,
NModal,
NPopconfirm,
NSelect,
NSpace,
NTabPane,
NTabs,
NTag,
useMessage,
NDataTable,
NSwitch,
NDivider,
NIcon,
NText,
NTooltip,
NInput
} from 'naive-ui';
import { computed, h, onMounted, onUnmounted, ref, watch, reactive } from 'vue';
import { ArrowUp24Regular, ArrowDown24Regular, Target24Filled, Edit16Regular } from '@vicons/fluent';
import AutoActionEditor from './components/autoaction/AutoActionEditor.vue';
import GlobalScheduledSettings from './components/autoaction/settings/GlobalScheduledSettings.vue';
import TimerCountdown from './components/autoaction/TimerCountdown.vue';
import DataManager from './components/autoaction/DataManager.vue';
import ActionHistoryViewer from './components/autoaction/ActionHistoryViewer.vue';
const autoActionStore = useAutoAction();
const message = useMessage();
const danmakuClient = useDanmakuClient();
const biliCookieStore = useBiliCookie();
const webFetcherStore = useWebFetcher();
// 从 store 获取 enabledTriggerTypes
const enabledTriggerTypes = computed(() => autoActionStore.enabledTriggerTypes);
// 分类标签
const typeMap = {
[TriggerType.DANMAKU]: '自动回复',
[TriggerType.GIFT]: '礼物感谢',
[TriggerType.GUARD]: '上舰感谢',
[TriggerType.FOLLOW]: '关注感谢',
[TriggerType.ENTER]: '入场欢迎',
[TriggerType.SCHEDULED]: '定时发送',
[TriggerType.SUPER_CHAT]: 'SC感谢',
};
const activeTab = ref(TriggerType.GIFT);
const activeMainTab = ref('action-management');
const showAddModal = ref(false);
const selectedTriggerType = ref<TriggerType>(TriggerType.GIFT);
const editingActionId = ref<string | null>(null);
const showSetNextModal = ref(false);
const targetNextActionId = ref<string | null>(null);
const showTestModal = ref(false);
const testUid = ref<string>('10004');
const currentTestType = ref<TriggerType | 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[]>>({});
function toggleActionStatus(action: AutoActionItem) {
autoActionStore.toggleAutoAction(action.id, !action.enabled);
message.success(`${!action.enabled ? '启用' : '禁用'}操作: ${action.name || '未命名自动操作'}`);
}
function getStatusTag(action: AutoActionItem) {
// 检查是否需要登录且未登录
const config = action.actionConfig as any; // 使用类型断言访问 type
const requiresLogin = config.type === 'sendDanmaku' || config.type === 'sendMessage';
if (requiresLogin && !biliCookieStore.isCookieValid) {
return { type: 'error' as const, text: '需登录', tooltip: '发送弹幕或私信需要登录B站账号' };
}
// 1. Check type enable switch (从 store 读取)
if (!enabledTriggerTypes.value[action.triggerType]) {
return { type: 'warning' as const, text: '类型已禁用', tooltip: `所有${typeMap[action.triggerType]}类型的操作已禁用` };
}
// 2. Check action self enabled
if (!action.enabled) {
return { type: 'error' as const, text: '已禁用', tooltip: '此操作已被手动禁用' };
}
// 3. Check if template is empty
if (!action.template ||
(typeof action.template === 'string' && action.template.trim() === '') ||
(Array.isArray(action.template) && action.template.length === 0)) {
return { type: 'warning' as const, text: '模板为空', tooltip: '请设置有效的模板内容' };
}
// 4. Check onlyDuringLive condition
if (action.triggerConfig.onlyDuringLive && !autoActionStore.isLive) {
return { type: 'warning' as const, text: '待机中', tooltip: '此操作设置为仅在直播时触发' };
}
// 5. Check ignoreTianXuan condition
if (action.triggerConfig.ignoreTianXuan && autoActionStore.isTianXuanActive) {
return { type: 'warning' as const, text: '暂停中', tooltip: '此操作设置为忽略天选时刻,当前正在进行天选' };
}
// 6. All conditions met, enabled
return { type: 'success' as const, text: '已启用', tooltip: '此操作当前处于活动状态' };
}
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);
const options = [
{
label: row.enabled ? '禁用此操作' : '启用此操作',
key: 'toggleEnable',
props: {
onClick: () => {
autoActionStore.toggleAutoAction(row.id, !row.enabled);
message.success(`${!row.enabled ? '启用' : '禁用'}操作: ${row.name || '未命名自动操作'}`);
}
}
},
{
label: row.triggerConfig.onlyDuringLive ? '取消"仅直播触发"' : '设为"仅直播触发"',
key: 'toggleLive',
props: {
onClick: () => {
row.triggerConfig.onlyDuringLive = !row.triggerConfig.onlyDuringLive;
message.success(`操作"${row.name || '未命名'}"已${row.triggerConfig.onlyDuringLive ? '设为' : '取消'}仅直播触发`);
if (row.triggerType === TriggerType.SCHEDULED) {
if (row.triggerConfig.useGlobalTimer) autoActionStore.restartGlobalTimer();
else {
autoActionStore.stopIndividualTimer(row.id);
autoActionStore.startIndividualTimer(row);
}
}
}
}
},
{
label: row.triggerConfig.ignoreTianXuan ? '取消"忽略天选暂停"' : '设为"忽略天选暂停"',
key: 'toggleTianXuan',
props: {
onClick: () => {
row.triggerConfig.ignoreTianXuan = !row.triggerConfig.ignoreTianXuan;
message.success(`操作"${row.name || '未命名'}"已${row.triggerConfig.ignoreTianXuan ? '设为' : '取消'}忽略天选暂停`);
if (row.triggerType === TriggerType.SCHEDULED) {
if (row.triggerConfig.useGlobalTimer) autoActionStore.restartGlobalTimer();
else {
autoActionStore.stopIndividualTimer(row.id);
autoActionStore.startIndividualTimer(row);
}
}
}
}
}
];
return h(
NDropdown,
{
trigger: 'click',
options: options,
showArrow: true,
},
{
default: () => h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h(
NTag,
{
type: status.type,
size: 'small',
round: true,
style: 'cursor: pointer;'
},
{ default: () => status.text }
),
default: () => status.tooltip
}
)
}
);
}
},
];
const remainingTimeColumn = {
title: '下一次发送 (估算)',
key: 'remainingTime',
width: 180,
render: (row: AutoActionItem) => {
if (!enabledTriggerTypes.value[TriggerType.SCHEDULED]) {
return h(NText, { depth: 3 }, '类型已禁用');
}
return h(TimerCountdown, { actionId: row.id });
}
};
const createActionsColumn = (type: TriggerType, items: AutoActionItem[]) => {
return {
title: '操作',
key: 'actions',
width: type === TriggerType.SCHEDULED ? 240 : 180,
render: (row: AutoActionItem, index: number) => {
const buttons = [
h(
NButton,
{
size: 'small',
type: 'primary',
ghost: true,
onClick: () => editAction(row.id)
},
{ default: () => '编辑' }
)
];
if (type === TriggerType.SCHEDULED) {
buttons.unshift(
h(NButton,
{
size: 'small',
circle: true,
tertiary: true,
disabled: index === 0,
onClick: () => moveAction(row.id, 'up'),
title: '上移'
},
{ icon: () => h(NIcon, { component: ArrowUp24Regular }) }
),
h(NButton,
{
size: 'small',
circle: true,
tertiary: true,
style: 'margin-left: 6px;',
disabled: index === items.length - 1,
onClick: () => moveAction(row.id, 'down'),
title: '下移'
},
{ icon: () => h(NIcon, { component: ArrowDown24Regular }) }
)
);
}
buttons.push(
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; margin-left: 6px;' },
{ default: () => '•••' }
)
}
)
);
return h(NSpace, { justify: 'end', align: 'center' }, { default: () => buttons });
}
};
};
const getColumnsForType = (type: TriggerType) => {
const items = groupedActions.value[type] || [];
const customCols = customColumnsByType[type] || [];
const actionsCol = createActionsColumn(type, items);
if (type === TriggerType.SCHEDULED) {
return [...baseColumns, remainingTimeColumn, ...customCols, actionsCol];
}
return [...baseColumns, ...customCols, actionsCol];
};
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;
});
const eligibleGlobalActions = computed(() => {
if (!enabledTriggerTypes.value) return [];
return autoActionStore.autoActions.filter(action =>
action.triggerType === TriggerType.SCHEDULED &&
enabledTriggerTypes.value[TriggerType.SCHEDULED] &&
action.enabled &&
action.triggerConfig.useGlobalTimer &&
(!action.triggerConfig.onlyDuringLive || autoActionStore.isLive) &&
(!action.triggerConfig.ignoreTianXuan || !autoActionStore.isTianXuanActive)
).map(action => ({
label: action.name || '未命名操作',
value: action.id
}));
});
function openSetNextModal() {
targetNextActionId.value = autoActionStore.nextScheduledAction?.id ?? null;
showSetNextModal.value = true;
}
function confirmSetNextAction() {
if (targetNextActionId.value) {
autoActionStore.setNextGlobalAction(targetNextActionId.value);
message.success('已指定下一条执行的操作');
}
showSetNextModal.value = false;
}
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 newActionData = JSON.parse(JSON.stringify(action));
const newActionId = `auto-action-${Date.now()}`;
newActionData.id = newActionId;
newActionData.name += ' (复制)';
autoActionStore.autoActions.push(newActionData);
if (newActionData.triggerType === TriggerType.SCHEDULED) {
const addedAction = autoActionStore.autoActions.find(a => a.id === newActionId);
if (addedAction) {
if (addedAction.triggerConfig.useGlobalTimer) {
autoActionStore.restartGlobalTimer();
} else {
autoActionStore.startIndividualTimer(addedAction);
}
} else {
console.error("[ClientAutoAction] Could not find duplicated action after pushing.");
}
}
message.success('已复制自动操作');
}
function moveAction(actionId: string, direction: 'up' | 'down') {
autoActionStore.moveAction(actionId, direction);
}
function editAction(actionId: string) {
editingActionId.value = actionId;
}
function backToOverview() {
editingActionId.value = null;
}
function toggleTypeStatus(type: string) {
const triggerType = type as TriggerType;
const newState = !enabledTriggerTypes.value[triggerType];
autoActionStore.setTriggerTypeEnabled(triggerType, newState);
message.success(`${newState ? '启用' : '禁用'}所有 ${typeMap[triggerType]}`);
}
function handleTestClick(type: TriggerType) {
const requiresLogin = [TriggerType.DANMAKU, TriggerType.GUARD, TriggerType.SUPER_CHAT].includes(type);
if (requiresLogin && !biliCookieStore.isCookieValid) {
message.error('此测试需要登录B站账号请先前往设置页面登录');
return;
}
if (type === TriggerType.GUARD) {
// 为舰长相关(私信)测试显示UID输入对话框
currentTestType.value = type;
testUid.value = '10004'; // 默认值
showTestModal.value = true;
} else {
// 其他类型直接测试
autoActionStore.triggerTestActionByType(type);
}
}
function confirmTest() {
if (currentTestType.value === TriggerType.GUARD) {
// 在执行私信测试前再次确认登录状态
if (!biliCookieStore.isCookieValid) {
message.error('无法发送私信测试请先登录B站账号');
showTestModal.value = false;
return;
}
const uid = parseInt(testUid.value);
if (isNaN(uid) || uid <= 0) {
message.error('请输入有效的UID');
return;
}
autoActionStore.triggerTestActionByType(currentTestType.value, uid);
}
showTestModal.value = false;
}
onMounted(() => {
autoActionStore.init();
});
</script>
<template>
<div>
<NCard
title="自动操作设置"
size="small"
>
<template #header-extra>
<NButton
type="primary"
@click="showAddModal = true"
>
添加自动操作
</NButton>
</template>
<!-- 添加全局登录提示 -->
<NAlert
v-if="!biliCookieStore.isCookieValid"
type="warning"
title="未登录B站账号"
style="margin-bottom: 16px;"
:bordered="false"
closable
>
部分需要发送弹幕或私信的自动操作如自动回复上舰感谢将无法执行请前往设置- 账号设置页面登录
</NAlert>
<NSpace
vertical
size="large"
>
<NTabs
v-model:value="activeMainTab"
type="line"
animated
@update:value="editingActionId = null"
>
<!-- 操作管理标签页 -->
<NTabPane
name="action-management"
tab="操作管理"
>
<NTabs
v-model:value="activeTab"
type="segment"
animated
style="margin-bottom: 16px"
>
<NTabPane
v-for="(label, type) in typeMap"
:key="type"
:name="type"
:tab="label"
>
<NSpace vertical>
<NSpace
v-if="enabledTriggerTypes"
align="center"
style="padding: 8px 0; margin-bottom: 8px"
>
<NSwitch
:value="enabledTriggerTypes[type]"
@update:value="toggleTypeStatus(type)"
/>
<span>{{ enabledTriggerTypes[type] ? '启用' : '禁用' }}所有{{ label }}</span>
</NSpace>
<NAlert
v-if="type === TriggerType.GUARD && webFetcherStore.webfetcherType === 'openlive'"
type="warning"
title="功能限制提醒"
style="margin-bottom: 12px;"
:bordered="false"
>
当前连接模式 (OpenLive) 无法获取用户UID因此无法执行发送私信操作如需使用私信功能请考虑切换至直连模式
</NAlert>
<NSpace
justify="end"
style="margin-bottom: 12px;"
>
<NPopconfirm
:negative-text="'取消'"
:positive-text="'确认测试'"
@positive-click="() => handleTestClick(type as TriggerType)"
>
<template #trigger>
<NButton
size="small"
type="warning"
ghost
>
测试 {{ label }} 类型
</NButton>
</template>
{{ `确认模拟一个 ${label} 事件来测试所有启用的 ${label} 操作吗?\n注意这可能会发送真实的消息、执行操作并可能触发B站风控限制。` }}
</NPopconfirm>
</NSpace>
<div v-if="activeTab === TriggerType.SCHEDULED">
<GlobalScheduledSettings />
<div
v-if="enabledTriggerTypes && enabledTriggerTypes[TriggerType.SCHEDULED] && autoActionStore.globalSchedulingMode === 'sequential' && autoActionStore.nextScheduledAction"
class="next-action-display"
>
<NDivider style="margin: 12px 0 8px 0;" />
<NSpace
align="center"
justify="space-between"
>
<NText type="success">
<NIcon
:component="Target24Filled"
style="vertical-align: -0.15em; margin-right: 4px;"
/>
下一个执行:
<NTag
type="info"
size="small"
round
>
{{ autoActionStore.nextScheduledAction?.name || '未命名操作' }}
</NTag>
</NText>
<NTooltip trigger="hover">
<template #trigger>
<NButton
text
icon-placement="right"
size="small"
@click="openSetNextModal"
>
<template #icon>
<NIcon :component="Edit16Regular" />
</template>
手动指定
</NButton>
</template>
手动设置下一个要执行的操作
</NTooltip>
</NSpace>
</div>
<NAlert
v-else-if="enabledTriggerTypes && !enabledTriggerTypes[TriggerType.SCHEDULED]"
type="warning"
:bordered="false"
style="margin-bottom: 12px;"
>
定时发送类型已被禁用,所有相关操作不会执行。
</NAlert>
</div>
<NEmpty
v-if="groupedActions[type].length === 0"
description="暂无自动操作"
>
<template #extra>
<NButton
type="primary"
@click="() => { selectedTriggerType = type as TriggerType; showAddModal = true; }"
>
添加{{ typeMap[type as TriggerType] }}
</NButton>
</template>
</NEmpty>
<div
v-else-if="editingActionId === null"
class="overview-container"
>
<NDataTable
:bordered="false"
:single-line="false"
:columns="getColumnsForType(type as TriggerType)"
:data="groupedActions[type]"
:row-key="(row: AutoActionItem) => row.id"
>
<template #empty>
<NEmpty description="暂无数据" />
</template>
</NDataTable>
<NButton
type="default"
style="width: 100%; margin-top: 16px;"
class="btn-with-transition"
@click="() => { selectedTriggerType = type as TriggerType; showAddModal = true; }"
>
+ 添加{{ typeMap[type as TriggerType] }}
</NButton>
</div>
<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>
</NTabPane>
<!-- 历史记录标签页 -->
<NTabPane
name="action-history"
tab="执行历史"
>
<ActionHistoryViewer />
</NTabPane>
<!-- 数据管理标签页 -->
<NTabPane
name="data-manager"
tab="数据管理"
>
<DataManager />
</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>
<NModal
v-model:show="showSetNextModal"
preset="dialog"
title="手动指定下一条 (顺序模式)"
positive-text="确认指定"
negative-text="取消"
@positive-click="confirmSetNextAction"
>
<NSpace vertical>
<div>选择下一个要执行的定时操作:</div>
<NSelect
v-model:value="targetNextActionId"
:options="eligibleGlobalActions"
placeholder="选择操作"
filterable
clearable
style="width: 100%"
/>
<NText
type="info"
:depth="3"
style="font-size: 12px;"
>
只会列出当前已启用、类型也已启用且使用全局定时器的操作。
选择后,下一个全局定时周期将执行您指定的操作。
</NText>
</NSpace>
</NModal>
<NModal
v-model:show="showTestModal"
preset="dialog"
title="测试舰长私信"
positive-text="确认测试"
negative-text="取消"
@positive-click="confirmTest"
>
<NSpace vertical>
<div>请输入私信接收者的UID</div>
<NInput
v-model:value="testUid"
placeholder="请输入UID"
type="text"
/>
<NText
type="info"
:depth="3"
style="font-size: 12px;"
>
这是接收私信消息的B站用户UID测试将向此UID发送私信请确保该UID有效且您有权限向其发送私信
</NText>
</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;
}
.next-action-display {
margin-top: 12px;
padding: 8px 12px;
background-color: var(--n-color-embedded);
border-radius: var(--n-border-radius);
font-size: 13px;
}
</style>