kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/list.tsx

1343 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { TableColumnType } from 'antdv-next';
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 { 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 { useVbenForm } from '#/adapter/form';
import {
createQqbotAccount,
deleteQqbotAccount,
getQqbotAccountList,
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';
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 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: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: 'NapCat 当前登录 QQ',
},
fieldName: 'selfId',
label: 'Self ID',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '便于后台识别',
},
fieldName: 'name',
label: '账号名称',
},
{
component: 'InputPassword',
componentProps: () => ({
placeholder: editingId.value
? '留空表示不修改'
: 'OneBot 反向 WS token',
}),
fieldName: 'accessToken',
label: 'Token',
},
{
component: 'InputPassword',
componentProps: () => ({
placeholder: editingId.value
? '留空表示不修改 QQ 登录密码'
: '可选,用于 NapCat 密码登录',
}),
fieldName: 'loginPassword',
label: '登录密码',
},
{
component: 'Switch',
fieldName: 'enabled',
label: '启用',
},
{
component: 'Input',
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.Account>> = [
{ dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 140 },
{ dataIndex: 'name', key: 'name', title: '账号名称', width: 150 },
{
dataIndex: 'connectStatus',
key: 'accountOnlineStatus',
title: 'OneBot 连接',
width: 140,
},
{
dataIndex: 'napcat',
key: 'qqLoginStatus',
title: 'QQ 登录',
width: 150,
},
{
dataIndex: 'napcat',
key: 'napcatRuntime',
title: 'NapCat 运行',
width: 240,
},
{
dataIndex: 'lastHeartbeatAt',
key: 'lastHeartbeatAt',
title: '最近活动',
width: 190,
},
{
dataIndex: 'lastError',
key: 'runtimeSummary',
title: '运行说明',
width: 220,
},
];
const api: KtTableApi<QqbotApi.Account> = {
list: async (params) => await getQqbotAccountList(params),
};
const buttons: Array<KtTableButton<QqbotApi.Account>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'scanCreate',
label: '扫码新增账号',
onClick: openScanCreate,
permissionCodes: ['QqBot:Account:Create'],
type: 'primary',
},
{
key: 'manualCreate',
label: '手动维护',
onClick: openCreate,
permissionCodes: ['QqBot:Account:Create'],
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Account>> = [
{
key: 'config',
label: '配置',
onClick: openConfig,
permissionCodes: ['QqBot:Account:Config'],
},
{
key: 'refreshLogin',
label: '更新登录',
onClick: openScanRefresh,
permissionCodes: ['QqBot:Account:RefreshLogin'],
},
{
confirm: (row) =>
`确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`,
danger: true,
key: 'delete',
label: '删除',
onClick: async (row, context) => {
const result = await deleteQqbotAccount(row.id);
message.success(
result.deletedContainers > 0
? `账号删除成功,已删除 ${result.deletedContainers} 个 NapCat 容器`
: '账号删除成功',
);
await context.reload();
},
permissionCodes: ['QqBot:Account:Delete'],
},
{
disabled: (row) => getOneBotStatus(row) !== 'online',
key: 'kick',
label: '断开',
onClick: async (row, context) => {
await kickQqbotAccount(row.selfId);
message.success('连接已断开');
await context.reload();
},
permissionCodes: ['QqBot:Account:Kick'],
},
{
key: 'edit',
label: '编辑',
onClick: openEdit,
permissionCodes: ['QqBot:Account:Edit'],
},
];
const [registerTable, tableApi] = useKtTable<QqbotApi.Account>({
api,
buttons,
columns,
formOptions: {
schema: [
{
component: 'Input',
componentProps: { allowClear: true, placeholder: 'Self ID' },
fieldName: 'selfId',
label: 'Self ID',
},
{
component: 'Input',
componentProps: { allowClear: true, placeholder: '账号名称' },
fieldName: 'name',
label: '账号名称',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: 'OneBot 在线', value: 'online' },
{ label: 'OneBot 离线', value: 'offline' },
],
},
fieldName: 'connectStatus',
label: 'OneBot',
},
],
},
rowActions,
tableTitle: 'QQBot 账号连接',
});
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]',
fullscreenButton: false,
async onConfirm() {
await submitAccount();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = accountModalApi.getData<{
values?: QqbotApi.AccountBody;
}>();
void resetAccountForm(values || getAccountFormDefaults());
},
});
onBeforeUnmount(() => {
stopScanPolling();
stopScanEvents();
});
async function openScanCreate() {
await startScan('create');
}
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')
);
}
const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
if (!row.enabled) {
return <Tag color="default"></Tag>;
}
const online = getOneBotStatus(row) === 'online';
return (
<Tag color={online ? 'success' : 'default'}>
{online ? 'OneBot 在线' : 'OneBot 离线'}
</Tag>
);
};
const renderQqLoginStatus = (row: QqbotApi.Account) => {
const meta = getQqLoginStatusMeta(row);
const message = getQqLoginMessage(row);
return (
<Space direction="vertical" size={2}>
<Tag color={meta.color}>{meta.label}</Tag>
{message ? (
<ATypographyText type="secondary">{message}</ATypographyText>
) : null}
</Space>
);
};
const renderNapcatRuntime = (row: QqbotApi.Account) => {
const napcat = row.napcat;
const meta = getNapcatStatusMeta(row);
const webuiMeta = getNapcatWebuiMeta(row);
return (
<Space direction="vertical" size={2}>
<Space size={4} wrap>
<Tag color={meta.color}>{meta.label}</Tag>
<Tag color={webuiMeta.color}>{webuiMeta.label}</Tag>
</Space>
{napcat?.containerName ? (
<ATypographyText type="secondary">
{napcat.containerName}
{napcat.webuiPort ? `:${napcat.webuiPort}` : ''}
</ATypographyText>
) : null}
</Space>
);
};
const renderRecentActivity = (row: QqbotApi.Account) => {
const active = getRecentActivity(row);
return (
<Space direction="vertical" size={2}>
<span>{active.label}</span>
<ATypographyText type="secondary">
{active.time || '暂无记录'}
</ATypographyText>
</Space>
);
};
const renderRuntimeSummary = (row: QqbotApi.Account) => {
const summary = getRuntimeSummary(row);
return (
<ATypographyText
title={summary.text}
type={summary.level === 'warning' ? 'warning' : undefined}
>
{summary.text}
</ATypographyText>
);
};
function getNapcatStatusMeta(row: QqbotApi.Account) {
const status = row.containerStatus || row.napcat?.containerStatus;
if (!row.napcat && !status) {
return { color: 'default', label: '未绑定专属容器' };
}
if (!status) {
return { color: 'warning', label: '容器记录缺失' };
}
const statusMap: Record<
NonNullable<QqbotApi.AccountNapcatRuntime['containerStatus']>,
{ color: string; label: string }
> = {
creating: { color: 'processing', label: '容器创建中' },
error: { color: 'error', label: '容器异常' },
running: { color: 'success', label: '容器运行中' },
stopped: { color: 'default', label: '容器已停止' },
};
return statusMap[status];
}
function getNapcatWebuiMeta(row: QqbotApi.Account) {
const status = getWebuiStatus(row);
if (!row.napcat && status === 'unknown') {
return { color: 'default', label: 'WebUI 未绑定' };
}
if (status === 'online') {
return { color: 'success', label: 'WebUI 可用' };
}
if (status === 'offline') {
return { color: 'error', label: 'WebUI 不可用' };
}
return { color: 'default', label: 'WebUI 未检查' };
}
function getQqLoginStatusMeta(row: QqbotApi.Account) {
if (!row.napcat && !row.qqLoginStatus) {
return { color: 'default', label: '未绑定容器' };
}
const statusMap: Record<
QqbotApi.QqLoginStatus,
{ color: string; label: string }
> = {
offline: { color: 'error', label: 'QQ 离线' },
online: { color: 'success', label: 'QQ 在线' },
qrcode_expired: { color: 'warning', label: '二维码过期' },
qrcode_pending: { color: 'processing', label: '等待扫码' },
unknown: { color: 'default', label: '状态未知' },
};
return statusMap[getQqLoginStatus(row)];
}
function getOneBotStatus(row: QqbotApi.Account): QqbotApi.OneBotStatus {
if (row.oneBotStatus) return row.oneBotStatus;
if (row.napcat?.oneBotOnline !== undefined) {
return row.napcat.oneBotOnline ? 'online' : 'offline';
}
return row.connectStatus === 'online' ? 'online' : 'offline';
}
function getWebuiStatus(row: QqbotApi.Account): QqbotApi.WebuiStatus {
if (row.webuiStatus) return row.webuiStatus;
if (row.napcat?.webuiOnline === true) return 'online';
if (row.napcat?.webuiOnline === false) return 'offline';
return 'unknown';
}
function getQqLoginStatus(row: QqbotApi.Account): QqbotApi.QqLoginStatus {
return row.qqLoginStatus || row.napcat?.qqLoginStatus || 'unknown';
}
function getQqLoginMessage(row: QqbotApi.Account) {
return row.qqLoginMessage ?? row.napcat?.qqLoginMessage;
}
function getRecentActivity(row: QqbotApi.Account) {
const candidates = [
{ label: '最近心跳', value: row.lastHeartbeatAt },
{ label: '最近连接', value: row.lastConnectedAt },
{ label: '最近扫码登录', value: row.napcat?.lastLoginAt },
{ label: '容器启动', value: row.napcat?.lastStartedAt },
].filter((item) => item.value);
const latest = candidates.toSorted(
(left, right) =>
new Date(right.value || '').getTime() -
new Date(left.value || '').getTime(),
)[0];
return {
label: latest?.label || '暂无活动',
time: latest?.value ? formatDisplayTime(latest.value) : '',
};
}
function getRuntimeSummary(row: QqbotApi.Account) {
if (!row.enabled) {
return { level: 'warning', text: '账号已停用' };
}
if (row.lastError) {
return { level: 'warning', text: `账号异常:${row.lastError}` };
}
const qqLoginMessage = getQqLoginMessage(row);
const qqLoginStatus = getQqLoginStatus(row);
const containerStatus =
row.containerStatus || row.napcat?.containerStatus;
if (qqLoginMessage) {
return {
level: 'warning',
text: `QQ 登录:${qqLoginMessage}`,
};
}
if (row.napcat?.lastError) {
return { level: 'warning', text: `NapCat${row.napcat.lastError}` };
}
if (qqLoginStatus === 'qrcode_expired') {
return { level: 'warning', text: '二维码已过期,点击更新登录' };
}
if (getOneBotStatus(row) === 'online') {
return { level: 'normal', text: '消息链路可用' };
}
if (qqLoginStatus === 'online') {
return { level: 'warning', text: 'QQ 在线,等待 OneBot 连接' };
}
if (containerStatus === 'running') {
return { level: 'warning', text: 'NapCat 运行中,等待 OneBot 连接' };
}
if (containerStatus === 'creating') {
return { level: 'warning', text: '容器创建中' };
}
if (containerStatus === 'stopped') {
return { level: 'warning', text: '容器已停止' };
}
if (!row.napcat) {
return {
level: 'warning',
text: '可更新登录绑定容器',
};
}
return { level: 'normal', text: '暂无异常记录' };
}
function formatDisplayTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
}
function getAccountFormDefaults(): QqbotApi.AccountBody {
return {
accessToken: '',
connectionMode: 'reverse-ws',
enabled: true,
loginPassword: '',
name: '',
remark: '',
selfId: '',
};
}
async function resetAccountForm(values: QqbotApi.AccountBody) {
await accountFormApi.resetForm();
await accountFormApi.setValues(values);
await accountFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
accountModalApi.setData({ values: getAccountFormDefaults() }).open();
}
function openConfig(row: QqbotApi.Account) {
void router.push({
name: 'QqBotAccountConfig',
query: {
selfId: row.selfId,
},
});
}
function openEdit(row: QqbotApi.Account) {
editingId.value = row.id;
accountModalApi
.setData({
values: {
accessToken: '',
connectionMode: row.connectionMode,
enabled: row.enabled,
id: row.id,
loginPassword: '',
name: row.name,
remark: row.remark || '',
selfId: row.selfId,
},
})
.open();
}
async function submitAccount() {
const { valid } = await accountFormApi.validate();
if (!valid) return;
const values = await accountFormApi.getValues<QqbotApi.AccountBody>();
const selfId = values.selfId?.trim();
if (!selfId) {
message.warning('请填写 Self ID');
return;
}
accountModalApi.lock();
try {
const payload = {
...values,
id: editingId.value,
selfId,
};
if (!payload.accessToken) delete payload.accessToken;
if (!payload.loginPassword) delete payload.loginPassword;
await (editingId.value
? updateQqbotAccount(payload)
: createQqbotAccount(payload));
message.success('账号保存成功');
await accountModalApi.close();
await tableApi.reload();
} finally {
accountModalApi.unlock();
}
}
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Account;
if (column.key === 'accountOnlineStatus') {
return renderAccountOnlineStatus(row);
}
if (column.key === 'qqLoginStatus') {
return renderQqLoginStatus(row);
}
if (column.key === 'napcatRuntime') {
return renderNapcatRuntime(row);
}
if (column.key === 'lastHeartbeatAt') {
return renderRecentActivity(row);
}
if (column.key === 'runtimeSummary') {
return renderRuntimeSummary(row);
}
return undefined;
},
}}
/>
<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>,
],
}}
>
<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}>
<AccountForm class="mx-2" />
</AccountModal>
</Page>
);
},
});
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;
}
}