feat: 增加QQBot管理页面

This commit is contained in:
sunlei 2026-05-30 20:51:46 +08:00
parent 14880dfcf1
commit 8644cf8eab
12 changed files with 1982 additions and 1 deletions

View File

@ -16,6 +16,27 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'BlogTagCreate', 'BlogTagCreate',
'BlogTagDelete', 'BlogTagDelete',
'BlogTagEdit', 'BlogTagEdit',
'QqBot',
'QqBotAccount',
'QqBotAccountCreate',
'QqBotAccountDelete',
'QqBotAccountEdit',
'QqBotAccountKick',
'QqBotConversation',
'QqBotDashboard',
'QqBotMessage',
'QqBotPermission',
'QqBotPermissionCreate',
'QqBotPermissionDelete',
'QqBotPermissionEdit',
'QqBotRule',
'QqBotRuleCreate',
'QqBotRuleDelete',
'QqBotRuleEdit',
'QqBotRuleToggle',
'QqBotSendGroup',
'QqBotSendLog',
'QqBotSendPrivate',
'System', 'System',
'SystemDept', 'SystemDept',
'SystemDeptCreate', 'SystemDeptCreate',

View File

@ -0,0 +1,270 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace QqbotApi {
export interface PageResult<T> {
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<any>;
}
export function getQqbotDashboardSummary() {
return requestClient.get<QqbotApi.DashboardSummary>(
'/qqbot/dashboard/summary',
);
}
export function getQqbotAccountList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Account>>(
'/qqbot/account/list',
{ params },
);
}
export function getQqbotEnabledAccounts() {
return requestClient.get<QqbotApi.Account[]>('/qqbot/account/enabled');
}
export function createQqbotAccount(data: QqbotApi.AccountBody) {
return requestClient.post<string>('/qqbot/account/save', data);
}
export function updateQqbotAccount(data: QqbotApi.AccountBody) {
return requestClient.post<boolean>('/qqbot/account/update', data);
}
export function deleteQqbotAccount(id: string) {
return requestClient.post<boolean>(`/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<QqbotApi.PageResult<QqbotApi.Rule>>(
'/qqbot/rule/list',
{ params },
);
}
export function createQqbotRule(data: QqbotApi.RuleBody) {
return requestClient.post<string>('/qqbot/rule/save', data);
}
export function updateQqbotRule(data: QqbotApi.RuleBody) {
return requestClient.post<boolean>('/qqbot/rule/update', data);
}
export function deleteQqbotRule(id: string) {
return requestClient.post<boolean>(`/qqbot/rule/delete?id=${id}`);
}
export function toggleQqbotRule(id: string, enabled: boolean) {
return requestClient.post<boolean>(
`/qqbot/rule/toggle?id=${id}&enabled=${enabled}`,
);
}
export function getQqbotConversationList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Conversation>>(
'/qqbot/conversation/list',
{ params },
);
}
export function getQqbotMessageList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Message>>(
'/qqbot/message/list',
{ params },
);
}
export function getQqbotSendLogList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.SendLog>>(
'/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<QqbotApi.PageResult<QqbotApi.Permission>>(
`/qqbot/permission/${kind}`,
{ params },
);
}
export function createQqbotPermission(
kind: 'allowlist' | 'blocklist',
data: QqbotApi.PermissionBody,
) {
return requestClient.post<string>(`/qqbot/permission/${kind}/save`, data);
}
export function updateQqbotPermission(
kind: 'allowlist' | 'blocklist',
data: QqbotApi.PermissionBody,
) {
return requestClient.post<boolean>(`/qqbot/permission/${kind}/update`, data);
}
export function deleteQqbotPermission(
kind: 'allowlist' | 'blocklist',
id: string,
) {
return requestClient.post<boolean>(
`/qqbot/permission/${kind}/delete?id=${id}`,
);
}

View File

@ -44,6 +44,11 @@ const componentKeys: string[] = Object.keys({
const path = v.replace('../../views/', '/'); const path = v.replace('../../views/', '/');
return path.replace(/\.(tsx|vue)$/, ''); 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 }; export { accessRoutes, componentKeys, coreRouteNames, routes };

View File

@ -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;

View File

@ -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<string>();
const form = reactive<QqbotApi.AccountBody>({
accessToken: '',
connectionMode: 'reverse-ws',
enabled: true,
name: '',
remark: '',
selfId: '',
});
const columns: Array<TableColumnType<QqbotApi.Account>> = [
{ 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<QqbotApi.Account> = {
list: async (params) => await getQqbotAccountList(params),
};
const buttons: Array<KtTableButton<QqbotApi.Account>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建账号',
onClick: openCreate,
permissionCodes: ['QqBot:Account:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Account>> = [
{
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<QqbotApi.Account>({
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Account;
if (column.key === 'connectStatus') {
return (
<Tag
color={
row.connectStatus === 'online' ? 'success' : 'default'
}
>
{row.connectStatus === 'online' ? '在线' : '离线'}
</Tag>
);
}
return undefined;
},
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitAccount}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title={modalTitle.value}
width="620px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="Self ID" required>
<AInput
{...{
'onUpdate:value': (value: string) => {
form.selfId = value;
},
}}
placeholder="NapCat 当前登录 QQ"
value={form.selfId}
/>
</FormItem>
<FormItem label="账号名称">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.name = value;
},
}}
placeholder="便于后台识别"
value={form.name}
/>
</FormItem>
<FormItem label="Token">
<AInput.Password
{...{
'onUpdate:value': (value: string) => {
form.accessToken = value;
},
}}
placeholder={
editingId.value ? '留空表示不修改' : 'OneBot 反向 WS token'
}
value={form.accessToken}
/>
</FormItem>
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
<FormItem label="备注">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.remark = value;
},
}}
value={form.remark}
/>
</FormItem>
</Form>
</AModal>
</Page>
);
},
});

View File

@ -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<TableColumnType<QqbotApi.Conversation>> = [
{ 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<QqbotApi.Conversation> = {
list: async (params) => await getQqbotConversationList(params),
};
const [registerTable] = useKtTable<QqbotApi.Conversation>({
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Conversation;
if (column.key === 'targetType') {
return (
<Tag color={row.targetType === 'group' ? 'blue' : 'green'}>
{getOptionLabel(qqbotMessageTypeOptions, row.targetType)}
</Tag>
);
}
return undefined;
},
}}
/>
</Page>
);
},
});

View File

@ -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<QqbotApi.DashboardSummary>();
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: (
<ATag color={data?.runtime.enabled ? 'success' : 'default'}>
{data?.runtime.enabled ? '已启用' : '未启用'}
</ATag>
),
key: 'runtime',
label: 'QQBot Runtime',
},
{
content: data?.runtime.path || '-',
key: 'reverseWsPath',
label: '反向 WS 路径',
},
{
content: data?.runtime.sessions?.length || 0,
key: 'sessions',
label: '在线会话',
},
{
content: (
<ATag color={data?.bus.connected ? 'success' : 'default'}>
{data?.bus.mode || 'local'} /{' '}
{data?.bus.connected ? '已连接' : '未连接'}
</ATag>
),
key: 'mqtt',
label: 'MQTT',
},
{
content: data?.conversationTotal || 0,
key: 'conversationTotal',
label: '会话数',
},
{
content: `${data?.sendSuccessTotal || 0}/${data?.sendFailedTotal || 0}`,
key: 'sendResult',
label: '发送成功/失败',
},
];
return (
<Page autoContentHeight>
<div style={{ display: 'grid', gap: '16px' }}>
<ARow gutter={[16, 16]}>
<ACol span={6}>
<ACard loading={loading.value}>
<AStatistic
title="账号总数"
value={data?.accountTotal || 0}
/>
</ACard>
</ACol>
<ACol span={6}>
<ACard loading={loading.value}>
<AStatistic title="在线账号" value={data?.onlineTotal || 0} />
</ACard>
</ACol>
<ACol span={6}>
<ACard loading={loading.value}>
<AStatistic
title="启用规则"
value={data?.enabledRuleTotal || 0}
/>
</ACard>
</ACol>
<ACol span={6}>
<ACard loading={loading.value}>
<AStatistic
title="消息总数"
value={data?.messageTotal || 0}
/>
</ACard>
</ACol>
</ARow>
<ACard loading={loading.value} title="运行状态">
<ADescriptions
bordered
column={2}
items={runtimeItems}
size="small"
/>
</ACard>
</div>
</Page>
);
};
},
});

View File

@ -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<TableColumnType<QqbotApi.Message>> = [
{ 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<QqbotApi.Message> = {
list: async (params) => await getQqbotMessageList(params),
};
const [registerTable] = useKtTable<QqbotApi.Message>({
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Message;
if (column.key === 'messageType') {
return (
<Tag color={row.messageType === 'group' ? 'blue' : 'green'}>
{getOptionLabel(qqbotMessageTypeOptions, row.messageType)}
</Tag>
);
}
if (column.key === 'direction') {
return (
<Tag
color={
row.direction === 'inbound' ? 'default' : 'processing'
}
>
{row.direction === 'inbound' ? '接收' : '发送'}
</Tag>
);
}
return undefined;
},
}}
/>
</Page>
);
},
});

View File

@ -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
);
}

View File

@ -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<PermissionKind>('allowlist');
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<string>();
const form = reactive<QqbotApi.PermissionBody>({
enabled: true,
remark: '',
selfId: '',
targetId: '',
targetType: 'private',
});
const columns: Array<TableColumnType<QqbotApi.Permission>> = [
{ 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<QqbotApi.Permission> = {
list: async (params) =>
await getQqbotPermissionList(activeKind.value, params),
};
const buttons: Array<KtTableButton<QqbotApi.Permission>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新增名单',
onClick: openCreate,
permissionCodes: ['QqBot:Permission:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Permission>> = [
{
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<QqbotApi.Permission>({
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 () => (
<Page autoContentHeight>
<div style={{ display: 'grid', gap: '12px' }}>
<ATabs
activeKey={activeKind.value}
items={[
{ key: 'allowlist', label: '白名单' },
{ key: 'blocklist', label: '黑名单' },
]}
{...{
'onUpdate:activeKey': (value: PermissionKind) => {
activeKind.value = value;
},
}}
/>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Permission;
if (column.key === 'enabled') {
return (
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'targetType') {
return getOptionLabel(qqbotTargetTypeOptions, row.targetType);
}
return undefined;
},
}}
/>
</div>
<AModal
confirmLoading={saving.value}
onOk={submitPermission}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title={modalTitle.value}
width="620px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="Self ID">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.selfId = value;
},
}}
placeholder="留空代表全部账号"
value={form.selfId}
/>
</FormItem>
<FormItem label="目标类型">
<ASelect
{...{
'onUpdate:value': (
value: QqbotApi.PermissionBody['targetType'],
) => {
form.targetType = value;
},
}}
options={qqbotTargetTypeOptions}
value={form.targetType}
/>
</FormItem>
<FormItem label="目标 ID">
<AInput
disabled={form.targetType === 'all'}
{...{
'onUpdate:value': (value: string) => {
form.targetId = value;
},
}}
placeholder="私聊填 QQ 号,群聊填群号"
value={form.targetId}
/>
</FormItem>
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
<FormItem label="备注">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.remark = value;
},
}}
value={form.remark}
/>
</FormItem>
</Form>
</AModal>
</Page>
);
},
});

View File

@ -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<string>();
const form = reactive<QqbotApi.RuleBody>({
cooldownMs: 1500,
enabled: true,
keyword: '',
matchType: 'keyword',
name: '',
priority: 0,
replyContent: '',
targetType: 'all',
});
const columns: Array<TableColumnType<QqbotApi.Rule>> = [
{ 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<QqbotApi.Rule> = {
list: async (params) => await getQqbotRuleList(params),
};
const buttons: Array<KtTableButton<QqbotApi.Rule>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建规则',
onClick: openCreate,
permissionCodes: ['QqBot:Rule:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Rule>> = [
{
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<QqbotApi.Rule>({
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Rule;
if (column.key === 'enabled') {
return (
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'matchType') {
return getOptionLabel(qqbotRuleMatchOptions, row.matchType);
}
if (column.key === 'targetType') {
return getOptionLabel(qqbotRuleTargetOptions, row.targetType);
}
return undefined;
},
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitRule}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title={modalTitle.value}
width="720px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="规则名称">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.name = value;
},
}}
value={form.name}
/>
</FormItem>
<FormItem label="匹配方式" required>
<ASelect
{...{
'onUpdate:value': (value: QqbotApi.RuleBody['matchType']) => {
form.matchType = value;
},
}}
options={qqbotRuleMatchOptions}
value={form.matchType}
/>
</FormItem>
<FormItem label="关键词" required>
<AInput
{...{
'onUpdate:value': (value: string) => {
form.keyword = value;
},
}}
value={form.keyword}
/>
</FormItem>
<FormItem label="目标范围">
<ASelect
{...{
'onUpdate:value': (
value: QqbotApi.RuleBody['targetType'],
) => {
form.targetType = value;
},
}}
options={qqbotRuleTargetOptions}
value={form.targetType}
/>
</FormItem>
<FormItem label="回复内容" required>
<ATextArea
autoSize={{ maxRows: 6, minRows: 3 }}
{...{
'onUpdate:value': (value: string) => {
form.replyContent = value;
},
}}
value={form.replyContent}
/>
</FormItem>
<FormItem label="优先级">
<AInputNumber
{...{
'onUpdate:value': (value: number) => {
form.priority = value || 0;
},
}}
value={form.priority}
/>
</FormItem>
<FormItem label="冷却时间">
<AInputNumber
addonAfter="ms"
min={0}
{...{
'onUpdate:value': (value: number) => {
form.cooldownMs = value || 0;
},
}}
value={form.cooldownMs}
/>
</FormItem>
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
</Form>
</AModal>
</Page>
);
},
});

View File

@ -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<TableColumnType<QqbotApi.SendLog>> = [
{ 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<QqbotApi.SendLog> = {
list: async (params) => await getQqbotSendLogList(params),
};
const buttons: Array<KtTableButton<QqbotApi.SendLog>> = [
{
key: 'send',
label: '手动发送',
onClick: openSend,
permissionCodes: ['QqBot:Send:Private', 'QqBot:Send:Group'],
type: 'primary',
},
];
const [registerTable, tableApi] = useKtTable<QqbotApi.SendLog>({
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
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 <Tag color={status.color}>{status.label}</Tag>;
}
return undefined;
},
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitSend}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title="手动发送"
width="620px"
>
<Form
labelCol={{ span: 5 }}
model={sendForm}
wrapperCol={{ span: 18 }}
>
<FormItem label="Self ID">
<AInput
{...{
'onUpdate:value': (value: string) => {
sendForm.selfId = value;
},
}}
placeholder="留空使用默认启用账号"
value={sendForm.selfId}
/>
</FormItem>
<FormItem label="目标类型">
<ASelect
{...{
'onUpdate:value': (value: 'group' | 'private') => {
sendForm.targetType = value;
},
}}
options={qqbotMessageTypeOptions}
value={sendForm.targetType}
/>
</FormItem>
<FormItem label={targetLabel.value} required>
<AInput
{...{
'onUpdate:value': (value: string) => {
sendForm.targetId = value;
},
}}
value={sendForm.targetId}
/>
</FormItem>
<FormItem label="消息内容" required>
<AInput.TextArea
autoSize={{ maxRows: 6, minRows: 3 }}
{...{
'onUpdate:value': (value: string) => {
sendForm.message = value;
},
}}
value={sendForm.message}
/>
</FormItem>
</Form>
</AModal>
</Page>
);
},
});