refactor: 收敛QQBot账号登录页面边界
This commit is contained in:
parent
788aba00a8
commit
c95ac9df9e
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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