Files
yingqing/core/frontend/src/routes/auth-screen.tsx
T
2026-08-19 17:44:56 +08:00

585 lines
24 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import {
ArrowRight,
Boxes,
Eye,
EyeOff,
LoaderCircle,
Lock,
LockKeyhole,
ScanLine,
ShieldCheck,
Sparkles,
Ticket,
UserRound,
Users,
Workflow
} from "lucide-react";
import { api, getRemember, setRemember as persistRemember } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
import "../login-page.css";
type LoginProgress = "checking" | "entering";
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
checking: "正在验证用户名和密码…",
entering: "登录成功,正在进入工作台…"
};
const LOGIN_PLUS_MARKS = Array.from({ length: 168 }, (_, index) => (
<span key={index}>+</span>
));
type FieldErrors = {
username?: string;
password?: string;
};
type RegisterErrors = {
team?: string;
username?: string;
password?: string;
password2?: string;
agree?: string;
};
// 注册码优先:先验码,验过才展开对应表单(create_team 开团队 / join_team 加入已有团队)。
type InviteState =
| { status: "idle" }
| { status: "checking" }
| { status: "valid"; kind: "join_team" | "create_team"; teamName: string | null; role?: string }
| { status: "invalid"; detail: string };
export function AuthScreen({
initialMode,
onModeChange,
onAuthed
}: {
initialMode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onAuthed: (payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) => void | Promise<void>;
}) {
const remembered = getRemember();
const [mode, setMode] = useState<AuthMode>(initialMode);
// ── 登录态 ──
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 [regTeam, setRegTeam] = useState("");
const [regUsername, setRegUsername] = useState("");
const [regPassword, setRegPassword] = useState("");
const [regPassword2, setRegPassword2] = useState("");
const [regInvite, setRegInvite] = useState("");
const [inviteState, setInviteState] = useState<InviteState>({ status: "idle" });
const [regAgree, setRegAgree] = useState(true);
const [regShowPwd, setRegShowPwd] = useState(false);
const [regShowPwd2, setRegShowPwd2] = useState(false);
const [regErrors, setRegErrors] = useState<RegisterErrors>({});
// ── 共用 ──
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);
// initialMode 由路由驱动(/login ↔ /register),外部回退/前进时跟随
useEffect(() => {
setMode(initialMode);
}, [initialMode]);
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
// 邀请链接 ?invite=CODE:进注册页自动回填 + 验码(团管/超管发的链接点开即识别团队)
useEffect(() => {
if (mode !== "register") return;
const code = new URLSearchParams(window.location.search).get("invite");
if (code && !regInvite) {
setRegInvite(code);
void checkInvite(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// 验码:验过才展开对应表单;未验/无效不暴露团队信息
async function checkInvite(rawCode?: string) {
const code = (rawCode ?? regInvite).trim().toUpperCase();
setError("");
if (!code) { setInviteState({ status: "invalid", detail: "请填写邀请码" }); return; }
setInviteState({ status: "checking" });
try {
const res = await api.validateInvite(code);
if (res.valid && res.kind) {
setInviteState({ status: "valid", kind: res.kind, teamName: res.team_name ?? null, role: res.role });
if (res.kind === "join_team") setRegTeam(res.team_name || "");
} else {
setInviteState({ status: "invalid", detail: res.detail || "邀请码无效或已过期" });
}
} catch {
setInviteState({ status: "invalid", detail: "邀请码校验失败,请重试" });
}
}
// 改码即重置验证态:必须重新验证(避免拿旧验证结果提交)
function onInviteChange(value: string) {
setRegInvite(value);
setError("");
if (inviteState.status !== "idle") setInviteState({ status: "idle" });
}
function showToast(title: string, sub: string) {
setToast({ title, sub });
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 2800);
}
function switchMode(next: AuthMode) {
if (next === mode) return;
setMode(next);
setError("");
setLoginProgress(null);
setFieldErrors({});
setRegErrors({});
onModeChange(next);
}
// ── 登录 ──
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 submitLogin(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);
}
}
// ── 注册 ──
function setRegValue(field: keyof RegisterErrors, value: string) {
if (field === "team") setRegTeam(value);
if (field === "username") setRegUsername(value);
if (field === "password") setRegPassword(value);
if (field === "password2") setRegPassword2(value);
setError("");
setRegErrors((current) => ({ ...current, [field]: undefined }));
}
function validateRegister() {
if (inviteState.status !== "valid") {
setError("请先验证邀请码");
return false;
}
const next: RegisterErrors = {};
// 仅开团队码需要填团队名;加入码用只读团队名
if (inviteState.kind === "create_team" && !regTeam.trim()) next.team = "请输入团队名";
if (!regUsername.trim()) next.username = "请输入用户名";
if (!regPassword) next.password = "请输入密码";
else if (regPassword.length < 8) next.password = "密码至少 8 位";
if (!regPassword2) next.password2 = "请再次输入密码";
else if (regPassword && regPassword2 !== regPassword) next.password2 = "两次密码不一致";
if (!regAgree) next.agree = "请阅读并同意用户协议";
setRegErrors(next);
if (Object.keys(next).length > 0) {
setError("请补全必填字段后再继续");
return false;
}
return true;
}
async function submitRegister(event?: FormEvent) {
event?.preventDefault();
if (busy) return;
if (!validateRegister() || inviteState.status !== "valid") return;
setBusy(true);
setError("");
setLoginProgress("entering");
try {
const payload = await api.register({
username: regUsername.trim(),
password: regPassword,
invite_code: regInvite.trim().toUpperCase(),
// 开团队码才带团队名;加入码服务端按邀请所属团队入伙
...(inviteState.kind === "create_team" ? { team_name: regTeam.trim() } : {})
});
// 注册即登录:记住新账号用户名,与登录一致地交给 onAuthed 完成身份+数据水合
persistRemember(regUsername.trim(), true);
await onAuthed({ ...payload, remember: true });
} catch (err) {
setLoginProgress(null);
const raw = err instanceof Error ? err.message : "";
const friendly = /already exists|unique|taken/i.test(raw)
? "该用户名已被占用,换一个试试"
: /invite|invitation|邀请码/i.test(raw)
? "邀请码无效或已过期,请重新验证"
: raw || "提交失败,请稍后重试";
setError(friendly);
} finally {
setBusy(false);
}
}
const brand = (
<aside className="login-visual">
<div className="login-brand-lockup">
<img className="login-logo" src="/assets/yz/logo-on-dark.png" alt="影擎" />
<div className="login-brand-copy"><span>YINGQING AIGC STUDIO</span></div>
</div>
<div className="login-hero">
<p className="login-eyebrow">// AIGC COMMERCE CONTENT ENGINE</p>
<div className="login-hero-copy">
<h1>
<span className="login-hero-line">让每一次商品表达,</span>
<span className="login-hero-line login-hero-accent">更快成为内容。</span>
</h1>
<p>从商品资产、脚本、故事板到视频生成,以统一的创作工作流连接品牌内容生产的每一个环节。</p>
</div>
<div className="login-capabilities" aria-label="平台能力">
<div className="login-capability">
<Sparkles />
<strong>智能创作</strong>
<span>图像与视频生成</span>
</div>
<div className="login-capability">
<Workflow />
<strong>流程协同</strong>
<span>五阶段项目管理</span>
</div>
<div className="login-capability">
<Boxes />
<strong>资产沉淀</strong>
<span>商品、角色与场景</span>
</div>
</div>
</div>
<div className="login-visual-footer">
<span>© 2026 影擎智能内容平台</span>
<span className="login-system-status">创作服务正常</span>
</div>
</aside>
);
const toastNode = toast ? (
<div className="login-toast" role="status" aria-live="polite">
{toast.title}
<span>{toast.sub}</span>
</div>
) : null;
if (mode === "register") {
return (
<section className="login-page" aria-labelledby="loginTitle">
{brand}
<main className="login-form-panel">
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
<div className="login-card is-register">
<div className="login-card-heading">
<span className="login-step"><ShieldCheck />安全访问</span>
<h2 id="loginTitle">开通账户</h2>
<p>
{inviteState.status === "valid"
? inviteState.kind === "create_team"
? "邀请码有效 · 开新团队,你将成为团队超管。"
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
</p>
</div>
<form className="login-form" autoComplete="off" onSubmit={submitRegister} noValidate>
<label className={`login-field${inviteState.status === "invalid" ? " has-error" : ""}`}>
<span>邀请码 <em className="req">*</em></span>
<div className="login-verify">
<span className="login-input-wrap">
<Ticket />
<input
id="reg-invite"
type="text"
placeholder="例: TEAM-XXXX-XXXX"
value={regInvite}
autoComplete="off"
aria-invalid={inviteState.status === "invalid"}
onChange={(event) => onInviteChange(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void checkInvite(); } }}
/>
</span>
<button
type="button"
className="login-verify-btn"
disabled={inviteState.status === "checking" || !regInvite.trim()}
onClick={() => void checkInvite()}
>
{inviteState.status === "checking" ? "验证中…" : inviteState.status === "valid" ? "重新验证" : "验证"}
</button>
</div>
{inviteState.status === "invalid" && <p className="login-note err">{inviteState.detail}</p>}
{inviteState.status === "valid" && (
<p className="login-note ok">
<ShieldCheck size={13} />
{inviteState.kind === "create_team" ? "可开新团队" : `将加入「${inviteState.teamName || "团队"}」`}
</p>
)}
</label>
{inviteState.status === "valid" && (
<>
{inviteState.kind === "create_team" ? (
<label className={`login-field${regErrors.team ? " has-error" : ""}`}>
<span>团队名 <em className="req">*</em></span>
<span className="login-input-wrap">
<Users />
<input
id="reg-team"
type="text"
placeholder="例: 小李的店 / XX 文化传媒"
value={regTeam}
aria-invalid={Boolean(regErrors.team)}
onChange={(event) => setRegValue("team", event.target.value)}
/>
</span>
</label>
) : (
<label className="login-field">
<span>团队</span>
<span className="login-input-wrap">
<Users />
<input type="text" value={inviteState.teamName || "已绑定团队"} readOnly disabled />
</span>
<p className="login-note">邀请码已绑定该团队,你将作为成员加入。</p>
</label>
)}
<label className={`login-field${regErrors.username ? " has-error" : ""}`}>
<span>用户名 <em className="req">*</em></span>
<span className="login-input-wrap">
<UserRound />
<input
id="reg-username"
type="text"
placeholder="登录用,建议字母 / 数字"
value={regUsername}
autoComplete="username"
aria-invalid={Boolean(regErrors.username)}
onChange={(event) => setRegValue("username", event.target.value)}
/>
</span>
</label>
<div className="login-pass-row">
<label className={`login-field${regErrors.password ? " has-error" : ""}`}>
<span>密码 <em className="req">*</em></span>
<span className="login-input-wrap">
<LockKeyhole />
<input
id="reg-pwd"
type={regShowPwd ? "text" : "password"}
placeholder="至少 8 位"
value={regPassword}
autoComplete="new-password"
aria-invalid={Boolean(regErrors.password)}
onChange={(event) => setRegValue("password", event.target.value)}
/>
<button
type="button"
className="login-password-toggle"
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd((value) => !value)}
>
{regShowPwd ? <EyeOff /> : <Eye />}
</button>
</span>
</label>
<label className={`login-field${regErrors.password2 ? " has-error" : ""}`}>
<span>确认密码 <em className="req">*</em></span>
<span className="login-input-wrap">
<LockKeyhole />
<input
id="reg-pwd2"
type={regShowPwd2 ? "text" : "password"}
placeholder="再输一次"
value={regPassword2}
autoComplete="new-password"
aria-invalid={Boolean(regErrors.password2)}
onChange={(event) => setRegValue("password2", event.target.value)}
/>
<button
type="button"
className="login-password-toggle"
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd2((value) => !value)}
>
{regShowPwd2 ? <EyeOff /> : <Eye />}
</button>
</span>
</label>
</div>
<label className={`login-agree${regErrors.agree ? " has-error" : ""}`}>
<input
type="checkbox"
checked={regAgree}
onChange={(event) => { setRegAgree(event.target.checked); setRegErrors((c) => ({ ...c, agree: undefined })); setError(""); }}
/>
<span>
我已阅读并同意{" "}
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a>
{" "}{" "}
<a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}>隐私政策</a>
</span>
</label>
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? <LoaderCircle /> : <ArrowRight />}
{busy
? "提交中"
: inviteState.kind === "create_team"
? "创建团队 · 开始使用"
: "加入团队 · 开始使用"}
</button>
</>
)}
{inviteState.status !== "valid" && (
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
)}
</form>
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
</div>
<div className="login-extras">
<p className="login-switch">
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 </a>
</p>
</div>
</main>
{toastNode}
</section>
);
}
return (
<section className="login-page" aria-labelledby="loginTitle">
{brand}
<main className="login-form-panel">
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
<div className={`login-card${error ? " shake" : ""}`}>
<div className="login-card-heading">
<span className="login-step"><ShieldCheck />安全访问</span>
<h2 id="loginTitle">欢迎回来</h2>
<p>请输入影擎账号与密码,进入内容创作工作台。</p>
</div>
<form className="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
<label className={`login-field${fieldErrors.username ? " has-error" : ""}`}>
<span>账号</span>
<span className="login-input-wrap">
<UserRound />
<input
id="auth-username"
type="text"
placeholder="请输入账号"
value={username}
autoComplete="username"
aria-invalid={Boolean(fieldErrors.username)}
onChange={(event) => setFieldValue("username", event.target.value)}
/>
</span>
</label>
<label className={`login-field${fieldErrors.password ? " has-error" : ""}`}>
<span>密码</span>
<span className="login-input-wrap">
<LockKeyhole />
<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="login-password-toggle"
aria-label={showPwd ? "隐藏密码" : "显示密码"}
aria-pressed={showPwd}
onClick={() => setShowPwd((value) => !value)}
>
{showPwd ? <EyeOff /> : <Eye />}
</button>
</span>
</label>
<p className={`login-error${error ? " show" : ""}`} role="alert">
{error || "账号或密码不正确,请重新输入。"}
</p>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
</form>
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
</div>
<div className="login-extras">
<div className="login-row">
<label>
<input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />
记住我 7
</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码?</a>
</div>
<div className="login-sso">
<button type="button" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
<ScanLine /> 微信扫码
</button>
<button type="button" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
<ShieldCheck /> 飞书 SSO
</button>
</div>
<p className="login-switch">
还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 </a>
</p>
</div>
</main>
{toastNode}
</section>
);
}