This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Checkbox, Form } from "tdesign-react";
|
||||
import { useAppDispatch, useAppSelector, clearSessionError, loginThunk } from "../../store";
|
||||
import type { Admin } from "../../types/auth";
|
||||
import { LoginUserIcon, StarterPasswordInput } from "./StarterAuth";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
|
||||
const REMEMBERED_USERNAME_KEY = "tallynote.login.username";
|
||||
|
||||
export interface LoginPageProps {
|
||||
notice?: string;
|
||||
onSuccess?: (admin: Admin) => void;
|
||||
}
|
||||
|
||||
function getRememberedUsername(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return window.localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage({ notice, onSuccess }: LoginPageProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, error } = useAppSelector((state) => state.session);
|
||||
const [username, setUsername] = useState(getRememberedUsername);
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberAccount, setRememberAccount] = useState(Boolean(username));
|
||||
const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const busy = status === "loading";
|
||||
|
||||
const updateUsername = (value: string) => {
|
||||
setUsername(value);
|
||||
setFieldErrors(current => ({ ...current, username: undefined }));
|
||||
if (error) dispatch(clearSessionError());
|
||||
};
|
||||
|
||||
const updatePassword = (value: string) => {
|
||||
setPassword(value);
|
||||
setFieldErrors(current => ({ ...current, password: undefined }));
|
||||
if (error) dispatch(clearSessionError());
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const normalizedUsername = username.trim();
|
||||
const nextErrors: { username?: string; password?: string } = {};
|
||||
if (!normalizedUsername) nextErrors.username = "请输入用户名";
|
||||
if (!password) nextErrors.password = "请输入密码";
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setFieldErrors(nextErrors);
|
||||
const focusInvalid = () => document.querySelector<HTMLElement>("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea")?.focus();
|
||||
requestAnimationFrame(focusInvalid);
|
||||
window.setTimeout(focusInvalid, 60);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (rememberAccount) {
|
||||
window.localStorage.setItem(REMEMBERED_USERNAME_KEY, normalizedUsername);
|
||||
} else {
|
||||
window.localStorage.removeItem(REMEMBERED_USERNAME_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Private browsing or a locked-down browser may reject localStorage.
|
||||
}
|
||||
try {
|
||||
const admin = await dispatch(loginThunk({ username: normalizedUsername, password })).unwrap();
|
||||
onSuccess?.(admin);
|
||||
} catch {
|
||||
// The rejected thunk exposes its server message through session.error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="tn-login-page" data-page="login">
|
||||
<section className="tn-login-container" aria-labelledby="login-title">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="tn-login-form"
|
||||
labelWidth={0}
|
||||
onSubmit={(context) => { context.e?.preventDefault(); void submit(); }}
|
||||
>
|
||||
{notice && <div role="status" aria-live="polite"><Alert className="tn-login-alert" theme="info" message={notice} /></div>}
|
||||
{error && <div role="alert" aria-live="assertive"><Alert className="tn-login-alert" theme="error" message={error} /></div>}
|
||||
<Form.FormItem label="用户名" requiredMark={false} name="username" status={fieldErrors.username ? "error" : undefined} help={fieldErrors.username}>
|
||||
<AccessibleInput
|
||||
inputAriaLabel="用户名"
|
||||
inputAriaInvalid={Boolean(fieldErrors.username)}
|
||||
inputAriaDescribedby={fieldErrors.username ? "login-username-error" : undefined}
|
||||
size="large"
|
||||
value={username}
|
||||
onChange={(value) => updateUsername(String(value))}
|
||||
clearable
|
||||
disabled={busy}
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
prefixIcon={<LoginUserIcon />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
{fieldErrors.username && <span id="login-username-error" className="tn-sr-only">{fieldErrors.username}</span>}
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="密码" requiredMark={false} name="password" status={fieldErrors.password ? "error" : undefined} help={fieldErrors.password}>
|
||||
<StarterPasswordInput
|
||||
ariaLabel="密码"
|
||||
ariaInvalid={Boolean(fieldErrors.password)}
|
||||
ariaDescribedby={fieldErrors.password ? "login-password-error" : undefined}
|
||||
value={password}
|
||||
onChange={updatePassword}
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
placeholder="请输入登录密码"
|
||||
/>
|
||||
{fieldErrors.password && <span id="login-password-error" className="tn-sr-only">{fieldErrors.password}</span>}
|
||||
</Form.FormItem>
|
||||
<div className="tn-login-options"><Checkbox checked={rememberAccount} onChange={setRememberAccount} disabled={busy}>记住账号</Checkbox></div>
|
||||
<Form.FormItem className="tn-login-submit">
|
||||
<Button block size="large" theme="primary" type="submit" loading={busy} disabled={busy}>登录</Button>
|
||||
</Form.FormItem>
|
||||
</Form>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user