release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+128
View File
@@ -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>
);
}