feat(core): Wave 3.5 前端 — 注册页/登录双态 + 邀请制 API 接线(认证定调:无邮箱)

scaffolding: types.ts 加 Invitation 类型;api.ts 加 listInvitations/createInvitation/revokeInvitation +
  register() 加 invite_code;route-config AuthMode "login"|"register" + /register 返回 register 态;
  App.tsx onModeChange 按 mode 切 /login↔/register URL
auth-screen.tsx(sub-agent):登录+注册双态(按 initialMode 渲染),去掉 /register→/login 强制改写;
  注册表单 = 团队名/用户名/密码/确认密码/邀请码(去邮箱!)+ 左价值点 + 协议勾选 + 一致性校验,
  有码提示「加入团队」无码「开新团队」;登录补第三方(微信/飞书+OR线)+ 切换行 + lead/mono;登录保持秒进
styles.css:仅加 .auth-* 注册态/第三方/切换行 CSS(restraint token)

验证: tsc 0 · build 0 · 走查 登录(无邮箱/第三方×2/切换/秒进698ms)+ 注册(5字段/0邮箱/0「邮箱」文案)
  + 切换态(URL→/register)全过 · 0 console error

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 12:41:36 +08:00
co-authored by Claude Opus 4.8
parent ba855cc219
commit c0a8582f3b
7 changed files with 648 additions and 25 deletions
+322 -21
View File
@@ -1,6 +1,18 @@
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Eye, EyeOff, Info, LockKeyhole, UserRound } from "lucide-react";
import {
ArrowRight,
Check,
Eye,
EyeOff,
Info,
LockKeyhole,
ScanLine,
ShieldCheck,
Ticket,
UserRound,
Users
} from "lucide-react";
import { api, getRemember, setRemember as persistRemember } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
@@ -17,9 +29,24 @@ type FieldErrors = {
password?: string;
};
type RegisterErrors = {
team?: string;
username?: string;
password?: string;
password2?: string;
agree?: string;
};
const VALUE_POINTS: { strong: string; rest: string }[] = [
{ strong: "5 阶段流水线", rest: "脚本 → 资产 → 故事板 → 视频片段 → 拼接导出" },
{ strong: "失败不扣费", rest: "任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣" },
{ strong: "团队 + 角色 + 四层额度", rest: "超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支" },
{ strong: "跨项目资产库", rest: "主播 / 场景 / 商品图沉淀复用,不重复生成" }
];
export function AuthScreen({
initialMode: _initialMode,
onModeChange: _onModeChange,
initialMode,
onModeChange,
onAuthed
}: {
initialMode: AuthMode;
@@ -27,23 +54,37 @@ export function AuthScreen({
onAuthed: (payload: { token: string; user: User; team: Team; 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 [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(() => {
const path = window.location.pathname.replace(/\/+$/, "").toLowerCase();
if (path === "/register" || window.location.hash === "#register") {
window.history.replaceState(null, "", "/login");
}
}, []);
setMode(initialMode);
}, [initialMode]);
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
function showToast(title: string, sub: string) {
@@ -52,6 +93,17 @@ export function AuthScreen({
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);
@@ -72,7 +124,7 @@ export function AuthScreen({
return true;
}
async function submit(event?: FormEvent) {
async function submitLogin(event?: FormEvent) {
event?.preventDefault();
if (busy) return;
if (!validateLogin()) return;
@@ -98,22 +150,259 @@ export function AuthScreen({
}
}
// ── 注册 ──
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() {
const next: RegisterErrors = {};
if (!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()) return;
setBusy(true);
setError("");
setLoginProgress("entering");
try {
const invite = regInvite.trim();
const payload = await api.register({
username: regUsername.trim(),
password: regPassword,
team_name: regTeam.trim(),
...(invite ? { invite_code: invite } : {})
});
// 注册即登录:记住新账号用户名,与登录一致地交给 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="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>
{mode === "register" ? (
<>
<div className="hero hero-top">
<h1><br /> <span className="h">AI </span> </h1>
<p> / 1-2 </p>
</div>
<div className="val-list">
{VALUE_POINTS.map((item) => (
<div className="val-item" key={item.strong}>
<span className="ic-v"><Check size={12} strokeWidth={2.2} aria-hidden="true" /></span>
<span className="txt-v"><b>{item.strong}</b> · {item.rest}</span>
</div>
))}
</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>
);
if (mode === "register") {
return (
<main className="auth-exact-page auth-register-page">
<div className="auth-wrap">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
{brand}
<section className="auth-form" aria-label="注册团队">
<div className="h-row"><h2></h2><span className="sub">// /auth/register</span></div>
<p className="lead"></p>
<form id="register-form" autoComplete="off" onSubmit={submitRegister} noValidate>
<div className={`field${regErrors.team ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-team"> <span className="req">*</span></label>
<div className="field-input-wrap">
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<input
id="reg-team"
type="text"
placeholder="例: 小李的店 / XX 文化传媒"
value={regTeam}
aria-invalid={Boolean(regErrors.team)}
onChange={(event) => setRegValue("team", event.target.value)}
/>
</div>
</div>
<div className={`field${regErrors.username ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-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="reg-username"
type="text"
placeholder="登录用,建议字母 / 数字"
value={regUsername}
autoComplete="username"
aria-invalid={Boolean(regErrors.username)}
onChange={(event) => setRegValue("username", event.target.value)}
/>
</div>
</div>
<div className="field-row">
<div className={`field${regErrors.password ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-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="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={`toggle-pwd${regShowPwd ? " active" : ""}`}
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
aria-pressed={regShowPwd}
title={regShowPwd ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd((value) => !value)}
>
{regShowPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
</button>
</div>
</div>
<div className={`field${regErrors.password2 ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-pwd2"> <span className="req">*</span></label>
<div className="field-input-wrap">
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<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={`toggle-pwd${regShowPwd2 ? " active" : ""}`}
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
aria-pressed={regShowPwd2}
title={regShowPwd2 ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd2((value) => !value)}
>
{regShowPwd2 ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
</button>
</div>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="reg-invite">
<span className="hint"> · </span>
</label>
<div className="field-input-wrap">
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<input
id="reg-invite"
type="text"
placeholder="例: TEAM-XXXX-XXXX"
value={regInvite}
onChange={(event) => { setRegInvite(event.target.value); setError(""); }}
/>
</div>
<p className="field-note">
{regInvite.trim() ? "填了码:将加入已有团队" : "没填码:将开新团队,你是超管"}
</p>
</div>
<label className={`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>
{loginProgress && !error && (
<div className="login-progress" role="status" aria-live="polite">
<span className="login-progress-spinner" aria-hidden="true"></span>
<span> Airshelf</span>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
{busy ? "创建团队中" : <> · 使 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
</button>
<div className="switch-row">
? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a>
</div>
</form>
</section>
</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>
);
}
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>
{brand}
<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="h-row"><h2></h2><span className="sub">// /auth/login</span></div>
<form id="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
<p className="lead">线</p>
<div className={`field${fieldErrors.username ? " has-error" : ""}`}>
<label className="field-label" htmlFor="auth-username"> <span className="req">*</span></label>
<div className="field-input-wrap">
@@ -168,9 +457,21 @@ export function AuthScreen({
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<button className="btn-cta" type="button" form="login-form" disabled={busy} onClick={() => { void submit(); }}>
<button className="btn-cta" type="button" form="login-form" disabled={busy} onClick={() => { void submitLogin(); }}>
{busy ? "登录中" : "登录"}
</button>
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
<div className="sso-row">
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "对接中 · 当前请用用户名 + 密码登录")}>
<ScanLine size={14} strokeWidth={1.6} aria-hidden="true" />
</button>
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用用户名 + 密码登录")}>
<ShieldCheck size={14} strokeWidth={1.6} aria-hidden="true" /> SSO
</button>
</div>
<div className="switch-row">
? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a>
</div>
</div>
</div>
{toast && (
+2 -2
View File
@@ -33,7 +33,7 @@ export type Page =
| "settings"
| "settingsNotify";
export type AuthMode = "login";
export type AuthMode = "login" | "register";
export type ResolvedRoute = {
page: Page;
authMode: AuthMode;
@@ -111,7 +111,7 @@ export function resolveRoute(): ResolvedRoute {
const search = new URLSearchParams(window.location.search);
const hash = window.location.hash.replace("#", "");
if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "login", hash };
if (path === "/register" || hash === "register") return { page: "dashboard", authMode: "register", hash };
if (path === "/login") return { page: "dashboard", authMode: "login", hash };
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash };