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:
co-authored by
Claude Opus 4.8
parent
ba855cc219
commit
c0a8582f3b
@@ -560,7 +560,7 @@ export function App() {
|
||||
initialMode={authMode}
|
||||
onModeChange={(next) => {
|
||||
setAuthMode(next);
|
||||
window.history.pushState(null, "", "/login");
|
||||
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
|
||||
}}
|
||||
onAuthed={onAuthed}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BillingTrend,
|
||||
Ledger,
|
||||
LoginSession,
|
||||
Invitation,
|
||||
ModelConfig,
|
||||
Notification,
|
||||
NotificationList,
|
||||
@@ -105,7 +106,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register(payload: { username: string; password: string; email?: string; team_name?: string }) {
|
||||
register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code?: string }) {
|
||||
return request<AuthPayload>("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
login(payload: { username: string; password: string }) {
|
||||
@@ -172,6 +173,15 @@ export const api = {
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
},
|
||||
listInvitations() {
|
||||
return request<Invitation[]>("/api/auth/team/invitations/");
|
||||
},
|
||||
createInvitation(payload: { role?: string; email?: string; monthly_credit_limit?: number | string }) {
|
||||
return request<Invitation>("/api/auth/team/invitations/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
revokeInvitation(id: string) {
|
||||
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
|
||||
},
|
||||
products() {
|
||||
return request<Paginated<Product>>("/api/products/");
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
// ── 注册 ──
|
||||
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>
|
||||
{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 && (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1075,6 +1075,213 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* lead 引导文案 + mono 副标(登录补、注册自带) */
|
||||
.auth-exact-page .auth-form .lead {
|
||||
font-size: 13px;
|
||||
color: var(--black-alpha-56);
|
||||
margin: 0 0 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.auth-exact-page.auth-login-page .auth-form .lead { margin: 0 0 20px; }
|
||||
|
||||
/* OR 分割线 + 第三方 SSO(登录页) */
|
||||
.auth-exact-page .divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
.auth-exact-page .divider .line { flex: 1; height: 1px; background: var(--border-faint); }
|
||||
.auth-exact-page .divider .txt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--black-alpha-32);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.auth-exact-page .sso-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.auth-exact-page .sso-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-black);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.auth-exact-page .sso-btn:hover { border-color: var(--black-alpha-32); }
|
||||
.auth-exact-page .sso-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
/* 登录 / 注册切换行 */
|
||||
.auth-exact-page .switch-row {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
.auth-exact-page .switch-row a {
|
||||
color: var(--heat);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-exact-page .switch-row a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── 注册态:表单更宽 + 自然流(不再绝对定位 .auth-form) ── */
|
||||
.auth-exact-page.auth-register-page .auth-wrap {
|
||||
height: auto;
|
||||
min-height: 620px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.auth-exact-page.auth-register-page .auth-brand {
|
||||
flex: 0 0 calc(100% - 460px);
|
||||
width: calc(100% - 460px);
|
||||
}
|
||||
.auth-exact-page.auth-register-page .auth-form {
|
||||
position: static;
|
||||
width: 460px;
|
||||
flex: 0 0 460px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
padding: 40px 44px 32px;
|
||||
}
|
||||
.auth-exact-page.auth-register-page .auth-form form {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.auth-exact-page.auth-register-page .auth-form .h-row { gap: 6px; }
|
||||
.auth-exact-page.auth-register-page .auth-form h2 { font-size: 24px; }
|
||||
|
||||
/* 注册:品牌区顶对齐 hero + 价值点列表 */
|
||||
.auth-exact-page .auth-brand .hero.hero-top {
|
||||
margin-top: 26px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.auth-exact-page .auth-brand .hero.hero-top h1 { font-size: 28px; }
|
||||
.auth-exact-page .val-list {
|
||||
margin: 28px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.auth-exact-page .val-item {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.auth-exact-page .val-item .ic-v {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, .18);
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--heat);
|
||||
}
|
||||
.auth-exact-page .val-item .ic-v svg { width: 12px; height: 12px; }
|
||||
.auth-exact-page .val-item .txt-v {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, .78);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.auth-exact-page .val-item .txt-v b { color: var(--accent-white); font-weight: 600; }
|
||||
|
||||
/* 注册:label 行尾提示 + 双列密码 + 邀请码动态提示 + 协议勾选 */
|
||||
.auth-exact-page .field-label .hint {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
.auth-exact-page .field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.auth-exact-page .field-row .field { margin-bottom: 0; }
|
||||
.auth-exact-page .field-note {
|
||||
margin: 7px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.auth-exact-page .agree {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--black-alpha-56);
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.auth-exact-page .agree input {
|
||||
margin-top: 3px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
accent-color: var(--heat);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.auth-exact-page .agree a { color: var(--heat); text-decoration: none; }
|
||||
.auth-exact-page .agree a:hover { text-decoration: underline; }
|
||||
.auth-exact-page .agree.has-error a { color: var(--accent-crimson); }
|
||||
|
||||
/* 注册:CTA / 提示在表单内自然流(不借用 .auth-submit-zone 的绝对定位) */
|
||||
.auth-exact-page.auth-register-page .btn-cta { margin-top: 4px; }
|
||||
.auth-exact-page.auth-register-page .form-error,
|
||||
.auth-exact-page.auth-register-page .login-progress {
|
||||
position: static;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* ── 登录态:加了 OR / 第三方 / 切换行后内容变高,改自然流让卡片随内容长高 ── */
|
||||
.auth-exact-page.auth-login-page .auth-wrap {
|
||||
height: auto;
|
||||
min-height: 560px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.auth-exact-page.auth-login-page .auth-brand {
|
||||
flex: 0 0 calc(100% - 420px);
|
||||
width: calc(100% - 420px);
|
||||
}
|
||||
.auth-exact-page.auth-login-page .auth-form {
|
||||
position: static;
|
||||
width: 420px;
|
||||
flex: 0 0 420px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
.auth-exact-page.auth-login-page .auth-form form {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.auth-exact-page.auth-login-page .auth-submit-zone {
|
||||
position: static;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.auth-exact-page.auth-login-page .form-error,
|
||||
.auth-exact-page.auth-login-page .login-progress {
|
||||
position: static;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.auth-exact-page .auth-wrap {
|
||||
flex-direction: column;
|
||||
@@ -1097,6 +1304,13 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
width: auto;
|
||||
margin-top: auto;
|
||||
}
|
||||
.auth-exact-page.auth-register-page .auth-brand,
|
||||
.auth-exact-page.auth-register-page .auth-form {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
.auth-exact-page.auth-register-page .val-list { display: none; }
|
||||
.auth-exact-page.auth-register-page .auth-form { padding: 32px 28px; }
|
||||
}
|
||||
|
||||
/* Dashboard 区类(.dash-grid/.dash-side/.recent-row/.shortcut/.tip)已迁入
|
||||
|
||||
@@ -20,6 +20,21 @@ export type TeamMember = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type Invitation = {
|
||||
id: string;
|
||||
code: string;
|
||||
role: string;
|
||||
email: string;
|
||||
monthly_credit_limit: string;
|
||||
status: "pending" | "used" | "revoked" | "expired";
|
||||
expires_at: string;
|
||||
used_by: string | null;
|
||||
used_by_username: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
register_url: string;
|
||||
};
|
||||
|
||||
export type AuthPayload = {
|
||||
token: string;
|
||||
user: User;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Wave 3.5 auth walkthrough (scratch): 登录(秒进/无邮箱/第三方/切换) + 注册(无邮箱/5字段) + 切换态.
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://localhost:5173";
|
||||
const OUT = path.resolve("../../../_qa_shots/wave35");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const r = { login: {}, register: {}, switch: {}, consoleErrors: [] };
|
||||
|
||||
// 登录态(无 token,真在登录页)
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("login:" + m.text()); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push("login:PAGEERR:" + e.message));
|
||||
await p.goto(BASE + "/login", { waitUntil: "load" });
|
||||
await p.waitForTimeout(500);
|
||||
r.login = {
|
||||
usernameField: await p.locator("#auth-username").count(),
|
||||
emailField: await p.locator("input[type=email]").count(),
|
||||
ssoButtons: await p.locator(".sso-btn").count(),
|
||||
switchRow: await p.locator(".switch-row").count(),
|
||||
};
|
||||
await p.screenshot({ path: path.join(OUT, "login.png") });
|
||||
// 登录秒进(demo 账号)
|
||||
await p.fill("#auth-username", "airshelf");
|
||||
await p.fill("#auth-pwd", "Restraint2026");
|
||||
const t0 = Date.now();
|
||||
await p.click("button.btn-cta");
|
||||
try {
|
||||
await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 });
|
||||
r.login.loginMs = Date.now() - t0;
|
||||
} catch { r.login.loginMs = -1; }
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// 注册态
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("reg:" + m.text()); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push("reg:PAGEERR:" + e.message));
|
||||
await p.goto(BASE + "/register", { waitUntil: "load" });
|
||||
await p.waitForTimeout(600);
|
||||
const bodyText = await p.locator("body").innerText().catch(() => "");
|
||||
r.register = {
|
||||
emailField: await p.locator("input[type=email]").count(),
|
||||
textInputs: await p.locator(".auth-form input[type=text], .auth-form input[type=password]").count(),
|
||||
hasTeamLabel: /团队名|团队名称/.test(bodyText),
|
||||
hasInvite: /邀请码/.test(bodyText),
|
||||
hasEmailText: /邮箱/.test(bodyText),
|
||||
ctaButton: await p.locator(".btn-cta").count(),
|
||||
switchRow: await p.locator(".switch-row").count(),
|
||||
};
|
||||
await p.screenshot({ path: path.join(OUT, "register.png") });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// 切换:login → 点切换行 → register
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
await p.goto(BASE + "/login", { waitUntil: "load" });
|
||||
await p.waitForTimeout(500);
|
||||
const link = p.locator(".switch-row a, .switch-row button").first();
|
||||
if (await link.count()) {
|
||||
await link.click();
|
||||
await p.waitForTimeout(400);
|
||||
r.switch = {
|
||||
urlNowRegister: p.url().includes("/register"),
|
||||
registerFormShown: /邀请码/.test(await p.locator("body").innerText().catch(() => "")),
|
||||
};
|
||||
await p.screenshot({ path: path.join(OUT, "switched-to-register.png") });
|
||||
} else r.switch = "no switch link";
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));
|
||||
Reference in New Issue
Block a user