Compare commits

..

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

13 changed files with 61 additions and 806 deletions

View File

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

View File

@ -1,80 +0,0 @@
import { requestClient } from '#/api/request';
export namespace BlogAssetApi {
export interface BucketStatus {
bucketName: string;
exists: boolean;
}
export interface ListQuery {
bucketName?: string;
prefix?: string;
recursive?: boolean;
}
export interface ObjectItem {
etag: string;
lastModified?: string;
name: string;
size: number;
}
export interface ObjectQuery {
bucketName?: string;
objectName: string;
}
export interface PresignedUrlQuery extends ObjectQuery {
expiry?: number;
}
export interface UploadOptions {
bucketName?: string;
objectName?: string;
}
export interface UploadResult {
bucketName: string;
etag: string;
mimeType: string;
objectName: string;
size: number;
url: string;
}
}
export function checkAssetBucket(bucketName?: string) {
return requestClient.get<BlogAssetApi.BucketStatus>('/minio/check', {
params: { bucketName },
});
}
export function createAssetBucket(bucketName?: string) {
return requestClient.post<string>('/minio/bucket', undefined, {
params: { bucketName },
});
}
export function uploadBlogAsset(
file: Blob | File,
options: BlogAssetApi.UploadOptions = {},
) {
return requestClient.upload<BlogAssetApi.UploadResult>('/minio/upload', {
...options,
file,
});
}
export function getAssetList(params: BlogAssetApi.ListQuery = {}) {
return requestClient.get<BlogAssetApi.ObjectItem[]>('/minio/list', {
params,
});
}
export function getAssetPresignedUrl(params: BlogAssetApi.PresignedUrlQuery) {
return requestClient.get<string>('/minio/url', { params });
}
export function removeBlogAsset(params: BlogAssetApi.ObjectQuery) {
return requestClient.delete<boolean>('/minio/remove', { params });
}

View File

@ -1,2 +1 @@
export * from './asset';
export * from './wordpress'; export * from './wordpress';

View File

@ -21,7 +21,7 @@ export namespace WordpressBlogApi {
contentMarkdown?: string; contentMarkdown?: string;
date?: string; date?: string;
excerpt?: RenderedField | string; excerpt?: RenderedField | string;
id: string; id: number | string;
link?: string; link?: string;
modified?: string; modified?: string;
slug?: string; slug?: string;
@ -39,7 +39,7 @@ export namespace WordpressBlogApi {
contentFormat?: 'html' | 'markdown'; contentFormat?: 'html' | 'markdown';
cover?: string; cover?: string;
excerpt?: string; excerpt?: string;
id?: string; id?: number | string;
slug?: string; slug?: string;
status?: string; status?: string;
sticky?: boolean; sticky?: boolean;
@ -119,17 +119,17 @@ export namespace WordpressBlogApi {
export interface Term { export interface Term {
count?: number; count?: number;
description?: string; description?: string;
id: string; id: number | string;
name: string; name: string;
parent?: string; parent?: number | string;
slug?: string; slug?: string;
} }
export interface TermBody { export interface TermBody {
description?: string; description?: string;
id?: string; id?: number | string;
name: string; name: string;
parent?: string; parent?: number | string;
slug?: string; slug?: string;
} }
@ -137,7 +137,7 @@ export namespace WordpressBlogApi {
hide_empty?: boolean; hide_empty?: boolean;
pageNo?: number; pageNo?: number;
pageSize?: number; pageSize?: number;
parent?: string; parent?: number | string;
search?: string; search?: string;
} }
} }
@ -148,7 +148,7 @@ export function getArticleList(params: WordpressBlogApi.ArticleQuery) {
>('/blog/article/list', { params }); >('/blog/article/list', { params });
} }
export function getArticleDetail(id: string) { export function getArticleDetail(id: number | string) {
return requestClient.get<WordpressBlogApi.Article>('/blog/article/detail', { return requestClient.get<WordpressBlogApi.Article>('/blog/article/detail', {
params: { id }, params: { id },
}); });
@ -168,7 +168,7 @@ export function updateArticle(data: WordpressBlogApi.ArticleBody) {
); );
} }
export function deleteArticle(id: string) { export function deleteArticle(id: number | string) {
return requestClient.post<WordpressBlogApi.Article>( return requestClient.post<WordpressBlogApi.Article>(
`/blog/article/remove?id=${id}`, `/blog/article/remove?id=${id}`,
); );
@ -234,7 +234,7 @@ export function updateCategory(data: WordpressBlogApi.TermBody) {
); );
} }
export function deleteCategory(id: string, force = true) { export function deleteCategory(id: number | string, force = true) {
return requestClient.post<WordpressBlogApi.Term>( return requestClient.post<WordpressBlogApi.Term>(
`/blog/category/remove?id=${id}&force=${force}`, `/blog/category/remove?id=${id}&force=${force}`,
); );
@ -255,7 +255,7 @@ export function updateTag(data: WordpressBlogApi.TermBody) {
return requestClient.post<WordpressBlogApi.Term>('/blog/tag/update', data); return requestClient.post<WordpressBlogApi.Term>('/blog/tag/update', data);
} }
export function deleteTag(id: string, force = true) { export function deleteTag(id: number | string, force = true) {
return requestClient.post<WordpressBlogApi.Term>( return requestClient.post<WordpressBlogApi.Term>(
`/blog/tag/remove?id=${id}&force=${force}`, `/blog/tag/remove?id=${id}&force=${force}`,
); );

View File

@ -1,20 +1,10 @@
import type { Recordable } from '@vben/types'; import type { Recordable } from '@vben/types';
import type { NapcatLoginNewDeviceStatus } from './napcat';
import { encryptPassword } from '#/api/core/auth'; import { encryptPassword } from '#/api/core/auth';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace QqbotApi { export namespace QqbotApi {
export type PluginTriggerMode = 'command' | 'event'; export type PluginTriggerMode = 'command' | 'event';
export type OneBotStatus = 'offline' | 'online';
export type WebuiStatus = 'offline' | 'online' | 'unknown';
export type QqLoginStatus =
| 'offline'
| 'online'
| 'qrcode_expired'
| 'qrcode_pending'
| 'unknown';
export interface PageResult<T> { export interface PageResult<T> {
list: T[]; list: T[];
@ -47,7 +37,6 @@ export namespace QqbotApi {
clientRole?: string; clientRole?: string;
connectStatus: 'offline' | 'online'; connectStatus: 'offline' | 'online';
connectionMode: 'reverse-ws'; connectionMode: 'reverse-ws';
containerStatus?: AccountNapcatRuntime['containerStatus'];
createTime?: string; createTime?: string;
enabled: boolean; enabled: boolean;
id: string; id: string;
@ -56,12 +45,8 @@ export namespace QqbotApi {
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
name: string; name: string;
napcat?: AccountNapcatRuntime | null; napcat?: AccountNapcatRuntime | null;
oneBotStatus?: OneBotStatus;
qqLoginMessage?: null | string;
qqLoginStatus?: QqLoginStatus;
remark?: string; remark?: string;
selfId: string; selfId: string;
webuiStatus?: WebuiStatus;
} }
export interface AccountNapcatRuntime { export interface AccountNapcatRuntime {
@ -76,7 +61,12 @@ export namespace QqbotApi {
lastStartedAt?: string; lastStartedAt?: string;
oneBotOnline?: boolean; oneBotOnline?: boolean;
qqLoginMessage?: null | string; qqLoginMessage?: null | string;
qqLoginStatus?: QqLoginStatus; qqLoginStatus?:
| 'offline'
| 'online'
| 'qrcode_expired'
| 'qrcode_pending'
| 'unknown';
webuiOnline?: boolean | null; webuiOnline?: boolean | null;
webuiPort?: null | number; webuiPort?: null | number;
} }
@ -98,12 +88,9 @@ export namespace QqbotApi {
captchaUrl?: string; captchaUrl?: string;
containerId?: string; containerId?: string;
containerName?: string; containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string; errorMessage?: string;
expiresAt?: number; expiresAt?: number;
mode: 'create' | 'refresh'; mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
qrcode?: string; qrcode?: string;
selfId?: string; selfId?: string;
sessionId?: string; sessionId?: string;

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getNapcatNewDeviceStatusMessage,
NAPCAT_LOGIN_PROGRESS_LABELS,
resolveNapcatLoginDisplayQrcode,
} from './napcat';
describe('napcat login display helpers', () => {
it('keeps all Chinese progress labels required by the login flow', () => {
expect(NAPCAT_LOGIN_PROGRESS_LABELS).toMatchObject({
'captcha-submit': '验证码已提交,等待确认',
'login-failed': '登录失败',
'login-success': '登录成功',
'manual-qr-required': '正在生成手动二维码',
'new-device-confirming': '新设备确认中',
'new-device-qrcode-ready': '新设备二维码待扫码',
'new-device-required': '需要新设备验证二维码',
'new-device-scanned': '新设备二维码已扫码',
'new-device-verified': '新设备验证成功,继续登录',
'password-login': '正在密码登录',
'password-login-captcha': '需要验证码',
'password-login-captcha-submit': '验证码已提交,等待确认',
'password-login-failed': '登录失败',
'password-login-start': '正在密码登录',
'quick-login-fallback': '快速登录失败,进入密码登录',
'quick-login-start': '正在快速登录',
'runtime-cleanup-failed': '运行态清理失败',
});
});
it('shows new-device QR separately before normal login QR or captcha', () => {
expect(
resolveNapcatLoginDisplayQrcode({
captchaUrl: 'https://captcha.qq.com/',
newDeviceQrcode: 'data:image/png;base64,new-device',
qrcode: 'normal-login-qrcode',
}),
).toBe('data:image/png;base64,new-device');
expect(getNapcatNewDeviceStatusMessage('scanned')).toBe(
'新设备二维码已扫码',
);
});
});

View File

@ -1,50 +0,0 @@
export type NapcatLoginNewDeviceStatus =
| 'confirming'
| 'expired'
| 'failed'
| 'qr-pending'
| 'scanned'
| 'verified';
export type NapcatLoginDisplayQrcodeSource = {
captchaUrl?: string;
newDeviceQrcode?: string;
qrcode?: string;
};
export const NAPCAT_LOGIN_PROGRESS_LABELS = {
'captcha-submit': '验证码已提交,等待确认',
'login-failed': '登录失败',
'login-success': '登录成功',
'manual-qr-required': '正在生成手动二维码',
'new-device-confirming': '新设备确认中',
'new-device-qrcode-ready': '新设备二维码待扫码',
'new-device-required': '需要新设备验证二维码',
'new-device-scanned': '新设备二维码已扫码',
'new-device-verified': '新设备验证成功,继续登录',
'password-login': '正在密码登录',
'password-login-captcha': '需要验证码',
'password-login-captcha-submit': '验证码已提交,等待确认',
'password-login-failed': '登录失败',
'password-login-start': '正在密码登录',
'quick-login-fallback': '快速登录失败,进入密码登录',
'quick-login-start': '正在快速登录',
'runtime-cleanup-failed': '运行态清理失败',
} as const;
export function resolveNapcatLoginDisplayQrcode(
source: NapcatLoginDisplayQrcodeSource,
) {
return source.newDeviceQrcode || source.qrcode || '';
}
export function getNapcatNewDeviceStatusMessage(
status?: NapcatLoginNewDeviceStatus,
) {
if (status === 'scanned') return '新设备二维码已扫码';
if (status === 'confirming') return '新设备确认中';
if (status === 'verified') return '新设备验证成功,继续登录';
if (status === 'expired') return '新设备二维码已过期';
if (status === 'failed') return '新设备验证失败';
return '新设备二维码待扫码';
}

View File

@ -1,164 +0,0 @@
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

@ -54,7 +54,7 @@ export default defineComponent({
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const editingId = ref<string>(); const editingId = ref<number | string>();
const tableRows = ref<WordpressBlogApi.Term[]>([]); const tableRows = ref<WordpressBlogApi.Term[]>([]);
const parentOptions = computed(() => const parentOptions = computed(() =>
tableRows.value tableRows.value

View File

@ -1,6 +1,6 @@
export interface BlogArticleFilters { export interface BlogArticleFilters {
categories?: string[]; categories?: Array<number | string>;
tags?: string[]; tags?: Array<number | string>;
} }
let pendingFilters: BlogArticleFilters | null = null; let pendingFilters: BlogArticleFilters | null = null;

View File

@ -1,7 +1,6 @@
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 { NapcatLoginNewDeviceStatus } from '#/api/qqbot/napcat';
import type { import type {
KtTableApi, KtTableApi,
KtTableButton, KtTableButton,
@ -40,10 +39,6 @@ import {
submitQqbotAccountScanCaptcha, submitQqbotAccountScanCaptcha,
updateQqbotAccount, updateQqbotAccount,
} from '#/api/qqbot'; } from '#/api/qqbot';
import {
getNapcatNewDeviceStatusMessage,
resolveNapcatLoginDisplayQrcode,
} from '#/api/qqbot/napcat';
import { KtTable, useKtTable } from '#/components/ktTable'; import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any; const AKtTable = KtTable as any;
@ -92,12 +87,9 @@ export default defineComponent({
captchaUrl?: string; captchaUrl?: string;
containerId?: string; containerId?: string;
containerName?: string; containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string; errorMessage?: string;
expiresAt?: number; expiresAt?: number;
mode: 'create' | 'refresh'; mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
selfId?: string; selfId?: string;
sessionId?: string; sessionId?: string;
status: 'error' | 'expired' | 'idle' | 'pending' | 'success'; status: 'error' | 'expired' | 'idle' | 'pending' | 'success';
@ -138,9 +130,6 @@ export default defineComponent({
Math.max(scanProgressItems.value.length - 1, 0), Math.max(scanProgressItems.value.length - 1, 0),
); );
const scanQrcodePlaceholderText = computed(() => { const scanQrcodePlaceholderText = computed(() => {
if (scanState.newDeviceStatus) {
return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus);
}
if (scanState.captchaUrl) { if (scanState.captchaUrl) {
return '等待安全验证'; return '等待安全验证';
} }
@ -305,7 +294,7 @@ export default defineComponent({
permissionCodes: ['QqBot:Account:Delete'], permissionCodes: ['QqBot:Account:Delete'],
}, },
{ {
disabled: (row) => getOneBotStatus(row) !== 'online', disabled: (row) => row.connectStatus !== 'online',
key: 'kick', key: 'kick',
label: '断开', label: '断开',
onClick: async (row, context) => { onClick: async (row, context) => {
@ -440,17 +429,14 @@ export default defineComponent({
scanState.captchaUrl = result.captchaUrl; scanState.captchaUrl = result.captchaUrl;
scanState.containerId = result.containerId; scanState.containerId = result.containerId;
scanState.containerName = result.containerName; scanState.containerName = result.containerName;
scanState.deviceVerifyUrl = result.deviceVerifyUrl;
scanState.errorMessage = result.errorMessage; scanState.errorMessage = result.errorMessage;
scanState.expiresAt = result.expiresAt; scanState.expiresAt = result.expiresAt;
scanState.mode = result.mode; scanState.mode = result.mode;
scanState.newDeviceQrcode = result.newDeviceQrcode;
scanState.newDeviceStatus = result.newDeviceStatus;
scanState.selfId = result.selfId; scanState.selfId = result.selfId;
scanState.sessionId = result.sessionId; scanState.sessionId = result.sessionId;
scanState.status = result.status; scanState.status = result.status;
scanState.webuiPort = result.webuiPort; scanState.webuiPort = result.webuiPort;
const nextQrcode = resolveNapcatLoginDisplayQrcode(result); const nextQrcode = result.qrcode || '';
const qrcodeChanged = nextQrcode !== scanQrcodeText.value; const qrcodeChanged = nextQrcode !== scanQrcodeText.value;
if (qrcodeChanged) { if (qrcodeChanged) {
scanQrcodeImageFailed.value = false; scanQrcodeImageFailed.value = false;
@ -586,9 +572,7 @@ export default defineComponent({
} }
if (event.result) { if (event.result) {
void applyScanResult(event.result, { void applyScanResult(event.result, {
reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes( reloadQrcode: event.step === 'qrcode-ready',
event.step,
),
}); });
} }
} catch { } catch {
@ -603,12 +587,9 @@ export default defineComponent({
captchaUrl: undefined, captchaUrl: undefined,
containerId: undefined, containerId: undefined,
containerName: undefined, containerName: undefined,
deviceVerifyUrl: undefined,
errorMessage: undefined, errorMessage: undefined,
expiresAt: undefined, expiresAt: undefined,
mode, mode,
newDeviceQrcode: undefined,
newDeviceStatus: undefined,
selfId: undefined, selfId: undefined,
sessionId: undefined, sessionId: undefined,
status: 'idle', status: 'idle',
@ -640,12 +621,6 @@ export default defineComponent({
return 'info'; return 'info';
} }
function getNewDeviceAlertType(status?: NapcatLoginNewDeviceStatus) {
if (status === 'failed') return 'error';
if (status === 'verified') return 'success';
return 'warning';
}
function getScanMessage() { function getScanMessage() {
if (scanState.status === 'success') return '扫码登录成功'; if (scanState.status === 'success') return '扫码登录成功';
if (scanState.status === 'error') { if (scanState.status === 'error') {
@ -721,7 +696,7 @@ export default defineComponent({
if (!row.enabled) { if (!row.enabled) {
return <Tag color="default"></Tag>; return <Tag color="default"></Tag>;
} }
const online = getOneBotStatus(row) === 'online'; const online = row.napcat?.oneBotOnline ?? row.connectStatus === 'online';
return ( return (
<Tag color={online ? 'success' : 'default'}> <Tag color={online ? 'success' : 'default'}>
{online ? 'OneBot 在线' : 'OneBot 离线'} {online ? 'OneBot 在线' : 'OneBot 离线'}
@ -730,13 +705,14 @@ export default defineComponent({
}; };
const renderQqLoginStatus = (row: QqbotApi.Account) => { const renderQqLoginStatus = (row: QqbotApi.Account) => {
const meta = getQqLoginStatusMeta(row); const meta = getQqLoginStatusMeta(row.napcat);
const message = getQqLoginMessage(row);
return ( return (
<Space direction="vertical" size={2}> <Space direction="vertical" size={2}>
<Tag color={meta.color}>{meta.label}</Tag> <Tag color={meta.color}>{meta.label}</Tag>
{message ? ( {row.napcat?.qqLoginMessage ? (
<ATypographyText type="secondary">{message}</ATypographyText> <ATypographyText type="secondary">
{row.napcat.qqLoginMessage}
</ATypographyText>
) : null} ) : null}
</Space> </Space>
); );
@ -744,8 +720,8 @@ export default defineComponent({
const renderNapcatRuntime = (row: QqbotApi.Account) => { const renderNapcatRuntime = (row: QqbotApi.Account) => {
const napcat = row.napcat; const napcat = row.napcat;
const meta = getNapcatStatusMeta(row); const meta = getNapcatStatusMeta(napcat);
const webuiMeta = getNapcatWebuiMeta(row); const webuiMeta = getNapcatWebuiMeta(napcat);
return ( return (
<Space direction="vertical" size={2}> <Space direction="vertical" size={2}>
<Space size={4} wrap> <Space size={4} wrap>
@ -786,12 +762,13 @@ export default defineComponent({
); );
}; };
function getNapcatStatusMeta(row: QqbotApi.Account) { function getNapcatStatusMeta(
const status = row.containerStatus || row.napcat?.containerStatus; napcat?: null | QqbotApi.AccountNapcatRuntime,
if (!row.napcat && !status) { ) {
if (!napcat) {
return { color: 'default', label: '未绑定专属容器' }; return { color: 'default', label: '未绑定专属容器' };
} }
if (!status) { if (!napcat.containerStatus) {
return { color: 'warning', label: '容器记录缺失' }; return { color: 'warning', label: '容器记录缺失' };
} }
const statusMap: Record< const statusMap: Record<
@ -803,29 +780,26 @@ export default defineComponent({
running: { color: 'success', label: '容器运行中' }, running: { color: 'success', label: '容器运行中' },
stopped: { color: 'default', label: '容器已停止' }, stopped: { color: 'default', label: '容器已停止' },
}; };
return statusMap[status]; return statusMap[napcat.containerStatus];
} }
function getNapcatWebuiMeta(row: QqbotApi.Account) { function getNapcatWebuiMeta(napcat?: null | QqbotApi.AccountNapcatRuntime) {
const status = getWebuiStatus(row); if (!napcat) return { color: 'default', label: 'WebUI 未绑定' };
if (!row.napcat && status === 'unknown') { if (napcat.webuiOnline === true) {
return { color: 'default', label: 'WebUI 未绑定' };
}
if (status === 'online') {
return { color: 'success', label: 'WebUI 可用' }; return { color: 'success', label: 'WebUI 可用' };
} }
if (status === 'offline') { if (napcat.webuiOnline === false) {
return { color: 'error', label: 'WebUI 不可用' }; return { color: 'error', label: 'WebUI 不可用' };
} }
return { color: 'default', label: 'WebUI 未检查' }; return { color: 'default', label: 'WebUI 未检查' };
} }
function getQqLoginStatusMeta(row: QqbotApi.Account) { function getQqLoginStatusMeta(
if (!row.napcat && !row.qqLoginStatus) { napcat?: null | QqbotApi.AccountNapcatRuntime,
return { color: 'default', label: '未绑定容器' }; ) {
} if (!napcat) return { color: 'default', label: '未绑定容器' };
const statusMap: Record< const statusMap: Record<
QqbotApi.QqLoginStatus, NonNullable<QqbotApi.AccountNapcatRuntime['qqLoginStatus']>,
{ color: string; label: string } { color: string; label: string }
> = { > = {
offline: { color: 'error', label: 'QQ 离线' }, offline: { color: 'error', label: 'QQ 离线' },
@ -834,30 +808,7 @@ export default defineComponent({
qrcode_pending: { color: 'processing', label: '等待扫码' }, qrcode_pending: { color: 'processing', label: '等待扫码' },
unknown: { color: 'default', label: '状态未知' }, unknown: { color: 'default', label: '状态未知' },
}; };
return statusMap[getQqLoginStatus(row)]; return statusMap[napcat.qqLoginStatus || 'unknown'];
}
function getOneBotStatus(row: QqbotApi.Account): QqbotApi.OneBotStatus {
if (row.oneBotStatus) return row.oneBotStatus;
if (row.napcat?.oneBotOnline !== undefined) {
return row.napcat.oneBotOnline ? 'online' : 'offline';
}
return row.connectStatus === 'online' ? 'online' : 'offline';
}
function getWebuiStatus(row: QqbotApi.Account): QqbotApi.WebuiStatus {
if (row.webuiStatus) return row.webuiStatus;
if (row.napcat?.webuiOnline === true) return 'online';
if (row.napcat?.webuiOnline === false) return 'offline';
return 'unknown';
}
function getQqLoginStatus(row: QqbotApi.Account): QqbotApi.QqLoginStatus {
return row.qqLoginStatus || row.napcat?.qqLoginStatus || 'unknown';
}
function getQqLoginMessage(row: QqbotApi.Account) {
return row.qqLoginMessage ?? row.napcat?.qqLoginMessage;
} }
function getRecentActivity(row: QqbotApi.Account) { function getRecentActivity(row: QqbotApi.Account) {
@ -885,35 +836,31 @@ export default defineComponent({
if (row.lastError) { if (row.lastError) {
return { level: 'warning', text: `账号异常:${row.lastError}` }; return { level: 'warning', text: `账号异常:${row.lastError}` };
} }
const qqLoginMessage = getQqLoginMessage(row); if (row.napcat?.qqLoginMessage) {
const qqLoginStatus = getQqLoginStatus(row);
const containerStatus =
row.containerStatus || row.napcat?.containerStatus;
if (qqLoginMessage) {
return { return {
level: 'warning', level: 'warning',
text: `QQ 登录:${qqLoginMessage}`, text: `QQ 登录:${row.napcat.qqLoginMessage}`,
}; };
} }
if (row.napcat?.lastError) { if (row.napcat?.lastError) {
return { level: 'warning', text: `NapCat${row.napcat.lastError}` }; return { level: 'warning', text: `NapCat${row.napcat.lastError}` };
} }
if (qqLoginStatus === 'qrcode_expired') { if (row.napcat?.qqLoginStatus === 'qrcode_expired') {
return { level: 'warning', text: '二维码已过期,点击更新登录' }; return { level: 'warning', text: '二维码已过期,点击更新登录' };
} }
if (getOneBotStatus(row) === 'online') { if (row.connectStatus === 'online') {
return { level: 'normal', text: '消息链路可用' }; return { level: 'normal', text: '消息链路可用' };
} }
if (qqLoginStatus === 'online') { if (row.napcat?.qqLoginStatus === 'online') {
return { level: 'warning', text: 'QQ 在线,等待 OneBot 连接' }; return { level: 'warning', text: 'QQ 在线,等待 OneBot 连接' };
} }
if (containerStatus === 'running') { if (row.napcat?.containerStatus === 'running') {
return { level: 'warning', text: 'NapCat 运行中,等待 OneBot 连接' }; return { level: 'warning', text: 'NapCat 运行中,等待 OneBot 连接' };
} }
if (containerStatus === 'creating') { if (row.napcat?.containerStatus === 'creating') {
return { level: 'warning', text: '容器创建中' }; return { level: 'warning', text: '容器创建中' };
} }
if (containerStatus === 'stopped') { if (row.napcat?.containerStatus === 'stopped') {
return { level: 'warning', text: '容器已停止' }; return { level: 'warning', text: '容器已停止' };
} }
if (!row.napcat) { if (!row.napcat) {
@ -1046,11 +993,7 @@ export default defineComponent({
</AButton>, </AButton>,
<AButton <AButton
disabled={ disabled={!scanState.sessionId}
!scanState.sessionId ||
!!scanState.captchaUrl ||
!!scanState.newDeviceStatus
}
key="refresh" key="refresh"
loading={scanLoading.value} loading={scanLoading.value}
onClick={refreshScanQrcode} onClick={refreshScanQrcode}
@ -1086,32 +1029,7 @@ export default defineComponent({
type="info" type="info"
/> />
) : null} ) : null}
{scanState.newDeviceStatus ? ( {scanState.captchaUrl ? (
<Alert
description={
<Space direction="vertical">
<ATypographyText>
使 QQ
</ATypographyText>
{scanState.deviceVerifyUrl ? (
<ATypographyLink
href={scanState.deviceVerifyUrl}
target="_blank"
>
</ATypographyLink>
) : null}
</Space>
}
message={getNapcatNewDeviceStatusMessage(
scanState.newDeviceStatus,
)}
showIcon
type={getNewDeviceAlertType(scanState.newDeviceStatus)}
/>
) : null}
{scanState.captchaUrl && !scanState.newDeviceStatus ? (
<Alert <Alert
description={ description={
<Space> <Space>

View File

@ -1,39 +1,23 @@
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 { computed, defineComponent, onMounted, ref } from 'vue'; import { defineComponent, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { Button, Drawer, message, Modal, Space, Tag } from 'antdv-next'; import { message, 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<
@ -42,59 +26,12 @@ 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,
@ -132,36 +69,6 @@ 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: '健康检查',
@ -227,194 +134,6 @@ 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
@ -446,35 +165,6 @@ 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>
); );
}, },