feat(admin): Phase 1 注册邀请码逻辑 — 码必填 + kind 分流 + validate-invite + 注册页码优先重做
后端:Invitation.kind(join_team/create_team)+ team 可空 + migration;RegisterSerializer 邀请码必填, create_team 开新团队当 owner+发试用额度、join_team 幂等入伙不重复发额度,删「空码开团队」; validate-invite 免登录验码端点;团管发码恒 join_team;InvitationSerializer 增 kind/team_name。 前端:注册页码优先(验过才展开;create_team 填团队名 / join_team 只读团队名,不暴露团队列表)、 邀请链接 ?invite= 自动验码、api.validateInvite、验码行/状态提示样式(仅 token)。 测试:accounts 22/22 单测过(RegisterInviteCodeTests + ValidateInviteTests); 无头 e2e _admin-p1.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
443023a1a9
commit
3f8547564c
@@ -106,9 +106,16 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code?: 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) });
|
||||
},
|
||||
// 免登录验码:注册页「填码 → 验过才展开表单」。始终 200,以 valid 标志区分。
|
||||
validateInvite(code: string) {
|
||||
return request<{ valid: boolean; kind?: "join_team" | "create_team"; role?: string; team_name?: string | null; detail?: string }>(
|
||||
"/api/auth/validate-invite/",
|
||||
{ method: "POST", body: JSON.stringify({ code }) }
|
||||
);
|
||||
},
|
||||
login(payload: { username: string; password: string }) {
|
||||
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -36,6 +36,13 @@ type RegisterErrors = {
|
||||
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,
|
||||
@@ -61,6 +68,7 @@ export function AuthScreen({
|
||||
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);
|
||||
@@ -79,6 +87,43 @@ export function AuthScreen({
|
||||
}, [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);
|
||||
@@ -153,8 +198,13 @@ export function AuthScreen({
|
||||
}
|
||||
|
||||
function validateRegister() {
|
||||
if (inviteState.status !== "valid") {
|
||||
setError("请先验证邀请码");
|
||||
return false;
|
||||
}
|
||||
const next: RegisterErrors = {};
|
||||
if (!regTeam.trim()) next.team = "请输入团队名";
|
||||
// 仅开团队码需要填团队名;加入码用只读团队名
|
||||
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 位";
|
||||
@@ -163,7 +213,7 @@ export function AuthScreen({
|
||||
if (!regAgree) next.agree = "请阅读并同意用户协议";
|
||||
setRegErrors(next);
|
||||
if (Object.keys(next).length > 0) {
|
||||
setError("请补全必填字段后再创建团队");
|
||||
setError("请补全必填字段后再继续");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -172,17 +222,17 @@ export function AuthScreen({
|
||||
async function submitRegister(event?: FormEvent) {
|
||||
event?.preventDefault();
|
||||
if (busy) return;
|
||||
if (!validateRegister()) return;
|
||||
if (!validateRegister() || inviteState.status !== "valid") 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 } : {})
|
||||
invite_code: regInvite.trim().toUpperCase(),
|
||||
// 开团队码才带团队名;加入码服务端按邀请所属团队入伙
|
||||
...(inviteState.kind === "create_team" ? { team_name: regTeam.trim() } : {})
|
||||
});
|
||||
// 注册即登录:记住新账号用户名,与登录一致地交给 onAuthed 完成身份+数据水合
|
||||
persistRemember(regUsername.trim(), true);
|
||||
@@ -192,9 +242,9 @@ export function AuthScreen({
|
||||
const raw = err instanceof Error ? err.message : "";
|
||||
const friendly = /already exists|unique|taken/i.test(raw)
|
||||
? "该用户名已被占用,换一个试试"
|
||||
: /invite|invitation/i.test(raw)
|
||||
? "邀请码无效或已过期"
|
||||
: raw || "创建团队失败,请稍后重试";
|
||||
: /invite|invitation|邀请码/i.test(raw)
|
||||
? "邀请码无效或已过期,请重新验证"
|
||||
: raw || "提交失败,请稍后重试";
|
||||
setError(friendly);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -228,139 +278,182 @@ export function AuthScreen({
|
||||
<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>
|
||||
<section className="auth-form" aria-label="注册">
|
||||
<div className="h-row"><h2>注册</h2><span className="sub">// /auth/register</span></div>
|
||||
<p className="lead">
|
||||
{inviteState.status === "valid"
|
||||
? inviteState.kind === "create_team"
|
||||
? "邀请码有效 · 开新团队,你将成为团队超管。"
|
||||
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
|
||||
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
|
||||
</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${inviteState.status === "invalid" ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-invite">邀请码 <span className="req">*</span></label>
|
||||
<div className="invite-verify-row">
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<Ticket 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)}
|
||||
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(); } }}
|
||||
/>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn invite-verify-btn"
|
||||
disabled={inviteState.status === "checking" || !regInvite.trim()}
|
||||
onClick={() => void checkInvite()}
|
||||
>
|
||||
{inviteState.status === "checking" ? "验证中…" : inviteState.status === "valid" ? "重新验证" : "验证"}
|
||||
</button>
|
||||
</div>
|
||||
{inviteState.status === "invalid" && <p className="field-note err">{inviteState.detail}</p>}
|
||||
{inviteState.status === "valid" && (
|
||||
<p className="field-note ok">
|
||||
<ShieldCheck size={13} strokeWidth={1.8} aria-hidden="true" />
|
||||
{inviteState.kind === "create_team" ? "可开新团队" : `将加入「${inviteState.teamName || "团队"}」`}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
{/* 第二步:验过码后展开 —— 不暴露任何团队列表 */}
|
||||
{inviteState.status === "valid" && (
|
||||
<>
|
||||
{inviteState.kind === "create_team" ? (
|
||||
<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">
|
||||
<label className="field-label">团队</label>
|
||||
<div className="field-input-wrap is-readonly">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input type="text" value={inviteState.teamName || "已绑定团队"} readOnly disabled />
|
||||
</div>
|
||||
<p className="field-note">邀请码已绑定该团队,你将作为成员加入。</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>
|
||||
<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>
|
||||
|
||||
{loginProgress && !error && (
|
||||
<div className="login-progress" role="status" aria-live="polite">
|
||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||
<span>正在创建团队,进入 Airshelf…</span>
|
||||
</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>
|
||||
|
||||
<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>{inviteState.kind === "create_team" ? "正在创建团队,进入 Airshelf…" : "正在加入团队,进入 Airshelf…"}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy
|
||||
? "提交中"
|
||||
: inviteState.kind === "create_team"
|
||||
? <>创建团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>
|
||||
: <>加入团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
{inviteState.status !== "valid" && error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="switch-row">
|
||||
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 →</a>
|
||||
|
||||
@@ -1217,6 +1217,32 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
/* 注册码优先:验码行(输入 + 验证按钮)+ 状态提示 + 只读团队名 */
|
||||
.auth-exact-page .invite-verify-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.auth-exact-page .invite-verify-row .field-input-wrap { flex: 1; }
|
||||
.auth-exact-page .invite-verify-btn {
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.auth-exact-page .field-note.ok {
|
||||
color: var(--accent-forest);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.auth-exact-page .field-note.err { color: var(--accent-crimson); }
|
||||
.auth-exact-page .field-input-wrap.is-readonly input {
|
||||
background: var(--black-alpha-4);
|
||||
color: var(--black-alpha-56);
|
||||
cursor: default;
|
||||
}
|
||||
.auth-exact-page .agree {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user