feat: 完成NapCat登录进度展示

This commit is contained in:
sunlei 2026-06-15 06:16:55 +08:00
parent 15480ca705
commit 33ebdac389
5 changed files with 158 additions and 5 deletions

View File

@ -47,7 +47,7 @@ pnpm run build:antdv-next
## 业务页面
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / qrcode 每步进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`
## 部署说明

View File

@ -1,5 +1,7 @@
import type { Recordable } from '@vben/types';
import type { NapcatLoginNewDeviceStatus } from './napcat';
import { encryptPassword } from '#/api/core/auth';
import { requestClient } from '#/api/request';
@ -96,9 +98,12 @@ export namespace QqbotApi {
captchaUrl?: string;
containerId?: string;
containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt?: number;
mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
qrcode?: string;
selfId?: string;
sessionId?: string;

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
getNapcatNewDeviceStatusMessage,
NAPCAT_LOGIN_PROGRESS_LABELS,
resolveNapcatLoginDisplayQrcode,
} from './napcat';
describe('napcat login display helpers', () => {
it('keeps all Chinese progress labels required by the login flow', () => {
expect(NAPCAT_LOGIN_PROGRESS_LABELS).toMatchObject({
'captcha-submit': '验证码已提交,等待确认',
'login-failed': '登录失败',
'login-success': '登录成功',
'manual-qr-required': '正在生成手动二维码',
'new-device-confirming': '新设备确认中',
'new-device-qrcode-ready': '新设备二维码待扫码',
'new-device-required': '需要新设备验证二维码',
'new-device-scanned': '新设备二维码已扫码',
'new-device-verified': '新设备验证成功,继续登录',
'password-login': '正在密码登录',
'password-login-captcha': '需要验证码',
'password-login-captcha-submit': '验证码已提交,等待确认',
'password-login-failed': '登录失败',
'password-login-start': '正在密码登录',
'quick-login-fallback': '快速登录失败,进入密码登录',
'quick-login-start': '正在快速登录',
'runtime-cleanup-failed': '运行态清理失败',
});
});
it('shows new-device QR separately before normal login QR or captcha', () => {
expect(
resolveNapcatLoginDisplayQrcode({
captchaUrl: 'https://captcha.qq.com/',
newDeviceQrcode: 'data:image/png;base64,new-device',
qrcode: 'normal-login-qrcode',
}),
).toBe('data:image/png;base64,new-device');
expect(getNapcatNewDeviceStatusMessage('scanned')).toBe(
'新设备二维码已扫码',
);
});
});

View File

@ -0,0 +1,50 @@
export type NapcatLoginNewDeviceStatus =
| 'confirming'
| 'expired'
| 'failed'
| 'qr-pending'
| 'scanned'
| 'verified';
export type NapcatLoginDisplayQrcodeSource = {
captchaUrl?: string;
newDeviceQrcode?: string;
qrcode?: string;
};
export const NAPCAT_LOGIN_PROGRESS_LABELS = {
'captcha-submit': '验证码已提交,等待确认',
'login-failed': '登录失败',
'login-success': '登录成功',
'manual-qr-required': '正在生成手动二维码',
'new-device-confirming': '新设备确认中',
'new-device-qrcode-ready': '新设备二维码待扫码',
'new-device-required': '需要新设备验证二维码',
'new-device-scanned': '新设备二维码已扫码',
'new-device-verified': '新设备验证成功,继续登录',
'password-login': '正在密码登录',
'password-login-captcha': '需要验证码',
'password-login-captcha-submit': '验证码已提交,等待确认',
'password-login-failed': '登录失败',
'password-login-start': '正在密码登录',
'quick-login-fallback': '快速登录失败,进入密码登录',
'quick-login-start': '正在快速登录',
'runtime-cleanup-failed': '运行态清理失败',
} as const;
export function resolveNapcatLoginDisplayQrcode(
source: NapcatLoginDisplayQrcodeSource,
) {
return source.newDeviceQrcode || source.qrcode || '';
}
export function getNapcatNewDeviceStatusMessage(
status?: NapcatLoginNewDeviceStatus,
) {
if (status === 'scanned') return '新设备二维码已扫码';
if (status === 'confirming') return '新设备确认中';
if (status === 'verified') return '新设备验证成功,继续登录';
if (status === 'expired') return '新设备二维码已过期';
if (status === 'failed') return '新设备验证失败';
return '新设备二维码待扫码';
}

View File

@ -1,6 +1,7 @@
import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot';
import type { NapcatLoginNewDeviceStatus } from '#/api/qqbot/napcat';
import type {
KtTableApi,
KtTableButton,
@ -39,6 +40,10 @@ import {
submitQqbotAccountScanCaptcha,
updateQqbotAccount,
} from '#/api/qqbot';
import {
getNapcatNewDeviceStatusMessage,
resolveNapcatLoginDisplayQrcode,
} from '#/api/qqbot/napcat';
import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any;
@ -87,9 +92,12 @@ export default defineComponent({
captchaUrl?: string;
containerId?: string;
containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt?: number;
mode: 'create' | 'refresh';
newDeviceQrcode?: string;
newDeviceStatus?: NapcatLoginNewDeviceStatus;
selfId?: string;
sessionId?: string;
status: 'error' | 'expired' | 'idle' | 'pending' | 'success';
@ -130,6 +138,9 @@ export default defineComponent({
Math.max(scanProgressItems.value.length - 1, 0),
);
const scanQrcodePlaceholderText = computed(() => {
if (scanState.newDeviceStatus) {
return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus);
}
if (scanState.captchaUrl) {
return '等待安全验证';
}
@ -429,14 +440,17 @@ export default defineComponent({
scanState.captchaUrl = result.captchaUrl;
scanState.containerId = result.containerId;
scanState.containerName = result.containerName;
scanState.deviceVerifyUrl = result.deviceVerifyUrl;
scanState.errorMessage = result.errorMessage;
scanState.expiresAt = result.expiresAt;
scanState.mode = result.mode;
scanState.newDeviceQrcode = result.newDeviceQrcode;
scanState.newDeviceStatus = result.newDeviceStatus;
scanState.selfId = result.selfId;
scanState.sessionId = result.sessionId;
scanState.status = result.status;
scanState.webuiPort = result.webuiPort;
const nextQrcode = result.qrcode || '';
const nextQrcode = resolveNapcatLoginDisplayQrcode(result);
const qrcodeChanged = nextQrcode !== scanQrcodeText.value;
if (qrcodeChanged) {
scanQrcodeImageFailed.value = false;
@ -572,7 +586,9 @@ export default defineComponent({
}
if (event.result) {
void applyScanResult(event.result, {
reloadQrcode: event.step === 'qrcode-ready',
reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes(
event.step,
),
});
}
} catch {
@ -587,9 +603,12 @@ export default defineComponent({
captchaUrl: undefined,
containerId: undefined,
containerName: undefined,
deviceVerifyUrl: undefined,
errorMessage: undefined,
expiresAt: undefined,
mode,
newDeviceQrcode: undefined,
newDeviceStatus: undefined,
selfId: undefined,
sessionId: undefined,
status: 'idle',
@ -621,6 +640,12 @@ export default defineComponent({
return 'info';
}
function getNewDeviceAlertType(status?: NapcatLoginNewDeviceStatus) {
if (status === 'failed') return 'error';
if (status === 'verified') return 'success';
return 'warning';
}
function getScanMessage() {
if (scanState.status === 'success') return '扫码登录成功';
if (scanState.status === 'error') {
@ -1021,7 +1046,11 @@ export default defineComponent({
</AButton>,
<AButton
disabled={!scanState.sessionId}
disabled={
!scanState.sessionId ||
!!scanState.captchaUrl ||
!!scanState.newDeviceStatus
}
key="refresh"
loading={scanLoading.value}
onClick={refreshScanQrcode}
@ -1057,7 +1086,32 @@ export default defineComponent({
type="info"
/>
) : null}
{scanState.captchaUrl ? (
{scanState.newDeviceStatus ? (
<Alert
description={
<Space direction="vertical">
<ATypographyText>
使 QQ
</ATypographyText>
{scanState.deviceVerifyUrl ? (
<ATypographyLink
href={scanState.deviceVerifyUrl}
target="_blank"
>
</ATypographyLink>
) : null}
</Space>
}
message={getNapcatNewDeviceStatusMessage(
scanState.newDeviceStatus,
)}
showIcon
type={getNewDeviceAlertType(scanState.newDeviceStatus)}
/>
) : null}
{scanState.captchaUrl && !scanState.newDeviceStatus ? (
<Alert
description={
<Space>