feat: 展示NapCat运行态Profile证据
This commit is contained in:
parent
ef519fb798
commit
56aa200ea5
@ -68,6 +68,16 @@ export namespace QqbotApi {
|
|||||||
containerName?: string;
|
containerName?: string;
|
||||||
containerOnline?: boolean;
|
containerOnline?: boolean;
|
||||||
containerStatus?: 'creating' | 'error' | 'running' | 'stopped';
|
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;
|
lastCheckedAt?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
getNapcatNewDeviceStatusMessage,
|
getNapcatNewDeviceStatusMessage,
|
||||||
getQqbotAccountScanEventsUrl,
|
getQqbotAccountScanEventsUrl,
|
||||||
getQqbotAccountScanStatus,
|
getQqbotAccountScanStatus,
|
||||||
|
getQqbotNapcatRuntimeDetail,
|
||||||
mergeNapcatAccountScanResult,
|
mergeNapcatAccountScanResult,
|
||||||
NAPCAT_LOGIN_PROGRESS_LABELS,
|
NAPCAT_LOGIN_PROGRESS_LABELS,
|
||||||
refreshQqbotAccountScanQrcode,
|
refreshQqbotAccountScanQrcode,
|
||||||
@ -220,4 +221,17 @@ describe('napcat login display helpers', () => {
|
|||||||
'/api/qqbot/account/scan/events?sessionId=scan%201',
|
'/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';
|
| 'verified';
|
||||||
|
|
||||||
export namespace QqbotNapcatApi {
|
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 {
|
export interface AccountScanResult {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
captchaUrl?: 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) {
|
function buildApiUrl(path: string) {
|
||||||
const baseUrl = requestClient.getBaseUrl() || '';
|
const baseUrl = requestClient.getBaseUrl() || '';
|
||||||
if (!baseUrl) return path;
|
if (!baseUrl) return path;
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
|
|
||||||
import NapcatLoginModal from './napcat/NapcatLoginModal';
|
import NapcatLoginModal from './napcat/NapcatLoginModal';
|
||||||
|
import NapcatRuntimeProfileDrawer from './napcat/NapcatRuntimeProfileDrawer';
|
||||||
|
|
||||||
const AKtTable = KtTable as any;
|
const AKtTable = KtTable as any;
|
||||||
const ATypographyText = Typography.Text as any;
|
const ATypographyText = Typography.Text as any;
|
||||||
@ -37,6 +38,8 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const editingId = ref<string>();
|
const editingId = ref<string>();
|
||||||
const napcatLoginRef = ref<NapcatLoginModalExposed>();
|
const napcatLoginRef = ref<NapcatLoginModalExposed>();
|
||||||
|
const runtimeProfileAccount = ref<QqbotApi.Account>();
|
||||||
|
const runtimeProfileOpen = ref(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [AccountForm, accountFormApi] = useVbenForm({
|
const [AccountForm, accountFormApi] = useVbenForm({
|
||||||
@ -164,6 +167,12 @@ export default defineComponent({
|
|||||||
onClick: openScanRefresh,
|
onClick: openScanRefresh,
|
||||||
permissionCodes: ['QqBot:Account:RefreshLogin'],
|
permissionCodes: ['QqBot:Account:RefreshLogin'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'runtimeProfile',
|
||||||
|
label: '运行态',
|
||||||
|
onClick: openRuntimeProfile,
|
||||||
|
permissionCodes: ['QqBot:Account:Config'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
confirm: (row) =>
|
confirm: (row) =>
|
||||||
`确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`,
|
`确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`,
|
||||||
@ -261,6 +270,11 @@ export default defineComponent({
|
|||||||
await napcatLoginRef.value?.openRefresh(row);
|
await napcatLoginRef.value?.openRefresh(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRuntimeProfile(row: QqbotApi.Account) {
|
||||||
|
runtimeProfileAccount.value = row;
|
||||||
|
runtimeProfileOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
|
const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
|
||||||
if (!row.enabled) {
|
if (!row.enabled) {
|
||||||
return <Tag color="default">已停用</Tag>;
|
return <Tag color="default">已停用</Tag>;
|
||||||
@ -588,6 +602,13 @@ export default defineComponent({
|
|||||||
}}
|
}}
|
||||||
ref={napcatLoginRef as any}
|
ref={napcatLoginRef as any}
|
||||||
/>
|
/>
|
||||||
|
<NapcatRuntimeProfileDrawer
|
||||||
|
account={runtimeProfileAccount.value}
|
||||||
|
onUpdate:open={(open: boolean) => {
|
||||||
|
runtimeProfileOpen.value = open;
|
||||||
|
}}
|
||||||
|
open={runtimeProfileOpen.value}
|
||||||
|
/>
|
||||||
<AccountModal title={modalTitle.value}>
|
<AccountModal title={modalTitle.value}>
|
||||||
<AccountForm class="mx-2" />
|
<AccountForm class="mx-2" />
|
||||||
</AccountModal>
|
</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: '已停用',
|
disabled: '已停用',
|
||||||
enabled: '已启用',
|
enabled: '已启用',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
|
drift: '漂移',
|
||||||
offline: '离线',
|
offline: '离线',
|
||||||
|
ok: '正常',
|
||||||
online: '在线',
|
online: '在线',
|
||||||
pending: '处理中',
|
pending: '处理中',
|
||||||
|
cooldown: '降载冷却',
|
||||||
|
manual_only: '仅手动',
|
||||||
unknown: '未知',
|
unknown: '未知',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -21,8 +25,12 @@ export function getQqbotStatusColor(
|
|||||||
status: string | undefined,
|
status: string | undefined,
|
||||||
): TagProps['color'] {
|
): TagProps['color'] {
|
||||||
if (status === 'online' || status === 'enabled') return 'success';
|
if (status === 'online' || status === 'enabled') return 'success';
|
||||||
|
if (status === 'ok') return 'success';
|
||||||
if (status === 'offline' || status === 'disabled') return 'default';
|
if (status === 'offline' || status === 'disabled') return 'default';
|
||||||
if (status === 'failed') return 'error';
|
if (status === 'failed') return 'error';
|
||||||
|
if (status === 'drift' || status === 'cooldown' || status === 'manual_only') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
if (status === 'pending') return 'processing';
|
if (status === 'pending') return 'processing';
|
||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user