Compare commits
No commits in common. "e5c8bd28262048261913ebbab49ad39a589465aa" and "ef519fb79811bf59d011dcc13b77ce8b35d99d7a" have entirely different histories.
e5c8bd2826
...
ef519fb798
@ -47,7 +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,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示。
|
||||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||||
|
|
||||||
## 部署说明
|
## 部署说明
|
||||||
|
|||||||
@ -68,16 +68,6 @@ 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,7 +8,6 @@ import {
|
|||||||
getNapcatNewDeviceStatusMessage,
|
getNapcatNewDeviceStatusMessage,
|
||||||
getQqbotAccountScanEventsUrl,
|
getQqbotAccountScanEventsUrl,
|
||||||
getQqbotAccountScanStatus,
|
getQqbotAccountScanStatus,
|
||||||
getQqbotNapcatRuntimeDetail,
|
|
||||||
mergeNapcatAccountScanResult,
|
mergeNapcatAccountScanResult,
|
||||||
NAPCAT_LOGIN_PROGRESS_LABELS,
|
NAPCAT_LOGIN_PROGRESS_LABELS,
|
||||||
refreshQqbotAccountScanQrcode,
|
refreshQqbotAccountScanQrcode,
|
||||||
@ -221,17 +220,4 @@ 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,21 +9,6 @@ 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;
|
||||||
@ -209,15 +194,6 @@ 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,7 +28,6 @@ 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;
|
||||||
@ -38,8 +37,6 @@ 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({
|
||||||
@ -167,12 +164,6 @@ 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 容器。`,
|
||||||
@ -270,11 +261,6 @@ 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>;
|
||||||
@ -602,13 +588,6 @@ 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>
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
/* @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]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
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,13 +4,9 @@ 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;
|
||||||
|
|
||||||
@ -25,12 +21,8 @@ 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