feat: prepare Zhinian desktop client for pilot release
This commit is contained in:
226
src/pages/YinianLogin/index.tsx
Normal file
226
src/pages/YinianLogin/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, LockKeyhole, RefreshCw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import { YinianPanel, yinianPrimaryButton } from '@/components/yinian/ui';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import type { YinianImageCaptcha } from '../../../shared/yinian';
|
||||
import type { YinianServerStatus } from '../../../shared/yinian';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function YinianLogin() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('setup');
|
||||
const status = useYinianStore((state) => state.status);
|
||||
const error = useYinianStore((state) => state.error);
|
||||
const loginWithPassword = useYinianStore((state) => state.loginWithPassword);
|
||||
|
||||
const [account, setAccount] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
const [captchaCode, setCaptchaCode] = useState('');
|
||||
const [captcha, setCaptcha] = useState<YinianImageCaptcha | null>(null);
|
||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
const [serverStatus, setServerStatus] = useState<YinianServerStatus | null>(null);
|
||||
const isLoading = status === 'loading';
|
||||
const serverConfigured = Boolean(serverStatus?.apiBaseUrl) || serverStatus?.mode === 'mock';
|
||||
const captchaSrc = useMemo(() => {
|
||||
if (!captcha) return '';
|
||||
if (captcha.image?.startsWith('data:')) return captcha.image;
|
||||
if (captcha.image) return captcha.image;
|
||||
if (captcha.imageBase64) {
|
||||
return `data:${captcha.mimeType || 'image/png'};base64,${captcha.imageBase64}`;
|
||||
}
|
||||
return '';
|
||||
}, [captcha]);
|
||||
const captchaRequired = Boolean(captchaSrc);
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
setCaptchaLoading(true);
|
||||
try {
|
||||
const randomStr = crypto.randomUUID();
|
||||
const next = await window.yinian.auth.createImageCaptcha(randomStr);
|
||||
setCaptcha(next);
|
||||
setCaptchaCode('');
|
||||
} catch {
|
||||
setCaptcha(null);
|
||||
} finally {
|
||||
setCaptchaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void window.yinian.app.getServerStatus()
|
||||
.then(setServerStatus)
|
||||
.catch(() => setServerStatus(null));
|
||||
void refreshCaptcha();
|
||||
void window.yinian.auth.getSavedCredentials()
|
||||
.then((credentials) => {
|
||||
if (!credentials) return;
|
||||
setAccount(credentials.account);
|
||||
setPassword(credentials.password ?? '');
|
||||
setRememberPassword(credentials.rememberPassword);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const handlePasswordLogin = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
await loginWithPassword({
|
||||
account: account.trim(),
|
||||
password,
|
||||
captchaCode: captchaCode.trim(),
|
||||
randomStr: captcha?.randomStr,
|
||||
});
|
||||
if (rememberPassword) {
|
||||
void window.yinian.auth.saveCredentials({
|
||||
account: account.trim(),
|
||||
password,
|
||||
rememberPassword: true,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
void window.yinian.auth.clearSavedCredentials().catch(() => undefined);
|
||||
}
|
||||
navigate('/today', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="yinian-login-page" className="flex min-h-screen bg-slate-50 text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
||||
<section className="hidden w-[44%] min-w-[460px] flex-col items-center justify-center bg-[#0B1220] px-12 py-10 text-white lg:flex">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<span className="flex h-24 w-24 items-center justify-center rounded-[28px] bg-white/10 ring-1 ring-white/15">
|
||||
<img src={logoSvg} alt={t('login.appName')} className="h-20 w-20 rounded-3xl" />
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">{t('login.slogan')}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="flex flex-1 items-center justify-center px-6 py-10">
|
||||
<YinianPanel className="w-full max-w-[430px]">
|
||||
<div className="p-6 pb-0">
|
||||
<div className="mb-3 flex items-center gap-2 lg:hidden">
|
||||
<img src={logoSvg} alt={t('login.appName')} className="h-9 w-9 rounded-xl" />
|
||||
<span className="text-base font-semibold">{t('login.appName')}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">{t('login.title')}</h1>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{t('login.slogan')}</p>
|
||||
{serverStatus && (
|
||||
<div className={`mt-4 rounded-lg border px-3 py-2 text-xs ${
|
||||
serverConfigured
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{serverConfigured
|
||||
? t('login.serverEnabled', { baseUrl: serverStatus.apiBaseUrl ? `: ${serverStatus.apiBaseUrl}` : '' })
|
||||
: serverStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<form className="space-y-4" onSubmit={handlePasswordLogin}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account">{t('login.account')}</Label>
|
||||
<Input
|
||||
id="account"
|
||||
value={account}
|
||||
onChange={(event) => setAccount(event.target.value)}
|
||||
placeholder={t('login.accountPlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('login.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<CaptchaField
|
||||
value={captchaCode}
|
||||
onChange={setCaptchaCode}
|
||||
imageSrc={captchaSrc}
|
||||
loading={captchaLoading}
|
||||
onRefresh={refreshCaptcha}
|
||||
label={t('login.captcha')}
|
||||
placeholder={t('login.captchaPlaceholder')}
|
||||
refreshTitle={t('login.refreshCaptcha')}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberPassword}
|
||||
onChange={(event) => setRememberPassword(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
{t('login.rememberPassword')}
|
||||
</label>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className={`w-full ${yinianPrimaryButton}`} disabled={isLoading || !serverConfigured || (captchaRequired && !captchaCode.trim())}>
|
||||
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LockKeyhole className="mr-2 h-4 w-4" />}
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</YinianPanel>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptchaField({
|
||||
value,
|
||||
onChange,
|
||||
imageSrc,
|
||||
loading,
|
||||
onRefresh,
|
||||
label,
|
||||
placeholder,
|
||||
refreshTitle,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
imageSrc: string;
|
||||
loading: boolean;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
refreshTitle: string;
|
||||
}) {
|
||||
if (!imageSrc) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="captcha-code">{label}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="captcha-code"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRefresh()}
|
||||
className="flex h-10 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border border-slate-200 bg-white text-xs text-muted-foreground transition-colors hover:bg-slate-50 dark:border-white/10 dark:bg-slate-900 dark:hover:bg-white/5"
|
||||
title={refreshTitle}
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<img src={imageSrc} alt={label} className="h-full w-full object-contain" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default YinianLogin;
|
||||
Reference in New Issue
Block a user