diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts index 6715231..224176c 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.ts +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -68,6 +68,16 @@ export namespace QqbotApi { containerName?: string; containerOnline?: boolean; containerStatus?: 'creating' | 'error' | 'running' | 'stopped'; + profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown'; + recoveryState?: 'idle' | 'password' | 'quick' | 'suspended'; + riskMode?: 'cooldown' | 'manual_only' | 'normal'; + runtimeProfile?: { + desktopProfileVersion?: string; + imageDigest?: string; + imageRef?: string; + locale?: string; + shmSize?: string; + }; lastCheckedAt?: string; lastError?: string; lastLoginAt?: string; diff --git a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts b/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts index 63166a9..4cc14ff 100644 --- a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts +++ b/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts @@ -8,6 +8,7 @@ import { getNapcatNewDeviceStatusMessage, getQqbotAccountScanEventsUrl, getQqbotAccountScanStatus, + getQqbotNapcatRuntimeDetail, mergeNapcatAccountScanResult, NAPCAT_LOGIN_PROGRESS_LABELS, refreshQqbotAccountScanQrcode, @@ -220,4 +221,17 @@ describe('napcat login display helpers', () => { '/api/qqbot/account/scan/events?sessionId=scan%201', ); }); + + it('builds the read-only NapCat runtime detail request', async () => { + vi.mocked(requestClient.get).mockResolvedValue({}); + + await getQqbotNapcatRuntimeDetail('account-1'); + + expect(requestClient.get).toHaveBeenCalledWith( + '/qqbot/napcat/runtime/detail', + { + params: { accountId: 'account-1' }, + }, + ); + }); }); diff --git a/apps/web-antdv-next/src/api/qqbot/napcat.ts b/apps/web-antdv-next/src/api/qqbot/napcat.ts index 3a6139f..237007e 100644 --- a/apps/web-antdv-next/src/api/qqbot/napcat.ts +++ b/apps/web-antdv-next/src/api/qqbot/napcat.ts @@ -9,6 +9,21 @@ export type NapcatLoginNewDeviceStatus = | 'verified'; export namespace QqbotNapcatApi { + export interface RuntimeProfileDetail { + accountId: string; + inspectionTimeoutMs?: number; + loginEvents?: Array<{ + createTime?: string; + eventKind: string; + eventSource: string; + eventStatus: string; + }>; + protocolProfile?: Record; + riskMode?: Record; + runtimeProfile?: Record; + sessionBehaviorProfile?: Record; + } + export interface AccountScanResult { accountId?: string; captchaUrl?: string; @@ -194,6 +209,15 @@ export function getQqbotAccountScanEventsUrl(sessionId: string) { ); } +export function getQqbotNapcatRuntimeDetail(accountId: string) { + return requestClient.get( + '/qqbot/napcat/runtime/detail', + { + params: { accountId }, + }, + ); +} + function buildApiUrl(path: string) { const baseUrl = requestClient.getBaseUrl() || ''; if (!baseUrl) return path; 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 c845b36..e5c4dd3 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -28,6 +28,7 @@ import { import { KtTable, useKtTable } from '#/components/ktTable'; import NapcatLoginModal from './napcat/NapcatLoginModal'; +import NapcatRuntimeProfileDrawer from './napcat/NapcatRuntimeProfileDrawer'; const AKtTable = KtTable as any; const ATypographyText = Typography.Text as any; @@ -37,6 +38,8 @@ export default defineComponent({ setup() { const editingId = ref(); const napcatLoginRef = ref(); + const runtimeProfileAccount = ref(); + const runtimeProfileOpen = ref(false); const router = useRouter(); const [AccountForm, accountFormApi] = useVbenForm({ @@ -164,6 +167,12 @@ export default defineComponent({ onClick: openScanRefresh, permissionCodes: ['QqBot:Account:RefreshLogin'], }, + { + key: 'runtimeProfile', + label: '运行态', + onClick: openRuntimeProfile, + permissionCodes: ['QqBot:Account:Config'], + }, { confirm: (row) => `确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`, @@ -261,6 +270,11 @@ export default defineComponent({ await napcatLoginRef.value?.openRefresh(row); } + function openRuntimeProfile(row: QqbotApi.Account) { + runtimeProfileAccount.value = row; + runtimeProfileOpen.value = true; + } + const renderAccountOnlineStatus = (row: QqbotApi.Account) => { if (!row.enabled) { return 已停用; @@ -588,6 +602,13 @@ export default defineComponent({ }} ref={napcatLoginRef as any} /> + { + runtimeProfileOpen.value = open; + }} + open={runtimeProfileOpen.value} + /> diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx new file mode 100644 index 0000000..d8d029b --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx @@ -0,0 +1,91 @@ +/* @vitest-environment happy-dom */ +/* eslint-disable vue/one-component-per-file, vue/require-default-prop */ + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getQqbotNapcatRuntimeDetail } from '#/api/qqbot/napcat'; + +import NapcatRuntimeProfileDrawer from './NapcatRuntimeProfileDrawer'; + +vi.mock('#/api/qqbot/napcat', () => ({ + getQqbotNapcatRuntimeDetail: vi.fn(), +})); + +vi.mock('antdv-next', () => ({ + Drawer: defineComponent({ + name: 'MockDrawer', + props: { + open: Boolean, + title: String, + }, + setup(props, { slots }) { + return () => + props.open + ? h('aside', [h('h2', props.title as string), slots.default?.()]) + : null; + }, + }), + Spin: defineComponent({ + name: 'MockSpin', + props: { + spinning: Boolean, + }, + setup(_, { slots }) { + return () => h('div', slots.default?.()); + }, + }), + Tag: defineComponent({ + name: 'MockTag', + props: { + color: String, + }, + setup(_, { slots }) { + return () => h('span', slots.default?.()); + }, + }), +})); + +describe('napcat runtime profile drawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads and renders sanitized runtime profile evidence', async () => { + vi.mocked(getQqbotNapcatRuntimeDetail).mockResolvedValue({ + accountId: 'account-1', + inspectionTimeoutMs: 15_000, + protocolProfile: { + reverseWsUrl: 'ws://host/qqbot/onebot/reverse?token=[REDACTED]', + }, + runtimeProfile: { + imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', + locale: 'zh_CN.UTF-8', + shmSize: '512m', + }, + }); + + const wrapper = mount(NapcatRuntimeProfileDrawer, { + props: { + account: { + connectStatus: 'online', + connectionMode: 'reverse-ws', + enabled: true, + id: 'account-1', + name: '主账号', + selfId: '10001', + }, + open: true, + }, + }); + await flushPromises(); + + expect(getQqbotNapcatRuntimeDetail).toHaveBeenCalledWith('account-1'); + expect(wrapper.text()).toContain('NapCat 运行态证据'); + expect(wrapper.text()).toContain('kt-napcat-desktop-cn'); + expect(wrapper.text()).toContain('zh_CN.UTF-8'); + expect(wrapper.text()).toContain('[REDACTED]'); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.tsx b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.tsx new file mode 100644 index 0000000..4acfbe6 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.tsx @@ -0,0 +1,150 @@ +import type { PropType } from 'vue'; + +import type { QqbotApi } from '#/api/qqbot'; +import type { QqbotNapcatApi } from '#/api/qqbot/napcat'; + +import { defineComponent, ref, watch } from 'vue'; + +import { Drawer, Spin, Tag } from 'antdv-next'; + +import { getQqbotNapcatRuntimeDetail } from '#/api/qqbot/napcat'; + +import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status'; + +const ADrawer = Drawer as any; +const ASpin = Spin as any; + +export default defineComponent({ + name: 'NapcatRuntimeProfileDrawer', + props: { + account: { + default: undefined, + type: Object as PropType, + }, + open: { + default: false, + type: Boolean, + }, + }, + emits: ['close', 'update:open'], + setup(props, { emit }) { + const detail = ref(); + const loading = ref(false); + + watch( + () => [props.open, props.account?.id] as const, + () => { + if (props.open && props.account?.id) void loadDetail(); + }, + { immediate: true }, + ); + + /** + * Loads sanitized runtime profile evidence for the selected account. + */ + async function loadDetail() { + if (!props.account?.id) return; + loading.value = true; + try { + detail.value = await getQqbotNapcatRuntimeDetail(props.account.id); + } finally { + loading.value = false; + } + } + + /** + * Emits both drawer close contracts used by existing QQBot views. + */ + function closeDrawer() { + emit('update:open', false); + emit('close'); + } + + /** + * Renders compact key/value evidence rows without exposing interactive controls. + * @param label - Human readable field label. + * @param value - Runtime evidence value already sanitized by the API. + */ + const renderField = (label: string, value: unknown) => { + return ( +
+ {label} + {formatValue(value)} +
+ ); + }; + + /** + * Renders a JSON evidence block for profile sections. + * @param title - Section title shown above the evidence block. + * @param value - Sanitized profile object returned by the API. + */ + const renderJsonBlock = (title: string, value: unknown) => { + if (!value) return null; + return ( +
+

{title}

+
+            {JSON.stringify(value, null, 2)}
+          
+
+ ); + }; + + /** + * Converts scalar runtime evidence into stable display text. + * @param value - Sanitized value from profile detail. + * @returns Display text for compact rows. + */ + function formatValue(value: unknown) { + if (value === undefined || value === null || value === '') return '-'; + if (typeof value === 'object') return JSON.stringify(value); + return `${value}`; + } + + const renderSummary = () => { + const napcat = props.account?.napcat; + const runtimeProfile = + (detail.value?.runtimeProfile as Record | undefined) || + napcat?.runtimeProfile; + return ( +
+
+ + Profile {getQqbotStatusLabel(napcat?.profileStatus)} + + + 风险 {getQqbotStatusLabel(napcat?.riskMode)} + +
+ {renderField('账号', props.account?.selfId)} + {renderField('镜像', runtimeProfile?.imageRef)} + {renderField('Locale', runtimeProfile?.locale)} + {renderField('SHM', runtimeProfile?.shmSize)} + {renderField('检查超时', detail.value?.inspectionTimeoutMs)} +
+ ); + }; + + return () => ( + + + {renderSummary()} + {renderJsonBlock('Runtime Profile', detail.value?.runtimeProfile)} + {renderJsonBlock('Protocol Profile', detail.value?.protocolProfile)} + {renderJsonBlock( + 'Session Behavior', + detail.value?.sessionBehaviorProfile, + )} + {renderJsonBlock('Risk Mode', detail.value?.riskMode)} + {renderJsonBlock('Login Events', detail.value?.loginEvents)} + + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/modules/status.ts b/apps/web-antdv-next/src/views/qqbot/modules/status.ts index a436b91..e31fb6a 100644 --- a/apps/web-antdv-next/src/views/qqbot/modules/status.ts +++ b/apps/web-antdv-next/src/views/qqbot/modules/status.ts @@ -4,9 +4,13 @@ export const qqbotStatusLabels = { disabled: '已停用', enabled: '已启用', failed: '失败', + drift: '漂移', offline: '离线', + ok: '正常', online: '在线', pending: '处理中', + cooldown: '降载冷却', + manual_only: '仅手动', unknown: '未知', } as const; @@ -21,8 +25,12 @@ export function getQqbotStatusColor( status: string | undefined, ): TagProps['color'] { if (status === 'online' || status === 'enabled') return 'success'; + if (status === 'ok') return 'success'; if (status === 'offline' || status === 'disabled') return 'default'; if (status === 'failed') return 'error'; + if (status === 'drift' || status === 'cooldown' || status === 'manual_only') { + return 'warning'; + } if (status === 'pending') return 'processing'; return 'default'; }