mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-24 09:00:06 +08:00
Deleted the audio player component, songs context, and use-music hook, along with all related code and configuration. Updated affected components and pages to remove music player dependencies and UI. Also improved sidebar, background, and about page UI, and refactored site config icons to use react-icons.
272 lines
9.6 KiB
TypeScript
272 lines
9.6 KiB
TypeScript
import { Button } from '@heroui/button';
|
||
import { CardBody, CardHeader } from '@heroui/card';
|
||
import { Image } from '@heroui/image';
|
||
import { Input } from '@heroui/input';
|
||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||
import { useEffect, useState } from 'react';
|
||
import { toast } from 'react-hot-toast';
|
||
import { IoKeyOutline } from 'react-icons/io5';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
import logo from '@/assets/images/logo.png';
|
||
|
||
import key from '@/const/key';
|
||
|
||
import HoverEffectCard from '@/components/effect_card';
|
||
import { title } from '@/components/primitives';
|
||
import { ThemeSwitch } from '@/components/theme-switch';
|
||
|
||
import WebUIManager from '@/controllers/webui_manager';
|
||
import PureLayout from '@/layouts/pure';
|
||
import { motion } from 'motion/react';
|
||
|
||
export default function WebLoginPage () {
|
||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||
const token = urlSearchParams.get('token');
|
||
const navigate = useNavigate();
|
||
const [tokenValue, setTokenValue] = useState<string>(token || '');
|
||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||
const [isPasskeyLoading, setIsPasskeyLoading] = useState<boolean>(true); // 初始为true,表示正在检查passkey
|
||
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
|
||
|
||
// Helper function to decode base64url
|
||
function base64UrlToUint8Array (base64Url: string): Uint8Array {
|
||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||
const binaryString = atob(base64);
|
||
const bytes = new Uint8Array(binaryString.length);
|
||
for (let i = 0; i < binaryString.length; i++) {
|
||
bytes[i] = binaryString.charCodeAt(i);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
// Helper function to encode Uint8Array to base64url
|
||
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
|
||
const base64 = btoa(String.fromCharCode(...uint8Array));
|
||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||
}
|
||
|
||
// 自动检查并尝试passkey登录
|
||
const tryPasskeyLogin = async () => {
|
||
try {
|
||
// 检查是否有passkey
|
||
const options = await WebUIManager.generatePasskeyAuthenticationOptions();
|
||
|
||
// 如果有passkey,自动进行认证
|
||
const credential = await navigator.credentials.get({
|
||
publicKey: {
|
||
challenge: base64UrlToUint8Array(options.challenge) as BufferSource,
|
||
allowCredentials: options.allowCredentials?.map((cred: any) => ({
|
||
id: base64UrlToUint8Array(cred.id) as BufferSource,
|
||
type: cred.type,
|
||
transports: cred.transports,
|
||
})),
|
||
userVerification: options.userVerification,
|
||
},
|
||
}) as PublicKeyCredential;
|
||
|
||
if (!credential) {
|
||
throw new Error('Passkey authentication cancelled');
|
||
}
|
||
|
||
// 准备响应进行验证 - 转换为base64url字符串格式
|
||
const authResponse = credential.response as AuthenticatorAssertionResponse;
|
||
const response = {
|
||
id: credential.id,
|
||
rawId: uint8ArrayToBase64Url(new Uint8Array(credential.rawId)),
|
||
response: {
|
||
authenticatorData: uint8ArrayToBase64Url(new Uint8Array(authResponse.authenticatorData)),
|
||
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(authResponse.clientDataJSON)),
|
||
signature: uint8ArrayToBase64Url(new Uint8Array(authResponse.signature)),
|
||
userHandle: authResponse.userHandle ? uint8ArrayToBase64Url(new Uint8Array(authResponse.userHandle)) : null,
|
||
},
|
||
type: credential.type,
|
||
};
|
||
|
||
// 验证认证
|
||
const data = await WebUIManager.verifyPasskeyAuthentication(response);
|
||
|
||
if (data && data.Credential) {
|
||
setLocalToken(data.Credential);
|
||
navigate('/qq_login', { replace: true });
|
||
return true; // 登录成功
|
||
}
|
||
} catch (error) {
|
||
// passkey登录失败,继续显示token登录界面
|
||
console.log('Passkey login failed or not available:', error);
|
||
}
|
||
return false; // 登录失败
|
||
};
|
||
|
||
const onSubmit = async () => {
|
||
if (!tokenValue) {
|
||
toast.error('请输入token');
|
||
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
try {
|
||
const data = await WebUIManager.loginWithToken(tokenValue);
|
||
|
||
if (data) {
|
||
setLocalToken(data);
|
||
navigate('/qq_login', { replace: true });
|
||
}
|
||
} catch (error) {
|
||
toast.error((error as Error).message);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理全局键盘事件
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) {
|
||
onSubmit();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
|
||
// 清理函数
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [tokenValue, isLoading, isPasskeyLoading]); // 依赖项包含用于登录的状态
|
||
|
||
useEffect(() => {
|
||
// 如果URL中有token,直接登录
|
||
if (token) {
|
||
onSubmit();
|
||
return;
|
||
}
|
||
|
||
// 否则尝试passkey自动登录
|
||
tryPasskeyLogin().finally(() => {
|
||
setIsPasskeyLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<title>WebUI登录 - NapCat WebUI</title>
|
||
<PureLayout>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
transition={{ duration: 0.5, type: "spring", stiffness: 120, damping: 20 }}
|
||
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
|
||
>
|
||
<HoverEffectCard
|
||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||
maxXRotation={3}
|
||
maxYRotation={3}
|
||
>
|
||
<CardHeader className='inline-block max-w-lg text-center justify-center'>
|
||
<div className='flex items-center justify-center w-full gap-2 pt-10'>
|
||
<Image alt='logo' height='7em' src={logo} />
|
||
<div>
|
||
<span className={title()}>Web </span>
|
||
<span className={title({ color: 'violet' })}>
|
||
Login
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<ThemeSwitch className='absolute right-4 top-4' />
|
||
</CardHeader>
|
||
|
||
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
|
||
{isPasskeyLoading && (
|
||
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
|
||
🔐 正在检查Passkey...
|
||
</div>
|
||
)}
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
onSubmit();
|
||
}}
|
||
>
|
||
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
|
||
<input
|
||
type='text'
|
||
name='username'
|
||
value='napcat-webui'
|
||
autoComplete='username'
|
||
className='absolute -left-[9999px] opacity-0 pointer-events-none'
|
||
readOnly
|
||
tabIndex={-1}
|
||
aria-label='Username'
|
||
/>
|
||
<Input
|
||
isClearable
|
||
type='password'
|
||
name='password'
|
||
autoComplete='current-password'
|
||
classNames={{
|
||
label: 'text-black/50 dark:text-white/90',
|
||
input: [
|
||
'bg-transparent',
|
||
'text-black/90 dark:text-white/90',
|
||
'placeholder:text-default-700/50 dark:placeholder:text-white/60',
|
||
],
|
||
innerWrapper: 'bg-transparent',
|
||
inputWrapper: [
|
||
'shadow-xl',
|
||
'bg-default-100/70',
|
||
'dark:bg-default/60',
|
||
'backdrop-blur-xl',
|
||
'backdrop-saturate-200',
|
||
'hover:bg-default-0/70',
|
||
'dark:hover:bg-default/70',
|
||
'group-data-[focus=true]:bg-default-100/50',
|
||
'dark:group-data-[focus=true]:bg-default/60',
|
||
'!cursor-text',
|
||
],
|
||
}}
|
||
isDisabled={isLoading || isPasskeyLoading}
|
||
label='Token'
|
||
placeholder='请输入token'
|
||
radius='lg'
|
||
size='lg'
|
||
startContent={
|
||
<IoKeyOutline className='text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0' />
|
||
}
|
||
value={tokenValue}
|
||
onChange={(e) => setTokenValue(e.target.value)}
|
||
onClear={() => setTokenValue('')}
|
||
/>
|
||
</form>
|
||
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
|
||
💡 提示:请从 NapCat 启动日志中查看登录密钥
|
||
</div>
|
||
<Button
|
||
className='mx-10 mt-10 text-lg py-7'
|
||
color='primary'
|
||
isLoading={isLoading}
|
||
radius='full'
|
||
size='lg'
|
||
variant='shadow'
|
||
onPress={onSubmit}
|
||
>
|
||
{!isLoading && (
|
||
<Image
|
||
alt='logo'
|
||
classNames={{
|
||
wrapper: '-ml-8',
|
||
}}
|
||
height='2em'
|
||
src={logo}
|
||
/>
|
||
)}
|
||
登录
|
||
</Button>
|
||
</CardBody>
|
||
</HoverEffectCard>
|
||
</motion.div>
|
||
</PureLayout>
|
||
</>
|
||
);
|
||
}
|