refactor: 收敛QQBot账号登录页面边界

This commit is contained in:
sunlei 2026-06-16 13:00:19 +08:00
parent 788aba00a8
commit c95ac9df9e
6 changed files with 912 additions and 759 deletions

View File

@ -1,32 +1,21 @@
import type { TableColumnType } from 'antdv-next'; import type { TableColumnType } from 'antdv-next';
import type { NapcatLoginModalExposed } from './napcat/NapcatLoginModal';
import type { QqbotApi } from '#/api/qqbot'; import type { QqbotApi } from '#/api/qqbot';
import type {
NapcatLoginNewDeviceStatus,
QqbotNapcatApi,
} from '#/api/qqbot/napcat';
import type { import type {
KtTableApi, KtTableApi,
KtTableButton, KtTableButton,
KtTableRowAction, KtTableRowAction,
} from '#/components/ktTable'; } from '#/components/ktTable';
import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue'; import { computed, defineComponent, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
import { useQRCode } from '@vueuse/integrations/useQRCode'; import { message, Space, Tag, Typography } from 'antdv-next';
import {
Alert,
Button,
message,
Space,
Steps,
Tag,
Typography,
} from 'antdv-next';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { import {
@ -36,136 +25,19 @@ import {
kickQqbotAccount, kickQqbotAccount,
updateQqbotAccount, updateQqbotAccount,
} from '#/api/qqbot'; } from '#/api/qqbot';
import {
cancelQqbotAccountScan,
getNapcatLoginProgressLabel,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
mergeNapcatAccountScanResult,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
startQqbotAccountScanCreate,
startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha,
} from '#/api/qqbot/napcat';
import { KtTable, useKtTable } from '#/components/ktTable'; import { KtTable, useKtTable } from '#/components/ktTable';
import NapcatLoginModal from './napcat/NapcatLoginModal';
const AKtTable = KtTable as any; const AKtTable = KtTable as any;
const AButton = Button as any;
const ASteps = Steps as any;
const ATypographyLink = Typography.Link as any;
const ATypographyText = Typography.Text as any; const ATypographyText = Typography.Text as any;
type TencentCaptchaResult = {
appid?: string;
errorCode?: number;
errorMessage?: string;
randstr?: string;
ret: number;
ticket?: string;
};
type TencentCaptchaInstance = {
destroy: () => void;
show: () => void;
};
declare global {
interface Window {
TencentCaptcha?: new (
appid: string,
callback: (res: TencentCaptchaResult) => void,
options?: Record<string, unknown>,
) => TencentCaptchaInstance;
}
}
let tencentCaptchaScriptPromise: Promise<void> | undefined;
export default defineComponent({ export default defineComponent({
name: 'QqBotAccountList', name: 'QqBotAccountList',
setup() { setup() {
const editingId = ref<string>(); const editingId = ref<string>();
const napcatLoginRef = ref<NapcatLoginModalExposed>();
const router = useRouter(); const router = useRouter();
const scanLoading = ref(false);
const scanQrcodeImageFailed = ref(false);
const scanQrcodeRevision = ref(0);
const scanQrcodeText = ref('');
const scanEvents = ref<QqbotNapcatApi.AccountScanEvent[]>([]);
const scanState = reactive<{
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';
webuiPort?: null | number;
}>({
mode: 'create',
status: 'idle',
});
const scanQrcode = useQRCode(scanQrcodeText, {
errorCorrectionLevel: 'H',
margin: 2,
scale: 8,
});
const scanQrcodeImageSrc = computed(() => {
const qrcode = scanQrcodeText.value.trim();
if (!qrcode) return '';
if (!scanQrcodeImageFailed.value && isQrcodeImageCandidate(qrcode)) {
return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value);
}
return scanQrcode.value;
});
const scanQrcodeOpenHref = computed(() => {
const qrcode = scanQrcodeText.value.trim();
if (!qrcode) return '';
if (isQrcodeImageCandidate(qrcode)) {
return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value);
}
return qrcode;
});
const scanProgressItems = computed(() =>
scanEvents.value.map((event) => ({
description: formatEventTime(event.createdAt),
status: getScanStepStatus(event.status),
title: getNapcatLoginProgressLabel(event),
})),
);
const scanProgressCurrent = computed(() =>
Math.max(scanProgressItems.value.length - 1, 0),
);
const scanQrcodePlaceholderText = computed(() => {
if (scanState.newDeviceStatus) {
return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus);
}
if (scanState.captchaUrl) {
return '等待安全验证';
}
if (
scanState.mode === 'refresh' &&
scanState.errorMessage?.includes('正在尝试快速登录')
) {
return '正在尝试快速登录';
}
if (
scanState.mode === 'refresh' &&
scanState.errorMessage?.includes('正在尝试密码登录')
) {
return '正在尝试密码登录';
}
return '二维码生成中';
});
let scanTimer: number | undefined;
let scanEventSessionId = '';
let scanEventSource: EventSource | undefined;
const [AccountForm, accountFormApi] = useVbenForm({ const [AccountForm, accountFormApi] = useVbenForm({
commonConfig: { commonConfig: {
@ -365,21 +237,6 @@ export default defineComponent({
const modalTitle = computed(() => const modalTitle = computed(() =>
editingId.value ? '编辑账号' : '新建账号', editingId.value ? '编辑账号' : '新建账号',
); );
const scanTitle = computed(() =>
scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号',
);
const [ScanModal, scanModalApi] = useVbenModal({
class: 'w-[520px]',
fullscreenButton: false,
onBeforeClose() {
cleanupScanSession();
return true;
},
onCancel() {
closeScanModal();
},
});
const [AccountModal, accountModalApi] = useVbenModal({ const [AccountModal, accountModalApi] = useVbenModal({
class: 'w-[620px]', class: 'w-[620px]',
@ -395,334 +252,13 @@ export default defineComponent({
void resetAccountForm(values || getAccountFormDefaults()); void resetAccountForm(values || getAccountFormDefaults());
}, },
}); });
onBeforeUnmount(() => {
stopScanPolling();
stopScanEvents();
});
async function openScanCreate() { async function openScanCreate() {
await startScan('create'); await napcatLoginRef.value?.openCreate();
} }
async function openScanRefresh(row: QqbotApi.Account) { async function openScanRefresh(row: QqbotApi.Account) {
await startScan('refresh', row); await napcatLoginRef.value?.openRefresh(row);
}
async function startScan(
mode: 'create' | 'refresh',
row?: QqbotApi.Account,
) {
resetScanState(mode);
scanModalApi.setState({ title: scanTitle.value }).open();
scanLoading.value = true;
try {
if (mode === 'create') {
await applyScanResult(await startQqbotAccountScanCreate(), {
reloadQrcode: true,
});
return;
}
if (!row) {
message.warning('请选择需要更新登录的账号');
return;
}
await applyScanResult(await startQqbotAccountScanRefresh(row.id), {
reloadQrcode: true,
});
} catch (error) {
stopScanPolling();
scanState.status = 'error';
scanState.errorMessage = getErrorMessage(error);
} finally {
scanLoading.value = false;
}
}
async function applyScanResult(
result: QqbotNapcatApi.AccountScanResult,
options: { reloadQrcode?: boolean } = {},
) {
const nextState = mergeNapcatAccountScanResult(scanState, result);
scanState.captchaUrl = nextState.captchaUrl;
scanState.containerId = nextState.containerId;
scanState.containerName = nextState.containerName;
scanState.deviceVerifyUrl = nextState.deviceVerifyUrl;
scanState.errorMessage = nextState.errorMessage;
scanState.expiresAt = nextState.expiresAt;
scanState.mode = nextState.mode;
scanState.newDeviceQrcode = nextState.newDeviceQrcode;
scanState.newDeviceStatus = nextState.newDeviceStatus;
scanState.selfId = nextState.selfId;
scanState.sessionId = nextState.sessionId;
scanState.status = nextState.status;
scanState.webuiPort = nextState.webuiPort;
const nextQrcode = resolveNapcatLoginDisplayQrcode(nextState);
const qrcodeChanged = nextQrcode !== scanQrcodeText.value;
if (qrcodeChanged) {
scanQrcodeImageFailed.value = false;
}
scanQrcodeText.value = nextQrcode;
if (nextQrcode && (qrcodeChanged || options.reloadQrcode)) {
scanQrcodeRevision.value += 1;
scanQrcodeImageFailed.value = false;
}
if (result.status === 'pending') {
startScanPolling();
startScanEvents(result.sessionId);
return;
}
stopScanPolling();
stopScanEvents();
if (result.status === 'success') {
message.success(
result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新',
);
await scanModalApi.close();
await tableApi.reload();
}
}
async function pollScanStatus() {
if (!scanState.sessionId || scanLoading.value) return;
scanLoading.value = true;
try {
await applyScanResult(
await getQqbotAccountScanStatus(scanState.sessionId),
);
} finally {
scanLoading.value = false;
}
}
async function refreshScanQrcode() {
if (!scanState.sessionId) return;
scanLoading.value = true;
try {
await applyScanResult(
await refreshQqbotAccountScanQrcode(scanState.sessionId),
{ reloadQrcode: true },
);
} finally {
scanLoading.value = false;
}
}
async function submitScanCaptcha() {
const sessionId = scanState.sessionId;
const captchaUrl = scanState.captchaUrl;
if (!sessionId || !captchaUrl || scanLoading.value) {
return;
}
scanLoading.value = true;
try {
const captcha = await requestTencentCaptcha(captchaUrl);
if (
scanState.sessionId !== sessionId ||
scanState.captchaUrl !== captchaUrl
) {
return;
}
await applyScanResult(
await submitQqbotAccountScanCaptcha({
...captcha,
sessionId,
}),
);
} catch (error) {
const text = getErrorMessage(error);
if (text !== '已取消安全验证') {
message.error(text);
}
} finally {
scanLoading.value = false;
}
}
function startScanPolling() {
if (scanTimer) return;
scanTimer = window.setInterval(() => {
void pollScanStatus();
}, 2000);
}
function stopScanPolling() {
if (!scanTimer) return;
window.clearInterval(scanTimer);
scanTimer = undefined;
}
function startScanEvents(sessionId?: string) {
if (!sessionId || scanEventSessionId === sessionId) return;
stopScanEvents();
scanEventSessionId = sessionId;
const source = new EventSource(getQqbotAccountScanEventsUrl(sessionId), {
withCredentials: true,
});
scanEventSource = source;
source.addEventListener('message', (event) => {
handleScanEvent(event.data);
});
source.addEventListener('error', () => {
stopScanEvents();
});
}
function stopScanEvents() {
if (scanEventSource) {
scanEventSource.close();
}
scanEventSource = undefined;
scanEventSessionId = '';
}
function handleScanEvent(payload: string) {
try {
const event = JSON.parse(payload) as QqbotNapcatApi.AccountScanEvent;
const index = scanEvents.value.findIndex(
(item) => item.step === event.step,
);
if (index === -1) {
scanEvents.value.push(event);
} else {
scanEvents.value.splice(index, 1, event);
}
if (scanEvents.value.length > 20) {
scanEvents.value.splice(0, scanEvents.value.length - 20);
}
if (event.result) {
void applyScanResult(event.result, {
reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes(
event.step,
),
});
}
} catch {
// Ignore malformed SSE chunks and wait for the next event.
}
}
function resetScanState(mode: 'create' | 'refresh') {
stopScanPolling();
stopScanEvents();
Object.assign(scanState, {
captchaUrl: undefined,
containerId: undefined,
containerName: undefined,
deviceVerifyUrl: undefined,
errorMessage: undefined,
expiresAt: undefined,
mode,
newDeviceQrcode: undefined,
newDeviceStatus: undefined,
selfId: undefined,
sessionId: undefined,
status: 'idle',
webuiPort: undefined,
});
scanQrcodeImageFailed.value = false;
scanQrcodeRevision.value = 0;
scanQrcodeText.value = '';
scanEvents.value = [];
}
function cleanupScanSession() {
const sessionId = scanState.sessionId;
stopScanPolling();
stopScanEvents();
if (sessionId && scanState.status === 'pending') {
void cancelQqbotAccountScan(sessionId);
}
}
function closeScanModal() {
void scanModalApi.close();
}
function getScanAlertType() {
if (scanState.status === 'success') return 'success';
if (scanState.status === 'error') return 'error';
if (scanState.status === 'expired') return 'warning';
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') {
return scanState.errorMessage || '扫码登录失败';
}
if (scanState.status === 'expired') return '二维码已过期,请刷新二维码';
if (scanState.errorMessage) return scanState.errorMessage;
return '请使用目标 QQ 扫码登录,页面会自动轮询登录结果';
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>;
return `${record.msg || record.message || record.err || '扫码登录请求失败'}`;
}
return '扫码登录请求失败';
}
function getScanStepStatus(
status: QqbotNapcatApi.AccountScanEvent['status'],
) {
if (status === 'error') return 'error';
if (status === 'processing') return 'process';
if (status === 'success') return 'finish';
return 'wait';
}
function formatEventTime(value: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString('zh-CN', { hour12: false });
}
function isQrcodeImageCandidate(value: string) {
return (
/^data:image\//i.test(value) ||
/^https?:\/\//i.test(value) ||
isRawBase64Image(value)
);
}
function normalizeQrcodeImageSrc(value: string, revision = 0) {
if (isRawBase64Image(value)) {
return `data:image/png;base64,${value}`;
}
if (/^https?:\/\//i.test(value) && revision > 0) {
return appendQrcodeCacheBuster(value, revision);
}
return value;
}
function appendQrcodeCacheBuster(value: string, revision: number) {
try {
const url = new URL(value);
url.searchParams.set('_kt_qrcode_v', `${revision}`);
return url.toString();
} catch {
const joiner = value.includes('?') ? '&' : '?';
return `${value}${joiner}_kt_qrcode_v=${revision}`;
}
}
function isRawBase64Image(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('iVBORw0KGgo') ||
normalized.startsWith('/9j/') ||
normalized.startsWith('R0lGOD')
);
} }
const renderAccountOnlineStatus = (row: QqbotApi.Account) => { const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
@ -1046,150 +582,12 @@ export default defineComponent({
}, },
}} }}
/> />
<ScanModal <NapcatLoginModal
title={scanTitle.value} onSuccess={() => {
v-slots={{ void tableApi.reload();
footer: () => [
<AButton key="close" onClick={closeScanModal}>
</AButton>,
<AButton
disabled={
!scanState.sessionId ||
!!scanState.captchaUrl ||
!!scanState.newDeviceStatus
}
key="refresh"
loading={scanLoading.value}
onClick={refreshScanQrcode}
>
</AButton>,
<AButton
disabled={!scanState.sessionId}
key="check"
loading={scanLoading.value}
onClick={pollScanStatus}
type="primary"
>
</AButton>,
],
}} }}
> ref={napcatLoginRef as any}
<Space direction="vertical" size={16} style={{ width: '100%' }}> />
<Alert
message={getScanMessage()}
showIcon
type={getScanAlertType() as any}
/>
{scanState.containerName ? (
<Alert
message={`NapCat 容器:${scanState.containerName}${
scanState.webuiPort
? `WebUI 端口:${scanState.webuiPort}`
: ''
}`}
showIcon
type="info"
/>
) : null}
{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>
<ATypographyText>
NapCat
</ATypographyText>
<AButton
loading={scanLoading.value}
onClick={submitScanCaptcha}
type="primary"
>
</AButton>
</Space>
}
message="QQ 密码登录需要安全验证"
showIcon
type="warning"
/>
) : null}
{scanProgressItems.value.length > 0 ? (
<ASteps
current={scanProgressCurrent.value}
direction="vertical"
items={scanProgressItems.value}
size="small"
/>
) : null}
<div style={{ display: 'flex', justifyContent: 'center' }}>
{scanQrcodeText.value ? (
<img
alt="qqbot-login-qrcode"
onError={() => {
if (isQrcodeImageCandidate(scanQrcodeText.value)) {
scanQrcodeImageFailed.value = true;
}
}}
src={scanQrcodeImageSrc.value}
style={{
background: '#fff',
borderRadius: '8px',
height: '240px',
padding: '12px',
width: '240px',
}}
/>
) : (
<div
style={{
alignItems: 'center',
border: '1px dashed var(--border-color)',
borderRadius: '8px',
display: 'flex',
height: '240px',
justifyContent: 'center',
width: '240px',
}}
>
{scanQrcodePlaceholderText.value}
</div>
)}
</div>
{scanQrcodeText.value ? (
<ATypographyLink href={scanQrcodeOpenHref.value} target="_blank">
</ATypographyLink>
) : null}
</Space>
</ScanModal>
<AccountModal title={modalTitle.value}> <AccountModal title={modalTitle.value}>
<AccountForm class="mx-2" /> <AccountForm class="mx-2" />
</AccountModal> </AccountModal>
@ -1197,146 +595,3 @@ export default defineComponent({
); );
}, },
}); });
function requestTencentCaptcha(
proofWaterUrl: string,
): Promise<Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>> {
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
return loadTencentCaptchaScript().then(
() =>
new Promise((resolve, reject) => {
if (!window.TencentCaptcha) {
reject(new Error('腾讯验证码组件加载失败'));
return;
}
let captcha: TencentCaptchaInstance | undefined;
let settled = false;
const finish = (
error?: Error,
value?: Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>,
) => {
if (settled) return;
settled = true;
try {
captcha?.destroy();
} catch {
// The captcha SDK may already have cleaned up its popup.
}
if (error) {
reject(error);
return;
}
if (!value) {
reject(new Error('腾讯验证码未返回验证结果'));
return;
}
resolve(value);
};
captcha = new window.TencentCaptcha(
appid,
(res) => {
if (res.ret === 0 && res.ticket && res.randstr) {
finish(undefined, {
randstr: res.randstr,
sid,
ticket: res.ticket,
});
return;
}
finish(new Error('已取消安全验证'));
},
{
enableAged: true,
login_appid: params.login_appid,
showHeader: false,
sid: params.sid,
type: 'popup',
uin: params.uin,
},
);
captcha.show();
}),
);
}
async function loadTencentCaptchaScript() {
if (window.TencentCaptcha) return;
tencentCaptchaScriptPromise =
tencentCaptchaScriptPromise ||
loadScriptWithFallback([
'https://captcha.gtimg.com/TCaptcha.js',
'https://ssl.captcha.qq.com/TCaptcha.js',
]);
try {
await tencentCaptchaScriptPromise;
} catch (error) {
tencentCaptchaScriptPromise = undefined;
throw error;
}
}
async function loadScriptWithFallback(sources: string[]) {
let lastError: unknown;
for (const source of sources) {
try {
await loadScript(source);
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error
? lastError
: new Error('腾讯验证码脚本加载失败');
}
function loadScript(source: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${source}"]`,
);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.async = true;
script.src = source;
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
document.head.append(script);
});
}
function parseUrlParams(url: string) {
const params: Record<string, string> = {};
try {
const parsed = new URL(url);
parsed.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
} catch {
const query = url.split('?')[1] || '';
query.split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) params[key] = decodeURIComponent(value);
});
return params;
}
}

View File

@ -0,0 +1,40 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { cwd } from 'node:process';
import { describe, expect, it } from 'vitest';
const accountRoot = resolve(
cwd(),
'apps/web-antdv-next/src/views/qqbot/account',
);
const readAccountSource = (relativePath: string) =>
readFileSync(resolve(accountRoot, relativePath), 'utf8');
describe('qqbot account NapCat login view boundary', () => {
it('keeps login session state out of account/list.tsx', () => {
const source = readAccountSource('list.tsx');
expect(source).toContain('NapcatLoginModal');
expect(source).not.toContain('EventSource');
expect(source).not.toContain('TencentCaptcha');
expect(source).not.toContain('useQRCode');
expect(source).not.toContain('/qqbot/account/scan');
expect(source).not.toContain('startQqbotAccountScan');
expect(source).not.toContain('submitQqbotAccountScanCaptcha');
});
it('keeps NapCat login modal and session helpers in the napcat package', () => {
expect(
existsSync(resolve(accountRoot, 'napcat/NapcatLoginModal.tsx')),
).toBe(true);
expect(
existsSync(resolve(accountRoot, 'napcat/useNapcatLoginSession.ts')),
).toBe(true);
expect(existsSync(resolve(accountRoot, 'napcat/tencentCaptcha.ts'))).toBe(
true,
);
expect(existsSync(resolve(accountRoot, 'napcat/qrcode.ts'))).toBe(true);
});
});

View File

@ -0,0 +1,207 @@
import type { QqbotApi } from '#/api/qqbot';
import { defineComponent } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Space, Steps, Typography } from 'antdv-next';
import { getNapcatNewDeviceStatusMessage } from '#/api/qqbot/napcat';
import { useNapcatLoginSession } from './useNapcatLoginSession';
const AButton = Button as any;
const ASteps = Steps as any;
const ATypographyLink = Typography.Link as any;
const ATypographyText = Typography.Text as any;
export type NapcatLoginModalExposed = {
openCreate: () => Promise<void>;
openRefresh: (row: QqbotApi.Account) => Promise<void>;
};
export default defineComponent({
name: 'NapcatLoginModal',
emits: ['success'],
setup(_, { emit, expose }) {
let cleanupScanSession = () => {};
let closeScanModal = () => {};
const [ScanModal, scanModalApi] = useVbenModal({
class: 'w-[520px]',
fullscreenButton: false,
onBeforeClose() {
cleanupScanSession();
return true;
},
onCancel() {
closeScanModal();
},
});
const session = useNapcatLoginSession({
closeModal: () => scanModalApi.close(),
onSuccess: () => {
emit('success');
},
openModal: (title) => {
scanModalApi.setState({ title }).open();
},
});
cleanupScanSession = session.cleanupScanSession;
closeScanModal = session.closeScanModal;
expose({
openCreate: session.openCreate,
openRefresh: session.openRefresh,
} satisfies NapcatLoginModalExposed);
return () => (
<ScanModal
title={session.scanTitle.value}
v-slots={{
footer: () => [
<AButton key="close" onClick={session.closeScanModal}>
</AButton>,
<AButton
disabled={
!session.scanState.sessionId ||
!!session.scanState.captchaUrl ||
!!session.scanState.newDeviceStatus
}
key="refresh"
loading={session.scanLoading.value}
onClick={session.refreshScanQrcode}
>
</AButton>,
<AButton
disabled={!session.scanState.sessionId}
key="check"
loading={session.scanLoading.value}
onClick={session.pollScanStatus}
type="primary"
>
</AButton>,
],
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
message={session.getScanMessage()}
showIcon
type={session.getScanAlertType() as any}
/>
{session.scanState.containerName ? (
<Alert
message={`NapCat 容器:${session.scanState.containerName}${
session.scanState.webuiPort
? `WebUI 端口:${session.scanState.webuiPort}`
: ''
}`}
showIcon
type="info"
/>
) : null}
{session.scanState.newDeviceStatus ? (
<Alert
description={
<Space direction="vertical">
<ATypographyText>
使 QQ
</ATypographyText>
{session.scanState.deviceVerifyUrl ? (
<ATypographyLink
href={session.scanState.deviceVerifyUrl}
target="_blank"
>
</ATypographyLink>
) : null}
</Space>
}
message={getNapcatNewDeviceStatusMessage(
session.scanState.newDeviceStatus,
)}
showIcon
type={
session.getNewDeviceAlertType(
session.scanState.newDeviceStatus,
) as any
}
/>
) : null}
{session.scanState.captchaUrl &&
!session.scanState.newDeviceStatus ? (
<Alert
description={
<Space>
<ATypographyText>
NapCat
</ATypographyText>
<AButton
loading={session.scanLoading.value}
onClick={session.submitScanCaptcha}
type="primary"
>
</AButton>
</Space>
}
message="QQ 密码登录需要安全验证"
showIcon
type="warning"
/>
) : null}
{session.scanProgressItems.value.length > 0 ? (
<ASteps
current={session.scanProgressCurrent.value}
direction="vertical"
items={session.scanProgressItems.value}
size="small"
/>
) : null}
<div style={{ display: 'flex', justifyContent: 'center' }}>
{session.scanQrcodeText.value ? (
<img
alt="qqbot-login-qrcode"
onError={session.onQrcodeImageError}
src={session.scanQrcodeImageSrc.value}
style={{
background: '#fff',
borderRadius: '8px',
height: '240px',
padding: '12px',
width: '240px',
}}
/>
) : (
<div
style={{
alignItems: 'center',
border: '1px dashed var(--border-color)',
borderRadius: '8px',
display: 'flex',
height: '240px',
justifyContent: 'center',
width: '240px',
}}
>
{session.scanQrcodePlaceholderText.value}
</div>
)}
</div>
{session.scanQrcodeText.value ? (
<ATypographyLink
href={session.scanQrcodeOpenHref.value}
target="_blank"
>
</ATypographyLink>
) : null}
</Space>
</ScanModal>
);
},
});

View File

@ -0,0 +1,54 @@
import type { QqbotNapcatApi } from '#/api/qqbot/napcat';
export function formatEventTime(value: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString('zh-CN', { hour12: false });
}
export function getScanStepStatus(
status: QqbotNapcatApi.AccountScanEvent['status'],
) {
if (status === 'error') return 'error';
if (status === 'processing') return 'process';
if (status === 'success') return 'finish';
return 'wait';
}
export function isQrcodeImageCandidate(value: string) {
return (
/^data:image\//i.test(value) ||
/^https?:\/\//i.test(value) ||
isRawBase64Image(value)
);
}
export function normalizeQrcodeImageSrc(value: string, revision = 0) {
if (isRawBase64Image(value)) {
return `data:image/png;base64,${value}`;
}
if (/^https?:\/\//i.test(value) && revision > 0) {
return appendQrcodeCacheBuster(value, revision);
}
return value;
}
function appendQrcodeCacheBuster(value: string, revision: number) {
try {
const url = new URL(value);
url.searchParams.set('_kt_qrcode_v', `${revision}`);
return url.toString();
} catch {
const joiner = value.includes('?') ? '&' : '?';
return `${value}${joiner}_kt_qrcode_v=${revision}`;
}
}
function isRawBase64Image(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('iVBORw0KGgo') ||
normalized.startsWith('/9j/') ||
normalized.startsWith('R0lGOD')
);
}

View File

@ -0,0 +1,170 @@
import type { QqbotNapcatApi } from '#/api/qqbot/napcat';
type TencentCaptchaResult = {
appid?: string;
errorCode?: number;
errorMessage?: string;
randstr?: string;
ret: number;
ticket?: string;
};
type TencentCaptchaInstance = {
destroy: () => void;
show: () => void;
};
declare global {
interface Window {
TencentCaptcha?: new (
appid: string,
callback: (res: TencentCaptchaResult) => void,
options?: Record<string, unknown>,
) => TencentCaptchaInstance;
}
}
let tencentCaptchaScriptPromise: Promise<void> | undefined;
export function requestTencentCaptcha(
proofWaterUrl: string,
): Promise<Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>> {
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
return loadTencentCaptchaScript().then(
() =>
new Promise((resolve, reject) => {
if (!window.TencentCaptcha) {
reject(new Error('腾讯验证码组件加载失败'));
return;
}
let captcha: TencentCaptchaInstance | undefined;
let settled = false;
const finish = (
error?: Error,
value?: Omit<QqbotNapcatApi.AccountScanCaptchaBody, 'sessionId'>,
) => {
if (settled) return;
settled = true;
try {
captcha?.destroy();
} catch {
// The captcha SDK may already have cleaned up its popup.
}
if (error) {
reject(error);
return;
}
if (!value) {
reject(new Error('腾讯验证码未返回验证结果'));
return;
}
resolve(value);
};
captcha = new window.TencentCaptcha(
appid,
(res) => {
if (res.ret === 0 && res.ticket && res.randstr) {
finish(undefined, {
randstr: res.randstr,
sid,
ticket: res.ticket,
});
return;
}
finish(new Error('已取消安全验证'));
},
{
enableAged: true,
login_appid: params.login_appid,
showHeader: false,
sid: params.sid,
type: 'popup',
uin: params.uin,
},
);
captcha.show();
}),
);
}
async function loadTencentCaptchaScript() {
if (window.TencentCaptcha) return;
tencentCaptchaScriptPromise =
tencentCaptchaScriptPromise ||
loadScriptWithFallback([
'https://captcha.gtimg.com/TCaptcha.js',
'https://ssl.captcha.qq.com/TCaptcha.js',
]);
try {
await tencentCaptchaScriptPromise;
} catch (error) {
tencentCaptchaScriptPromise = undefined;
throw error;
}
}
async function loadScriptWithFallback(sources: string[]) {
let lastError: unknown;
for (const source of sources) {
try {
await loadScript(source);
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error
? lastError
: new Error('腾讯验证码脚本加载失败');
}
function loadScript(source: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${source}"]`,
);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.async = true;
script.src = source;
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
document.head.append(script);
});
}
function parseUrlParams(url: string) {
const params: Record<string, string> = {};
try {
const parsed = new URL(url);
parsed.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
} catch {
const query = url.split('?')[1] || '';
query.split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) params[key] = decodeURIComponent(value);
});
return params;
}
}

View File

@ -0,0 +1,427 @@
import type { QqbotApi } from '#/api/qqbot';
import type {
NapcatLoginNewDeviceStatus,
QqbotNapcatApi,
} from '#/api/qqbot/napcat';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { message } from 'antdv-next';
import {
cancelQqbotAccountScan,
getNapcatLoginProgressLabel,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
mergeNapcatAccountScanResult,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
startQqbotAccountScanCreate,
startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha,
} from '#/api/qqbot/napcat';
import {
formatEventTime,
getScanStepStatus,
isQrcodeImageCandidate,
normalizeQrcodeImageSrc,
} from './qrcode';
import { requestTencentCaptcha } from './tencentCaptcha';
type NapcatLoginSessionOptions = {
closeModal: () => Promise<unknown> | unknown;
onSuccess?: () => Promise<unknown> | unknown;
openModal: (title: string) => void;
};
export type NapcatLoginScanState = {
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';
webuiPort?: null | number;
};
export function useNapcatLoginSession(options: NapcatLoginSessionOptions) {
const scanLoading = ref(false);
const scanQrcodeImageFailed = ref(false);
const scanQrcodeRevision = ref(0);
const scanQrcodeText = ref('');
const scanEvents = ref<QqbotNapcatApi.AccountScanEvent[]>([]);
const scanState = reactive<NapcatLoginScanState>({
mode: 'create',
status: 'idle',
});
const scanQrcode = useQRCode(scanQrcodeText, {
errorCorrectionLevel: 'H',
margin: 2,
scale: 8,
});
const scanQrcodeImageSrc = computed(() => {
const qrcode = scanQrcodeText.value.trim();
if (!qrcode) return '';
if (!scanQrcodeImageFailed.value && isQrcodeImageCandidate(qrcode)) {
return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value);
}
return scanQrcode.value;
});
const scanQrcodeOpenHref = computed(() => {
const qrcode = scanQrcodeText.value.trim();
if (!qrcode) return '';
if (isQrcodeImageCandidate(qrcode)) {
return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value);
}
return qrcode;
});
const scanProgressItems = computed(() =>
scanEvents.value.map((event) => ({
description: formatEventTime(event.createdAt),
status: getScanStepStatus(event.status),
title: getNapcatLoginProgressLabel(event),
})),
);
const scanProgressCurrent = computed(() =>
Math.max(scanProgressItems.value.length - 1, 0),
);
const scanQrcodePlaceholderText = computed(() => {
if (scanState.newDeviceStatus) {
return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus);
}
if (scanState.captchaUrl) {
return '等待安全验证';
}
if (
scanState.mode === 'refresh' &&
scanState.errorMessage?.includes('正在尝试快速登录')
) {
return '正在尝试快速登录';
}
if (
scanState.mode === 'refresh' &&
scanState.errorMessage?.includes('正在尝试密码登录')
) {
return '正在尝试密码登录';
}
return '二维码生成中';
});
const scanTitle = computed(() =>
scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号',
);
let scanTimer: number | undefined;
let scanEventSessionId = '';
let scanEventSource: EventSource | undefined;
onBeforeUnmount(() => {
stopScanPolling();
stopScanEvents();
});
async function openCreate() {
await startScan('create');
}
async function openRefresh(row: QqbotApi.Account) {
await startScan('refresh', row);
}
async function startScan(mode: 'create' | 'refresh', row?: QqbotApi.Account) {
resetScanState(mode);
options.openModal(scanTitle.value);
scanLoading.value = true;
try {
if (mode === 'create') {
await applyScanResult(await startQqbotAccountScanCreate(), {
reloadQrcode: true,
});
return;
}
if (!row) {
message.warning('请选择需要更新登录的账号');
return;
}
await applyScanResult(await startQqbotAccountScanRefresh(row.id), {
reloadQrcode: true,
});
} catch (error) {
stopScanPolling();
scanState.status = 'error';
scanState.errorMessage = getErrorMessage(error);
} finally {
scanLoading.value = false;
}
}
async function applyScanResult(
result: QqbotNapcatApi.AccountScanResult,
applyOptions: { reloadQrcode?: boolean } = {},
) {
const nextState = mergeNapcatAccountScanResult(scanState, result);
scanState.captchaUrl = nextState.captchaUrl;
scanState.containerId = nextState.containerId;
scanState.containerName = nextState.containerName;
scanState.deviceVerifyUrl = nextState.deviceVerifyUrl;
scanState.errorMessage = nextState.errorMessage;
scanState.expiresAt = nextState.expiresAt;
scanState.mode = nextState.mode;
scanState.newDeviceQrcode = nextState.newDeviceQrcode;
scanState.newDeviceStatus = nextState.newDeviceStatus;
scanState.selfId = nextState.selfId;
scanState.sessionId = nextState.sessionId;
scanState.status = nextState.status;
scanState.webuiPort = nextState.webuiPort;
const nextQrcode = resolveNapcatLoginDisplayQrcode(nextState);
const qrcodeChanged = nextQrcode !== scanQrcodeText.value;
if (qrcodeChanged) {
scanQrcodeImageFailed.value = false;
}
scanQrcodeText.value = nextQrcode;
if (nextQrcode && (qrcodeChanged || applyOptions.reloadQrcode)) {
scanQrcodeRevision.value += 1;
scanQrcodeImageFailed.value = false;
}
if (result.status === 'pending') {
startScanPolling();
startScanEvents(result.sessionId);
return;
}
stopScanPolling();
stopScanEvents();
if (result.status === 'success') {
message.success(
result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新',
);
await options.closeModal();
await options.onSuccess?.();
}
}
async function pollScanStatus() {
if (!scanState.sessionId || scanLoading.value) return;
scanLoading.value = true;
try {
await applyScanResult(
await getQqbotAccountScanStatus(scanState.sessionId),
);
} finally {
scanLoading.value = false;
}
}
async function refreshScanQrcode() {
if (!scanState.sessionId) return;
scanLoading.value = true;
try {
await applyScanResult(
await refreshQqbotAccountScanQrcode(scanState.sessionId),
{ reloadQrcode: true },
);
} finally {
scanLoading.value = false;
}
}
async function submitScanCaptcha() {
const sessionId = scanState.sessionId;
const captchaUrl = scanState.captchaUrl;
if (!sessionId || !captchaUrl || scanLoading.value) {
return;
}
scanLoading.value = true;
try {
const captcha = await requestTencentCaptcha(captchaUrl);
if (
scanState.sessionId !== sessionId ||
scanState.captchaUrl !== captchaUrl
) {
return;
}
await applyScanResult(
await submitQqbotAccountScanCaptcha({
...captcha,
sessionId,
}),
);
} catch (error) {
const text = getErrorMessage(error);
if (text !== '已取消安全验证') {
message.error(text);
}
} finally {
scanLoading.value = false;
}
}
function startScanPolling() {
if (scanTimer) return;
scanTimer = window.setInterval(() => {
void pollScanStatus();
}, 2000);
}
function stopScanPolling() {
if (!scanTimer) return;
window.clearInterval(scanTimer);
scanTimer = undefined;
}
function startScanEvents(sessionId?: string) {
if (!sessionId || scanEventSessionId === sessionId) return;
stopScanEvents();
scanEventSessionId = sessionId;
const source = new EventSource(getQqbotAccountScanEventsUrl(sessionId), {
withCredentials: true,
});
scanEventSource = source;
source.addEventListener('message', (event) => {
handleScanEvent(event.data);
});
source.addEventListener('error', () => {
stopScanEvents();
});
}
function stopScanEvents() {
if (scanEventSource) {
scanEventSource.close();
}
scanEventSource = undefined;
scanEventSessionId = '';
}
function handleScanEvent(payload: string) {
try {
const event = JSON.parse(payload) as QqbotNapcatApi.AccountScanEvent;
const index = scanEvents.value.findIndex(
(item) => item.step === event.step,
);
if (index === -1) {
scanEvents.value.push(event);
} else {
scanEvents.value.splice(index, 1, event);
}
if (scanEvents.value.length > 20) {
scanEvents.value.splice(0, scanEvents.value.length - 20);
}
if (event.result) {
void applyScanResult(event.result, {
reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes(
event.step,
),
});
}
} catch {
// Ignore malformed SSE chunks and wait for the next event.
}
}
function resetScanState(mode: 'create' | 'refresh') {
stopScanPolling();
stopScanEvents();
Object.assign(scanState, {
captchaUrl: undefined,
containerId: undefined,
containerName: undefined,
deviceVerifyUrl: undefined,
errorMessage: undefined,
expiresAt: undefined,
mode,
newDeviceQrcode: undefined,
newDeviceStatus: undefined,
selfId: undefined,
sessionId: undefined,
status: 'idle',
webuiPort: undefined,
});
scanQrcodeImageFailed.value = false;
scanQrcodeRevision.value = 0;
scanQrcodeText.value = '';
scanEvents.value = [];
}
function cleanupScanSession() {
const sessionId = scanState.sessionId;
stopScanPolling();
stopScanEvents();
if (sessionId && scanState.status === 'pending') {
void cancelQqbotAccountScan(sessionId);
}
}
function closeScanModal() {
void options.closeModal();
}
function getScanAlertType() {
if (scanState.status === 'success') return 'success';
if (scanState.status === 'error') return 'error';
if (scanState.status === 'expired') return 'warning';
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') {
return scanState.errorMessage || '扫码登录失败';
}
if (scanState.status === 'expired') return '二维码已过期,请刷新二维码';
if (scanState.errorMessage) return scanState.errorMessage;
return '请使用目标 QQ 扫码登录,页面会自动轮询登录结果';
}
function onQrcodeImageError() {
if (isQrcodeImageCandidate(scanQrcodeText.value)) {
scanQrcodeImageFailed.value = true;
}
}
return {
cleanupScanSession,
closeScanModal,
getNewDeviceAlertType,
getScanAlertType,
getScanMessage,
onQrcodeImageError,
openCreate,
openRefresh,
pollScanStatus,
refreshScanQrcode,
scanLoading,
scanProgressCurrent,
scanProgressItems,
scanQrcodeImageSrc,
scanQrcodeOpenHref,
scanQrcodePlaceholderText,
scanQrcodeText,
scanState,
scanTitle,
submitScanCaptcha,
};
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>;
return `${record.msg || record.message || record.err || '扫码登录请求失败'}`;
}
return '扫码登录请求失败';
}