Compare commits

..

No commits in common. "b549e30dd511045a9961cfb8e6ff1f52321e382f" and "33ebdac389cef4ebea02278b31f5ff3e80173cb6" have entirely different histories.

18 changed files with 314 additions and 854 deletions

View File

@ -1,28 +0,0 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const readApiFile = (name: string) =>
readFileSync(new URL(name, import.meta.url), 'utf8');
describe('qqbot core API caller boundary', () => {
it('keeps plugin platform and NapCat scan routes out of the core caller', () => {
const source = readApiFile('index.ts');
expect(source).not.toContain('/qqbot/plugin');
expect(source).not.toContain('/qqbot/plugin-platform');
expect(source).not.toContain('/qqbot/account/scan');
});
it('keeps domain-specific caller routes in plugin and napcat callers', () => {
expect(readApiFile('plugin.ts')).toEqual(
expect.stringContaining('/qqbot/plugin/operation/page'),
);
expect(readApiFile('plugin.ts')).toEqual(
expect.stringContaining('/qqbot/plugin-platform/runtime-events'),
);
expect(readApiFile('napcat.ts')).toEqual(
expect.stringContaining('/qqbot/account/scan/events'),
);
});
});

View File

@ -1,5 +1,7 @@
import type { Recordable } from '@vben/types';
import type { NapcatLoginNewDeviceStatus } from './napcat';
import { encryptPassword } from '#/api/core/auth';
import { requestClient } from '#/api/request';
@ -91,6 +93,39 @@ export namespace QqbotApi {
selfId: string;
}
export interface AccountScanResult {
accountId?: string;
captchaUrl?: string;
containerId?: string;
containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt?: number;
mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
qrcode?: string;
selfId?: string;
sessionId?: string;
status: 'error' | 'expired' | 'pending' | 'success';
webuiPort?: null | number;
}
export interface AccountScanEvent {
createdAt: number;
message: string;
result?: AccountScanResult;
status: 'error' | 'info' | 'processing' | 'success';
step: string;
}
export interface AccountScanCaptchaBody {
randstr: string;
sessionId: string;
sid?: string;
ticket: string;
}
export interface Rule {
cooldownMs: number;
enabled: boolean;
@ -249,13 +284,6 @@ export namespace QqbotApi {
triggerMode: PluginTriggerMode;
}
export interface PluginOperationQuery extends Recordable<any> {
pageNo?: number;
pageSize?: number;
pluginKey?: string;
triggerMode?: PluginTriggerMode;
}
export interface PluginHealth {
checkedAt: string;
message?: string;
@ -356,6 +384,62 @@ export function kickQqbotAccount(selfId: string) {
);
}
export function startQqbotAccountScanCreate() {
return requestClient.post<QqbotApi.AccountScanResult>(
'/qqbot/account/scan/create',
);
}
export function startQqbotAccountScanRefresh(id: string) {
return requestClient.post<QqbotApi.AccountScanResult>(
`/qqbot/account/scan/refresh?id=${id}`,
);
}
export function getQqbotAccountScanStatus(sessionId: string) {
return requestClient.get<QqbotApi.AccountScanResult>(
'/qqbot/account/scan/status',
{ params: { sessionId } },
);
}
export function refreshQqbotAccountScanQrcode(sessionId: string) {
return requestClient.post<QqbotApi.AccountScanResult>(
`/qqbot/account/scan/qrcode/refresh?sessionId=${sessionId}`,
);
}
export function submitQqbotAccountScanCaptcha(
data: QqbotApi.AccountScanCaptchaBody,
) {
return requestClient.post<QqbotApi.AccountScanResult>(
'/qqbot/account/scan/captcha/submit',
data,
);
}
export function cancelQqbotAccountScan(sessionId: string) {
return requestClient.post<boolean>(
`/qqbot/account/scan/cancel?sessionId=${sessionId}`,
);
}
export function getQqbotAccountScanEventsUrl(sessionId: string) {
return buildApiUrl(
`/qqbot/account/scan/events?sessionId=${encodeURIComponent(sessionId)}`,
);
}
function buildApiUrl(path: string) {
const baseUrl = requestClient.getBaseUrl() || '';
if (!baseUrl) return path;
if (/^https?:\/\//i.test(path)) return path;
if (/^https?:\/\//i.test(baseUrl)) {
return new URL(path, baseUrl).toString();
}
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}
export function getQqbotRuleList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Rule>>(
'/qqbot/rule/list',
@ -504,3 +588,48 @@ export function testQqbotCommand(data: {
data,
);
}
export function getQqbotPluginList(triggerMode?: QqbotApi.PluginTriggerMode) {
return requestClient.get<QqbotApi.Plugin[]>('/qqbot/plugin/list', {
params: { triggerMode },
});
}
export function getQqbotPluginOperationList(
pluginKey?: string,
triggerMode?: QqbotApi.PluginTriggerMode,
) {
return requestClient.get<QqbotApi.PluginOperation[]>(
'/qqbot/plugin/operation/list',
{ params: { pluginKey, triggerMode } },
);
}
export function getQqbotPluginHealth(
pluginKey?: string,
triggerMode?: QqbotApi.PluginTriggerMode,
) {
return requestClient.get<QqbotApi.PluginHealth[]>('/qqbot/plugin/health', {
params: { pluginKey, triggerMode },
});
}
export function getQqbotEventPluginList(params?: { selfId?: string }) {
return requestClient.get<QqbotApi.EventPlugin[]>('/qqbot/plugin/event/list', {
params,
});
}
export function bindQqbotEventPlugin(selfId: string, pluginKey: string) {
const params = new URLSearchParams({ pluginKey, selfId });
return requestClient.post<QqbotApi.EventPlugin>(
`/qqbot/plugin/event/bind?${params.toString()}`,
);
}
export function unbindQqbotEventPlugin(selfId: string, pluginKey: string) {
const params = new URLSearchParams({ pluginKey, selfId });
return requestClient.post<boolean>(
`/qqbot/plugin/event/unbind?${params.toString()}`,
);
}

View File

@ -1,33 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
import { describe, expect, it } from 'vitest';
import {
cancelQqbotAccountScan,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
NAPCAT_LOGIN_PROGRESS_LABELS,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
startQqbotAccountScanCreate,
startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha,
} from './napcat';
vi.mock('#/api/request', () => ({
requestClient: {
get: vi.fn(),
getBaseUrl: vi.fn(() => '/api'),
post: vi.fn(),
},
}));
describe('napcat login display helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps all Chinese progress labels required by the login flow', () => {
expect(NAPCAT_LOGIN_PROGRESS_LABELS).toMatchObject({
'captcha-submit': '验证码已提交,等待确认',
@ -62,73 +41,4 @@ describe('napcat login display helpers', () => {
'新设备二维码已扫码',
);
});
it('keeps the complete new-device progress key set in caller helpers', () => {
expect(
Object.keys(NAPCAT_LOGIN_PROGRESS_LABELS)
.filter((key) => key.startsWith('new-device-'))
.toSorted(),
).toEqual([
'new-device-confirming',
'new-device-qrcode-ready',
'new-device-required',
'new-device-scanned',
'new-device-verified',
]);
});
it('owns create, refresh, status, QR refresh, captcha, cancel, and SSE caller routes', async () => {
const scanResult = {
mode: 'refresh' as const,
sessionId: 'scan-1',
status: 'pending' as const,
};
vi.mocked(requestClient.post).mockResolvedValue(scanResult);
vi.mocked(requestClient.get).mockResolvedValue(scanResult);
await expect(startQqbotAccountScanCreate()).resolves.toBe(scanResult);
await expect(startQqbotAccountScanRefresh('account-1')).resolves.toBe(
scanResult,
);
await expect(getQqbotAccountScanStatus('scan-1')).resolves.toBe(scanResult);
await expect(refreshQqbotAccountScanQrcode('scan-1')).resolves.toBe(
scanResult,
);
await expect(
submitQqbotAccountScanCaptcha({
randstr: '@rand',
sessionId: 'scan-1',
ticket: 'ticket',
}),
).resolves.toBe(scanResult);
await expect(cancelQqbotAccountScan('scan-1')).resolves.toBe(scanResult);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/account/scan/create',
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/account/scan/refresh?id=account-1',
);
expect(requestClient.get).toHaveBeenCalledWith(
'/qqbot/account/scan/status',
{ params: { sessionId: 'scan-1' } },
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/account/scan/qrcode/refresh?sessionId=scan-1',
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/account/scan/captcha/submit',
{
randstr: '@rand',
sessionId: 'scan-1',
ticket: 'ticket',
},
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/account/scan/cancel?sessionId=scan-1',
);
expect(getQqbotAccountScanEventsUrl('scan 1')).toBe(
'/api/qqbot/account/scan/events?sessionId=scan%201',
);
});
});

View File

@ -1,5 +1,3 @@
import { requestClient } from '#/api/request';
export type NapcatLoginNewDeviceStatus =
| 'confirming'
| 'expired'
@ -8,41 +6,6 @@ export type NapcatLoginNewDeviceStatus =
| 'scanned'
| 'verified';
export namespace QqbotNapcatApi {
export interface AccountScanResult {
accountId?: string;
captchaUrl?: string;
containerId?: string;
containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt?: number;
mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
qrcode?: string;
selfId?: string;
sessionId?: string;
status: 'error' | 'expired' | 'pending' | 'success';
webuiPort?: null | number;
}
export interface AccountScanEvent {
createdAt: number;
message: string;
result?: AccountScanResult;
status: 'error' | 'info' | 'processing' | 'success';
step: string;
}
export interface AccountScanCaptchaBody {
randstr: string;
sessionId: string;
sid?: string;
ticket: string;
}
}
export type NapcatLoginDisplayQrcodeSource = {
captchaUrl?: string;
newDeviceQrcode?: string;
@ -85,59 +48,3 @@ export function getNapcatNewDeviceStatusMessage(
if (status === 'failed') return '新设备验证失败';
return '新设备二维码待扫码';
}
export function startQqbotAccountScanCreate() {
return requestClient.post<QqbotNapcatApi.AccountScanResult>(
'/qqbot/account/scan/create',
);
}
export function startQqbotAccountScanRefresh(id: string) {
return requestClient.post<QqbotNapcatApi.AccountScanResult>(
`/qqbot/account/scan/refresh?id=${id}`,
);
}
export function getQqbotAccountScanStatus(sessionId: string) {
return requestClient.get<QqbotNapcatApi.AccountScanResult>(
'/qqbot/account/scan/status',
{ params: { sessionId } },
);
}
export function refreshQqbotAccountScanQrcode(sessionId: string) {
return requestClient.post<QqbotNapcatApi.AccountScanResult>(
`/qqbot/account/scan/qrcode/refresh?sessionId=${sessionId}`,
);
}
export function submitQqbotAccountScanCaptcha(
data: QqbotNapcatApi.AccountScanCaptchaBody,
) {
return requestClient.post<QqbotNapcatApi.AccountScanResult>(
'/qqbot/account/scan/captcha/submit',
data,
);
}
export function cancelQqbotAccountScan(sessionId: string) {
return requestClient.post<boolean>(
`/qqbot/account/scan/cancel?sessionId=${sessionId}`,
);
}
export function getQqbotAccountScanEventsUrl(sessionId: string) {
return buildApiUrl(
`/qqbot/account/scan/events?sessionId=${encodeURIComponent(sessionId)}`,
);
}
function buildApiUrl(path: string) {
const baseUrl = requestClient.getBaseUrl() || '';
if (!baseUrl) return path;
if (/^https?:\/\//i.test(path)) return path;
if (/^https?:\/\//i.test(baseUrl)) {
return new URL(path, baseUrl).toString();
}
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}

View File

@ -1,102 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
import {
enableQqbotPluginInstallation,
getQqbotPluginAccountBindings,
getQqbotPluginOperationPage,
getQqbotPluginPlatformInstallations,
getQqbotPluginRuntimeEvents,
installLocalQqbotPluginPackage,
uploadQqbotPluginPackage,
validateQqbotPluginManifest,
} from './plugin';
vi.mock('#/api/request', () => ({
requestClient: {
get: vi.fn(),
getBaseUrl: vi.fn(() => ''),
post: vi.fn(),
},
}));
describe('qqbot plugin API wrappers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses the paged plugin operation endpoint for KtTable', async () => {
const pageResult = {
list: [],
pageNo: 2,
pageSize: 1,
total: 3,
};
vi.mocked(requestClient.get).mockResolvedValueOnce(pageResult);
await expect(
getQqbotPluginOperationPage({
pageNo: 2,
pageSize: 1,
pluginKey: 'bangdream',
triggerMode: 'command',
}),
).resolves.toBe(pageResult);
expect(requestClient.get).toHaveBeenCalledWith(
'/qqbot/plugin/operation/page',
{
params: {
pageNo: 2,
pageSize: 1,
pluginKey: 'bangdream',
triggerMode: 'command',
},
},
);
});
it('owns plugin-platform management caller routes', async () => {
const manifest = { capabilities: [], key: 'demo' };
const packageBody = { manifest, packagePath: '/tmp/demo.zip' };
vi.mocked(requestClient.get).mockResolvedValue([]);
vi.mocked(requestClient.post).mockResolvedValue({});
await getQqbotPluginPlatformInstallations();
await uploadQqbotPluginPackage(packageBody);
await validateQqbotPluginManifest(manifest);
await installLocalQqbotPluginPackage(packageBody);
await enableQqbotPluginInstallation('installation-1');
await getQqbotPluginRuntimeEvents('demo');
await getQqbotPluginAccountBindings('demo');
expect(requestClient.get).toHaveBeenCalledWith(
'/qqbot/plugin-platform/installations',
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/plugin-platform/upload',
packageBody,
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/plugin-platform/validate',
{ manifest },
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/plugin-platform/install-local',
packageBody,
);
expect(requestClient.post).toHaveBeenCalledWith(
'/qqbot/plugin-platform/enable',
{ id: 'installation-1' },
);
expect(requestClient.get).toHaveBeenCalledWith(
'/qqbot/plugin-platform/runtime-events',
{ params: { pluginId: 'demo' } },
);
expect(requestClient.get).toHaveBeenCalledWith(
'/qqbot/plugin-platform/account-bindings',
{ params: { pluginId: 'demo' } },
);
});
});

View File

@ -1,7 +1,5 @@
import type { Recordable } from '@vben/types';
import type { QqbotApi } from './index';
import { requestClient } from '#/api/request';
export namespace QqbotPluginPlatformApi {
@ -68,60 +66,6 @@ export namespace QqbotPluginPlatformApi {
}
}
export function getQqbotPluginList(triggerMode?: QqbotApi.PluginTriggerMode) {
return requestClient.get<QqbotApi.Plugin[]>('/qqbot/plugin/list', {
params: { triggerMode },
});
}
export function getQqbotPluginOperationList(
pluginKey?: string,
triggerMode?: QqbotApi.PluginTriggerMode,
) {
return requestClient.get<QqbotApi.PluginOperation[]>(
'/qqbot/plugin/operation/list',
{ params: { pluginKey, triggerMode } },
);
}
export function getQqbotPluginOperationPage(
params: QqbotApi.PluginOperationQuery,
) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.PluginOperation>>(
'/qqbot/plugin/operation/page',
{ params },
);
}
export function getQqbotPluginHealth(
pluginKey?: string,
triggerMode?: QqbotApi.PluginTriggerMode,
) {
return requestClient.get<QqbotApi.PluginHealth[]>('/qqbot/plugin/health', {
params: { pluginKey, triggerMode },
});
}
export function getQqbotEventPluginList(params?: { selfId?: string }) {
return requestClient.get<QqbotApi.EventPlugin[]>('/qqbot/plugin/event/list', {
params,
});
}
export function bindQqbotEventPlugin(selfId: string, pluginKey: string) {
const params = new URLSearchParams({ pluginKey, selfId });
return requestClient.post<QqbotApi.EventPlugin>(
`/qqbot/plugin/event/bind?${params.toString()}`,
);
}
export function unbindQqbotEventPlugin(selfId: string, pluginKey: string) {
const params = new URLSearchParams({ pluginKey, selfId });
return requestClient.post<boolean>(
`/qqbot/plugin/event/unbind?${params.toString()}`,
);
}
export function getQqbotPluginPlatformInstallations() {
return requestClient.get<QqbotPluginPlatformApi.Installation[]>(
'/qqbot/plugin-platform/installations',

View File

@ -12,16 +12,14 @@ import { message, Spin, Tabs, Tag } from 'antdv-next';
import {
bindQqbotAccountCommand,
bindQqbotAccountRule,
bindQqbotEventPlugin,
getQqbotCommandList,
getQqbotEventPluginList,
getQqbotRuleList,
unbindQqbotAccountCommand,
unbindQqbotAccountRule,
} from '#/api/qqbot';
import {
bindQqbotEventPlugin,
getQqbotEventPluginList,
unbindQqbotEventPlugin,
} from '#/api/qqbot/plugin';
} from '#/api/qqbot';
import { KtTable } from '#/components/ktTable';
import {
@ -29,7 +27,6 @@ import {
qqbotRuleMatchOptions,
qqbotRuleTargetOptions,
} from '../../modules/options';
import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status';
const AKtTable = KtTable as any;
const ASpin = Spin as any;
@ -345,10 +342,9 @@ export default defineComponent({
};
const renderEnabledTag = (enabled: boolean) => {
const status = enabled ? 'enabled' : 'disabled';
return (
<Tag color={getQqbotStatusColor(status)}>
{getQqbotStatusLabel(status)}
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '启用' : '停用'}
</Tag>
);
};

View File

@ -1,10 +1,7 @@
import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot';
import type {
NapcatLoginNewDeviceStatus,
QqbotNapcatApi,
} from '#/api/qqbot/napcat';
import type { NapcatLoginNewDeviceStatus } from '#/api/qqbot/napcat';
import type {
KtTableApi,
KtTableButton,
@ -30,22 +27,22 @@ import {
import { useVbenForm } from '#/adapter/form';
import {
cancelQqbotAccountScan,
createQqbotAccount,
deleteQqbotAccount,
getQqbotAccountList,
kickQqbotAccount,
updateQqbotAccount,
} from '#/api/qqbot';
import {
cancelQqbotAccountScan,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
kickQqbotAccount,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
startQqbotAccountScanCreate,
startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha,
updateQqbotAccount,
} from '#/api/qqbot';
import {
getNapcatNewDeviceStatusMessage,
resolveNapcatLoginDisplayQrcode,
} from '#/api/qqbot/napcat';
import { KtTable, useKtTable } from '#/components/ktTable';
@ -90,7 +87,7 @@ export default defineComponent({
const scanQrcodeImageFailed = ref(false);
const scanQrcodeRevision = ref(0);
const scanQrcodeText = ref('');
const scanEvents = ref<QqbotNapcatApi.AccountScanEvent[]>([]);
const scanEvents = ref<QqbotApi.AccountScanEvent[]>([]);
const scanState = reactive<{
captchaUrl?: string;
containerId?: string;
@ -437,7 +434,7 @@ export default defineComponent({
}
async function applyScanResult(
result: QqbotNapcatApi.AccountScanResult,
result: QqbotApi.AccountScanResult,
options: { reloadQrcode?: boolean } = {},
) {
scanState.captchaUrl = result.captchaUrl;
@ -575,7 +572,7 @@ export default defineComponent({
function handleScanEvent(payload: string) {
try {
const event = JSON.parse(payload) as QqbotNapcatApi.AccountScanEvent;
const event = JSON.parse(payload) as QqbotApi.AccountScanEvent;
const index = scanEvents.value.findIndex(
(item) => item.step === event.step,
);
@ -669,9 +666,7 @@ export default defineComponent({
return '扫码登录请求失败';
}
function getScanStepStatus(
status: QqbotNapcatApi.AccountScanEvent['status'],
) {
function getScanStepStatus(status: QqbotApi.AccountScanEvent['status']) {
if (status === 'error') return 'error';
if (status === 'processing') return 'process';
if (status === 'success') return 'finish';
@ -1197,7 +1192,7 @@ export default defineComponent({
function requestTencentCaptcha(
proofWaterUrl: string,
): Promise<Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>> {
): Promise<Omit<QqbotApi.AccountScanCaptchaBody, 'sessionId'>> {
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
@ -1214,7 +1209,7 @@ function requestTencentCaptcha(
let settled = false;
const finish = (
error?: Error,
value?: Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>,
value?: Omit<QqbotApi.AccountScanCaptchaBody, 'sessionId'>,
) => {
if (settled) return;
settled = true;

View File

@ -19,14 +19,12 @@ import {
createQqbotCommand,
deleteQqbotCommand,
getQqbotCommandList,
getQqbotPluginList,
getQqbotPluginOperationList,
testQqbotCommand,
toggleQqbotCommand,
updateQqbotCommand,
} from '#/api/qqbot';
import {
getQqbotPluginList,
getQqbotPluginOperationList,
} from '#/api/qqbot/plugin';
import { KtTable, useKtTable } from '#/components/ktTable';
import {
@ -34,7 +32,6 @@ import {
qqbotCommandParserOptions,
qqbotRuleTargetOptions,
} from '../modules/options';
import { getQqbotStatusColor, getQqbotStatusLabel } from '../modules/status';
const AKtTable = KtTable as any;
@ -565,10 +562,9 @@ export default defineComponent({
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Command;
if (column.key === 'enabled') {
const status = row.enabled ? 'enabled' : 'disabled';
return (
<Tag color={getQqbotStatusColor(status)}>
{getQqbotStatusLabel(status)}
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}

View File

@ -1,84 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { defineComponent, h, isVNode } from 'vue';
import { describe, expect, it, vi } from 'vitest';
vi.mock('antdv-next', () => ({
Button: defineComponent({
name: 'MockButton',
setup(_, { slots }) {
return () => h('button', slots.default?.());
},
}),
Popconfirm: defineComponent({
name: 'MockPopconfirm',
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
Space: defineComponent({
name: 'MockSpace',
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
}));
describe('qqbot shared action renderer', () => {
it('renders link buttons and preserves action props', async () => {
const { renderQqbotActions } = await import('./actions');
const TestIcon = defineComponent({
name: 'TestIcon',
setup: () => () => h('i'),
});
const onEdit = vi.fn();
const onDelete = vi.fn();
const vnode = renderQqbotActions([
{
disabled: true,
icon: TestIcon,
key: 'edit',
label: '编辑',
loading: true,
onClick: onEdit,
},
{
confirmText: '确认删除吗?',
danger: true,
key: 'delete',
label: '删除',
onClick: onDelete,
},
]);
expect(isVNode(vnode)).toBe(true);
const children = vnode.children as {
default?: () => unknown[];
};
const actionNodes = children.default?.().flat() as any[];
expect(actionNodes).toHaveLength(2);
const editButton = actionNodes[0].children[0];
expect(editButton.props).toMatchObject({
disabled: true,
loading: true,
size: 'small',
type: 'link',
});
expect(isVNode(editButton.props.icon)).toBe(true);
expect(editButton.props.onClick).toBe(onEdit);
const deleteConfirm = actionNodes[1];
expect(deleteConfirm.props.title).toBe('确认删除吗?');
expect(deleteConfirm.props.onConfirm).toBe(onDelete);
const confirmSlots = deleteConfirm.children as {
default?: () => unknown;
};
const deleteButton = confirmSlots.default?.() as any;
expect(deleteButton.props.danger).toBe(true);
expect(deleteButton.props.onClick).toBeUndefined();
});
});

View File

@ -1,54 +0,0 @@
import type { Component } from 'vue';
import { h } from 'vue';
import { Button, Popconfirm, Space } from 'antdv-next';
const AButton = Button as any;
const APopconfirm = Popconfirm as any;
const ASpace = Space as any;
export interface QqbotActionItem {
confirmText?: string;
danger?: boolean;
disabled?: boolean;
icon?: Component;
key: string;
label: string;
loading?: boolean;
onClick: () => Promise<void> | void;
}
export const renderQqbotActions = (actions: QqbotActionItem[]) => {
return (
<ASpace size="small">
{actions.map((action) => {
const button = (
<AButton
danger={action.danger}
disabled={action.disabled}
icon={action.icon ? h(action.icon) : undefined}
loading={action.loading}
onClick={action.confirmText ? undefined : action.onClick}
size="small"
type="link"
>
{action.label}
</AButton>
);
if (!action.confirmText) return <span key={action.key}>{button}</span>;
return (
<APopconfirm
key={action.key}
onConfirm={action.onClick}
title={action.confirmText}
>
{{ default: () => button }}
</APopconfirm>
);
})}
</ASpace>
);
};

View File

@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getQqbotStatusColor,
getQqbotStatusLabel,
qqbotStatusLabels,
} from './status';
describe('qqbot shared status helpers', () => {
it('maps common QQBot status keys to Chinese labels', () => {
expect(qqbotStatusLabels).toMatchObject({
disabled: '已停用',
enabled: '已启用',
failed: '失败',
offline: '离线',
online: '在线',
pending: '处理中',
unknown: '未知',
});
expect(getQqbotStatusLabel(undefined)).toBe('未知');
expect(getQqbotStatusLabel('custom')).toBe('custom');
});
it('uses stable tag colors for common QQBot status keys', () => {
expect(getQqbotStatusColor('online')).toBe('success');
expect(getQqbotStatusColor('enabled')).toBe('success');
expect(getQqbotStatusColor('offline')).toBe('default');
expect(getQqbotStatusColor('disabled')).toBe('default');
expect(getQqbotStatusColor('failed')).toBe('error');
expect(getQqbotStatusColor('pending')).toBe('processing');
expect(getQqbotStatusColor(undefined)).toBe('default');
});
});

View File

@ -1,28 +0,0 @@
import type { TagProps } from 'antdv-next';
export const qqbotStatusLabels = {
disabled: '已停用',
enabled: '已启用',
failed: '失败',
offline: '离线',
online: '在线',
pending: '处理中',
unknown: '未知',
} as const;
export type QqbotStatusKey = keyof typeof qqbotStatusLabels;
export function getQqbotStatusLabel(status: string | undefined): string {
if (!status) return qqbotStatusLabels.unknown;
return qqbotStatusLabels[status as QqbotStatusKey] ?? status;
}
export function getQqbotStatusColor(
status: string | undefined,
): TagProps['color'] {
if (status === 'online' || status === 'enabled') return 'success';
if (status === 'offline' || status === 'disabled') return 'default';
if (status === 'failed') return 'error';
if (status === 'pending') return 'processing';
return 'default';
}

View File

@ -29,7 +29,6 @@ import {
getOptionLabel,
qqbotPermissionTargetOptions,
} from '../modules/options';
import { getQqbotStatusColor, getQqbotStatusLabel } from '../modules/status';
const AKtTable = KtTable as any;
const ASwitch = Switch as any;
@ -464,10 +463,9 @@ export default defineComponent({
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Permission;
if (column.key === 'enabled') {
const status = row.enabled ? 'enabled' : 'disabled';
return (
<Tag color={getQqbotStatusColor(status)}>
{getQqbotStatusLabel(status)}
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}

View File

@ -1,49 +0,0 @@
import { defineComponent } from 'vue';
import { Modal } from 'antdv-next';
const AModal = Modal as any;
export default defineComponent({
name: 'QqBotPluginManifestModal',
props: {
loading: {
default: false,
type: Boolean,
},
open: {
default: false,
type: Boolean,
},
title: {
default: '',
type: String,
},
value: {
default: '',
type: String,
},
},
emits: ['close', 'submit', 'update:value'],
setup(props, { emit }) {
return () => (
<AModal
confirmLoading={props.loading}
onCancel={() => emit('close')}
onOk={() => emit('submit')}
open={props.open}
title={props.title}
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) => {
emit('update:value', (event.target as HTMLTextAreaElement).value);
}}
rows={18}
value={props.value}
/>
</AModal>
);
},
});

View File

@ -1,155 +0,0 @@
import type { PropType } from 'vue';
import type { QqbotPluginPlatformApi } from '#/api/qqbot/plugin';
import { defineComponent } from 'vue';
import { Drawer, Tag } from 'antdv-next';
import { renderQqbotActions } from '../../modules/actions';
import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status';
const ADrawer = Drawer as any;
export type PluginPlatformDrawerMode = 'bindings' | 'events' | 'installations';
export default defineComponent({
name: 'QqBotPluginPlatformStateDrawer',
props: {
accountBindings: {
default: () => [],
type: Array as PropType<QqbotPluginPlatformApi.AccountBinding[]>,
},
installations: {
default: () => [],
type: Array as PropType<QqbotPluginPlatformApi.Installation[]>,
},
mode: {
default: 'installations',
type: String as PropType<PluginPlatformDrawerMode>,
},
open: {
default: false,
type: Boolean,
},
runtimeEvents: {
default: () => [],
type: Array as PropType<QqbotPluginPlatformApi.RuntimeEvent[]>,
},
title: {
default: '',
type: String,
},
},
emits: ['close', 'installationAction'],
setup(props, { emit }) {
const renderStatusTag = (status?: string) => {
if (!status) return <Tag color="default">-</Tag>;
const color =
status === 'uninstalled' ? 'error' : getQqbotStatusColor(status);
return <Tag color={color}>{getQqbotStatusLabel(status)}</Tag>;
};
const renderEvents = () =>
props.runtimeEvents.length > 0 ? (
<div class="space-y-3">
{props.runtimeEvents.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>
);
const renderBindings = () =>
props.accountBindings.length > 0 ? (
<div class="space-y-3">
{props.accountBindings.map((item) => (
<div
class="border-b border-solid border-gray-100 pb-3"
key={item.id}
>
{renderStatusTag(item.enabled ? 'enabled' : 'disabled')}
<span>
{item.pluginId} / {item.accountId}
</span>
</div>
))}
</div>
) : (
<span></span>
);
const renderInstallations = () =>
props.installations.length > 0 ? (
<div class="space-y-3">
{props.installations.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>
{renderQqbotActions([
{
disabled: item.status === 'enabled',
key: 'enable',
label: '启用',
onClick: () => emit('installationAction', item, 'enable'),
},
{
disabled: item.status === 'disabled',
key: 'disable',
label: '禁用',
onClick: () => emit('installationAction', item, 'disable'),
},
{
danger: true,
key: 'uninstall',
label: '卸载',
onClick: () => emit('installationAction', item, 'uninstall'),
},
])}
</div>
))}
</div>
) : (
<span></span>
);
const renderContent = () => {
if (props.mode === 'events') return renderEvents();
if (props.mode === 'bindings') return renderBindings();
return renderInstallations();
};
return () => (
<ADrawer
onClose={() => emit('close')}
open={props.open}
size="large"
title={props.title}
>
{renderContent()}
</ADrawer>
);
},
});

View File

@ -1,7 +1,5 @@
import type { TableColumnType } from 'antdv-next';
import type { PluginPlatformDrawerMode } from './components/PluginPlatformStateDrawer';
import type { QqbotApi } from '#/api/qqbot';
import type { QqbotPluginPlatformApi } from '#/api/qqbot/plugin';
import type { KtTableApi, KtTableButton } from '#/components/ktTable';
@ -11,15 +9,17 @@ import { computed, defineComponent, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message, Tag } from 'antdv-next';
import { Button, Drawer, message, Modal, Space, Tag } from 'antdv-next';
import {
getQqbotPluginHealth,
getQqbotPluginList,
getQqbotPluginOperationList,
} from '#/api/qqbot';
import {
disableQqbotPluginInstallation,
enableQqbotPluginInstallation,
getQqbotPluginAccountBindings,
getQqbotPluginHealth,
getQqbotPluginList,
getQqbotPluginOperationPage,
getQqbotPluginPlatformInstallations,
getQqbotPluginRuntimeEvents,
installLocalQqbotPluginPackage,
@ -30,9 +30,10 @@ import {
import { KtTable, useKtTable } from '#/components/ktTable';
import { useDict } from '#/hooks/useDict';
import PluginManifestModal from './components/PluginManifestModal';
import PluginPlatformStateDrawer from './components/PluginPlatformStateDrawer';
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 QQBOT_PLUGIN_TRIGGER_MODE_DICT = 'QQBOT_PLUGIN_TRIGGER_MODE';
const qqbotPluginTriggerModeFallback: Array<
@ -72,7 +73,9 @@ export default defineComponent({
name: 'QqBotPluginList',
setup() {
const accountBindings = ref<QqbotPluginPlatformApi.AccountBinding[]>([]);
const drawerMode = ref<PluginPlatformDrawerMode>('installations');
const drawerMode = ref<'bindings' | 'events' | 'installations'>(
'installations',
);
const drawerOpen = ref(false);
const installations = ref<QqbotPluginPlatformApi.Installation[]>([]);
const manifestMode = ref<'install' | 'upload' | 'validate'>('validate');
@ -125,7 +128,8 @@ export default defineComponent({
},
];
const api: KtTableApi<QqbotApi.PluginOperation> = {
list: async (params) => await getQqbotPluginOperationPage(params),
list: async (params) =>
await getQqbotPluginOperationList(params.pluginKey, params.triggerMode),
};
const buttons: Array<KtTableButton<QqbotApi.PluginOperation>> = [
{
@ -296,6 +300,121 @@ export default defineComponent({
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 () => (
<Page autoContentHeight>
<AKtTable
@ -327,34 +446,35 @@ export default defineComponent({
},
}}
/>
<PluginManifestModal
loading={platformLoading.value}
onClose={() => {
<AModal
confirmLoading={platformLoading.value}
onCancel={() => {
manifestModalOpen.value = false;
}}
onSubmit={() => void submitManifest()}
onUpdate:value={(value: string) => {
manifestText.value = value;
}}
onOk={() => void submitManifest()}
open={manifestModalOpen.value}
title={manifestModalTitle.value}
value={manifestText.value}
/>
<PluginPlatformStateDrawer
accountBindings={accountBindings.value}
installations={installations.value}
mode={drawerMode.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;
}}
onInstallationAction={(
row: QqbotPluginPlatformApi.Installation,
action: 'disable' | 'enable' | 'uninstall',
) => void updateInstallationStatus(row, action)}
open={drawerOpen.value}
runtimeEvents={runtimeEvents.value}
title={drawerTitle.value}
/>
width={760}
>
{renderDrawerContent()}
</ADrawer>
</Page>
);
},

View File

@ -29,7 +29,6 @@ import {
qqbotRuleMatchOptions,
qqbotRuleTargetOptions,
} from '../modules/options';
import { getQqbotStatusColor, getQqbotStatusLabel } from '../modules/status';
const AKtTable = KtTable as any;
@ -300,10 +299,9 @@ export default defineComponent({
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Rule;
if (column.key === 'enabled') {
const status = row.enabled ? 'enabled' : 'disabled';
return (
<Tag color={getQqbotStatusColor(status)}>
{getQqbotStatusLabel(status)}
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}