feat: 展示NapCat运行态Profile证据
This commit is contained in:
parent
ef519fb798
commit
56aa200ea5
@ -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;
|
||||
|
||||
@ -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' },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user