Files
yingqing/core/frontend/src/routes/auth-screen.tsx
T
seaislee1209andClaude Opus 4.8 74e3c818c5 feat(core): Wave 1 part1 — 浮层进场动画/ESC + Toast 归正 + 账户表横线
S4 浮层: overlays.tsx 新增共享 useOverlayTransition(挂载→下一帧上 .show 触发
  scale(.96→1)/translateX 进场过渡;关闭先撤 .show 播退场再卸载)+ 统一 ESC 关闭,
  应用 TeamModal/ConfirmModal/SuccessModal/Drawer;EmptyPanel 补 .ic-empty 灰 icon
S3 Toast: ToastLike 改用设计系统 .toast(右下/单橙 ic-t/shadow-floating/滑入),
  不再随 success/error 变绿红边;auth login-toast → .toast
S9 表格: account-page.css 删 tbody td border-bottom(对照 V1 account.html 确认应无行线)

验证: tsc 0 · 交互走查 toast出现+ESC关弹窗 · 14 路由回归 boot 0 报错 · 登录 643ms

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:51:30 +08:00

185 lines
8.1 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Eye, EyeOff, Info, LockKeyhole, UserRound } from "lucide-react";
import { api, getRemember, setRemember as persistRemember } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
type LoginProgress = "checking" | "entering";
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
checking: "正在验证用户名和密码…",
entering: "登录成功,正在进入 Airshelf"
};
type FieldErrors = {
username?: string;
password?: string;
};
export function AuthScreen({
initialMode: _initialMode,
onModeChange: _onModeChange,
onAuthed
}: {
initialMode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onAuthed: (payload: { token: string; user: User; team: Team; remember?: boolean }) => void | Promise<void>;
}) {
const remembered = getRemember();
const [username, setUsername] = useState(remembered?.username || "");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(Boolean(remembered));
const [showPwd, setShowPwd] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
const [loginProgress, setLoginProgress] = useState<LoginProgress | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const path = window.location.pathname.replace(/\/+$/, "").toLowerCase();
if (path === "/register" || window.location.hash === "#register") {
window.history.replaceState(null, "", "/login");
}
}, []);
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
function showToast(title: string, sub: string) {
setToast({ title, sub });
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2800);
}
function setFieldValue(field: keyof FieldErrors, value: string) {
if (field === "username") setUsername(value);
if (field === "password") setPassword(value);
setError("");
setLoginProgress(null);
setFieldErrors((current) => ({ ...current, [field]: undefined }));
}
function validateLogin() {
const next: FieldErrors = {};
if (!username.trim()) next.username = "请输入用户名";
if (!password) next.password = "请输入密码";
setFieldErrors(next);
if (Object.keys(next).length > 0) {
setError("请补全必填字段后再登录");
return false;
}
return true;
}
async function submit(event?: FormEvent) {
event?.preventDefault();
if (busy) return;
if (!validateLogin()) return;
setBusy(true);
setError("");
setLoginProgress("checking");
try {
const payload = await api.login({ username: username.trim(), password });
// 记住我:勾选则存用户名 + 7 天有效期,token 落 localStorage;否则只存当前会话
persistRemember(username.trim(), remember);
// 凭证验过即进:不再做任何人为延迟动画。"进入"态由真实的 onAuthed(身份+数据水合)驱动。
setLoginProgress("entering");
await onAuthed({ ...payload, remember });
} catch (err) {
setLoginProgress(null);
const raw = err instanceof Error ? err.message : "";
const friendly = /invalid credentials|unable to log in|non_field_errors/i.test(raw)
? "用户名或密码不正确"
: raw || "登录失败,请稍后重试";
setError(friendly);
} finally {
setBusy(false);
}
}
return (
<main className="auth-exact-page auth-login-page">
<div className="auth-wrap">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
<aside className="auth-brand">
<div className="logo"><img className="logo-img" src="/assets/logo-dark.png" alt="Airshelf" /></div>
<div className="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div className="hero">
<h1>AI 全流程<br /><span className="h">短剧化</span>带货生成</h1>
<p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p>
</div>
<div className="foot"><a href="#">关于</a><a href="#">定价</a><a href="#">联系</a><a href="#">隐私</a></div>
</aside>
<section className="auth-form" aria-label="登录">
<div className="h-row"><h2>登录</h2><span className="sub">// LOGIN</span></div>
<form id="login-form" autoComplete="off" onSubmit={submit} noValidate>
<div className={`field${fieldErrors.username ? " has-error" : ""}`}>
<label className="field-label" htmlFor="auth-username">用户名 <span className="req">*</span></label>
<div className="field-input-wrap">
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<input
id="auth-username"
type="text"
placeholder="请输入用户名"
value={username}
autoComplete="username"
aria-invalid={Boolean(fieldErrors.username)}
onChange={(event) => setFieldValue("username", event.target.value)}
/>
</div>
</div>
<div className={`field${fieldErrors.password ? " has-error" : ""}`}>
<label className="field-label" htmlFor="auth-pwd">密码 <span className="req">*</span></label>
<div className="field-input-wrap">
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<input
id="auth-pwd"
type={showPwd ? "text" : "password"}
placeholder="请输入密码"
value={password}
autoComplete="current-password"
aria-invalid={Boolean(fieldErrors.password)}
onChange={(event) => setFieldValue("password", event.target.value)}
/>
<button
type="button"
className={`toggle-pwd${showPwd ? " active" : ""}`}
aria-label={showPwd ? "隐藏密码" : "显示密码"}
aria-pressed={showPwd}
title={showPwd ? "隐藏密码" : "显示密码"}
onClick={() => setShowPwd((value) => !value)}
>
{showPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
</button>
</div>
</div>
<div className="row-between">
<label><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} /> 记住我 7 天</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码?</a>
</div>
</form>
</section>
<div className="auth-submit-zone">
{loginProgress && !error && (
<div className="login-progress" role="status" aria-live="polite">
<span className="login-progress-spinner" aria-hidden="true"></span>
<span>{LOGIN_PROGRESS_COPY[loginProgress]}</span>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<button className="btn-cta" type="button" form="login-form" disabled={busy} onClick={() => { void submit(); }}>
{busy ? "登录中" : "登录"}
</button>
</div>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
</div>
)}
</main>
);
}