feat: 优化离线重连机制,支持通过前端实现重新登录

* feat: 优化离线重连机制,增加前端登录错误提示与二维码刷新功能

- 增加全局掉线检测弹窗
- 增强登录错误解析,支持显示 serverErrorCode 和 message
- 优化二维码登录 UI,错误时显示详细原因并提供大按钮重新获取
- 核心层解耦,通过事件抛出 KickedOffLine 通知
- 支持前端点击刷新二维码接口

* feat: 新增看门狗汪汪汪

* cp napcat-shell-loader/launcher-win.bat

* refactor: 重构重启流程,移除旧的重启逻辑,新增基于 WebUI 的重启请求处理

* fix: 刷新二维码清楚错误信息
This commit is contained in:
时瑾 2026-01-17 15:38:24 +08:00 committed by GitHub
parent 822f683a14
commit 0ca68010a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 374 additions and 59 deletions

View File

@ -125,7 +125,7 @@ export class NapCatCore {
container.bind(TypedEventEmitter).toConstantValue(this.event); container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => { ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf(); 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 }) => { this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass); const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data); return serviceInstance.handler(seq, hex_data);
@ -177,8 +177,10 @@ export class NapCatCore {
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => { 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.selfInfo.online = false;
this.event.emit('KickedOffLine', tips);
}; };
msgListener.onRecvMsg = (msgs) => { msgListener.onRecvMsg = (msgs) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo)); msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));

View File

@ -1,6 +1,7 @@
import { TypedEventEmitter } from './typeEvent'; import { TypedEventEmitter } from './typeEvent';
export interface AppEvents { 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>(); export const appEvent = new TypedEventEmitter<AppEvents>();

View File

@ -86,6 +86,7 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl'; import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus'; import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
import { GetCredentials } from './system/GetCredentials'; import { GetCredentials } from './system/GetCredentials';
import { SetRestart } from './system/SetRestart';
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign'; import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain'; import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely'; import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
@ -266,6 +267,7 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
new GetGroupFileSystemInfo(obContext, core), new GetGroupFileSystemInfo(obContext, core),
new GetGroupFilesByFolder(obContext, core), new GetGroupFilesByFolder(obContext, core),
new GetPacketStatus(obContext, core), new GetPacketStatus(obContext, core),
new SetRestart(obContext, core),
new GroupPoke(obContext, core), new GroupPoke(obContext, core),
new FriendPoke(obContext, core), new FriendPoke(obContext, core),
new GetUserStatus(obContext, core), new GetUserStatus(obContext, core),

View File

@ -81,7 +81,7 @@ export const ActionName = {
CanSendRecord: 'can_send_record', CanSendRecord: 'can_send_record',
GetStatus: 'get_status', GetStatus: 'get_status',
GetVersionInfo: 'get_version_info', GetVersionInfo: 'get_version_info',
// Reboot : 'set_restart', Reboot: 'set_restart',
CleanCache: 'clean_cache', CleanCache: 'clean_cache',
Exit: 'bot_exit', Exit: 'bot_exit',
// go-cqhttp // go-cqhttp

View 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 || '进程重启失败');
}
}
}

View File

@ -387,6 +387,7 @@ export class NapCatOneBot11Adapter {
} }
}; };
msgListener.onKickedOffLine = async (kick) => { msgListener.onKickedOffLine = async (kick) => {
WebUiDataRuntime.setQQLoginStatus(false);
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc); const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
this.networkManager this.networkManager
.emitEvent(event) .emitEvent(event)

View File

@ -127,10 +127,13 @@ async function handleLogin (
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => { loginListener.onUserLoggedIn = (userid: string) => {
logger.logError(`当前账号(${userid})已登录,无法重复登录`); const tips = `当前账号(${userid})已登录,无法重复登录`;
logger.logError(tips);
WebUiDataRuntime.setQQLoginError(tips);
}; };
loginListener.onQRCodeLoginSucceed = async (loginResult) => { loginListener.onQRCodeLoginSucceed = async (loginResult) => {
context.isLogined = true; context.isLogined = true;
WebUiDataRuntime.setQQLoginStatus(true);
inner_resolve({ inner_resolve({
uid: loginResult.uid, uid: loginResult.uid,
uin: loginResult.uin, uin: loginResult.uin,
@ -169,13 +172,16 @@ async function handleLogin (
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode); logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType === 1 && errCode === 3) { if (errType === 1 && errCode === 3) {
// 二维码过期刷新 // 二维码过期刷新
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
} }
loginService.getQRCodePicture(); loginService.getQRCodePicture();
} }
}; };
loginListener.onLoginFailed = (...args) => { 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)); loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
@ -183,17 +189,29 @@ async function handleLogin (
return await selfInfo; return await selfInfo;
} }
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) { 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) => { WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => { return await new Promise((resolve) => {
if (uin) { if (uin) {
logger.log('正在快速登录 ', uin); logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => { loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) { if (res.loginErrorInfo.errMsg) {
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: res.loginErrorInfo.errMsg }); resolve({ result: false, message: res.loginErrorInfo.errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
} }
resolve({ result: true, message: '' });
}).catch((e) => { }).catch((e) => {
logger.logError(e); logger.logError(e);
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '快速登录发生错误' }); resolve({ result: false, message: '快速登录发生错误' });
}); });
} else { } else {
@ -208,6 +226,7 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
.then(result => { .then(result => {
if (result.loginErrorInfo.errMsg) { if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg); logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
if (!context.isLogined) loginService.getQRCodePicture(); if (!context.isLogined) loginService.getQRCodePicture();
} }
}) })
@ -451,6 +470,10 @@ export class NapCatShell {
async InitNapCat () { async InitNapCat () {
await this.core.initCore(); 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); const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用 // 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter); WebUiDataRuntime.setOneBotContext(oneBotAdapter);
@ -458,4 +481,3 @@ export class NapCatShell {
.catch(e => this.context.logger.logError('初始化OneBot失败', e)); .catch(e => this.context.logger.logError('初始化OneBot失败', e));
} }
} }

View File

@ -1,24 +1,24 @@
{ {
"name": "napcat-vite", "name": "napcat-vite",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"build": "vite build" "_build": "vite build"
},
"exports": {
".": {
"import": "./index.ts"
}, },
"exports": { "./*": {
".": { "import": "./*"
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
} }
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
} }

View File

@ -1,9 +1,9 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; 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 { isEmpty } from '@/napcat-webui-backend/src/utils/check';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
// 获取QQ登录二维码 // 获取QQ登录二维码
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => { export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
@ -27,9 +27,17 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
// 获取QQ登录状态 // 获取QQ登录状态
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => { 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 = { const data = {
isLogin: WebUiDataRuntime.getQQLoginStatus(), isLogin,
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(), qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
loginError: WebUiDataRuntime.getQQLoginError(),
}; };
return sendSuccess(res, data); return sendSuccess(res, data);
}; };
@ -88,3 +96,15 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
await WebUiConfig.UpdateAutoLoginAccount(uin); await WebUiConfig.UpdateAutoLoginAccount(uin);
return sendSuccess(res, null); 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);
};

View File

@ -14,6 +14,7 @@ const LoginRuntime: LoginRuntimeType = {
uin: '', uin: '',
nick: '', nick: '',
}, },
QQLoginError: '',
QQVersion: 'unknown', QQVersion: 'unknown',
OneBotContext: null, OneBotContext: null,
onQQLoginStatusChange: async (status: boolean) => { onQQLoginStatusChange: async (status: boolean) => {
@ -21,6 +22,9 @@ const LoginRuntime: LoginRuntimeType = {
}, },
onWebUiTokenChange: async (_token: string) => { onWebUiTokenChange: async (_token: string) => {
},
onRefreshQRCode: async () => {
// 默认空实现,由 shell 注册真实回调
}, },
NapCatHelper: { NapCatHelper: {
onOB11ConfigChanged: async () => { onOB11ConfigChanged: async () => {
@ -174,4 +178,25 @@ export const WebUiDataRuntime = {
requestRestartProcess: async function () { requestRestartProcess: async function () {
return await LoginRuntime.NapCatHelper.onRestartProcessRequested(); 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();
},
}; };

View File

@ -9,6 +9,7 @@ import {
getQQLoginInfoHandler, getQQLoginInfoHandler,
getAutoLoginAccountHandler, getAutoLoginAccountHandler,
setAutoLoginAccountHandler, setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
} from '@/napcat-webui-backend/src/api/QQLogin'; } from '@/napcat-webui-backend/src/api/QQLogin';
const router = Router(); const router = Router();
@ -28,5 +29,7 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler); router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
// router:设置自动登录QQ账号 // router:设置自动登录QQ账号
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler); router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
export { router as QQLoginRouter }; export { router as QQLoginRouter };

View File

@ -43,9 +43,11 @@ export interface LoginRuntimeType {
QQQRCodeURL: string; QQQRCodeURL: string;
QQLoginUin: string; QQLoginUin: string;
QQLoginInfo: SelfInfo; QQLoginInfo: SelfInfo;
QQLoginError: string;
QQVersion: string; QQVersion: string;
onQQLoginStatusChange: (status: boolean) => Promise<void>; onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>; onWebUiTokenChange: (token: string) => Promise<void>;
onRefreshQRCode: () => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>; WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能 OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {

View File

@ -52,7 +52,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onNativeClose(); onNativeClose();
}} }}
classNames={{ classNames={{
backdrop: 'z-[99]', backdrop: 'z-[99] backdrop-blur-sm',
wrapper: 'z-[99]', wrapper: 'z-[99]',
}} }}
{...rest} {...rest}

View File

@ -1,22 +1,70 @@
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { IoAlertCircle, IoRefresh } from 'react-icons/io5';
interface QrCodeLoginProps { 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 ( return (
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'> {loginError
{!qrcode && ( ? (
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'> <div className='flex flex-col items-center py-4'>
<Spinner color='primary' /> <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>
)
: (
<>
<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> </div>
); );
}; };

View File

@ -1,3 +1,4 @@
import { AxiosRequestConfig } from 'axios';
import { serverRequest } from '@/utils/request'; import { serverRequest } from '@/utils/request';
import { SelfInfo } from '@/types/user'; import { SelfInfo } from '@/types/user';
@ -20,8 +21,8 @@ export default class QQManager {
public static async checkQQLoginStatus () { public static async checkQQLoginStatus () {
const data = await serverRequest.post< const data = await serverRequest.post<
ServerResponse<{ ServerResponse<{
isLogin: string isLogin: string;
qrcodeurl: string qrcodeurl: string;
}> }>
>('/QQLogin/CheckLoginStatus'); >('/QQLogin/CheckLoginStatus');
@ -30,16 +31,20 @@ export default class QQManager {
public static async checkQQLoginStatusWithQrcode () { public static async checkQQLoginStatusWithQrcode () {
const data = await serverRequest.post< const data = await serverRequest.post<
ServerResponse<{ qrcodeurl: string; isLogin: string }> ServerResponse<{ qrcodeurl: string; isLogin: string; loginError?: string; }>
>('/QQLogin/CheckLoginStatus'); >('/QQLogin/CheckLoginStatus');
return data.data.data; return data.data.data;
} }
public static async refreshQRCode () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/RefreshQRcode');
}
public static async getQQLoginQrcode () { public static async getQQLoginQrcode () {
const data = await serverRequest.post< const data = await serverRequest.post<
ServerResponse<{ ServerResponse<{
qrcode: string qrcode: string;
}> }>
>('/QQLogin/GetQQLoginQrcode'); >('/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>>( const data = await serverRequest.post<ServerResponse<SelfInfo>>(
'/QQLogin/GetQQLoginInfo' '/QQLogin/GetQQLoginInfo',
{},
config
); );
return data.data.data; return data.data.data;
} }

View File

@ -3,7 +3,7 @@ import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; 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 { ErrorBoundary } from 'react-error-boundary';
import { MdMenu, MdMenuOpen } from 'react-icons/md'; import { MdMenu, MdMenuOpen } from 'react-icons/md';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
@ -11,14 +11,17 @@ import { useLocation, useNavigate } from 'react-router-dom';
import key from '@/const/key'; import key from '@/const/key';
import errorFallbackRender from '@/components/error_fallback'; import errorFallbackRender from '@/components/error_fallback';
// import PageLoading from "@/components/Loading/PageLoading"; import PageLoading from '@/components/page_loading';
import SideBar from '@/components/sidebar'; import SideBar from '@/components/sidebar';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog';
import type { MenuItem } from '@/config/site'; import type { MenuItem } from '@/config/site';
import { siteConfig } from '@/config/site'; import { siteConfig } from '@/config/site';
import QQManager from '@/controllers/qq_manager'; import QQManager from '@/controllers/qq_manager';
import ProcessManager from '@/controllers/process_manager';
import { waitForBackendReady } from '@/utils/process_utils';
const menus: MenuItem[] = siteConfig.navItems; 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 [openSideBar, setOpenSideBar] = useLocalStorage(key.sideBarOpen, true);
const [b64img] = useLocalStorage(key.backgroundImage, ''); const [b64img] = useLocalStorage(key.backgroundImage, '');
const navigate = useNavigate(); 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 () => { const checkIsQQLogin = async () => {
try { try {
const result = await QQManager.checkQQLoginStatus(); const result = await QQManager.checkQQLoginStatus();
@ -86,6 +149,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
> >
<PageLoading loading={isRestarting} />
<SideBar <SideBar
items={menus} items={menus}
open={openSideBar} open={openSideBar}

View File

@ -10,6 +10,7 @@ import PageLoading from '@/components/page_loading';
import QQManager from '@/controllers/qq_manager'; import QQManager from '@/controllers/qq_manager';
import ProcessManager from '@/controllers/process_manager'; import ProcessManager from '@/controllers/process_manager';
import { waitForBackendReady } from '@/utils/process_utils';
const LoginConfigCard = () => { const LoginConfigCard = () => {
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
@ -60,11 +61,24 @@ const LoginConfigCard = () => {
setIsRestarting(true); setIsRestarting(true);
try { try {
const result = await ProcessManager.restartProcess(); const result = await ProcessManager.restartProcess();
toast.success(result.message || '进程重启成功'); toast.success(result.message || '进程重启请求已发送');
// 等待 5 秒后刷新页面
setTimeout(() => { // 轮询探测后端是否恢复
window.location.reload(); const isReady = await waitForBackendReady(
}, 5000); 30000, // 30秒超时
() => {
setIsRestarting(false);
toast.success('进程重启完成');
},
() => {
setIsRestarting(false);
toast.error('后端在 30 秒内未响应,请检查 NapCat 运行日志');
}
);
if (!isReady) {
setIsRestarting(false);
}
} catch (error) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
toast.error(`进程重启失败: ${msg}`); toast.error(`进程重启失败: ${msg}`);
@ -114,7 +128,7 @@ const LoginConfigCard = () => {
{isRestarting ? '正在重启进程...' : '重启进程'} {isRestarting ? '正在重启进程...' : '重启进程'}
</Button> </Button>
<div className='mt-2 text-xs text-default-500'> <div className='mt-2 text-xs text-default-500'>
Worker 3 5 Worker 3
</div> </div>
</div> </div>
</> </>

View File

@ -16,14 +16,39 @@ import type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch'; import { ThemeSwitch } from '@/components/theme-switch';
import QQManager from '@/controllers/qq_manager'; import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog';
import PureLayout from '@/layouts/pure'; import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react'; 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 () { export default function QQLoginPage () {
const navigate = useNavigate(); const navigate = useNavigate();
const dialog = useDialog();
const [uinValue, setUinValue] = useState<string>(''); const [uinValue, setUinValue] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [qrcode, setQrcode] = useState<string>(''); const [qrcode, setQrcode] = useState<string>('');
const [loginError, setLoginError] = useState<string>('');
const lastErrorRef = useRef<string>('');
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]); const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
const [refresh, setRefresh] = useState<boolean>(false); const [refresh, setRefresh] = useState<boolean>(false);
const firstLoad = useRef<boolean>(true); const firstLoad = useRef<boolean>(true);
@ -61,6 +86,20 @@ export default function QQLoginPage () {
navigate('/', { replace: true }); navigate('/', { replace: true });
} else { } else {
setQrcode(data.qrcodeurl); 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) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
@ -99,6 +138,18 @@ export default function QQLoginPage () {
setUinValue(e.target.value); 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(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
onUpdateQrCode(); onUpdateQrCode();
@ -159,7 +210,11 @@ export default function QQLoginPage () {
/> />
</Tab> </Tab>
<Tab key='qrcode' title='扫码登录'> <Tab key='qrcode' title='扫码登录'>
<QrCodeLogin qrcode={qrcode} /> <QrCodeLogin
loginError={parseLoginError(loginError)}
qrcode={qrcode}
onRefresh={onRefreshQRCode}
/>
</Tab> </Tab>
</Tabs> </Tabs>
<Button <Button

View 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 探测一次
});
}

View File

@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite'; 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 { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
plugins: [ plugins: [
react(), react(),
tsconfigPaths(), tsconfigPaths(),
ViteImageOptimizer({}) ViteImageOptimizer({}),
], ],
base: '/webui/', base: '/webui/',
server: { server: {