本轮(生图模型选择 + 火山接入): - 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill), 默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回 - 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model - worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走 image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图 → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核 模特上身图提示词: - 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应 - _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张 其他(并入此前各会话未提交改动): - 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤 - accounts/projects/assets/team/auth 等零散调整、相关测试 - 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
563 lines
26 KiB
TypeScript
563 lines
26 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import {
|
|
ArrowRight,
|
|
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";
|
|
|
|
type LoginProgress = "checking" | "entering";
|
|
|
|
const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
|
|
checking: "正在验证用户名和密码…",
|
|
entering: "登录成功,正在进入工作台…"
|
|
};
|
|
|
|
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="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">
|
|
{mode === "register" ? (
|
|
<>
|
|
<h1>开通团队,<br />拍出第一条 <span className="h">带货短剧</span></h1>
|
|
<p>1-2 小时,从一张商品图,到一条能卖货的短剧。</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h1>让每件商品<br />都有自己的<span className="h">带货短剧</span></h1>
|
|
<p>商品图进,带货短剧出 —— 你只管挑成片。</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">
|
|
{inviteState.status === "valid"
|
|
? inviteState.kind === "create_team"
|
|
? "邀请码有效 · 开新团队,你将成为团队超管。"
|
|
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
|
|
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
|
|
</p>
|
|
<form id="register-form" autoComplete="off" onSubmit={submitRegister} noValidate>
|
|
{/* 第一步:邀请码(必填)· 验过才展开后续表单 */}
|
|
<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">
|
|
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
|
<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(); } }}
|
|
/>
|
|
</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>
|
|
|
|
{/* 第二步:验过码后展开 —— 不暴露任何团队列表 */}
|
|
{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>
|
|
)}
|
|
|
|
<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>
|
|
|
|
<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" ? "正在创建团队,进入工作台…" : "正在加入团队,进入工作台…"}</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>
|
|
</>
|
|
)}
|
|
|
|
{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>
|
|
</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">// /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">
|
|
<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>
|
|
<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="submit" disabled={busy}>
|
|
{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>
|
|
</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>
|
|
);
|
|
}
|