From 8644cf8eabe5ef5fa70cc42cb0264d8cb2eb90f9 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 30 May 2026 20:51:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0QQBot=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/api/core/menu.ts | 21 ++ apps/web-antdv-next/src/api/qqbot/index.ts | 270 ++++++++++++++ .../web-antdv-next/src/router/routes/index.ts | 7 +- .../src/router/routes/modules/qqbot.ts | 81 ++++ .../src/views/qqbot/account/list.tsx | 305 +++++++++++++++ .../src/views/qqbot/conversation/list.tsx | 107 ++++++ .../src/views/qqbot/dashboard/list.tsx | 129 +++++++ .../src/views/qqbot/message/list.tsx | 125 +++++++ .../src/views/qqbot/modules/options.ts | 44 +++ .../src/views/qqbot/permission/list.tsx | 297 +++++++++++++++ .../src/views/qqbot/rule/list.tsx | 349 ++++++++++++++++++ .../src/views/qqbot/sendLog/list.tsx | 248 +++++++++++++ 12 files changed, 1982 insertions(+), 1 deletion(-) create mode 100644 apps/web-antdv-next/src/api/qqbot/index.ts create mode 100644 apps/web-antdv-next/src/router/routes/modules/qqbot.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/account/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/conversation/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/dashboard/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/modules/options.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/permission/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/rule/list.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/sendLog/list.tsx diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index 5a68c88..742a167 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -16,6 +16,27 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([ 'BlogTagCreate', 'BlogTagDelete', 'BlogTagEdit', + 'QqBot', + 'QqBotAccount', + 'QqBotAccountCreate', + 'QqBotAccountDelete', + 'QqBotAccountEdit', + 'QqBotAccountKick', + 'QqBotConversation', + 'QqBotDashboard', + 'QqBotMessage', + 'QqBotPermission', + 'QqBotPermissionCreate', + 'QqBotPermissionDelete', + 'QqBotPermissionEdit', + 'QqBotRule', + 'QqBotRuleCreate', + 'QqBotRuleDelete', + 'QqBotRuleEdit', + 'QqBotRuleToggle', + 'QqBotSendGroup', + 'QqBotSendLog', + 'QqBotSendPrivate', 'System', 'SystemDept', 'SystemDeptCreate', diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts new file mode 100644 index 0000000..69b54e9 --- /dev/null +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -0,0 +1,270 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace QqbotApi { + export interface PageResult { + list: T[]; + pageNo?: number; + pageSize?: number; + total: number; + } + + export interface DashboardSummary { + accountTotal: number; + bus: { + connected: boolean; + mode: string; + url: string; + }; + conversationTotal: number; + enabledRuleTotal: number; + messageTotal: number; + onlineTotal: number; + runtime: { + enabled: boolean; + path: string; + sessions: string[]; + }; + sendFailedTotal: number; + sendSuccessTotal: number; + } + + export interface Account { + clientRole?: string; + connectStatus: 'offline' | 'online'; + connectionMode: 'reverse-ws'; + createTime?: string; + enabled: boolean; + id: string; + lastConnectedAt?: string; + lastError?: string; + lastHeartbeatAt?: string; + name: string; + remark?: string; + selfId: string; + } + + export interface AccountBody { + accessToken?: string; + connectionMode?: 'reverse-ws'; + enabled?: boolean; + id?: string; + name?: string; + remark?: string; + selfId: string; + } + + export interface Rule { + cooldownMs: number; + enabled: boolean; + id: string; + keyword: string; + lastHitAt?: string; + matchType: 'equals' | 'keyword' | 'regex'; + name: string; + priority: number; + remark?: string; + replyContent: string; + targetType: 'all' | 'group' | 'private'; + } + + export interface RuleBody { + cooldownMs?: number; + enabled?: boolean; + id?: string; + keyword: string; + matchType: 'equals' | 'keyword' | 'regex'; + name?: string; + priority?: number; + remark?: string; + replyContent: string; + targetType?: 'all' | 'group' | 'private'; + } + + export interface Conversation { + createTime?: string; + id: string; + lastMessageText?: string; + lastMessageTime?: string; + messageCount: number; + selfId: string; + targetId: string; + targetName?: string; + targetType: 'group' | 'private'; + } + + export interface Message { + direction: 'inbound' | 'outbound'; + eventTime: string; + id: string; + messageText: string; + messageType: 'group' | 'private'; + senderNickname?: string; + selfId: string; + targetId: string; + userId: string; + } + + export interface SendLog { + action: string; + createTime?: string; + errorMessage?: string; + id: string; + messageText: string; + selfId: string; + status: 'failed' | 'pending' | 'success'; + targetId: string; + targetType: 'group' | 'private'; + } + + export interface Permission { + enabled: boolean; + id: string; + remark?: string; + selfId?: string; + targetId: string; + targetType: 'all' | 'group' | 'private'; + } + + export interface PermissionBody { + enabled?: boolean; + id?: string; + remark?: string; + selfId?: string; + targetId: string; + targetType: 'all' | 'group' | 'private'; + } + + export type Query = Recordable; +} + +export function getQqbotDashboardSummary() { + return requestClient.get( + '/qqbot/dashboard/summary', + ); +} + +export function getQqbotAccountList(params: QqbotApi.Query) { + return requestClient.get>( + '/qqbot/account/list', + { params }, + ); +} + +export function getQqbotEnabledAccounts() { + return requestClient.get('/qqbot/account/enabled'); +} + +export function createQqbotAccount(data: QqbotApi.AccountBody) { + return requestClient.post('/qqbot/account/save', data); +} + +export function updateQqbotAccount(data: QqbotApi.AccountBody) { + return requestClient.post('/qqbot/account/update', data); +} + +export function deleteQqbotAccount(id: string) { + return requestClient.post(`/qqbot/account/delete?id=${id}`); +} + +export function kickQqbotAccount(selfId: string) { + return requestClient.post<{ count: number }>( + `/qqbot/account/kick?selfId=${selfId}`, + ); +} + +export function getQqbotRuleList(params: QqbotApi.Query) { + return requestClient.get>( + '/qqbot/rule/list', + { params }, + ); +} + +export function createQqbotRule(data: QqbotApi.RuleBody) { + return requestClient.post('/qqbot/rule/save', data); +} + +export function updateQqbotRule(data: QqbotApi.RuleBody) { + return requestClient.post('/qqbot/rule/update', data); +} + +export function deleteQqbotRule(id: string) { + return requestClient.post(`/qqbot/rule/delete?id=${id}`); +} + +export function toggleQqbotRule(id: string, enabled: boolean) { + return requestClient.post( + `/qqbot/rule/toggle?id=${id}&enabled=${enabled}`, + ); +} + +export function getQqbotConversationList(params: QqbotApi.Query) { + return requestClient.get>( + '/qqbot/conversation/list', + { params }, + ); +} + +export function getQqbotMessageList(params: QqbotApi.Query) { + return requestClient.get>( + '/qqbot/message/list', + { params }, + ); +} + +export function getQqbotSendLogList(params: QqbotApi.Query) { + return requestClient.get>( + '/qqbot/send/log/list', + { params }, + ); +} + +export function sendQqbotPrivate(data: { + message: string; + selfId?: string; + userId: string; +}) { + return requestClient.post('/qqbot/send/private', data); +} + +export function sendQqbotGroup(data: { + groupId: string; + message: string; + selfId?: string; +}) { + return requestClient.post('/qqbot/send/group', data); +} + +export function getQqbotPermissionList( + kind: 'allowlist' | 'blocklist', + params: QqbotApi.Query, +) { + return requestClient.get>( + `/qqbot/permission/${kind}`, + { params }, + ); +} + +export function createQqbotPermission( + kind: 'allowlist' | 'blocklist', + data: QqbotApi.PermissionBody, +) { + return requestClient.post(`/qqbot/permission/${kind}/save`, data); +} + +export function updateQqbotPermission( + kind: 'allowlist' | 'blocklist', + data: QqbotApi.PermissionBody, +) { + return requestClient.post(`/qqbot/permission/${kind}/update`, data); +} + +export function deleteQqbotPermission( + kind: 'allowlist' | 'blocklist', + id: string, +) { + return requestClient.post( + `/qqbot/permission/${kind}/delete?id=${id}`, + ); +} diff --git a/apps/web-antdv-next/src/router/routes/index.ts b/apps/web-antdv-next/src/router/routes/index.ts index 7960c38..eea4d20 100644 --- a/apps/web-antdv-next/src/router/routes/index.ts +++ b/apps/web-antdv-next/src/router/routes/index.ts @@ -44,6 +44,11 @@ const componentKeys: string[] = Object.keys({ const path = v.replace('../../views/', '/'); return path.replace(/\.(tsx|vue)$/, ''); }) - .filter((path) => path.startsWith('/blog/') || path.startsWith('/system/')); + .filter( + (path) => + path.startsWith('/blog/') || + path.startsWith('/qqbot/') || + path.startsWith('/system/'), + ); export { accessRoutes, componentKeys, coreRouteNames, routes }; diff --git a/apps/web-antdv-next/src/router/routes/modules/qqbot.ts b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts new file mode 100644 index 0000000..8a24dd8 --- /dev/null +++ b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts @@ -0,0 +1,81 @@ +import type { RouteRecordRaw } from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + meta: { + icon: 'lucide:bot', + order: 110, + title: 'QQBot 管理', + }, + name: 'QqBot', + path: '/qqbot', + redirect: '/qqbot/dashboard', + children: [ + { + component: () => import('#/views/qqbot/dashboard/list'), + meta: { + icon: 'lucide:gauge', + title: '工作台', + }, + name: 'QqBotDashboard', + path: '/qqbot/dashboard', + }, + { + component: () => import('#/views/qqbot/account/list'), + meta: { + icon: 'lucide:radio-receiver', + title: '账号连接', + }, + name: 'QqBotAccount', + path: '/qqbot/account', + }, + { + component: () => import('#/views/qqbot/rule/list'), + meta: { + icon: 'lucide:workflow', + title: '自动回复规则', + }, + name: 'QqBotRule', + path: '/qqbot/rule', + }, + { + component: () => import('#/views/qqbot/conversation/list'), + meta: { + icon: 'lucide:messages-square', + title: '会话管理', + }, + name: 'QqBotConversation', + path: '/qqbot/conversation', + }, + { + component: () => import('#/views/qqbot/message/list'), + meta: { + icon: 'lucide:message-square-text', + title: '消息日志', + }, + name: 'QqBotMessage', + path: '/qqbot/message', + }, + { + component: () => import('#/views/qqbot/sendLog/list'), + meta: { + icon: 'lucide:send', + title: '发送日志', + }, + name: 'QqBotSendLog', + path: '/qqbot/sendLog', + }, + { + component: () => import('#/views/qqbot/permission/list'), + meta: { + icon: 'lucide:shield-check', + title: '权限名单', + }, + name: 'QqBotPermission', + path: '/qqbot/permission', + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-antdv-next/src/views/qqbot/account/list.tsx b/apps/web-antdv-next/src/views/qqbot/account/list.tsx new file mode 100644 index 0000000..45b023d --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -0,0 +1,305 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { + KtTableApi, + KtTableButton, + KtTableRowAction, +} from '#/components/ktTable'; + +import { computed, defineComponent, reactive, ref } from 'vue'; + +import { Page } from '@vben/common-ui'; +import { Plus } from '@vben/icons'; + +import { Form, FormItem, Input, message, Modal, Switch, Tag } from 'antdv-next'; + +import { + createQqbotAccount, + deleteQqbotAccount, + getQqbotAccountList, + kickQqbotAccount, + updateQqbotAccount, +} from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +const AKtTable = KtTable as any; +const AInput = Input as any; +const AModal = Modal as any; +const ASwitch = Switch as any; + +export default defineComponent({ + name: 'QqBotAccountList', + setup() { + const saving = ref(false); + const modalOpen = ref(false); + const editingId = ref(); + const form = reactive({ + accessToken: '', + connectionMode: 'reverse-ws', + enabled: true, + name: '', + remark: '', + selfId: '', + }); + + const columns: Array> = [ + { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 160 }, + { dataIndex: 'name', key: 'name', title: '账号名称', width: 180 }, + { + dataIndex: 'connectStatus', + key: 'connectStatus', + title: '连接状态', + width: 120, + }, + { + dataIndex: 'clientRole', + key: 'clientRole', + title: '连接角色', + width: 120, + }, + { + dataIndex: 'lastHeartbeatAt', + key: 'lastHeartbeatAt', + title: '最后心跳', + width: 190, + }, + { + dataIndex: 'lastError', + key: 'lastError', + title: '错误信息', + width: 260, + }, + ]; + + const api: KtTableApi = { + list: async (params) => await getQqbotAccountList(params), + }; + const buttons: Array> = [ + { + icon: , + key: 'create', + label: '新建账号', + onClick: openCreate, + permissionCodes: ['QqBot:Account:Create'], + type: 'primary', + }, + ]; + const rowActions: Array> = [ + { + disabled: (row) => row.connectStatus !== 'online', + key: 'kick', + label: '断开', + onClick: async (row, context) => { + await kickQqbotAccount(row.selfId); + message.success('连接已断开'); + await context.reload(); + }, + permissionCodes: ['QqBot:Account:Kick'], + }, + { + key: 'edit', + label: '编辑', + onClick: openEdit, + permissionCodes: ['QqBot:Account:Edit'], + }, + { + confirm: (row) => `确认删除账号「${row.selfId}」吗?`, + danger: true, + key: 'delete', + label: '删除', + onClick: async (row, context) => { + await deleteQqbotAccount(row.id); + message.success('账号删除成功'); + await context.reload(); + }, + permissionCodes: ['QqBot:Account:Delete'], + }, + ]; + const [registerTable, tableApi] = useKtTable({ + api, + buttons, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true, placeholder: 'Self ID' }, + fieldName: 'selfId', + label: 'Self ID', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '账号名称' }, + fieldName: 'name', + label: '账号名称', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '在线', value: 'online' }, + { label: '离线', value: 'offline' }, + ], + }, + fieldName: 'connectStatus', + label: '连接状态', + }, + ], + }, + rowActions, + tableTitle: 'QQBot 账号连接', + }); + const modalTitle = computed(() => + editingId.value ? '编辑账号' : '新建账号', + ); + + function openCreate() { + editingId.value = undefined; + Object.assign(form, { + accessToken: '', + connectionMode: 'reverse-ws', + enabled: true, + name: '', + remark: '', + selfId: '', + }); + modalOpen.value = true; + } + + function openEdit(row: QqbotApi.Account) { + editingId.value = row.id; + Object.assign(form, { + accessToken: '', + connectionMode: row.connectionMode, + enabled: row.enabled, + id: row.id, + name: row.name, + remark: row.remark || '', + selfId: row.selfId, + }); + modalOpen.value = true; + } + + async function submitAccount() { + if (!form.selfId.trim()) { + message.warning('请填写 Self ID'); + return; + } + + saving.value = true; + try { + const payload = { + ...form, + id: editingId.value, + selfId: form.selfId.trim(), + }; + if (!payload.accessToken) delete payload.accessToken; + await (editingId.value + ? updateQqbotAccount(payload) + : createQqbotAccount(payload)); + message.success('账号保存成功'); + modalOpen.value = false; + await tableApi.reload(); + } finally { + saving.value = false; + } + } + + return () => ( + + { + const row = record as QqbotApi.Account; + if (column.key === 'connectStatus') { + return ( + + {row.connectStatus === 'online' ? '在线' : '离线'} + + ); + } + return undefined; + }, + }} + /> + { + modalOpen.value = value; + }, + }} + open={modalOpen.value} + title={modalTitle.value} + width="620px" + > +
+ + { + form.selfId = value; + }, + }} + placeholder="NapCat 当前登录 QQ" + value={form.selfId} + /> + + + { + form.name = value; + }, + }} + placeholder="便于后台识别" + value={form.name} + /> + + + { + form.accessToken = value; + }, + }} + placeholder={ + editingId.value ? '留空表示不修改' : 'OneBot 反向 WS token' + } + value={form.accessToken} + /> + + + { + form.enabled = value; + }, + }} + /> + + + { + form.remark = value; + }, + }} + value={form.remark} + /> + +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/conversation/list.tsx b/apps/web-antdv-next/src/views/qqbot/conversation/list.tsx new file mode 100644 index 0000000..d787f89 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/conversation/list.tsx @@ -0,0 +1,107 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { KtTableApi } from '#/components/ktTable'; + +import { defineComponent } from 'vue'; + +import { Page } from '@vben/common-ui'; + +import { Tag } from 'antdv-next'; + +import { getQqbotConversationList } from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import { getOptionLabel, qqbotMessageTypeOptions } from '../modules/options'; + +const AKtTable = KtTable as any; + +export default defineComponent({ + name: 'QqBotConversationList', + setup() { + const columns: Array> = [ + { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '会话类型', + width: 110, + }, + { dataIndex: 'targetId', key: 'targetId', title: '目标 ID', width: 160 }, + { dataIndex: 'targetName', key: 'targetName', title: '名称', width: 160 }, + { + dataIndex: 'lastMessageText', + key: 'lastMessageText', + title: '最后消息', + width: 360, + }, + { + dataIndex: 'messageCount', + key: 'messageCount', + title: '消息数', + width: 100, + }, + { + dataIndex: 'lastMessageTime', + key: 'lastMessageTime', + title: '最后时间', + width: 190, + }, + ]; + const api: KtTableApi = { + list: async (params) => await getQqbotConversationList(params), + }; + const [registerTable] = useKtTable({ + api, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true, placeholder: 'Self ID' }, + fieldName: 'selfId', + label: 'Self ID', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotMessageTypeOptions, + }, + fieldName: 'targetType', + label: '会话类型', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '目标 ID' }, + fieldName: 'targetId', + label: '目标 ID', + }, + ], + }, + rowActions: [], + tableTitle: '会话管理', + }); + + return () => ( + + { + const row = record as QqbotApi.Conversation; + if (column.key === 'targetType') { + return ( + + {getOptionLabel(qqbotMessageTypeOptions, row.targetType)} + + ); + } + return undefined; + }, + }} + /> + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/dashboard/list.tsx b/apps/web-antdv-next/src/views/qqbot/dashboard/list.tsx new file mode 100644 index 0000000..973bc0a --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/dashboard/list.tsx @@ -0,0 +1,129 @@ +import type { DescriptionsItemType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; + +import { defineComponent, onMounted, ref } from 'vue'; + +import { Page } from '@vben/common-ui'; + +import { Card, Col, Descriptions, Row, Statistic, Tag } from 'antdv-next'; + +import { getQqbotDashboardSummary } from '#/api/qqbot'; + +const ACard = Card as any; +const ACol = Col as any; +const ADescriptions = Descriptions as any; +const ARow = Row as any; +const AStatistic = Statistic as any; +const ATag = Tag as any; + +export default defineComponent({ + name: 'QqBotDashboardList', + setup() { + const loading = ref(false); + const summary = ref(); + + async function loadSummary() { + loading.value = true; + try { + summary.value = await getQqbotDashboardSummary(); + } finally { + loading.value = false; + } + } + + onMounted(loadSummary); + + return () => { + const data = summary.value; + const runtimeItems: DescriptionsItemType[] = [ + { + content: ( + + {data?.runtime.enabled ? '已启用' : '未启用'} + + ), + key: 'runtime', + label: 'QQBot Runtime', + }, + { + content: data?.runtime.path || '-', + key: 'reverseWsPath', + label: '反向 WS 路径', + }, + { + content: data?.runtime.sessions?.length || 0, + key: 'sessions', + label: '在线会话', + }, + { + content: ( + + {data?.bus.mode || 'local'} /{' '} + {data?.bus.connected ? '已连接' : '未连接'} + + ), + key: 'mqtt', + label: 'MQTT', + }, + { + content: data?.conversationTotal || 0, + key: 'conversationTotal', + label: '会话数', + }, + { + content: `${data?.sendSuccessTotal || 0}/${data?.sendFailedTotal || 0}`, + key: 'sendResult', + label: '发送成功/失败', + }, + ]; + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }; + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message/list.tsx b/apps/web-antdv-next/src/views/qqbot/message/list.tsx new file mode 100644 index 0000000..06b8492 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message/list.tsx @@ -0,0 +1,125 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { KtTableApi } from '#/components/ktTable'; + +import { defineComponent } from 'vue'; + +import { Page } from '@vben/common-ui'; + +import { Tag } from 'antdv-next'; + +import { getQqbotMessageList } from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import { getOptionLabel, qqbotMessageTypeOptions } from '../modules/options'; + +const AKtTable = KtTable as any; + +export default defineComponent({ + name: 'QqBotMessageList', + setup() { + const columns: Array> = [ + { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 }, + { + dataIndex: 'messageType', + key: 'messageType', + title: '消息类型', + width: 110, + }, + { dataIndex: 'direction', key: 'direction', title: '方向', width: 100 }, + { dataIndex: 'targetId', key: 'targetId', title: '目标 ID', width: 150 }, + { dataIndex: 'userId', key: 'userId', title: '用户 ID', width: 150 }, + { + dataIndex: 'senderNickname', + key: 'senderNickname', + title: '发送人', + width: 150, + }, + { + dataIndex: 'messageText', + key: 'messageText', + title: '消息内容', + width: 420, + }, + { + dataIndex: 'eventTime', + key: 'eventTime', + title: '消息时间', + width: 190, + }, + ]; + const api: KtTableApi = { + list: async (params) => await getQqbotMessageList(params), + }; + const [registerTable] = useKtTable({ + api, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '关键词' }, + fieldName: 'keyword', + label: '关键词', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: 'Self ID' }, + fieldName: 'selfId', + label: 'Self ID', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotMessageTypeOptions, + }, + fieldName: 'targetType', + label: '消息类型', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '目标 ID' }, + fieldName: 'targetId', + label: '目标 ID', + }, + ], + }, + rowActions: [], + tableTitle: '消息日志', + }); + + return () => ( + + { + const row = record as QqbotApi.Message; + if (column.key === 'messageType') { + return ( + + {getOptionLabel(qqbotMessageTypeOptions, row.messageType)} + + ); + } + if (column.key === 'direction') { + return ( + + {row.direction === 'inbound' ? '接收' : '发送'} + + ); + } + return undefined; + }, + }} + /> + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/modules/options.ts b/apps/web-antdv-next/src/views/qqbot/modules/options.ts new file mode 100644 index 0000000..9058500 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/modules/options.ts @@ -0,0 +1,44 @@ +export const qqbotTargetTypeOptions = [ + { label: '全部', value: 'all' }, + { label: '私聊', value: 'private' }, + { label: '群聊', value: 'group' }, +]; + +export const qqbotMessageTypeOptions = [ + { label: '私聊', value: 'private' }, + { label: '群聊', value: 'group' }, +]; + +export const qqbotRuleMatchOptions = [ + { label: '关键词包含', value: 'keyword' }, + { label: '完全相等', value: 'equals' }, + { label: '正则匹配', value: 'regex' }, +]; + +export const qqbotRuleTargetOptions = qqbotTargetTypeOptions; + +const qqbotDefaultSendStatusOption = { + color: 'default', + label: '等待中', + value: 'pending', +}; + +export const qqbotSendStatusOptions = [ + qqbotDefaultSendStatusOption, + { color: 'success', label: '成功', value: 'success' }, + { color: 'error', label: '失败', value: 'failed' }, +]; + +export function getOptionLabel( + options: Array<{ label: string; value: string }>, + value?: string, +) { + return options.find((item) => item.value === value)?.label || value || '-'; +} + +export function getSendStatusOption(status?: string) { + return ( + qqbotSendStatusOptions.find((item) => item.value === status) || + qqbotDefaultSendStatusOption + ); +} diff --git a/apps/web-antdv-next/src/views/qqbot/permission/list.tsx b/apps/web-antdv-next/src/views/qqbot/permission/list.tsx new file mode 100644 index 0000000..d5035c2 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/permission/list.tsx @@ -0,0 +1,297 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { + KtTableApi, + KtTableButton, + KtTableRowAction, +} from '#/components/ktTable'; + +import { computed, defineComponent, reactive, ref, watch } from 'vue'; + +import { Page } from '@vben/common-ui'; +import { Plus } from '@vben/icons'; + +import { + Form, + FormItem, + Input, + message, + Modal, + Select, + Switch, + Tabs, + Tag, +} from 'antdv-next'; + +import { + createQqbotPermission, + deleteQqbotPermission, + getQqbotPermissionList, + updateQqbotPermission, +} from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import { getOptionLabel, qqbotTargetTypeOptions } from '../modules/options'; + +const AKtTable = KtTable as any; +const AInput = Input as any; +const AModal = Modal as any; +const ASelect = Select as any; +const ASwitch = Switch as any; +const ATabs = Tabs as any; + +type PermissionKind = 'allowlist' | 'blocklist'; + +export default defineComponent({ + name: 'QqBotPermissionList', + setup() { + const activeKind = ref('allowlist'); + const saving = ref(false); + const modalOpen = ref(false); + const editingId = ref(); + const form = reactive({ + enabled: true, + remark: '', + selfId: '', + targetId: '', + targetType: 'private', + }); + const columns: Array> = [ + { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '目标类型', + width: 110, + }, + { dataIndex: 'targetId', key: 'targetId', title: '目标 ID', width: 160 }, + { dataIndex: 'enabled', key: 'enabled', title: '状态', width: 100 }, + { dataIndex: 'remark', key: 'remark', title: '备注', width: 260 }, + ]; + const api: KtTableApi = { + list: async (params) => + await getQqbotPermissionList(activeKind.value, params), + }; + const buttons: Array> = [ + { + icon: , + key: 'create', + label: '新增名单', + onClick: openCreate, + permissionCodes: ['QqBot:Permission:Create'], + type: 'primary', + }, + ]; + const rowActions: Array> = [ + { + key: 'edit', + label: '编辑', + onClick: openEdit, + permissionCodes: ['QqBot:Permission:Edit'], + }, + { + confirm: (row) => + `确认删除名单「${row.targetId || row.targetType}」吗?`, + danger: true, + key: 'delete', + label: '删除', + onClick: async (row, context) => { + await deleteQqbotPermission(activeKind.value, row.id); + message.success('名单删除成功'); + await context.reload(); + }, + permissionCodes: ['QqBot:Permission:Delete'], + }, + ]; + const [registerTable, tableApi] = useKtTable({ + api, + buttons, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true, placeholder: 'Self ID' }, + fieldName: 'selfId', + label: 'Self ID', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotTargetTypeOptions, + }, + fieldName: 'targetType', + label: '目标类型', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '目标 ID' }, + fieldName: 'targetId', + label: '目标 ID', + }, + ], + }, + rowActions, + tableTitle: '权限名单', + }); + const modalTitle = computed( + () => + `${editingId.value ? '编辑' : '新增'}${activeKind.value === 'allowlist' ? '白名单' : '黑名单'}`, + ); + + watch(activeKind, async () => { + await tableApi.reset(); + }); + + function openCreate() { + editingId.value = undefined; + Object.assign(form, { + enabled: true, + remark: '', + selfId: '', + targetId: '', + targetType: 'private', + }); + modalOpen.value = true; + } + + function openEdit(row: QqbotApi.Permission) { + editingId.value = row.id; + Object.assign(form, { ...row }); + modalOpen.value = true; + } + + async function submitPermission() { + if (form.targetType !== 'all' && !form.targetId.trim()) { + message.warning('请填写目标 ID'); + return; + } + + saving.value = true; + try { + await (editingId.value + ? updateQqbotPermission(activeKind.value, { + ...form, + id: editingId.value, + }) + : createQqbotPermission(activeKind.value, form)); + message.success('名单保存成功'); + modalOpen.value = false; + await tableApi.reload(); + } finally { + saving.value = false; + } + } + + return () => ( + +
+ { + activeKind.value = value; + }, + }} + /> + { + const row = record as QqbotApi.Permission; + if (column.key === 'enabled') { + return ( + + {row.enabled ? '启用' : '停用'} + + ); + } + if (column.key === 'targetType') { + return getOptionLabel(qqbotTargetTypeOptions, row.targetType); + } + return undefined; + }, + }} + /> +
+ { + modalOpen.value = value; + }, + }} + open={modalOpen.value} + title={modalTitle.value} + width="620px" + > +
+ + { + form.selfId = value; + }, + }} + placeholder="留空代表全部账号" + value={form.selfId} + /> + + + { + form.targetType = value; + }, + }} + options={qqbotTargetTypeOptions} + value={form.targetType} + /> + + + { + form.targetId = value; + }, + }} + placeholder="私聊填 QQ 号,群聊填群号" + value={form.targetId} + /> + + + { + form.enabled = value; + }, + }} + /> + + + { + form.remark = value; + }, + }} + value={form.remark} + /> + +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/rule/list.tsx b/apps/web-antdv-next/src/views/qqbot/rule/list.tsx new file mode 100644 index 0000000..1ebeed7 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/rule/list.tsx @@ -0,0 +1,349 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { + KtTableApi, + KtTableButton, + KtTableRowAction, +} from '#/components/ktTable'; + +import { computed, defineComponent, reactive, ref } from 'vue'; + +import { Page } from '@vben/common-ui'; +import { Plus } from '@vben/icons'; + +import { + Form, + FormItem, + Input, + InputNumber, + message, + Modal, + Select, + Switch, + Tag, + TextArea, +} from 'antdv-next'; + +import { + createQqbotRule, + deleteQqbotRule, + getQqbotRuleList, + toggleQqbotRule, + updateQqbotRule, +} from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import { + getOptionLabel, + qqbotRuleMatchOptions, + qqbotRuleTargetOptions, +} from '../modules/options'; + +const AKtTable = KtTable as any; +const AInput = Input as any; +const AInputNumber = InputNumber as any; +const AModal = Modal as any; +const ASelect = Select as any; +const ASwitch = Switch as any; +const ATextArea = TextArea as any; + +export default defineComponent({ + name: 'QqBotRuleList', + setup() { + const saving = ref(false); + const modalOpen = ref(false); + const editingId = ref(); + const form = reactive({ + cooldownMs: 1500, + enabled: true, + keyword: '', + matchType: 'keyword', + name: '', + priority: 0, + replyContent: '', + targetType: 'all', + }); + + const columns: Array> = [ + { dataIndex: 'name', key: 'name', title: '规则名称', width: 180 }, + { dataIndex: 'keyword', key: 'keyword', title: '关键词', width: 220 }, + { + dataIndex: 'matchType', + key: 'matchType', + title: '匹配方式', + width: 120, + }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '目标范围', + width: 120, + }, + { dataIndex: 'enabled', key: 'enabled', title: '状态', width: 100 }, + { dataIndex: 'priority', key: 'priority', title: '优先级', width: 100 }, + { + dataIndex: 'lastHitAt', + key: 'lastHitAt', + title: '最后命中', + width: 190, + }, + ]; + const api: KtTableApi = { + list: async (params) => await getQqbotRuleList(params), + }; + const buttons: Array> = [ + { + icon: , + key: 'create', + label: '新建规则', + onClick: openCreate, + permissionCodes: ['QqBot:Rule:Create'], + type: 'primary', + }, + ]; + const rowActions: Array> = [ + { + key: 'toggle', + label: '启停', + onClick: async (row, context) => { + await toggleQqbotRule(row.id, !row.enabled); + message.success(row.enabled ? '规则已停用' : '规则已启用'); + await context.reload(); + }, + permissionCodes: ['QqBot:Rule:Toggle'], + }, + { + key: 'edit', + label: '编辑', + onClick: openEdit, + permissionCodes: ['QqBot:Rule:Edit'], + }, + { + confirm: (row) => `确认删除规则「${row.name || row.keyword}」吗?`, + danger: true, + key: 'delete', + label: '删除', + onClick: async (row, context) => { + await deleteQqbotRule(row.id); + message.success('规则删除成功'); + await context.reload(); + }, + permissionCodes: ['QqBot:Rule:Delete'], + }, + ]; + const [registerTable, tableApi] = useKtTable({ + api, + buttons, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { + allowClear: true, + placeholder: '规则名称/关键词', + }, + fieldName: 'keyword', + label: '关键词', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotRuleTargetOptions, + }, + fieldName: 'targetType', + label: '目标范围', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: true }, + { label: '停用', value: false }, + ], + }, + fieldName: 'enabled', + label: '状态', + }, + ], + }, + rowActions, + tableTitle: '自动回复规则', + }); + const modalTitle = computed(() => + editingId.value ? '编辑规则' : '新建规则', + ); + + function openCreate() { + editingId.value = undefined; + Object.assign(form, { + cooldownMs: 1500, + enabled: true, + keyword: '', + matchType: 'keyword', + name: '', + priority: 0, + replyContent: '', + targetType: 'all', + }); + modalOpen.value = true; + } + + function openEdit(row: QqbotApi.Rule) { + editingId.value = row.id; + Object.assign(form, { ...row }); + modalOpen.value = true; + } + + async function submitRule() { + if (!form.keyword.trim() || !form.replyContent.trim()) { + message.warning('请填写关键词和回复内容'); + return; + } + + saving.value = true; + try { + await (editingId.value + ? updateQqbotRule({ ...form, id: editingId.value }) + : createQqbotRule(form)); + message.success('规则保存成功'); + modalOpen.value = false; + await tableApi.reload(); + } finally { + saving.value = false; + } + } + + return () => ( + + { + const row = record as QqbotApi.Rule; + if (column.key === 'enabled') { + return ( + + {row.enabled ? '启用' : '停用'} + + ); + } + if (column.key === 'matchType') { + return getOptionLabel(qqbotRuleMatchOptions, row.matchType); + } + if (column.key === 'targetType') { + return getOptionLabel(qqbotRuleTargetOptions, row.targetType); + } + return undefined; + }, + }} + /> + { + modalOpen.value = value; + }, + }} + open={modalOpen.value} + title={modalTitle.value} + width="720px" + > +
+ + { + form.name = value; + }, + }} + value={form.name} + /> + + + { + form.matchType = value; + }, + }} + options={qqbotRuleMatchOptions} + value={form.matchType} + /> + + + { + form.keyword = value; + }, + }} + value={form.keyword} + /> + + + { + form.targetType = value; + }, + }} + options={qqbotRuleTargetOptions} + value={form.targetType} + /> + + + { + form.replyContent = value; + }, + }} + value={form.replyContent} + /> + + + { + form.priority = value || 0; + }, + }} + value={form.priority} + /> + + + { + form.cooldownMs = value || 0; + }, + }} + value={form.cooldownMs} + /> + + + { + form.enabled = value; + }, + }} + /> + +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/sendLog/list.tsx b/apps/web-antdv-next/src/views/qqbot/sendLog/list.tsx new file mode 100644 index 0000000..e27040e --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/sendLog/list.tsx @@ -0,0 +1,248 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { KtTableApi, KtTableButton } from '#/components/ktTable'; + +import { computed, defineComponent, reactive, ref } from 'vue'; + +import { Page } from '@vben/common-ui'; + +import { Form, FormItem, Input, message, Modal, Select, Tag } from 'antdv-next'; + +import { + getQqbotSendLogList, + sendQqbotGroup, + sendQqbotPrivate, +} from '#/api/qqbot'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import { + getOptionLabel, + getSendStatusOption, + qqbotMessageTypeOptions, + qqbotSendStatusOptions, +} from '../modules/options'; + +const AKtTable = KtTable as any; +const AInput = Input as any; +const AModal = Modal as any; +const ASelect = Select as any; + +export default defineComponent({ + name: 'QqBotSendLogList', + setup() { + const saving = ref(false); + const modalOpen = ref(false); + const sendForm = reactive({ + message: '', + selfId: '', + targetId: '', + targetType: 'private' as 'group' | 'private', + }); + const columns: Array> = [ + { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '目标类型', + width: 110, + }, + { dataIndex: 'targetId', key: 'targetId', title: '目标 ID', width: 160 }, + { dataIndex: 'status', key: 'status', title: '状态', width: 100 }, + { + dataIndex: 'messageText', + key: 'messageText', + title: '消息内容', + width: 420, + }, + { + dataIndex: 'errorMessage', + key: 'errorMessage', + title: '错误信息', + width: 260, + }, + { + dataIndex: 'createTime', + key: 'createTime', + title: '发送时间', + width: 190, + }, + ]; + const api: KtTableApi = { + list: async (params) => await getQqbotSendLogList(params), + }; + const buttons: Array> = [ + { + key: 'send', + label: '手动发送', + onClick: openSend, + permissionCodes: ['QqBot:Send:Private', 'QqBot:Send:Group'], + type: 'primary', + }, + ]; + const [registerTable, tableApi] = useKtTable({ + api, + buttons, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true, placeholder: 'Self ID' }, + fieldName: 'selfId', + label: 'Self ID', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotMessageTypeOptions, + }, + fieldName: 'targetType', + label: '目标类型', + }, + { + component: 'Input', + componentProps: { allowClear: true, placeholder: '目标 ID' }, + fieldName: 'targetId', + label: '目标 ID', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: qqbotSendStatusOptions, + }, + fieldName: 'status', + label: '状态', + }, + ], + }, + rowActions: [], + tableTitle: '发送日志', + }); + const targetLabel = computed(() => + sendForm.targetType === 'group' ? '群号' : 'QQ 号', + ); + + function openSend() { + Object.assign(sendForm, { + message: '', + selfId: '', + targetId: '', + targetType: 'private', + }); + modalOpen.value = true; + } + + async function submitSend() { + if (!sendForm.targetId.trim() || !sendForm.message.trim()) { + message.warning('请填写目标和消息内容'); + return; + } + + saving.value = true; + try { + await (sendForm.targetType === 'group' + ? sendQqbotGroup({ + groupId: sendForm.targetId, + message: sendForm.message, + selfId: sendForm.selfId || undefined, + }) + : sendQqbotPrivate({ + message: sendForm.message, + selfId: sendForm.selfId || undefined, + userId: sendForm.targetId, + })); + message.success('消息已发送'); + modalOpen.value = false; + await tableApi.reload(); + } finally { + saving.value = false; + } + } + + return () => ( + + { + const row = record as QqbotApi.SendLog; + if (column.key === 'targetType') { + return getOptionLabel(qqbotMessageTypeOptions, row.targetType); + } + if (column.key === 'status') { + const status = getSendStatusOption(row.status); + return {status.label}; + } + return undefined; + }, + }} + /> + { + modalOpen.value = value; + }, + }} + open={modalOpen.value} + title="手动发送" + width="620px" + > +
+ + { + sendForm.selfId = value; + }, + }} + placeholder="留空使用默认启用账号" + value={sendForm.selfId} + /> + + + { + sendForm.targetType = value; + }, + }} + options={qqbotMessageTypeOptions} + value={sendForm.targetType} + /> + + + { + sendForm.targetId = value; + }, + }} + value={sendForm.targetId} + /> + + + { + sendForm.message = value; + }, + }} + value={sendForm.message} + /> + +
+
+
+ ); + }, +});