feat: 增加QQBot插件平台管理

This commit is contained in:
sunlei 2026-06-15 04:41:57 +08:00
parent eceadc36f8
commit 15480ca705
4 changed files with 478 additions and 3 deletions

View File

@ -48,6 +48,7 @@ pnpm run build:antdv-next
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。 - 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / qrcode 每步进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API。 - QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / qrcode 每步进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API。
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`
## 部署说明 ## 部署说明

View File

@ -0,0 +1,164 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace QqbotPluginPlatformApi {
export type InstallStatus =
| 'disabled'
| 'enabled'
| 'failed'
| 'installed'
| 'uninstalled'
| 'uploaded'
| 'validated';
export type RuntimeStatus =
| 'crashed'
| 'healthy'
| 'starting'
| 'stopped'
| 'unhealthy';
export interface ManifestValidationResult {
manifest: Recordable<any>;
valid: boolean;
}
export interface Installation {
createTime?: string;
id: string;
installedPath?: string;
pluginId: string;
runtimeStatus: RuntimeStatus;
status: InstallStatus;
updateTime?: string;
versionId: string;
}
export interface RuntimeEvent {
createTime?: string;
eventType: string;
id: string;
installationId?: null | string;
level: 'error' | 'info' | 'warn';
pluginId: string;
safeSummary?: Recordable<any>;
}
export interface AccountBinding {
accountId: string;
createTime?: string;
enabled: boolean;
id: string;
pluginId: string;
}
export interface ConfigBody {
configKey: string;
pluginId: string;
value?: any;
}
export interface PackageBody {
manifest: Recordable<any>;
packageHash?: string;
packagePath?: string;
}
}
export function getQqbotPluginPlatformInstallations() {
return requestClient.get<QqbotPluginPlatformApi.Installation[]>(
'/qqbot/plugin-platform/installations',
);
}
export function uploadQqbotPluginPackage(
data: QqbotPluginPlatformApi.PackageBody,
) {
return requestClient.post<QqbotPluginPlatformApi.ManifestValidationResult>(
'/qqbot/plugin-platform/upload',
data,
);
}
export function validateQqbotPluginManifest(
manifest: QqbotPluginPlatformApi.PackageBody['manifest'],
) {
return requestClient.post<QqbotPluginPlatformApi.ManifestValidationResult>(
'/qqbot/plugin-platform/validate',
{ manifest },
);
}
export function installQqbotPluginPackage(
data: QqbotPluginPlatformApi.PackageBody,
) {
return requestClient.post<QqbotPluginPlatformApi.Installation>(
'/qqbot/plugin-platform/install',
data,
);
}
export function installLocalQqbotPluginPackage(
data: QqbotPluginPlatformApi.PackageBody,
) {
return requestClient.post<QqbotPluginPlatformApi.Installation>(
'/qqbot/plugin-platform/install-local',
data,
);
}
export function enableQqbotPluginInstallation(id: string) {
return requestClient.post<{ id: string; status: string }>(
'/qqbot/plugin-platform/enable',
{ id },
);
}
export function disableQqbotPluginInstallation(id: string) {
return requestClient.post<{ id: string; status: string }>(
'/qqbot/plugin-platform/disable',
{ id },
);
}
export function upgradeQqbotPluginInstallation(id: string) {
return requestClient.post<{ id: string; status: string }>(
'/qqbot/plugin-platform/upgrade',
{ id },
);
}
export function uninstallQqbotPluginInstallation(id: string) {
return requestClient.post<{ id: string; status: string }>(
'/qqbot/plugin-platform/uninstall',
{ id },
);
}
export function updateQqbotPluginConfig(
data: QqbotPluginPlatformApi.ConfigBody,
) {
return requestClient.post<QqbotPluginPlatformApi.ConfigBody>(
'/qqbot/plugin-platform/config',
data,
);
}
export function getQqbotPluginRuntimeEvents(pluginId?: string) {
return requestClient.get<QqbotPluginPlatformApi.RuntimeEvent[]>(
'/qqbot/plugin-platform/runtime-events',
{
params: { pluginId },
},
);
}
export function getQqbotPluginAccountBindings(pluginId?: string) {
return requestClient.get<QqbotPluginPlatformApi.AccountBinding[]>(
'/qqbot/plugin-platform/account-bindings',
{
params: { pluginId },
},
);
}

View File

@ -61,7 +61,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/qqbot/plugin/list'), component: () => import('#/views/qqbot/plugin/list'),
meta: { meta: {
icon: 'lucide:plug', icon: 'lucide:plug',
title: '插件能力', title: '插件平台',
}, },
name: 'QqBotPlugin', name: 'QqBotPlugin',
path: '/qqbot/plugin', path: '/qqbot/plugin',

View File

@ -1,23 +1,39 @@
import type { TableColumnType } from 'antdv-next'; import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot'; import type { QqbotApi } from '#/api/qqbot';
import type { QqbotPluginPlatformApi } from '#/api/qqbot/plugin';
import type { KtTableApi, KtTableButton } from '#/components/ktTable'; import type { KtTableApi, KtTableButton } from '#/components/ktTable';
import type { DictOption } from '#/hooks/useDict'; import type { DictOption } from '#/hooks/useDict';
import { defineComponent, onMounted, ref } from 'vue'; import { computed, defineComponent, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { message, Tag } from 'antdv-next'; import { Button, Drawer, message, Modal, Space, Tag } from 'antdv-next';
import { import {
getQqbotPluginHealth, getQqbotPluginHealth,
getQqbotPluginList, getQqbotPluginList,
getQqbotPluginOperationList, getQqbotPluginOperationList,
} from '#/api/qqbot'; } from '#/api/qqbot';
import {
disableQqbotPluginInstallation,
enableQqbotPluginInstallation,
getQqbotPluginAccountBindings,
getQqbotPluginPlatformInstallations,
getQqbotPluginRuntimeEvents,
installLocalQqbotPluginPackage,
uninstallQqbotPluginInstallation,
uploadQqbotPluginPackage,
validateQqbotPluginManifest,
} from '#/api/qqbot/plugin';
import { KtTable, useKtTable } from '#/components/ktTable'; import { KtTable, useKtTable } from '#/components/ktTable';
import { useDict } from '#/hooks/useDict'; import { useDict } from '#/hooks/useDict';
const AButton = Button as any;
const ADrawer = Drawer as any;
const AModal = Modal as any;
const ASpace = Space as any;
const AKtTable = KtTable as any; const AKtTable = KtTable as any;
const QQBOT_PLUGIN_TRIGGER_MODE_DICT = 'QQBOT_PLUGIN_TRIGGER_MODE'; const QQBOT_PLUGIN_TRIGGER_MODE_DICT = 'QQBOT_PLUGIN_TRIGGER_MODE';
const qqbotPluginTriggerModeFallback: Array< const qqbotPluginTriggerModeFallback: Array<
@ -26,12 +42,59 @@ const qqbotPluginTriggerModeFallback: Array<
{ label: '命令', value: 'command' }, { label: '命令', value: 'command' },
{ label: '事件', value: 'event' }, { label: '事件', value: 'event' },
]; ];
const defaultManifest = {
assets: [],
configSchema: { type: 'object' },
entry: 'src/index.ts',
events: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 3000,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'node-worker',
},
version: '0.1.0',
};
export default defineComponent({ export default defineComponent({
name: 'QqBotPluginList', name: 'QqBotPluginList',
setup() { setup() {
const accountBindings = ref<QqbotPluginPlatformApi.AccountBinding[]>([]);
const drawerMode = ref<'bindings' | 'events' | 'installations'>(
'installations',
);
const drawerOpen = ref(false);
const installations = ref<QqbotPluginPlatformApi.Installation[]>([]);
const manifestMode = ref<'install' | 'upload' | 'validate'>('validate');
const manifestModalOpen = ref(false);
const manifestText = ref(JSON.stringify(defaultManifest, null, 2));
const pluginOptions = ref<Array<{ label: string; value: string }>>([]); const pluginOptions = ref<Array<{ label: string; value: string }>>([]);
const pluginMap = ref<Record<string, QqbotApi.Plugin>>({}); const pluginMap = ref<Record<string, QqbotApi.Plugin>>({});
const platformLoading = ref(false);
const runtimeEvents = ref<QqbotPluginPlatformApi.RuntimeEvent[]>([]);
const drawerTitle = computed(() => {
if (drawerMode.value === 'events') return '插件运行事件';
if (drawerMode.value === 'bindings') return '插件账号绑定';
return '插件安装记录';
});
const manifestModalTitle = computed(() => {
if (manifestMode.value === 'install') return '安装插件 Manifest';
if (manifestMode.value === 'upload') return '上传插件 Manifest';
return '校验插件 Manifest';
});
const { const {
labelOf: getTriggerModeLabel, labelOf: getTriggerModeLabel,
options: triggerModeOptions, options: triggerModeOptions,
@ -69,6 +132,36 @@ export default defineComponent({
await getQqbotPluginOperationList(params.pluginKey, params.triggerMode), await getQqbotPluginOperationList(params.pluginKey, params.triggerMode),
}; };
const buttons: Array<KtTableButton<QqbotApi.PluginOperation>> = [ const buttons: Array<KtTableButton<QqbotApi.PluginOperation>> = [
{
key: 'manifestValidate',
label: '校验 Manifest',
onClick: () => openManifestModal('validate'),
},
{
key: 'manifestUpload',
label: '上传插件',
onClick: () => openManifestModal('upload'),
},
{
key: 'manifestInstall',
label: '本地安装',
onClick: () => openManifestModal('install'),
},
{
key: 'installations',
label: '安装记录',
onClick: () => void loadInstallations(),
},
{
key: 'runtimeEvents',
label: '运行事件',
onClick: () => void loadRuntimeEvents(),
},
{
key: 'accountBindings',
label: '账号绑定',
onClick: () => void loadAccountBindings(),
},
{ {
key: 'health', key: 'health',
label: '健康检查', label: '健康检查',
@ -134,6 +227,194 @@ export default defineComponent({
})); }));
} }
function openManifestModal(mode: typeof manifestMode.value) {
manifestMode.value = mode;
manifestText.value = JSON.stringify(defaultManifest, null, 2);
manifestModalOpen.value = true;
}
function parseManifestText() {
try {
return JSON.parse(manifestText.value);
} catch {
message.error('Manifest JSON 格式不正确');
return undefined;
}
}
async function submitManifest() {
const manifest = parseManifestText();
if (!manifest) return;
platformLoading.value = true;
try {
if (manifestMode.value === 'upload') {
await uploadQqbotPluginPackage({ manifest });
message.success('插件包上传校验通过');
} else if (manifestMode.value === 'install') {
await installLocalQqbotPluginPackage({ manifest });
message.success('插件已安装');
await loadInstallations(false);
} else {
await validateQqbotPluginManifest(manifest);
message.success('Manifest 校验通过');
}
manifestModalOpen.value = false;
} finally {
platformLoading.value = false;
}
}
async function loadInstallations(openDrawer = true) {
installations.value = await getQqbotPluginPlatformInstallations();
drawerMode.value = 'installations';
drawerOpen.value = openDrawer || drawerOpen.value;
}
async function loadRuntimeEvents() {
runtimeEvents.value = await getQqbotPluginRuntimeEvents();
drawerMode.value = 'events';
drawerOpen.value = true;
}
async function loadAccountBindings() {
accountBindings.value = await getQqbotPluginAccountBindings();
drawerMode.value = 'bindings';
drawerOpen.value = true;
}
async function updateInstallationStatus(
row: QqbotPluginPlatformApi.Installation,
action: 'disable' | 'enable' | 'uninstall',
) {
if (action === 'enable') {
await enableQqbotPluginInstallation(row.id);
message.success('插件已启用');
} else if (action === 'disable') {
await disableQqbotPluginInstallation(row.id);
message.success('插件已禁用');
} else {
await uninstallQqbotPluginInstallation(row.id);
message.success('插件已卸载');
}
await loadInstallations(false);
}
const renderStatusTag = (status?: string) => {
let color = 'processing';
switch (status) {
case 'disabled': {
color = 'warning';
break;
}
case 'enabled': {
color = 'success';
break;
}
case 'failed':
case 'uninstalled': {
color = 'error';
break;
}
}
return <Tag color={color}>{status || '-'}</Tag>;
};
const renderDrawerContent = () => {
if (drawerMode.value === 'events') {
return runtimeEvents.value.length > 0 ? (
<div class="space-y-3">
{runtimeEvents.value.map((item) => (
<div
class="border-b border-solid border-gray-100 pb-3"
key={item.id}
>
<div>
<Tag color={item.level === 'error' ? 'error' : 'processing'}>
{item.level}
</Tag>
<span>{item.eventType}</span>
</div>
<pre class="mt-2 whitespace-pre-wrap text-xs">
{JSON.stringify(item.safeSummary || {}, null, 2)}
</pre>
</div>
))}
</div>
) : (
<span></span>
);
}
if (drawerMode.value === 'bindings') {
return accountBindings.value.length > 0 ? (
<div class="space-y-3">
{accountBindings.value.map((item) => (
<div
class="border-b border-solid border-gray-100 pb-3"
key={item.id}
>
<Tag color={item.enabled ? 'success' : 'default'}>
{item.enabled ? '已启用' : '已停用'}
</Tag>
<span>
{item.pluginId} / {item.accountId}
</span>
</div>
))}
</div>
) : (
<span></span>
);
}
return installations.value.length > 0 ? (
<div class="space-y-3">
{installations.value.map((item) => (
<div
class="border-b border-solid border-gray-100 pb-3"
key={item.id}
>
<div class="mb-2 flex items-center gap-2">
{renderStatusTag(item.status)}
<Tag>{item.runtimeStatus || '-'}</Tag>
<span>
{item.pluginId} / {item.versionId}
</span>
</div>
<ASpace>
<AButton
disabled={item.status === 'enabled'}
onClick={() => void updateInstallationStatus(item, 'enable')}
size="small"
>
</AButton>
<AButton
disabled={item.status === 'disabled'}
onClick={() => void updateInstallationStatus(item, 'disable')}
size="small"
>
</AButton>
<AButton
danger
onClick={() =>
void updateInstallationStatus(item, 'uninstall')
}
size="small"
>
</AButton>
</ASpace>
</div>
))}
</div>
) : (
<span></span>
);
};
return () => ( return () => (
<Page autoContentHeight> <Page autoContentHeight>
<AKtTable <AKtTable
@ -165,6 +446,35 @@ export default defineComponent({
}, },
}} }}
/> />
<AModal
confirmLoading={platformLoading.value}
onCancel={() => {
manifestModalOpen.value = false;
}}
onOk={() => void submitManifest()}
open={manifestModalOpen.value}
title={manifestModalTitle.value}
width={760}
>
<textarea
class="w-full resize-y rounded border border-solid border-gray-200 p-3 font-mono text-sm outline-none"
onInput={(event) => {
manifestText.value = (event.target as HTMLTextAreaElement).value;
}}
rows={18}
value={manifestText.value}
/>
</AModal>
<ADrawer
onClose={() => {
drawerOpen.value = false;
}}
open={drawerOpen.value}
title={drawerTitle.value}
width={760}
>
{renderDrawerContent()}
</ADrawer>
</Page> </Page>
); );
}, },