feat: 展示NapCat运行态Profile证据

This commit is contained in:
sunlei 2026-06-18 12:56:12 +08:00
parent ef519fb798
commit 56aa200ea5
7 changed files with 318 additions and 0 deletions

View File

@ -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;

View File

@ -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' },
},
);
});
});

View File

@ -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<string, unknown>;
riskMode?: Record<string, unknown>;
runtimeProfile?: Record<string, unknown>;
sessionBehaviorProfile?: Record<string, unknown>;
}
export interface AccountScanResult {
accountId?: string;
captchaUrl?: string;
@ -194,6 +209,15 @@ export function getQqbotAccountScanEventsUrl(sessionId: string) {
);
}
export function getQqbotNapcatRuntimeDetail(accountId: string) {
return requestClient.get<QqbotNapcatApi.RuntimeProfileDetail>(
'/qqbot/napcat/runtime/detail',
{
params: { accountId },
},
);
}
function buildApiUrl(path: string) {
const baseUrl = requestClient.getBaseUrl() || '';
if (!baseUrl) return path;

View File

@ -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<string>();
const napcatLoginRef = ref<NapcatLoginModalExposed>();
const runtimeProfileAccount = ref<QqbotApi.Account>();
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 <Tag color="default"></Tag>;
@ -588,6 +602,13 @@ export default defineComponent({
}}
ref={napcatLoginRef as any}
/>
<NapcatRuntimeProfileDrawer
account={runtimeProfileAccount.value}
onUpdate:open={(open: boolean) => {
runtimeProfileOpen.value = open;
}}
open={runtimeProfileOpen.value}
/>
<AccountModal title={modalTitle.value}>
<AccountForm class="mx-2" />
</AccountModal>

View File

@ -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]');
});
});

View File

@ -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<QqbotApi.Account | undefined>,
},
open: {
default: false,
type: Boolean,
},
},
emits: ['close', 'update:open'],
setup(props, { emit }) {
const detail = ref<QqbotNapcatApi.RuntimeProfileDetail>();
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 (
<div class="grid grid-cols-[120px_1fr] gap-3 border-b border-solid border-gray-100 py-2 text-sm">
<span class="text-gray-500">{label}</span>
<span class="break-all">{formatValue(value)}</span>
</div>
);
};
/**
* 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 (
<section class="mt-4">
<h3 class="mb-2 text-sm font-medium">{title}</h3>
<pre class="max-h-72 overflow-auto rounded bg-gray-50 p-3 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
</section>
);
};
/**
* 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<string, unknown> | undefined) ||
napcat?.runtimeProfile;
return (
<div>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Tag color={getQqbotStatusColor(napcat?.profileStatus)}>
Profile {getQqbotStatusLabel(napcat?.profileStatus)}
</Tag>
<Tag color={getQqbotStatusColor(napcat?.riskMode)}>
{getQqbotStatusLabel(napcat?.riskMode)}
</Tag>
</div>
{renderField('账号', props.account?.selfId)}
{renderField('镜像', runtimeProfile?.imageRef)}
{renderField('Locale', runtimeProfile?.locale)}
{renderField('SHM', runtimeProfile?.shmSize)}
{renderField('检查超时', detail.value?.inspectionTimeoutMs)}
</div>
);
};
return () => (
<ADrawer
onClose={closeDrawer}
open={props.open}
size="large"
title="NapCat 运行态证据"
>
<ASpin spinning={loading.value}>
{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)}
</ASpin>
</ADrawer>
);
},
});

View File

@ -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';
}