refactor: 收敛QQBot账号登录页面边界
This commit is contained in:
parent
788aba00a8
commit
c95ac9df9e
@ -1,32 +1,21 @@
|
||||
import type { TableColumnType } from 'antdv-next';
|
||||
|
||||
import type { NapcatLoginModalExposed } from './napcat/NapcatLoginModal';
|
||||
|
||||
import type { QqbotApi } from '#/api/qqbot';
|
||||
import type {
|
||||
NapcatLoginNewDeviceStatus,
|
||||
QqbotNapcatApi,
|
||||
} from '#/api/qqbot/napcat';
|
||||
import type {
|
||||
KtTableApi,
|
||||
KtTableButton,
|
||||
KtTableRowAction,
|
||||
} from '#/components/ktTable';
|
||||
|
||||
import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { useQRCode } from '@vueuse/integrations/useQRCode';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
message,
|
||||
Space,
|
||||
Steps,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antdv-next';
|
||||
import { message, Space, Tag, Typography } from 'antdv-next';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
@ -36,136 +25,19 @@ import {
|
||||
kickQqbotAccount,
|
||||
updateQqbotAccount,
|
||||
} 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 NapcatLoginModal from './napcat/NapcatLoginModal';
|
||||
|
||||
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;
|
||||
|
||||
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({
|
||||
name: 'QqBotAccountList',
|
||||
setup() {
|
||||
const editingId = ref<string>();
|
||||
const napcatLoginRef = ref<NapcatLoginModalExposed>();
|
||||
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({
|
||||
commonConfig: {
|
||||
@ -365,21 +237,6 @@ export default defineComponent({
|
||||
const modalTitle = computed(() =>
|
||||
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({
|
||||
class: 'w-[620px]',
|
||||
@ -395,334 +252,13 @@ export default defineComponent({
|
||||
void resetAccountForm(values || getAccountFormDefaults());
|
||||
},
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
stopScanPolling();
|
||||
stopScanEvents();
|
||||
});
|
||||
|
||||
async function openScanCreate() {
|
||||
await startScan('create');
|
||||
await napcatLoginRef.value?.openCreate();
|
||||
}
|
||||
|
||||
async function openScanRefresh(row: QqbotApi.Account) {
|
||||
await startScan('refresh', 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')
|
||||
);
|
||||
await napcatLoginRef.value?.openRefresh(row);
|
||||
}
|
||||
|
||||
const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
|
||||
@ -1046,150 +582,12 @@ export default defineComponent({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ScanModal
|
||||
title={scanTitle.value}
|
||||
v-slots={{
|
||||
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>,
|
||||
],
|
||||
<NapcatLoginModal
|
||||
onSuccess={() => {
|
||||
void tableApi.reload();
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
message={getScanMessage()}
|
||||
showIcon
|
||||
type={getScanAlertType() as any}
|
||||
ref={napcatLoginRef 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}>
|
||||
<AccountForm class="mx-2" />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
54
apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts
Normal file
54
apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 '扫码登录请求失败';
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user