mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-17 13:50:36 +00:00
feat: 优化离线重连机制,支持通过前端实现重新登录
* feat: 优化离线重连机制,增加前端登录错误提示与二维码刷新功能 - 增加全局掉线检测弹窗 - 增强登录错误解析,支持显示 serverErrorCode 和 message - 优化二维码登录 UI,错误时显示详细原因并提供大按钮重新获取 - 核心层解耦,通过事件抛出 KickedOffLine 通知 - 支持前端点击刷新二维码接口 * feat: 新增看门狗汪汪汪 * cp napcat-shell-loader/launcher-win.bat * refactor: 重构重启流程,移除旧的重启逻辑,新增基于 WebUI 的重启请求处理 * fix: 刷新二维码清楚错误信息
This commit is contained in:
parent
822f683a14
commit
0ca68010a5
@ -21,4 +21,4 @@ export interface IStatusHelperSubscription {
|
||||
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
emit (event: 'statusUpdate', status: SystemStatus): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +125,7 @@ export class NapCatCore {
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
// console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
@ -177,8 +177,10 @@ export class NapCatCore {
|
||||
|
||||
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
|
||||
// 下线通知
|
||||
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
|
||||
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
|
||||
this.context.logger.logError(tips);
|
||||
this.selfInfo.online = false;
|
||||
this.event.emit('KickedOffLine', tips);
|
||||
};
|
||||
msgListener.onRecvMsg = (msgs) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export interface AppEvents {
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
|
||||
KickedOffLine: string;
|
||||
}
|
||||
export const appEvent = new TypedEventEmitter<AppEvents>();
|
||||
|
||||
@ -86,6 +86,7 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
|
||||
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
|
||||
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
|
||||
import { GetCredentials } from './system/GetCredentials';
|
||||
import { SetRestart } from './system/SetRestart';
|
||||
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
|
||||
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
|
||||
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
|
||||
@ -266,6 +267,7 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
||||
new GetGroupFileSystemInfo(obContext, core),
|
||||
new GetGroupFilesByFolder(obContext, core),
|
||||
new GetPacketStatus(obContext, core),
|
||||
new SetRestart(obContext, core),
|
||||
new GroupPoke(obContext, core),
|
||||
new FriendPoke(obContext, core),
|
||||
new GetUserStatus(obContext, core),
|
||||
|
||||
@ -81,7 +81,7 @@ export const ActionName = {
|
||||
CanSendRecord: 'can_send_record',
|
||||
GetStatus: 'get_status',
|
||||
GetVersionInfo: 'get_version_info',
|
||||
// Reboot : 'set_restart',
|
||||
Reboot: 'set_restart',
|
||||
CleanCache: 'clean_cache',
|
||||
Exit: 'bot_exit',
|
||||
// go-cqhttp
|
||||
|
||||
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OneBotAction } from '../OneBotAction';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
|
||||
export class SetRestart extends OneBotAction<void, void> {
|
||||
override actionName = ActionName.Reboot;
|
||||
|
||||
async _handle () {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (!result.result) {
|
||||
throw new Error(result.message || '进程重启失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -387,6 +387,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
};
|
||||
msgListener.onKickedOffLine = async (kick) => {
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
|
||||
this.networkManager
|
||||
.emitEvent(event)
|
||||
|
||||
@ -127,10 +127,13 @@ async function handleLogin (
|
||||
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onUserLoggedIn = (userid: string) => {
|
||||
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
|
||||
const tips = `当前账号(${userid})已登录,无法重复登录`;
|
||||
logger.logError(tips);
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
};
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
context.isLogined = true;
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
inner_resolve({
|
||||
uid: loginResult.uid,
|
||||
uin: loginResult.uin,
|
||||
@ -169,13 +172,16 @@ async function handleLogin (
|
||||
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
|
||||
if (errType === 1 && errCode === 3) {
|
||||
// 二维码过期刷新
|
||||
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
|
||||
}
|
||||
loginService.getQRCodePicture();
|
||||
}
|
||||
};
|
||||
|
||||
loginListener.onLoginFailed = (...args) => {
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
|
||||
const errInfo = JSON.stringify(args);
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
|
||||
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
|
||||
};
|
||||
|
||||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||||
@ -183,17 +189,29 @@ async function handleLogin (
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
loginService.getQRCodePicture();
|
||||
});
|
||||
|
||||
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
if (res.loginErrorInfo.errMsg) {
|
||||
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: res.loginErrorInfo.errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
resolve({ result: true, message: '' });
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '快速登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
@ -208,6 +226,7 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
.then(result => {
|
||||
if (result.loginErrorInfo.errMsg) {
|
||||
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
@ -451,6 +470,10 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
// 监听下线通知并同步到 WebUI
|
||||
this.core.event.on('KickedOffLine', (tips: string) => {
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
});
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
@ -458,4 +481,3 @@ export class NapCatShell {
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"_build": "vite build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
@ -27,9 +27,17 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
|
||||
// 获取QQ登录状态
|
||||
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
|
||||
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const selfInfo = oneBotContext?.core?.selfInfo;
|
||||
const isOnline = selfInfo?.online;
|
||||
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
|
||||
// 必须同时满足:已登录且在线(online 必须明确为 true)
|
||||
const isLogin = qqLoginStatus && isOnline === true;
|
||||
const data = {
|
||||
isLogin: WebUiDataRuntime.getQQLoginStatus(),
|
||||
isLogin,
|
||||
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
|
||||
loginError: WebUiDataRuntime.getQQLoginError(),
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
@ -88,3 +96,15 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiConfig.UpdateAutoLoginAccount(uin);
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 刷新QQ登录二维码
|
||||
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
// 判断是否已经登录
|
||||
if (WebUiDataRuntime.getQQLoginStatus()) {
|
||||
// 已经登录
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 刷新二维码
|
||||
await WebUiDataRuntime.refreshQRCode();
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
@ -21,6 +22,9 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
onRefreshQRCode: async () => {
|
||||
// 默认空实现,由 shell 注册真实回调
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
@ -174,4 +178,25 @@ export const WebUiDataRuntime = {
|
||||
requestRestartProcess: async function () {
|
||||
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
|
||||
},
|
||||
|
||||
setQQLoginError (error: string): void {
|
||||
LoginRuntime.QQLoginError = error;
|
||||
},
|
||||
|
||||
getQQLoginError (): string {
|
||||
return LoginRuntime.QQLoginError;
|
||||
},
|
||||
|
||||
setRefreshQRCodeCallback (func: () => Promise<void>): void {
|
||||
LoginRuntime.onRefreshQRCode = func;
|
||||
},
|
||||
|
||||
getRefreshQRCodeCallback (): () => Promise<void> {
|
||||
return LoginRuntime.onRefreshQRCode;
|
||||
},
|
||||
|
||||
refreshQRCode: async function () {
|
||||
LoginRuntime.QQLoginError = '';
|
||||
await LoginRuntime.onRefreshQRCode();
|
||||
},
|
||||
};
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
getQQLoginInfoHandler,
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router = Router();
|
||||
@ -28,5 +29,7 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
|
||||
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
||||
// router:设置自动登录QQ账号
|
||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
// router:刷新QQ登录二维码
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@ -43,9 +43,11 @@ export interface LoginRuntimeType {
|
||||
QQQRCodeURL: string;
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
|
||||
@ -52,7 +52,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
onNativeClose();
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99]',
|
||||
backdrop: 'z-[99] backdrop-blur-sm',
|
||||
wrapper: 'z-[99]',
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@ -1,22 +1,70 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { IoAlertCircle, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
qrcode: string;
|
||||
loginError?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode, loginError, onRefresh }) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
|
||||
<Spinner color='primary' />
|
||||
{loginError
|
||||
? (
|
||||
<div className='flex flex-col items-center py-4'>
|
||||
<div className='w-full flex justify-center mb-6'>
|
||||
<div className='p-4 bg-danger-50 rounded-full'>
|
||||
<IoAlertCircle className='text-danger' size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center space-y-2 px-4'>
|
||||
<div className='text-xl font-bold text-danger'>登录失败</div>
|
||||
<div className='text-default-600 text-sm leading-relaxed max-w-[300px]'>
|
||||
{loginError}
|
||||
</div>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
className='mt-8 min-w-[160px]'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
重新获取二维码
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-0 top-0 right-0 bottom-0 bg-white dark:bg-zinc-900 bg-opacity-90 backdrop-blur-sm flex items-center justify-center z-10'>
|
||||
<Spinner color='primary' />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode || ' '} />
|
||||
</div>
|
||||
<div className='mt-5 text-center text-default-500 text-sm'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
{onRefresh && qrcode && (
|
||||
<Button
|
||||
className='mt-4'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className='mt-5 text-center'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { serverRequest } from '@/utils/request';
|
||||
|
||||
import { SelfInfo } from '@/types/user';
|
||||
@ -20,8 +21,8 @@ export default class QQManager {
|
||||
public static async checkQQLoginStatus () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
isLogin: string
|
||||
qrcodeurl: string
|
||||
isLogin: string;
|
||||
qrcodeurl: string;
|
||||
}>
|
||||
>('/QQLogin/CheckLoginStatus');
|
||||
|
||||
@ -30,16 +31,20 @@ export default class QQManager {
|
||||
|
||||
public static async checkQQLoginStatusWithQrcode () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{ qrcodeurl: string; isLogin: string }>
|
||||
ServerResponse<{ qrcodeurl: string; isLogin: string; loginError?: string; }>
|
||||
>('/QQLogin/CheckLoginStatus');
|
||||
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async refreshQRCode () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/RefreshQRcode');
|
||||
}
|
||||
|
||||
public static async getQQLoginQrcode () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
qrcode: string
|
||||
qrcode: string;
|
||||
}>
|
||||
>('/QQLogin/GetQQLoginQrcode');
|
||||
|
||||
@ -67,9 +72,11 @@ export default class QQManager {
|
||||
});
|
||||
}
|
||||
|
||||
public static async getQQLoginInfo () {
|
||||
public static async getQQLoginInfo (config?: AxiosRequestConfig) {
|
||||
const data = await serverRequest.post<ServerResponse<SelfInfo>>(
|
||||
'/QQLogin/GetQQLoginInfo'
|
||||
'/QQLogin/GetQQLoginInfo',
|
||||
{},
|
||||
config
|
||||
);
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { MdMenu, MdMenuOpen } from 'react-icons/md';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
@ -11,14 +11,17 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import key from '@/const/key';
|
||||
|
||||
import errorFallbackRender from '@/components/error_fallback';
|
||||
// import PageLoading from "@/components/Loading/PageLoading";
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SideBar from '@/components/sidebar';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import type { MenuItem } from '@/config/site';
|
||||
import { siteConfig } from '@/config/site';
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import ProcessManager from '@/controllers/process_manager';
|
||||
import { waitForBackendReady } from '@/utils/process_utils';
|
||||
|
||||
const menus: MenuItem[] = siteConfig.navItems;
|
||||
|
||||
@ -48,7 +51,67 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
const [openSideBar, setOpenSideBar] = useLocalStorage(key.sideBarOpen, true);
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const navigate = useNavigate();
|
||||
const { isAuth } = useAuth();
|
||||
const { isAuth, revokeAuth } = useAuth();
|
||||
const dialog = useDialog();
|
||||
const isOnlineRef = useRef(true);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// 定期检查 QQ 在线状态,掉线时弹窗提示
|
||||
useEffect(() => {
|
||||
if (!isAuth) return;
|
||||
const checkOnlineStatus = async () => {
|
||||
const currentPath = location.pathname;
|
||||
if (currentPath === '/qq_login' || currentPath === '/web_login') return;
|
||||
try {
|
||||
const info = await QQManager.getQQLoginInfo();
|
||||
if (info?.online === false && isOnlineRef.current === true) {
|
||||
isOnlineRef.current = false;
|
||||
dialog.confirm({
|
||||
title: '账号已离线',
|
||||
content: '您的 QQ 账号已下线,请重新登录。',
|
||||
confirmText: '重新登陆',
|
||||
cancelText: '退出账户',
|
||||
onConfirm: async () => {
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await ProcessManager.restartProcess();
|
||||
} catch (_e) {
|
||||
// 忽略错误,因为后端正在重启关闭连接
|
||||
}
|
||||
|
||||
// 轮询探测后端是否恢复
|
||||
await waitForBackendReady(
|
||||
15000, // 15秒超时
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
window.location.reload();
|
||||
},
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
dialog.alert({
|
||||
title: '启动超时',
|
||||
content: '后端在 15 秒内未响应,请检查 NapCat 运行日志或手动重启。',
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
revokeAuth();
|
||||
navigate('/web_login');
|
||||
},
|
||||
});
|
||||
} else if (info?.online === true) {
|
||||
isOnlineRef.current = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// 忽略请求错误
|
||||
}
|
||||
};
|
||||
const timer = setInterval(checkOnlineStatus, 5000);
|
||||
checkOnlineStatus();
|
||||
return () => clearInterval(timer);
|
||||
}, [isAuth, location.pathname]);
|
||||
|
||||
const checkIsQQLogin = async () => {
|
||||
try {
|
||||
const result = await QQManager.checkQQLoginStatus();
|
||||
@ -86,6 +149,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<PageLoading loading={isRestarting} />
|
||||
<SideBar
|
||||
items={menus}
|
||||
open={openSideBar}
|
||||
|
||||
@ -10,6 +10,7 @@ import PageLoading from '@/components/page_loading';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import ProcessManager from '@/controllers/process_manager';
|
||||
import { waitForBackendReady } from '@/utils/process_utils';
|
||||
|
||||
const LoginConfigCard = () => {
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
@ -60,11 +61,24 @@ const LoginConfigCard = () => {
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
const result = await ProcessManager.restartProcess();
|
||||
toast.success(result.message || '进程重启成功');
|
||||
// 等待 5 秒后刷新页面
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
toast.success(result.message || '进程重启请求已发送');
|
||||
|
||||
// 轮询探测后端是否恢复
|
||||
const isReady = await waitForBackendReady(
|
||||
30000, // 30秒超时
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
toast.success('进程重启完成');
|
||||
},
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
toast.error('后端在 30 秒内未响应,请检查 NapCat 运行日志');
|
||||
}
|
||||
);
|
||||
|
||||
if (!isReady) {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`进程重启失败: ${msg}`);
|
||||
@ -114,7 +128,7 @@ const LoginConfigCard = () => {
|
||||
{isRestarting ? '正在重启进程...' : '重启进程'}
|
||||
</Button>
|
||||
<div className='mt-2 text-xs text-default-500'>
|
||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程,页面将在 5 秒后自动刷新
|
||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -16,14 +16,39 @@ import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const parseLoginError = (errorStr: string) => {
|
||||
if (errorStr.startsWith('登录失败: ')) {
|
||||
const jsonPart = errorStr.substring('登录失败: '.length);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonPart);
|
||||
|
||||
if (Array.isArray(parsed) && parsed[1]) {
|
||||
const info = parsed[1];
|
||||
const codeStr = info.serverErrorCode ? ` (错误码: ${info.serverErrorCode})` : '';
|
||||
|
||||
return `${info.message || errorStr}${codeStr}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
return errorStr;
|
||||
};
|
||||
|
||||
export default function QQLoginPage () {
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const [uinValue, setUinValue] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [qrcode, setQrcode] = useState<string>('');
|
||||
const [loginError, setLoginError] = useState<string>('');
|
||||
const lastErrorRef = useRef<string>('');
|
||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
@ -61,6 +86,20 @@ export default function QQLoginPage () {
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setQrcode(data.qrcodeurl);
|
||||
if (data.loginError && data.loginError !== lastErrorRef.current) {
|
||||
lastErrorRef.current = data.loginError;
|
||||
setLoginError(data.loginError);
|
||||
const friendlyMsg = parseLoginError(data.loginError);
|
||||
|
||||
dialog.alert({
|
||||
title: '登录失败',
|
||||
content: friendlyMsg,
|
||||
confirmText: '确定',
|
||||
});
|
||||
} else if (!data.loginError) {
|
||||
lastErrorRef.current = '';
|
||||
setLoginError('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
@ -99,6 +138,18 @@ export default function QQLoginPage () {
|
||||
setUinValue(e.target.value);
|
||||
};
|
||||
|
||||
const onRefreshQRCode = async () => {
|
||||
try {
|
||||
lastErrorRef.current = '';
|
||||
setLoginError('');
|
||||
await QQManager.refreshQRCode();
|
||||
toast.success('已发送刷新请求');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新二维码失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
onUpdateQrCode();
|
||||
@ -159,7 +210,11 @@ export default function QQLoginPage () {
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
<QrCodeLogin qrcode={qrcode} />
|
||||
<QrCodeLogin
|
||||
loginError={parseLoginError(loginError)}
|
||||
qrcode={qrcode}
|
||||
onRefresh={onRefreshQRCode}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Button
|
||||
|
||||
35
packages/napcat-webui-frontend/src/utils/process_utils.ts
Normal file
35
packages/napcat-webui-frontend/src/utils/process_utils.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
/**
|
||||
* 轮询等待后端进程恢复
|
||||
* @param maxWaitTime 最大等待时间,单位毫秒
|
||||
* @param onSuccess 成功回调
|
||||
* @param onTimeout 超时回调
|
||||
*/
|
||||
export async function waitForBackendReady (
|
||||
maxWaitTime: number = 15000,
|
||||
onSuccess?: () => void,
|
||||
onTimeout?: () => void
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
// 尝试请求后端,设置一个较短的请求超时避免挂起
|
||||
await QQManager.getQQLoginInfo({ timeout: 500 });
|
||||
// 如果能走到这一步说明请求成功了
|
||||
clearInterval(timer);
|
||||
onSuccess?.();
|
||||
resolve(true);
|
||||
} catch (_e) {
|
||||
// 如果请求失败(后端没起来),检查是否超时
|
||||
if (Date.now() - startTime > maxWaitTime) {
|
||||
clearInterval(timer);
|
||||
onTimeout?.();
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
}, 500); // 每 500ms 探测一次
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
// import viteCompression from 'vite-plugin-compression';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
ViteImageOptimizer({})
|
||||
ViteImageOptimizer({}),
|
||||
],
|
||||
base: '/webui/',
|
||||
server: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user