feat: 在 ClientAutoAction 组件中新增操作历史标签页和相关功能

- 引入 ActionHistoryViewer 组件,展示执行历史。
- 更新主标签页逻辑,调整为操作管理和执行历史两个标签。
- 在自动操作逻辑中增加弹幕和私信发送历史记录功能,提升操作追踪能力。
This commit is contained in:
2025-04-22 19:08:31 +08:00
parent d6577ec129
commit b97081a870
4 changed files with 695 additions and 170 deletions

View File

@@ -32,6 +32,7 @@ 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();
@@ -54,6 +55,7 @@ const typeMap = {
};
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);
@@ -499,194 +501,214 @@ onUnmounted(() => {
size="large"
>
<NTabs
v-model:value="activeTab"
v-model:value="activeMainTab"
type="line"
animated
@update:value="editingActionId = null"
>
<!-- 操作管理标签页 -->
<NTabPane
v-for="(label, type) in typeMap"
:key="type"
:name="type"
:tab="label"
name="action-management"
tab="操作管理"
>
<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"
<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>
<NButton
size="small"
style="align-self: flex-start; margin-bottom: 8px"
class="back-btn btn-with-transition"
@click="backToOverview"
<NSpace
v-if="enabledTriggerTypes"
align="center"
style="padding: 8px 0; margin-bottom: 8px"
>
← 返回列表
</NButton>
<NSwitch
:value="enabledTriggerTypes[type]"
@update:value="toggleTypeStatus(type)"
/>
<span>{{ enabledTriggerTypes[type] ? '启用' : '禁用' }}所有{{ label }}</span>
</NSpace>
<transition-group
name="fade-slide"
tag="div"
<NAlert
v-if="type === TriggerType.GUARD && webFetcherStore.webfetcherType === 'openlive'"
type="warning"
title="功能限制提醒"
style="margin-bottom: 12px;"
:bordered="false"
>
<div
v-for="action in groupedActions[type]"
v-show="action.id === editingActionId"
:key="action.id"
class="action-item"
当前连接模式 (OpenLive) 无法获取用户UID因此无法执行发送私信操作如需使用私信功能请考虑切换至直连模式
</NAlert>
<NSpace
justify="end"
style="margin-bottom: 12px;"
>
<NPopconfirm
:negative-text="'取消'"
:positive-text="'确认测试'"
@positive-click="() => handleTestClick(type as TriggerType)"
>
<AutoActionEditor :action="action" />
<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>
</transition-group>
<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>
</div>
</NSpace>
</NTabPane>
</NTabs>
</NTabPane>
<!-- 新增数据管理标签页 -->
<!-- 历史记录标签页 -->
<NTabPane
name="action-history"
tab="执行历史"
>
<ActionHistoryViewer />
</NTabPane>
<!-- 数据管理标签页 -->
<NTabPane
name="data-manager"
tab="数据管理"
>
<DataManager />
</NTabPane>
<!-- 结束新增 -->
</NTabs>
</NSpace>
</NCard>

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, h } from 'vue';
import {
NSpace, NSelect, NButton, NDataTable, NPopconfirm,
NCard, NEmpty, NTag, NTabPane, NTabs, NTime,
useMessage, NIcon, NTooltip, NSpin
} from 'naive-ui';
import { ArrowClockwise16Filled, Delete16Filled, CheckmarkCircle16Filled, DismissCircle16Filled } from '@vicons/fluent';
import { HistoryType, HistoryItem, getHistoryByType, clearHistory, clearAllHistory } from '../../store/autoAction/utils/historyLogger';
const message = useMessage();
const loading = ref(true);
const activeTab = ref(HistoryType.DANMAKU);
const historyData = ref<Record<HistoryType, HistoryItem[]>>({
[HistoryType.DANMAKU]: [],
[HistoryType.PRIVATE_MSG]: [],
[HistoryType.COMMAND]: []
});
// 类型名称映射
const typeNameMap = {
[HistoryType.DANMAKU]: '弹幕发送',
[HistoryType.PRIVATE_MSG]: '私信发送',
[HistoryType.COMMAND]: '命令执行'
};
// 刷新间隔(毫秒)
const refreshInterval = 10000;
let refreshTimer: number | null = null;
// 列定义
const columns = [
{
title: '时间',
key: 'timestamp',
width: 180,
render: (row: HistoryItem) => {
return h(NTime, {
time: new Date(row.timestamp),
format: 'yyyy-MM-dd HH:mm:ss'
});
}
},
{
title: '操作名称',
key: 'actionName',
width: 160
},
{
title: '内容',
key: 'content',
ellipsis: {
tooltip: true
}
},
{
title: '目标',
key: 'target',
width: 120
},
{
title: '状态',
key: 'success',
width: 100,
render: (row: HistoryItem) => {
if (row.success) {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h(
NTag,
{ type: 'success', size: 'small', round: true },
{ default: () => '成功', icon: () => h(NIcon, { component: CheckmarkCircle16Filled }) }
),
default: () => '执行成功'
}
);
} else {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => h(
NTag,
{ type: 'error', size: 'small', round: true },
{ default: () => '失败', icon: () => h(NIcon, { component: DismissCircle16Filled }) }
),
default: () => row.error || '执行失败'
}
);
}
}
},
];
// 加载历史数据
async function loadHistory() {
loading.value = true;
try {
// 并行加载所有类型的历史
const [danmakuHistory, privateMsgHistory, commandHistory] = await Promise.all([
getHistoryByType(HistoryType.DANMAKU),
getHistoryByType(HistoryType.PRIVATE_MSG),
getHistoryByType(HistoryType.COMMAND)
]);
historyData.value = {
[HistoryType.DANMAKU]: danmakuHistory,
[HistoryType.PRIVATE_MSG]: privateMsgHistory,
[HistoryType.COMMAND]: commandHistory
};
} catch (error) {
console.error('加载历史数据失败:', error);
message.error('加载历史数据失败');
} finally {
loading.value = false;
}
}
// 清除历史
async function handleClearHistory(type: HistoryType) {
try {
await clearHistory(type);
historyData.value[type] = [];
message.success(`已清空${typeNameMap[type]}历史`);
} catch (error) {
console.error('清除历史失败:', error);
message.error('清除历史失败');
}
}
// 清除所有历史
async function handleClearAllHistory() {
try {
await clearAllHistory();
Object.keys(historyData.value).forEach((type) => {
historyData.value[type as HistoryType] = [];
});
message.success('已清空所有历史记录');
} catch (error) {
console.error('清除所有历史失败:', error);
message.error('清除所有历史失败');
}
}
// 开始定时刷新
function startRefreshTimer() {
stopRefreshTimer();
refreshTimer = window.setInterval(() => {
loadHistory();
}, refreshInterval);
}
// 停止定时刷新
function stopRefreshTimer() {
if (refreshTimer !== null) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
onMounted(() => {
loadHistory();
startRefreshTimer();
});
onUnmounted(() => {
stopRefreshTimer();
});
</script>
<template>
<div class="history-viewer">
<NSpace vertical>
<NSpace justify="space-between">
<div class="history-title">
操作执行历史
</div>
<NSpace>
<NButton
size="small"
:loading="loading"
@click="loadHistory"
>
<template #icon>
<NIcon :component="ArrowClockwise16Filled" />
</template>
刷新
</NButton>
<NPopconfirm
placement="bottom"
@positive-click="handleClearAllHistory"
>
<template #trigger>
<NButton
size="small"
type="error"
ghost
>
<template #icon>
<NIcon :component="Delete16Filled" />
</template>
清空所有历史
</NButton>
</template>
确定要清空所有类型的历史记录吗此操作不可恢复
</NPopconfirm>
</NSpace>
</NSpace>
<NTabs
v-model:value="activeTab"
type="line"
animated
>
<NTabPane
v-for="(label, type) in typeNameMap"
:key="type"
:name="type"
:tab="label"
>
<NSpin :show="loading">
<NSpace vertical>
<NSpace justify="end">
<NPopconfirm
placement="bottom"
@positive-click="() => handleClearHistory(type as HistoryType)"
>
<template #trigger>
<NButton
size="small"
type="warning"
ghost
>
<template #icon>
<NIcon :component="Delete16Filled" />
</template>
清空{{ label }}历史
</NButton>
</template>
确定要清空所有{{ label }}历史记录吗?此操作不可恢复。
</NPopconfirm>
</NSpace>
<NDataTable
:columns="columns"
:data="historyData[type as HistoryType]"
:bordered="false"
:pagination="{
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
}"
:row-key="row => row.id"
>
<template #empty>
<NEmpty description="暂无历史记录" />
</template>
</NDataTable>
</NSpace>
</NSpin>
</NTabPane>
</NTabs>
</NSpace>
</div>
</template>
<style scoped>
.history-viewer {
width: 100%;
}
.history-title {
font-size: 16px;
font-weight: 500;
}
</style>

View File

@@ -11,6 +11,7 @@ import { buildExecutionContext, getRandomTemplate } from './utils';
import { evaluateTemplateExpressions } from './expressionEvaluator';
import { evaluateExpression } from './utils';
import { useBiliCookie } from '../useBiliCookie';
import { logDanmakuHistory, logPrivateMsgHistory, logCommandHistory } from './utils/historyLogger';
/**
* 过滤有效的自动操作项
@@ -223,11 +224,57 @@ export function executeActions(
if (action.actionConfig.delaySeconds && action.actionConfig.delaySeconds > 0) {
setTimeout(() => {
handlers.sendLiveDanmaku!(roomId, message)
.catch(err => console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err));
.then(success => {
// 记录弹幕发送历史
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录弹幕历史失败:', err));
return success;
})
.catch(err => {
console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err);
// 记录失败的发送
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
false,
err.toString()
).catch(e => console.error('记录弹幕历史失败:', e));
});
}, action.actionConfig.delaySeconds * 1000);
} else {
handlers.sendLiveDanmaku(roomId, message)
.catch(err => console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err));
.then(success => {
// 记录弹幕发送历史
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录弹幕历史失败:', err));
return success;
})
.catch(err => {
console.error(`[AutoAction] 发送弹幕失败 (${action.name || action.id}):`, err);
// 记录失败的发送
logDanmakuHistory(
action.id,
action.name || '未命名操作',
message,
roomId,
false,
err.toString()
).catch(e => console.error('记录弹幕历史失败:', e));
});
}
}
} else {
@@ -249,6 +296,16 @@ export function executeActions(
const sendPmPromise = (uid: number, msg: string) => {
return handlers.sendPrivateMessage!(uid, msg)
.then(success => {
// 记录私信发送历史
logPrivateMsgHistory(
action.id,
action.name || '未命名操作',
msg,
uid,
success,
success ? undefined : '发送失败'
).catch(err => console.error('记录私信历史失败:', err));
if (success && options?.onSuccess) {
// 发送成功后调用 onSuccess 回调
options.onSuccess(action, context);
@@ -257,6 +314,15 @@ export function executeActions(
})
.catch(err => {
console.error(`[AutoAction] 发送私信失败 (${action.name || action.id}):`, err);
// 记录失败的发送
logPrivateMsgHistory(
action.id,
action.name || '未命名操作',
msg,
uid,
false,
err.toString()
).catch(e => console.error('记录私信历史失败:', e));
return false; // 明确返回 false 表示失败
});
};
@@ -276,8 +342,22 @@ export function executeActions(
break;
case ActionType.EXECUTE_COMMAND:
// 执行自定义命令(未实现)
console.warn(`[AutoAction] 暂不支持执行自定义命令: ${action.name || action.id}`);
// 执行自定义命令
const command = processTemplate(action, context);
if (command) {
// 更新冷却时间
runtimeState.lastExecutionTime[action.id] = Date.now();
// 目前只记录执行历史,具体实现可在未来扩展
logCommandHistory(
action.id,
action.name || '未命名操作',
command,
true
).catch(err => console.error('记录命令执行历史失败:', err));
console.warn(`[AutoAction] 暂不支持执行自定义命令: ${action.name || action.id}`);
}
break;
default:

View File

@@ -0,0 +1,145 @@
import { get, set, del, update } from 'idb-keyval';
import { ActionType } from '../types';
// 历史记录类型常量
export enum HistoryType {
DANMAKU = 'danmaku',
PRIVATE_MSG = 'privateMsg',
COMMAND = 'command',
}
// 历史记录项结构
export interface HistoryItem {
id: string; // 唯一ID
actionId: string; // 操作ID
actionName: string; // 操作名称
actionType: ActionType; // 操作类型
timestamp: number; // 执行时间戳
content: string; // 发送的内容
target?: string; // 目标如UID或房间ID
success: boolean; // 是否成功
error?: string; // 错误信息(如果有)
}
// 每种类型的历史记录容量
const HISTORY_CAPACITY = 1000;
// 使用IDB存储的键名
const HISTORY_KEYS = {
[HistoryType.DANMAKU]: 'autoAction_history_danmaku',
[HistoryType.PRIVATE_MSG]: 'autoAction_history_privateMsg',
[HistoryType.COMMAND]: 'autoAction_history_command',
};
// 环形队列添加记录
async function addToCircularQueue(key: string, item: HistoryItem, capacity: number): Promise<void> {
await update<HistoryItem[]>(key, (history = []) => {
// 添加到队列末尾
history.push(item);
// 如果超出容量,移除最旧的记录
if (history.length > capacity) {
history.splice(0, history.length - capacity);
}
return history;
});
}
/**
* 记录弹幕发送历史
*/
export async function logDanmakuHistory(
actionId: string,
actionName: string,
content: string,
roomId: number,
success: boolean,
error?: string
): Promise<void> {
const historyItem: HistoryItem = {
id: `d_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
actionId,
actionName,
actionType: ActionType.SEND_DANMAKU,
timestamp: Date.now(),
content,
target: roomId.toString(),
success,
error
};
await addToCircularQueue(HISTORY_KEYS[HistoryType.DANMAKU], historyItem, HISTORY_CAPACITY);
}
/**
* 记录私信发送历史
*/
export async function logPrivateMsgHistory(
actionId: string,
actionName: string,
content: string,
userId: number,
success: boolean,
error?: string
): Promise<void> {
const historyItem: HistoryItem = {
id: `p_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
actionId,
actionName,
actionType: ActionType.SEND_PRIVATE_MSG,
timestamp: Date.now(),
content,
target: userId.toString(),
success,
error
};
await addToCircularQueue(HISTORY_KEYS[HistoryType.PRIVATE_MSG], historyItem, HISTORY_CAPACITY);
}
/**
* 记录命令执行历史
*/
export async function logCommandHistory(
actionId: string,
actionName: string,
content: string,
success: boolean,
error?: string
): Promise<void> {
const historyItem: HistoryItem = {
id: `c_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
actionId,
actionName,
actionType: ActionType.EXECUTE_COMMAND,
timestamp: Date.now(),
content,
success,
error
};
await addToCircularQueue(HISTORY_KEYS[HistoryType.COMMAND], historyItem, HISTORY_CAPACITY);
}
/**
* 获取历史记录
*/
export async function getHistoryByType(type: HistoryType): Promise<HistoryItem[]> {
return await get<HistoryItem[]>(HISTORY_KEYS[type]) || [];
}
/**
* 清除历史记录
*/
export async function clearHistory(type: HistoryType): Promise<void> {
await del(HISTORY_KEYS[type]);
}
/**
* 清除所有历史记录
*/
export async function clearAllHistory(): Promise<void> {
await Promise.all(
Object.values(HistoryType).map(type => clearHistory(type as HistoryType))
);
}