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>
185 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|