From c30d7d04a658910593a4735872d176e00f34922a Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 2 Jun 2026 19:24:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84QQBot=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E9=85=8D=E7=BD=AE=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/api/core/dict.ts | 14 + apps/web-antdv-next/src/api/core/index.ts | 1 + apps/web-antdv-next/src/api/core/menu.ts | 6 +- apps/web-antdv-next/src/api/qqbot/index.ts | 80 ++- apps/web-antdv-next/src/api/system/menu.ts | 4 +- apps/web-antdv-next/src/hooks/useDict.ts | 198 +++++++ .../src/router/routes/modules/qqbot.ts | 10 + .../account/components/AccountConfigPanel.tsx | 489 ++++++++++++++++++ .../src/views/qqbot/account/config.scss | 83 +++ .../src/views/qqbot/account/config.tsx | 126 +++++ .../src/views/qqbot/account/list.tsx | 18 +- .../src/views/qqbot/command/list.tsx | 4 +- .../src/views/qqbot/plugin/list.tsx | 57 +- 13 files changed, 1073 insertions(+), 17 deletions(-) create mode 100644 apps/web-antdv-next/src/api/core/dict.ts create mode 100644 apps/web-antdv-next/src/hooks/useDict.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/account/config.scss create mode 100644 apps/web-antdv-next/src/views/qqbot/account/config.tsx diff --git a/apps/web-antdv-next/src/api/core/dict.ts b/apps/web-antdv-next/src/api/core/dict.ts new file mode 100644 index 0000000..f3e1f11 --- /dev/null +++ b/apps/web-antdv-next/src/api/core/dict.ts @@ -0,0 +1,14 @@ +import { requestClient } from '#/api/request'; + +export namespace DictApi { + export interface Option { + label: string; + value: number | string; + } +} + +export function getDictByKey(dictKey: string) { + return requestClient.get('/dict/getDictByKey', { + params: { dictKey }, + }); +} diff --git a/apps/web-antdv-next/src/api/core/index.ts b/apps/web-antdv-next/src/api/core/index.ts index 7134366..ba64a03 100644 --- a/apps/web-antdv-next/src/api/core/index.ts +++ b/apps/web-antdv-next/src/api/core/index.ts @@ -1,4 +1,5 @@ export * from './auth'; +export * from './dict'; export * from './menu'; export * from './timezone'; export * from './user'; diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index 9df0d45..75e6841 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -18,6 +18,8 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([ 'BlogTagEdit', 'QqBot', 'QqBotAccount', + 'QqBotAccountConfig', + 'QqBotAccountConfigButton', 'QqBotAccountCreate', 'QqBotAccountDelete', 'QqBotAccountEdit', @@ -75,9 +77,11 @@ function filterSupportedAdminMenus( const children = menu.children ? filterSupportedAdminMenus(menu.children) : undefined; + const menuWithoutChildren = { ...menu }; + delete menuWithoutChildren.children; return { - ...menu, + ...menuWithoutChildren, ...(children && children.length > 0 ? { children } : {}), }; }) diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts index bce013f..85f86a6 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.ts +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -3,6 +3,8 @@ import type { Recordable } from '@vben/types'; import { requestClient } from '#/api/request'; export namespace QqbotApi { + export type PluginTriggerMode = 'command' | 'event'; + export interface PageResult { list: T[]; pageNo?: number; @@ -212,6 +214,7 @@ export namespace QqbotApi { key: string; name: string; operationCount: number; + triggerMode: PluginTriggerMode; version: string; } @@ -223,12 +226,29 @@ export namespace QqbotApi { name: string; outputSchema?: Recordable; pluginKey: string; + triggerMode: PluginTriggerMode; } export interface PluginHealth { checkedAt: string; message?: string; + name?: string; + pluginKey?: string; status: 'degraded' | 'healthy' | 'offline'; + triggerMode?: PluginTriggerMode; + } + + export interface EventPlugin { + accountName?: string; + bound: boolean; + connectStatus?: string; + description?: string; + key: string; + name: string; + remark?: string; + selfId: string; + triggerType: 'message'; + version: string; } export type Query = Recordable; @@ -265,6 +285,26 @@ export function deleteQqbotAccount(id: string) { ); } +export function bindQqbotAccountCommand(selfId: string, commandId: string) { + const params = new URLSearchParams({ commandId, selfId }); + return requestClient.post(`/qqbot/account/bind/command?${params}`); +} + +export function unbindQqbotAccountCommand(selfId: string, commandId: string) { + const params = new URLSearchParams({ commandId, selfId }); + return requestClient.post(`/qqbot/account/unbind/command?${params}`); +} + +export function bindQqbotAccountRule(selfId: string, ruleId: string) { + const params = new URLSearchParams({ ruleId, selfId }); + return requestClient.post(`/qqbot/account/bind/rule?${params}`); +} + +export function unbindQqbotAccountRule(selfId: string, ruleId: string) { + const params = new URLSearchParams({ ruleId, selfId }); + return requestClient.post(`/qqbot/account/unbind/rule?${params}`); +} + export function kickQqbotAccount(selfId: string) { return requestClient.post<{ count: number }>( `/qqbot/account/kick?selfId=${selfId}`, @@ -451,19 +491,47 @@ export function testQqbotCommand(data: { ); } -export function getQqbotPluginList() { - return requestClient.get('/qqbot/plugin/list'); +export function getQqbotPluginList(triggerMode?: QqbotApi.PluginTriggerMode) { + return requestClient.get('/qqbot/plugin/list', { + params: { triggerMode }, + }); } -export function getQqbotPluginOperationList(pluginKey?: string) { +export function getQqbotPluginOperationList( + pluginKey?: string, + triggerMode?: QqbotApi.PluginTriggerMode, +) { return requestClient.get( '/qqbot/plugin/operation/list', - { params: { pluginKey } }, + { params: { pluginKey, triggerMode } }, ); } -export function getQqbotPluginHealth(pluginKey?: string) { +export function getQqbotPluginHealth( + pluginKey?: string, + triggerMode?: QqbotApi.PluginTriggerMode, +) { return requestClient.get('/qqbot/plugin/health', { - params: { pluginKey }, + params: { pluginKey, triggerMode }, }); } + +export function getQqbotEventPluginList(params?: { selfId?: string }) { + return requestClient.get('/qqbot/plugin/event/list', { + params, + }); +} + +export function bindQqbotEventPlugin(selfId: string, pluginKey: string) { + const params = new URLSearchParams({ pluginKey, selfId }); + return requestClient.post( + `/qqbot/plugin/event/bind?${params.toString()}`, + ); +} + +export function unbindQqbotEventPlugin(selfId: string, pluginKey: string) { + const params = new URLSearchParams({ pluginKey, selfId }); + return requestClient.post( + `/qqbot/plugin/event/unbind?${params.toString()}`, + ); +} diff --git a/apps/web-antdv-next/src/api/system/menu.ts b/apps/web-antdv-next/src/api/system/menu.ts index de4e7d7..33a1cdb 100644 --- a/apps/web-antdv-next/src/api/system/menu.ts +++ b/apps/web-antdv-next/src/api/system/menu.ts @@ -100,9 +100,11 @@ function filterSupportedSystemMenus( const children = menu.children ? filterSupportedSystemMenus(menu.children) : undefined; + const menuWithoutChildren = { ...menu }; + delete menuWithoutChildren.children; return { - ...menu, + ...menuWithoutChildren, ...(children && children.length > 0 ? { children } : {}), }; }) diff --git a/apps/web-antdv-next/src/hooks/useDict.ts b/apps/web-antdv-next/src/hooks/useDict.ts new file mode 100644 index 0000000..315e22e --- /dev/null +++ b/apps/web-antdv-next/src/hooks/useDict.ts @@ -0,0 +1,198 @@ +import type { DictApi } from '#/api/core'; + +import { computed, readonly, ref, shallowRef } from 'vue'; + +import { getDictByKey } from '#/api/core'; + +export interface DictOption { + label: string; + raw?: DictApi.Option; + value: TValue; +} + +export interface LoadDictOptions { + fallbackOptions?: Array>; + refresh?: boolean; +} + +export interface UseDictOptions< + TValue = number | string, +> extends LoadDictOptions { + immediate?: boolean; +} + +const DICT_CACHE = new Map>(); +const DICT_PENDING = new Map>>(); + +/** + * @param dictKey 字典编码,对应后端 admin_dict.dict_code。 + * @param options 字典加载配置;refresh 为 true 时跳过缓存重新请求。 + */ +export async function loadDictOptions( + dictKey: string, + options: LoadDictOptions = {}, +): Promise>> { + if (!dictKey) { + return normalizeFallbackOptions(options.fallbackOptions); + } + + if (!options.refresh && DICT_CACHE.has(dictKey)) { + return DICT_CACHE.get(dictKey) as Array>; + } + + if (!options.refresh && DICT_PENDING.has(dictKey)) { + return DICT_PENDING.get(dictKey) as Promise>>; + } + + const pending = getDictByKey(dictKey) + .then((list) => { + const normalized = normalizeDictOptions(list); + const nextOptions = + normalized.length > 0 + ? normalized + : normalizeFallbackOptions(options.fallbackOptions); + DICT_CACHE.set(dictKey, nextOptions as Array); + return nextOptions; + }) + .catch((error) => { + const fallback = normalizeFallbackOptions(options.fallbackOptions); + if (fallback.length > 0) { + DICT_CACHE.set(dictKey, fallback as Array); + return fallback; + } + throw error; + }) + .finally(() => { + DICT_PENDING.delete(dictKey); + }); + + DICT_PENDING.set(dictKey, pending as Promise>); + return pending; +} + +/** + * @param dictKey 字典编码,对应后端 admin_dict.dict_code。 + */ +export function getCachedDictOptions( + dictKey: string, +) { + return (DICT_CACHE.get(dictKey) || []) as Array>; +} + +/** + * @param dictKey 可选字典编码;不传时清空全部前端字典缓存。 + */ +export function clearDictCache(dictKey?: string) { + if (dictKey) { + DICT_CACHE.delete(dictKey); + DICT_PENDING.delete(dictKey); + return; + } + DICT_CACHE.clear(); + DICT_PENDING.clear(); +} + +/** + * @param options 字典选项列表。 + * @param value 需要翻译的字典值。 + * @param fallback 未命中字典时展示的兜底文案;默认返回原值字符串。 + */ +export function getDictLabel( + options: Array, + value: unknown, + fallback?: string, +) { + const valueKey = getDictValueKey(value); + const matched = options.find( + (item) => getDictValueKey(item.value) === valueKey, + ); + return matched?.label ?? fallback ?? valueKey; +} + +/** + * @param dictKey 字典编码,对应后端 admin_dict.dict_code。 + * @param options 组合式字典配置;immediate 默认为 true。 + */ +export function useDict( + dictKey: string, + options: UseDictOptions = {}, +) { + const dictOptions = shallowRef>>( + normalizeFallbackOptions(options.fallbackOptions), + ); + const error = ref(); + const loading = ref(false); + const optionMap = computed(() => { + const map: Record> = {}; + for (const item of dictOptions.value) { + map[getDictValueKey(item.value)] = item; + } + return map; + }); + + async function reload(refresh = false) { + loading.value = true; + error.value = undefined; + try { + dictOptions.value = await loadDictOptions(dictKey, { + fallbackOptions: options.fallbackOptions, + refresh, + }); + return dictOptions.value; + } catch (currentError) { + error.value = currentError; + dictOptions.value = normalizeFallbackOptions(options.fallbackOptions); + return dictOptions.value; + } finally { + loading.value = false; + } + } + + function labelOf(value: unknown, fallback?: string) { + const valueKey = getDictValueKey(value); + return optionMap.value[valueKey]?.label ?? fallback ?? valueKey; + } + + if (options.immediate !== false) { + void reload(); + } + + return { + error: readonly(error), + labelOf, + loading: readonly(loading), + options: dictOptions, + reload, + }; +} + +/** + * @param list 后端字典接口返回的原始列表。 + */ +function normalizeDictOptions( + list: DictApi.Option[] = [], +): Array> { + return list + .filter((item) => item && item.label && item.value !== undefined) + .map((item) => ({ + label: item.label, + raw: item, + value: item.value as TValue, + })); +} + +/** + * @param options 业务传入的兜底字典项。 + */ +function normalizeFallbackOptions( + options: Array> = [], +) { + return options.map((item) => ({ ...item })); +} + +/** + * @param value 字典值;统一转字符串后比较,兼容后端数字/字符串混合返回。 + */ +function getDictValueKey(value: unknown) { + return value === undefined || value === null ? '' : String(value); +} diff --git a/apps/web-antdv-next/src/router/routes/modules/qqbot.ts b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts index 2ddbf43..ef63786 100644 --- a/apps/web-antdv-next/src/router/routes/modules/qqbot.ts +++ b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts @@ -29,6 +29,16 @@ const routes: RouteRecordRaw[] = [ name: 'QqBotAccount', path: '/qqbot/account', }, + { + component: () => import('#/views/qqbot/account/config'), + meta: { + activePath: '/qqbot/account', + hideInMenu: true, + title: '账号功能配置', + }, + name: 'QqBotAccountConfig', + path: '/qqbot/account/config', + }, { component: () => import('#/views/qqbot/rule/list'), meta: { diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx new file mode 100644 index 0000000..8a3a405 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx @@ -0,0 +1,489 @@ +import type { PropType } from 'vue'; + +import type { QqbotApi } from '#/api/qqbot'; + +import { computed, defineComponent, ref, watch } from 'vue'; + +import { + Button, + message, + Popconfirm, + Space, + Table, + Tabs, + Tag, +} from 'antdv-next'; + +import { + bindQqbotAccountCommand, + bindQqbotAccountRule, + bindQqbotEventPlugin, + getQqbotCommandList, + getQqbotEventPluginList, + getQqbotRuleList, + unbindQqbotAccountCommand, + unbindQqbotAccountRule, + unbindQqbotEventPlugin, +} from '#/api/qqbot'; + +import { + getOptionLabel, + qqbotRuleMatchOptions, + qqbotRuleTargetOptions, +} from '../../modules/options'; + +const AButton = Button as any; +const APopconfirm = Popconfirm as any; +const ASpace = Space as any; +const ATable = Table as any; +const ATabs = Tabs as any; + +export default defineComponent({ + name: 'QqBotAccountConfigPanel', + props: { + account: { + default: undefined, + type: Object as PropType, + }, + }, + setup(props) { + const activeTab = ref('command'); + const boundCommands = ref([]); + const boundRules = ref([]); + const commandTemplates = ref([]); + const eventPlugins = ref([]); + const loading = ref(false); + const ruleTemplates = ref([]); + + const currentSelfId = computed(() => props.account?.selfId || ''); + const boundCommandIds = computed( + () => new Set(boundCommands.value.map((item) => item.id)), + ); + const boundRuleIds = computed( + () => new Set(boundRules.value.map((item) => item.id)), + ); + const mergedCommandTemplates = computed(() => + mergeById(commandTemplates.value, boundCommands.value), + ); + const mergedRuleTemplates = computed(() => + mergeById(ruleTemplates.value, boundRules.value), + ); + + const commandColumns = [ + { dataIndex: 'name', key: 'name', title: '命令模板', width: 160 }, + { dataIndex: 'code', key: 'code', title: '命令编码', width: 140 }, + { dataIndex: 'aliases', key: 'aliases', title: '别名', width: 200 }, + { dataIndex: 'pluginKey', key: 'pluginKey', title: '插件', width: 140 }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '目标范围', + width: 100, + }, + { dataIndex: 'enabled', key: 'enabled', title: '模板状态', width: 100 }, + { dataIndex: 'bound', key: 'bound', title: '绑定状态', width: 100 }, + { + dataIndex: 'action', + fixed: 'right', + key: 'action', + title: '操作', + width: 100, + }, + ]; + const eventColumns = [ + { dataIndex: 'name', key: 'name', title: '插件模板', width: 160 }, + { dataIndex: 'key', key: 'key', title: '插件 Key', width: 160 }, + { + dataIndex: 'triggerType', + key: 'triggerType', + title: '触发类型', + width: 100, + }, + { + dataIndex: 'description', + key: 'description', + title: '说明', + width: 320, + }, + { dataIndex: 'bound', key: 'bound', title: '绑定状态', width: 100 }, + { + dataIndex: 'action', + fixed: 'right', + key: 'action', + title: '操作', + width: 100, + }, + ]; + const ruleColumns = [ + { dataIndex: 'name', key: 'name', title: '规则模板', width: 160 }, + { dataIndex: 'keyword', key: 'keyword', title: '关键词', width: 180 }, + { + dataIndex: 'matchType', + key: 'matchType', + title: '匹配方式', + width: 110, + }, + { + dataIndex: 'targetType', + key: 'targetType', + title: '目标范围', + width: 100, + }, + { + dataIndex: 'replyContent', + key: 'replyContent', + title: '回复模板', + width: 320, + }, + { dataIndex: 'enabled', key: 'enabled', title: '模板状态', width: 100 }, + { dataIndex: 'bound', key: 'bound', title: '绑定状态', width: 100 }, + { + dataIndex: 'action', + fixed: 'right', + key: 'action', + title: '操作', + width: 100, + }, + ]; + + watch( + currentSelfId, + (selfId) => { + if (!selfId) { + boundCommands.value = []; + boundRules.value = []; + commandTemplates.value = []; + eventPlugins.value = []; + ruleTemplates.value = []; + return; + } + void refreshAll(); + }, + { immediate: true }, + ); + + async function refreshAll() { + loading.value = true; + try { + await Promise.all([ + refreshCommandTemplates(), + refreshEventPlugins(), + refreshRuleTemplates(), + ]); + } finally { + loading.value = false; + } + } + + async function refreshCommandTemplates() { + const [templateResult, boundResult] = await Promise.all([ + getQqbotCommandList({ pageNo: 1, pageSize: 500 }), + getQqbotCommandList({ + pageNo: 1, + pageSize: 500, + selfId: currentSelfId.value, + }), + ]); + commandTemplates.value = templateResult.list || []; + boundCommands.value = boundResult.list || []; + } + + async function refreshCommandBindings() { + const result = await getQqbotCommandList({ + pageNo: 1, + pageSize: 500, + selfId: currentSelfId.value, + }); + boundCommands.value = result.list || []; + } + + async function refreshEventPlugins() { + eventPlugins.value = await getQqbotEventPluginList({ + selfId: currentSelfId.value, + }); + } + + async function refreshRuleTemplates() { + const [templateResult, boundResult] = await Promise.all([ + getQqbotRuleList({ pageNo: 1, pageSize: 500 }), + getQqbotRuleList({ + pageNo: 1, + pageSize: 500, + selfId: currentSelfId.value, + }), + ]); + ruleTemplates.value = templateResult.list || []; + boundRules.value = boundResult.list || []; + } + + async function refreshRuleBindings() { + const result = await getQqbotRuleList({ + pageNo: 1, + pageSize: 500, + selfId: currentSelfId.value, + }); + boundRules.value = result.list || []; + } + + async function handleCommandBind(row: QqbotApi.Command) { + if (!ensureSelfId()) return; + await bindQqbotAccountCommand(currentSelfId.value, row.id); + message.success('命令已绑定到当前账号'); + await refreshCommandBindings(); + } + + async function handleCommandUnbind(row: QqbotApi.Command) { + if (!ensureSelfId()) return; + await unbindQqbotAccountCommand(currentSelfId.value, row.id); + message.success('命令已从当前账号解绑'); + await refreshCommandBindings(); + } + + async function handleEventBind(row: QqbotApi.EventPlugin) { + if (!ensureSelfId()) return; + await bindQqbotEventPlugin(currentSelfId.value, row.key); + message.success('事件插件已绑定到当前账号'); + await refreshEventPlugins(); + } + + async function handleEventUnbind(row: QqbotApi.EventPlugin) { + if (!ensureSelfId()) return; + await unbindQqbotEventPlugin(currentSelfId.value, row.key); + message.success('事件插件已从当前账号解绑'); + await refreshEventPlugins(); + } + + async function handleRuleBind(row: QqbotApi.Rule) { + if (!ensureSelfId()) return; + await bindQqbotAccountRule(currentSelfId.value, row.id); + message.success('规则已绑定到当前账号'); + await refreshRuleBindings(); + } + + async function handleRuleUnbind(row: QqbotApi.Rule) { + if (!ensureSelfId()) return; + await unbindQqbotAccountRule(currentSelfId.value, row.id); + message.success('规则已从当前账号解绑'); + await refreshRuleBindings(); + } + + function ensureSelfId() { + if (currentSelfId.value) return true; + message.warning('缺少账号 Self ID,请从账号连接列表进入配置页'); + return false; + } + + function mergeById(templates: T[], bound: T[]) { + const map = new Map(); + templates.forEach((item) => map.set(item.id, item)); + bound.forEach((item) => { + if (!map.has(item.id)) map.set(item.id, item); + }); + return [...map.values()]; + } + + function renderBindAction(options: { + bound: boolean; + name: string; + onBind: () => Promise; + onUnbind: () => Promise; + }) { + if (!options.bound) { + return ( + + 绑定 + + ); + } + + return ( + + + 解绑 + + + ); + } + + function renderBoundTag(bound: boolean) { + return ( + + {bound ? '已绑定' : '未绑定'} + + ); + } + + function renderEnabledTag(enabled: boolean) { + return ( + + {enabled ? '启用' : '停用'} + + ); + } + + function renderCommandTable() { + return ( + { + const row = record as QqbotApi.Command; + const bound = boundCommandIds.value.has(row.id); + if (column.key === 'aliases') { + return row.aliases?.join(' / ') || '-'; + } + if (column.key === 'targetType') { + return getOptionLabel(qqbotRuleTargetOptions, row.targetType); + } + if (column.key === 'enabled') { + return renderEnabledTag(row.enabled); + } + if (column.key === 'bound') { + return renderBoundTag(bound); + } + if (column.key === 'action') { + return ( + + {renderBindAction({ + bound, + name: row.name || row.code, + onBind: () => handleCommandBind(row), + onUnbind: () => handleCommandUnbind(row), + })} + + ); + } + return undefined; + }, + }} + /> + ); + } + + function renderEventTable() { + return ( + + `${currentSelfId.value}:${row.key}` + } + scroll={{ x: 960, y: 420 }} + size="small" + v-slots={{ + bodyCell: ({ column, record }: any) => { + const row = record as QqbotApi.EventPlugin; + if (column.key === 'triggerType') { + return row.triggerType === 'message' + ? '消息事件' + : row.triggerType; + } + if (column.key === 'bound') { + return renderBoundTag(row.bound); + } + if (column.key === 'action') { + return ( + + {renderBindAction({ + bound: row.bound, + name: row.name, + onBind: () => handleEventBind(row), + onUnbind: () => handleEventUnbind(row), + })} + + ); + } + return undefined; + }, + }} + /> + ); + } + + function renderRuleTable() { + return ( + { + const row = record as QqbotApi.Rule; + const bound = boundRuleIds.value.has(row.id); + if (column.key === 'matchType') { + return getOptionLabel(qqbotRuleMatchOptions, row.matchType); + } + if (column.key === 'targetType') { + return getOptionLabel(qqbotRuleTargetOptions, row.targetType); + } + if (column.key === 'replyContent') { + return ( + + ); + } + if (column.key === 'enabled') { + return renderEnabledTag(row.enabled); + } + if (column.key === 'bound') { + return renderBoundTag(bound); + } + if (column.key === 'action') { + return ( + + {renderBindAction({ + bound, + name: row.name || row.keyword, + onBind: () => handleRuleBind(row), + onUnbind: () => handleRuleUnbind(row), + })} + + ); + } + return undefined; + }, + }} + /> + ); + } + + return () => ( + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/config.scss b/apps/web-antdv-next/src/views/qqbot/account/config.scss new file mode 100644 index 0000000..4004b48 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/config.scss @@ -0,0 +1,83 @@ +.qqbot-account-config { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; + min-height: 0; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 40px; + } + + &__back { + display: inline-flex; + gap: 6px; + align-items: center; + padding-inline: 0; + } + + &__back-icon { + width: 16px; + height: 16px; + } + + &__title { + display: inline-flex; + gap: 8px; + align-items: center; + min-width: 0; + font-size: 16px; + font-weight: 600; + } + + &__card { + flex: 1; + min-height: 0; + } + + &__card, + &__card > .ant-card-body { + display: flex; + flex-direction: column; + min-height: 0; + } + + &__card > .ant-card-body { + flex: 1; + } +} + +.qqbot-account-config-panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + + &__account { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + } + + &__tabs { + flex: none; + } + + &__content { + flex: 1; + min-height: 0; + } + + &__ellipsis { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; + } +} diff --git a/apps/web-antdv-next/src/views/qqbot/account/config.tsx b/apps/web-antdv-next/src/views/qqbot/account/config.tsx new file mode 100644 index 0000000..c62572b --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/config.tsx @@ -0,0 +1,126 @@ +import type { QqbotApi } from '#/api/qqbot'; + +import { computed, defineComponent, ref, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; + +import { Page } from '@vben/common-ui'; +import { ArrowLeft } from '@vben/icons'; + +import { Alert, Button, Card, Spin, Tag } from 'antdv-next'; + +import { getQqbotAccountList } from '#/api/qqbot'; + +import AccountConfigPanel from './components/AccountConfigPanel'; + +import './config.scss'; + +const AButton = Button as any; +const ACard = Card as any; +const ASpin = Spin as any; + +export default defineComponent({ + name: 'QqBotAccountConfig', + setup() { + const route = useRoute(); + const router = useRouter(); + const account = ref(); + const errorMessage = ref(''); + const loading = ref(false); + + const selfId = computed(() => normalizeQueryValue(route.query.selfId)); + const accountTitle = computed(() => { + if (!account.value) return '账号功能配置'; + return account.value.name + ? `${account.value.name}(${account.value.selfId})` + : account.value.selfId; + }); + + watch( + selfId, + () => { + void loadAccount(); + }, + { immediate: true }, + ); + + async function loadAccount() { + const currentSelfId = selfId.value; + account.value = undefined; + errorMessage.value = ''; + + if (!currentSelfId) { + errorMessage.value = '缺少账号 Self ID,请从账号连接列表进入配置页。'; + return; + } + + loading.value = true; + try { + const result = await getQqbotAccountList({ + pageNo: 1, + pageSize: 20, + selfId: currentSelfId, + }); + const matched = (result.list || []).find( + (item) => item.selfId === currentSelfId, + ); + if (!matched) { + errorMessage.value = `未找到账号 ${currentSelfId},请返回账号连接列表确认账号状态。`; + return; + } + account.value = matched; + } finally { + loading.value = false; + } + } + + function normalizeQueryValue(value: unknown) { + if (Array.isArray(value)) return `${value[0] || ''}`.trim(); + return `${value || ''}`.trim(); + } + + function goBack() { + void router.push({ name: 'QqBotAccount' }); + } + + return () => ( + + + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/list.tsx b/apps/web-antdv-next/src/views/qqbot/account/list.tsx index c1173ac..189b75e 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -8,6 +8,7 @@ import type { } from '#/components/ktTable'; import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue'; +import { useRouter } from 'vue-router'; import { Page, useVbenModal } from '@vben/common-ui'; import { Plus } from '@vben/icons'; @@ -38,6 +39,7 @@ export default defineComponent({ name: 'QqBotAccountList', setup() { const editingId = ref(); + const router = useRouter(); const scanLoading = ref(false); const scanQrcodeText = ref(''); const scanState = reactive<{ @@ -158,6 +160,12 @@ export default defineComponent({ }, ]; const rowActions: Array> = [ + { + key: 'config', + label: '配置', + onClick: openConfig, + permissionCodes: ['QqBot:Account:Config'], + }, { key: 'refreshLogin', label: '更新登录', @@ -267,7 +275,6 @@ export default defineComponent({ void resetAccountForm(values || getAccountFormDefaults()); }, }); - onBeforeUnmount(() => { stopScanPolling(); }); @@ -446,6 +453,15 @@ export default defineComponent({ accountModalApi.setData({ values: getAccountFormDefaults() }).open(); } + function openConfig(row: QqbotApi.Account) { + void router.push({ + name: 'QqBotAccountConfig', + query: { + selfId: row.selfId, + }, + }); + } + function openEdit(row: QqbotApi.Account) { editingId.value = row.id; accountModalApi diff --git a/apps/web-antdv-next/src/views/qqbot/command/list.tsx b/apps/web-antdv-next/src/views/qqbot/command/list.tsx index b899f32..5c8c3e6 100644 --- a/apps/web-antdv-next/src/views/qqbot/command/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/command/list.tsx @@ -390,8 +390,8 @@ export default defineComponent({ async function loadPlugins() { const [plugins, operations] = await Promise.all([ - getQqbotPluginList(), - getQqbotPluginOperationList(), + getQqbotPluginList('command'), + getQqbotPluginOperationList(undefined, 'command'), ]); pluginOptions.value = plugins.map((item) => ({ label: `${item.name} (${item.key})`, diff --git a/apps/web-antdv-next/src/views/qqbot/plugin/list.tsx b/apps/web-antdv-next/src/views/qqbot/plugin/list.tsx index f0ad5f8..927199b 100644 --- a/apps/web-antdv-next/src/views/qqbot/plugin/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/plugin/list.tsx @@ -2,6 +2,7 @@ import type { TableColumnType } from 'antdv-next'; import type { QqbotApi } from '#/api/qqbot'; import type { KtTableApi, KtTableButton } from '#/components/ktTable'; +import type { DictOption } from '#/hooks/useDict'; import { defineComponent, onMounted, ref } from 'vue'; @@ -15,17 +16,39 @@ import { getQqbotPluginOperationList, } from '#/api/qqbot'; import { KtTable, useKtTable } from '#/components/ktTable'; +import { useDict } from '#/hooks/useDict'; const AKtTable = KtTable as any; +const QQBOT_PLUGIN_TRIGGER_MODE_DICT = 'QQBOT_PLUGIN_TRIGGER_MODE'; +const qqbotPluginTriggerModeFallback: Array< + DictOption +> = [ + { label: '命令', value: 'command' }, + { label: '事件', value: 'event' }, +]; export default defineComponent({ name: 'QqBotPluginList', setup() { const pluginOptions = ref>([]); const pluginMap = ref>({}); + const { + labelOf: getTriggerModeLabel, + options: triggerModeOptions, + reload: reloadTriggerModeDict, + } = useDict(QQBOT_PLUGIN_TRIGGER_MODE_DICT, { + fallbackOptions: qqbotPluginTriggerModeFallback, + immediate: false, + }); const columns: Array> = [ { dataIndex: 'pluginKey', key: 'pluginKey', title: '插件', width: 160 }, + { + dataIndex: 'triggerMode', + key: 'triggerMode', + title: '触发方式', + width: 120, + }, { dataIndex: 'key', key: 'key', title: '能力 Key', width: 220 }, { dataIndex: 'name', key: 'name', title: '能力名称', width: 160 }, { @@ -43,7 +66,7 @@ export default defineComponent({ ]; const api: KtTableApi = { list: async (params) => - await getQqbotPluginOperationList(params.pluginKey), + await getQqbotPluginOperationList(params.pluginKey, params.triggerMode), }; const buttons: Array> = [ { @@ -52,7 +75,10 @@ export default defineComponent({ onClick: async () => { const health = await getQqbotPluginHealth(); const content = health - .map((item) => `${item.status}: ${item.message || 'OK'}`) + .map( + (item) => + `${getTriggerModeLabel(item.triggerMode, '-')} ${item.name || item.pluginKey || ''}: ${item.status}${item.message ? ` ${item.message}` : ''}`, + ) .join(';'); message.success(content || '插件健康检查完成'); }, @@ -64,6 +90,15 @@ export default defineComponent({ columns, formOptions: { schema: [ + { + component: 'Select', + componentProps: () => ({ + allowClear: true, + options: triggerModeOptions.value, + }), + fieldName: 'triggerMode', + label: '触发方式', + }, { component: 'Select', componentProps: () => ({ @@ -80,18 +115,21 @@ export default defineComponent({ }); onMounted(() => { - void loadPlugins(); + void loadMetadata(); }); - async function loadPlugins() { - const plugins = await getQqbotPluginList(); + async function loadMetadata() { + const [plugins] = await Promise.all([ + getQqbotPluginList(), + reloadTriggerModeDict(), + ]); const nextPluginMap: Record = {}; for (const item of plugins) { nextPluginMap[item.key] = item; } pluginMap.value = nextPluginMap; pluginOptions.value = plugins.map((item) => ({ - label: `${item.name} (${item.key})`, + label: `${item.name} (${item.key} / ${getTriggerModeLabel(item.triggerMode, '-')})`, value: item.key, })); } @@ -113,6 +151,13 @@ export default defineComponent({ row.pluginKey ); } + if (column.key === 'triggerMode') { + return ( + + {getTriggerModeLabel(row.triggerMode, '-')} + + ); + } if (column.key === 'cacheTtlMs') { return row.cacheTtlMs ? `${row.cacheTtlMs} ms` : '-'; }