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

- 增加全局掉线检测弹窗
- 增强登录错误解析,支持显示 serverErrorCode 和 message
- 优化二维码登录 UI,错误时显示详细原因并提供大按钮重新获取
- 核心层解耦,通过事件抛出 KickedOffLine 通知
- 支持前端点击刷新二维码接口
This commit is contained in:
时瑾 2026-01-14 18:54:59 +08:00
parent fbccf8be24
commit 0918b17257
No known key found for this signature in database
GPG Key ID: 023F70A1B8F8C196
16 changed files with 265 additions and 48 deletions

View File

@ -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;
}
}

View File

@ -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));

View File

@ -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>();

View File

@ -246,7 +246,7 @@ export class NapCatOneBot11Adapter {
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
private async handleConfigChange<CT extends NetworkAdapterConfig> (
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
@ -384,6 +384,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)

View File

@ -128,10 +128,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,
@ -170,13 +173,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));
@ -184,17 +190,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 {
@ -209,6 +227,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();
}
})
@ -452,6 +471,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);
@ -459,4 +482,3 @@ export class NapCatShell {
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
}
}

View File

@ -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"
}
}

View File

@ -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);
};

View File

@ -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 () => {
@ -163,4 +167,22 @@ export const WebUiDataRuntime = {
getOneBotContext (): any | null {
return LoginRuntime.OneBotContext;
},
setQQLoginError (error: string): void {
LoginRuntime.QQLoginError = error;
},
getQQLoginError (): string {
return LoginRuntime.QQLoginError;
},
setRefreshQRCodeCallback (func: () => Promise<void>): void {
LoginRuntime.onRefreshQRCode = func;
},
async refreshQRCode (): Promise<void> {
// 清除错误信息
LoginRuntime.QQLoginError = '';
await LoginRuntime.onRefreshQRCode();
},
};

View File

@ -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 };

View File

@ -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: {

View File

@ -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}

View File

@ -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>
);
};

View File

@ -20,8 +20,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 +30,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');

View File

@ -15,6 +15,7 @@ import errorFallbackRender from '@/components/error_fallback';
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';
@ -48,7 +49,43 @@ 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);
// 定期检查 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: () => navigate('/qq_login'),
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();

View File

@ -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

View File

@ -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: {