chore: drop leftover login/register template drafts and debug scripts; track Register pages and icon variants

This commit is contained in:
chunyu
2026-09-04 13:08:47 +08:00
parent e5594605c5
commit 8ad3abadfc
9 changed files with 1302 additions and 2 deletions
+412
View File
@@ -0,0 +1,412 @@
/* ============ Deep Space Register - 共享样式 ============ */
.register-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 20px;
position: relative;
overflow: hidden;
background: #0a0e1a;
}
/* 星空背景 */
.register-stars {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
.register-star {
position: absolute;
width: 2px;
height: 2px;
background: white;
border-radius: 50%;
animation: regTwinkle 3s ease-in-out infinite;
}
@keyframes regTwinkle {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.9; }
}
/* 浮动光球 */
.register-orb {
position: fixed;
border-radius: 50%;
filter: blur(80px);
opacity: 0.35;
pointer-events: none;
z-index: 0;
}
.register-orb-1 {
width: 300px; height: 300px;
background: #667eea;
top: -80px; right: -60px;
animation: regOrbFloat1 8s ease-in-out infinite;
}
.register-orb-2 {
width: 250px; height: 250px;
background: #764ba2;
bottom: 80px; left: -80px;
animation: regOrbFloat2 10s ease-in-out infinite;
}
.register-orb-3 {
width: 180px; height: 180px;
background: #f093fb;
top: 50%; right: -30px;
animation: regOrbFloat3 12s ease-in-out infinite;
}
@keyframes regOrbFloat1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 40px) scale(1.1); }
}
@keyframes regOrbFloat2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, -30px) scale(1.15); }
}
@keyframes regOrbFloat3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-20px, -40px) scale(0.9); }
}
/* 琉璃卡片 */
.register-glass-card {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
position: relative;
z-index: 1;
}
.register-glass-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: 28px;
padding: 1px;
background: linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.01));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* 轨道 Logo */
.register-orbit-ring {
width: 76px; height: 76px;
border-radius: 50%;
border: 2px solid rgba(129, 140, 248, 0.2);
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
animation: regOrbitSpin 12s linear infinite;
}
.register-orbit-ring .register-planet {
width: 34px; height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
animation: regOrbitSpin 12s linear infinite reverse;
}
.register-orbit-ring .register-planet svg {
width: 18px; height: 18px; color: white;
}
.register-orbit-dot {
position: absolute;
width: 6px; height: 6px;
border-radius: 50%;
background: #818cf8;
top: -3px; left: 50%;
transform: translateX(-50%);
box-shadow: 0 0 10px rgba(129, 140, 248, 0.6);
}
@keyframes regOrbitSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 玻璃输入框 */
.register-glass-input-wrap {
position: relative;
margin-bottom: 16px;
}
.register-glass-input {
width: 100%;
padding: 14px 16px 14px 48px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
color: #f1f5f9;
font-size: 15px;
font-family: inherit;
outline: none;
transition: all 0.3s;
}
.register-glass-input::placeholder {
color: #64748b;
}
.register-glass-input:focus {
border-color: rgba(102, 126, 234, 0.5);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12), inset 0 0 20px rgba(102, 126, 234, 0.04);
}
.register-glass-input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
pointer-events: none;
transition: color 0.3s;
}
.register-glass-input:focus ~ .register-glass-input-icon {
color: #818cf8;
}
/* 验证码按钮 */
.register-verify-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
padding: 7px 14px;
border-radius: 10px;
border: 1px solid rgba(129, 140, 248, 0.3);
background: rgba(129, 140, 248, 0.1);
color: #818cf8;
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: all 0.3s;
white-space: nowrap;
}
.register-verify-btn:hover:not(:disabled) {
background: rgba(129, 140, 248, 0.2);
}
.register-verify-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 主按钮 */
.register-btn-primary {
width: 100%;
padding: 15px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 16px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
.register-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 28px rgba(102, 126, 234, 0.5);
}
.register-btn-primary:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.3);
}
.register-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register-btn-primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, transparent 50%);
pointer-events: none;
}
/* 社交登录行 */
.register-social-row {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
}
.register-social-btn {
width: 52px; height: 52px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
color: #94a3b8;
}
.register-social-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.register-social-btn svg {
width: 22px; height: 22px;
}
/* 密码强度条 */
.register-strength-bar {
display: flex;
gap: 4px;
margin-top: -8px;
margin-bottom: 16px;
}
.register-strength-bar span {
flex: 1;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
transition: all 0.3s;
}
.register-strength-bar.weak span:nth-child(1) { background: #f87171; }
.register-strength-bar.fair span:nth-child(-n+2) { background: #fbbf24; }
.register-strength-bar.strong span:nth-child(-n+3) { background: #34d399; }
.register-strength-bar.very-strong span { background: #818cf8; }
/* 协议 */
.register-agreement {
text-align: center;
font-size: 12px;
color: #64748b;
margin-top: 20px;
line-height: 1.6;
}
.register-agreement a {
color: #818cf8;
text-decoration: none;
}
.register-agreement a:hover {
text-decoration: underline;
}
/* 分割线 */
.register-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 24px 0;
color: #64748b;
font-size: 13px;
}
.register-divider::before,
.register-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
}
/* 自定义复选框 */
.register-checkbox {
width: 20px; height: 20px;
border-radius: 6px;
border: 1.5px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
flex-shrink: 0;
margin-top: 1px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.register-checkbox.checked {
background: linear-gradient(135deg, #667eea, #764ba2);
border-color: transparent;
}
.register-checkbox svg {
width: 12px; height: 12px;
color: white;
display: none;
}
.register-checkbox.checked svg {
display: block;
}
/* 错误提示 */
.register-error-msg {
color: #f87171;
font-size: 13px;
margin-top: -8px;
margin-bottom: 12px;
}
/* 动画 */
@keyframes regSlideUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.reg-animate-in {
animation: regSlideUp 0.5s ease forwards;
}
.reg-animate-delay {
animation: regSlideUp 0.5s ease 0.1s forwards;
opacity: 0;
}
.reg-animate-delay2 {
animation: regSlideUp 0.5s ease 0.2s forwards;
opacity: 0;
}
/* ============ 桌面端 ============ */
.register-desktop {
width: 460px;
}
/* ============ 移动端 ============ */
.register-mobile {
width: 100%;
max-width: 420px;
}
/* ============ 暗色模式适配(琉璃拟态在亮色模式下调整) ============ */
[data-theme="light"] .register-page {
background: #0f172a;
}
[data-theme="light"] .register-glass-card {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
[data-theme="light"] .register-glass-input {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
color: #f1f5f9;
}
[data-theme="light"] .register-glass-input::placeholder {
color: #64748b;
}
/* ============ 移动端适配 ============ */
@media (max-width: 480px) {
.register-desktop {
width: 100%;
max-width: 100%;
}
.register-glass-card {
border-radius: 24px;
}
.register-orb-1 { width: 200px; height: 200px; }
.register-orb-2 { width: 160px; height: 160px; }
.register-orb-3 { width: 120px; height: 120px; }
}
+399
View File
@@ -0,0 +1,399 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Spin, App as AntdApp } from "antd";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "@/app/store";
import { selectLoading, emailCodeLogin, sendEmailCode } from "@/features/login";
import { useSelector } from "react-redux";
import "./Register.css";
/** 从 URL 获取邀请码 */
const getInviteCodeFromUrl = (): string | null => {
const params = new URLSearchParams(window.location.search);
const inviteParam = params.get("invite");
if (inviteParam) return inviteParam;
const pathParts = window.location.pathname.split("/");
const inviteIndex = pathParts.indexOf("invite");
if (inviteIndex !== -1 && pathParts[inviteIndex + 1]) {
return pathParts[inviteIndex + 1];
}
return null;
};
/** 生成随机星星 */
const generateStars = (count: number) =>
Array.from({ length: count }, (_, i) => ({
id: i,
left: Math.random() * 100,
top: Math.random() * 100,
delay: Math.random() * 3,
duration: 2 + Math.random() * 3,
size: 1 + Math.random() * 2,
}));
const Register: React.FC = () => {
const { t } = useTranslation();
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const loading = useSelector(selectLoading);
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [codeSent, setCodeSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const stars = useMemo(() => generateStars(80), []);
const codeInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const inviteCode = getInviteCodeFromUrl();
if (inviteCode) {
localStorage.setItem("invite_code", inviteCode);
}
}, []);
/** 密码强度 */
const passwordStrength = useMemo(() => {
if (!password) return "";
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 1) return "weak";
if (score <= 2) return "fair";
if (score <= 3) return "strong";
return "very-strong";
}, [password]);
/** 发送验证码 */
const handleSendCode = async () => {
const newErrors: Record<string, string> = {};
if (!email) {
newErrors.email = t("register.warnEmailRequired", "请输入邮箱");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("register.warnEmailFormat", "邮箱格式不正确");
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
try {
const res = await dispatch(sendEmailCode({ to_email: email }));
if (res.meta.requestStatus === "fulfilled") {
setCodeSent(true);
setCountdown(60);
message.success(t("register.codeSent", "验证码已发送"));
setTimeout(() => codeInputRef.current?.focus(), 100);
const intervalId = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(intervalId);
return 0;
}
return prev - 1;
});
}, 1000);
}
} catch {
// error handled by redux
}
};
/** 提交注册 */
const handleSubmit = async () => {
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = t("register.warnEmailRequired", "请输入邮箱");
if (!code) newErrors.code = t("register.warnCodeRequired", "请输入验证码");
if (password && password !== confirmPassword) {
newErrors.confirmPassword = t("register.warnPasswordMismatch", "两次密码不一致");
}
if (password && password.length < 8) {
newErrors.password = t("register.warnPasswordLength", "密码至少8位");
}
if (!agreed) {
newErrors.agreed = t("register.warnAgreement", "请同意用户协议");
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
const inviteCode = localStorage.getItem("invite_code");
try {
const result = await dispatch(
emailCodeLogin({
to_email: email,
code,
invite_code: inviteCode || undefined,
})
);
if (result.meta.requestStatus === "fulfilled" && result.payload && "access" in result.payload) {
localStorage.removeItem("invite_code");
message.success(t("register.success", "注册成功"));
navigate("/");
}
} catch {
// error handled by redux
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !loading) {
handleSubmit();
}
};
return (
<div className="register-page" onKeyDown={handleKeyDown}>
{/* 星空背景 */}
<div className="register-stars">
{stars.map((s) => (
<div
key={s.id}
className="register-star"
style={{
left: `${s.left}%`,
top: `${s.top}%`,
animationDelay: `${s.delay}s`,
animationDuration: `${s.duration}s`,
width: `${s.size}px`,
height: `${s.size}px`,
}}
/>
))}
</div>
{/* 浮动光球 */}
<div className="register-orb register-orb-1" />
<div className="register-orb register-orb-2" />
<div className="register-orb register-orb-3" />
{/* 琉璃卡片 */}
<div className="register-glass-card register-desktop reg-animate-in" style={{ padding: "40px 32px" }}>
{/* 轨道 Logo */}
<div style={{ textAlign: "center", marginBottom: 28 }} className="reg-animate-in">
<div className="register-orbit-ring">
<div className="register-planet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div className="register-orbit-dot" />
</div>
</div>
<h2 style={{
textAlign: "center",
fontSize: 24,
fontWeight: 700,
marginBottom: 6,
color: "#f1f5f9",
lineHeight: 1.3,
}} className="reg-animate-in">
{t("register.title", "创建你的账号")}
</h2>
<p style={{
textAlign: "center",
fontSize: 14,
color: "#64748b",
marginBottom: 32,
}} className="reg-animate-delay">
{t("register.subtitle", "加入我们,开启全新体验")}
</p>
{/* 邮箱 */}
<div className="register-glass-input-wrap reg-animate-delay">
<input
type="email"
className="register-glass-input"
placeholder={t("register.emailPlaceholder", "邮箱地址")}
value={email}
onChange={(e) => { setEmail(e.target.value); setErrors((p) => ({ ...p, email: "" })); }}
style={{ paddingRight: codeSent ? 110 : 16 }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</span>
{codeSent && (
<button
className="register-verify-btn"
disabled={countdown > 0}
onClick={handleSendCode}
>
{countdown > 0 ? `${countdown}s` : t("register.resend", "重新发送")}
</button>
)}
</div>
{errors.email && <div className="register-error-msg">{errors.email}</div>}
{/* 验证码 */}
<div className="register-glass-input-wrap reg-animate-delay">
<input
ref={codeInputRef}
type="text"
className="register-glass-input"
placeholder={t("register.codePlaceholder", "验证码")}
value={code}
onChange={(e) => { setCode(e.target.value); setErrors((p) => ({ ...p, code: "" })); }}
maxLength={8}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
{!codeSent && (
<button
className="register-verify-btn"
disabled={!email || loading}
onClick={handleSendCode}
>
{t("register.sendCode", "获取验证码")}
</button>
)}
</div>
{errors.code && <div className="register-error-msg">{errors.code}</div>}
{/* 用户名(选填) */}
<div className="register-glass-input-wrap reg-animate-delay2">
<input
type="text"
className="register-glass-input"
placeholder={t("register.usernameOptional", "用户名(选填)")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
</div>
{/* 密码 */}
<div className="register-glass-input-wrap reg-animate-delay2">
<input
type="password"
className="register-glass-input"
placeholder={t("register.passwordPlaceholder", "设置密码(选填,至少8位)")}
value={password}
onChange={(e) => { setPassword(e.target.value); setErrors((p) => ({ ...p, password: "" })); }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
</div>
{password && (
<div className={`register-strength-bar ${passwordStrength}`}>
<span /><span /><span /><span />
</div>
)}
{errors.password && <div className="register-error-msg">{errors.password}</div>}
{/* 确认密码 */}
{password && (
<div className="register-glass-input-wrap" style={{ animation: "regSlideUp 0.3s ease forwards" }}>
<input
type="password"
className="register-glass-input"
placeholder={t("register.confirmPasswordPlaceholder", "确认密码")}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setErrors((p) => ({ ...p, confirmPassword: "" })); }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
</div>
)}
{errors.confirmPassword && <div className="register-error-msg">{errors.confirmPassword}</div>}
{/* 协议复选框 */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, margin: "20px 0" }} className="reg-animate-delay2">
<div
className={`register-checkbox ${agreed ? "checked" : ""}`}
onClick={() => { setAgreed(!agreed); setErrors((p) => ({ ...p, agreed: "" })); }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.5 }}>
{t("register.agreementPrefix", "我已阅读并同意")}
<a href="/agreement" style={{ color: "#818cf8", textDecoration: "none" }} onClick={(e) => e.stopPropagation()}>
{t("register.userAgreement", "用户协议")}
</a>
{t("register.and", "和")}
<a href="/privacy" style={{ color: "#818cf8", textDecoration: "none" }} onClick={(e) => e.stopPropagation()}>
{t("register.privacyPolicy", "隐私政策")}
</a>
</div>
</div>
{errors.agreed && <div className="register-error-msg" style={{ marginTop: -12 }}>{errors.agreed}</div>}
{/* 注册按钮 */}
<button
className="register-btn-primary reg-animate-delay2"
disabled={loading}
onClick={handleSubmit}
>
{loading ? t("register.submitting", "注册中...") : t("register.submit", "创建账号")}
</button>
{/* 分割线 */}
<div className="register-divider reg-animate-delay2">
{t("register.orDivider", "或")}
</div>
{/* 社交登录 */}
<div className="register-social-row reg-animate-delay2">
<div className="register-social-btn" onClick={() => message.info(t("loginModal.qqNotAvailable", "QQ登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-8h2v8zm-4-8h-2V7h2v2z" /></svg>
</div>
<div className="register-social-btn" onClick={() => message.info(t("loginModal.wechatNotAvailable", "微信登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-8h2v8zm-4-8h-2V7h2v2z" /></svg>
</div>
<div className="register-social-btn" onClick={() => message.info(t("loginModal.weiboNotAvailable", "微博登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>
</div>
</div>
{/* 已有账号 */}
<div className="register-agreement reg-animate-delay2">
{t("register.hasAccount", "已有账号?")}
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate("/"); }}
style={{ fontWeight: 500 }}
>
{t("register.goLogin", "立即登录")}
</a>
</div>
</div>
<Spin spinning={loading} percent="auto" fullscreen />
</div>
);
};
export default Register;
+403
View File
@@ -0,0 +1,403 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Spin, App as AntdApp } from "antd";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "@/app/store";
import { selectLoading, emailCodeLogin, sendEmailCode } from "@/features/login";
import { useSelector } from "react-redux";
import "./Register.css";
/** 从 URL 获取邀请码 */
const getInviteCodeFromUrl = (): string | null => {
const params = new URLSearchParams(window.location.search);
const inviteParam = params.get("invite");
if (inviteParam) return inviteParam;
const pathParts = window.location.pathname.split("/");
const inviteIndex = pathParts.indexOf("invite");
if (inviteIndex !== -1 && pathParts[inviteIndex + 1]) {
return pathParts[inviteIndex + 1];
}
return null;
};
/** 生成随机星星 */
const generateStars = (count: number) =>
Array.from({ length: count }, (_, i) => ({
id: i,
left: Math.random() * 100,
top: Math.random() * 100,
delay: Math.random() * 3,
duration: 2 + Math.random() * 3,
size: 1 + Math.random() * 2,
}));
const RegisterMobile: React.FC = () => {
const { t } = useTranslation();
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const loading = useSelector(selectLoading);
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [codeSent, setCodeSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const stars = useMemo(() => generateStars(50), []);
const codeInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const inviteCode = getInviteCodeFromUrl();
if (inviteCode) {
localStorage.setItem("invite_code", inviteCode);
}
}, []);
/** 密码强度 */
const passwordStrength = useMemo(() => {
if (!password) return "";
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 1) return "weak";
if (score <= 2) return "fair";
if (score <= 3) return "strong";
return "very-strong";
}, [password]);
/** 发送验证码 */
const handleSendCode = async () => {
const newErrors: Record<string, string> = {};
if (!email) {
newErrors.email = t("register.warnEmailRequired", "请输入邮箱");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("register.warnEmailFormat", "邮箱格式不正确");
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
try {
const res = await dispatch(sendEmailCode({ to_email: email }));
if (res.meta.requestStatus === "fulfilled") {
setCodeSent(true);
setCountdown(60);
message.success(t("register.codeSent", "验证码已发送"));
setTimeout(() => codeInputRef.current?.focus(), 100);
const intervalId = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(intervalId);
return 0;
}
return prev - 1;
});
}, 1000);
}
} catch {
// error handled by redux
}
};
/** 提交注册 */
const handleSubmit = async () => {
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = t("register.warnEmailRequired", "请输入邮箱");
if (!code) newErrors.code = t("register.warnCodeRequired", "请输入验证码");
if (password && password !== confirmPassword) {
newErrors.confirmPassword = t("register.warnPasswordMismatch", "两次密码不一致");
}
if (password && password.length < 8) {
newErrors.password = t("register.warnPasswordLength", "密码至少8位");
}
if (!agreed) {
newErrors.agreed = t("register.warnAgreement", "请同意用户协议");
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
const inviteCode = localStorage.getItem("invite_code");
try {
const result = await dispatch(
emailCodeLogin({
to_email: email,
code,
invite_code: inviteCode || undefined,
})
);
if (result.meta.requestStatus === "fulfilled" && result.payload && "access" in result.payload) {
localStorage.removeItem("invite_code");
message.success(t("register.success", "注册成功"));
navigate("/");
}
} catch {
// error handled by redux
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !loading) {
handleSubmit();
}
};
return (
<div className="register-page" onKeyDown={handleKeyDown}>
{/* 星空背景 */}
<div className="register-stars">
{stars.map((s) => (
<div
key={s.id}
className="register-star"
style={{
left: `${s.left}%`,
top: `${s.top}%`,
animationDelay: `${s.delay}s`,
animationDuration: `${s.duration}s`,
width: `${s.size}px`,
height: `${s.size}px`,
}}
/>
))}
</div>
{/* 浮动光球 */}
<div className="register-orb register-orb-1" />
<div className="register-orb register-orb-2" />
{/* 琉璃卡片 - 移动端 */}
<div className="register-glass-card register-mobile reg-animate-in" style={{ padding: "32px 24px" }}>
{/* 轨道 Logo */}
<div style={{ textAlign: "center", marginBottom: 24 }} className="reg-animate-in">
<div className="register-orbit-ring">
<div className="register-planet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div className="register-orbit-dot" />
</div>
</div>
<h2 style={{
textAlign: "center",
fontSize: 22,
fontWeight: 700,
marginBottom: 6,
color: "#f1f5f9",
lineHeight: 1.3,
}} className="reg-animate-in">
{t("register.title", "创建你的账号")}
</h2>
<p style={{
textAlign: "center",
fontSize: 13,
color: "#64748b",
marginBottom: 28,
}} className="reg-animate-delay">
{t("register.subtitle", "加入我们,开启全新体验")}
</p>
{/* 邮箱 */}
<div className="register-glass-input-wrap reg-animate-delay">
<input
type="email"
className="register-glass-input"
placeholder={t("register.emailPlaceholder", "邮箱地址")}
value={email}
onChange={(e) => { setEmail(e.target.value); setErrors((p) => ({ ...p, email: "" })); }}
style={{ paddingRight: codeSent ? 100 : 16 }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</span>
{codeSent && (
<button
className="register-verify-btn"
disabled={countdown > 0}
onClick={handleSendCode}
style={{ fontSize: 12, padding: "6px 10px" }}
>
{countdown > 0 ? `${countdown}s` : t("register.resend", "重发")}
</button>
)}
</div>
{errors.email && <div className="register-error-msg">{errors.email}</div>}
{/* 验证码 */}
<div className="register-glass-input-wrap reg-animate-delay">
<input
ref={codeInputRef}
type="text"
className="register-glass-input"
placeholder={t("register.codePlaceholder", "验证码")}
value={code}
onChange={(e) => { setCode(e.target.value); setErrors((p) => ({ ...p, code: "" })); }}
maxLength={8}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
{!codeSent && (
<button
className="register-verify-btn"
disabled={!email || loading}
onClick={handleSendCode}
style={{ fontSize: 12, padding: "6px 10px" }}
>
{t("register.sendCode", "获取验证码")}
</button>
)}
</div>
{errors.code && <div className="register-error-msg">{errors.code}</div>}
{/* 用户名(选填) */}
<div className="register-glass-input-wrap reg-animate-delay2">
<input
type="text"
className="register-glass-input"
placeholder={t("register.usernameOptional", "用户名(选填)")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
</div>
{/* 密码 */}
<div className="register-glass-input-wrap reg-animate-delay2">
<input
type="password"
className="register-glass-input"
placeholder={t("register.passwordPlaceholder", "设置密码(选填,至少8位)")}
value={password}
onChange={(e) => { setPassword(e.target.value); setErrors((p) => ({ ...p, password: "" })); }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
</div>
{password && (
<div className={`register-strength-bar ${passwordStrength}`}>
<span /><span /><span /><span />
</div>
)}
{errors.password && <div className="register-error-msg">{errors.password}</div>}
{/* 确认密码 */}
{password && (
<div className="register-glass-input-wrap" style={{ animation: "regSlideUp 0.3s ease forwards" }}>
<input
type="password"
className="register-glass-input"
placeholder={t("register.confirmPasswordPlaceholder", "确认密码")}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setErrors((p) => ({ ...p, confirmPassword: "" })); }}
/>
<span className="register-glass-input-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
</div>
)}
{errors.confirmPassword && <div className="register-error-msg">{errors.confirmPassword}</div>}
{/* 协议复选框 */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, margin: "18px 0" }} className="reg-animate-delay2">
<div
className={`register-checkbox ${agreed ? "checked" : ""}`}
onClick={() => { setAgreed(!agreed); setErrors((p) => ({ ...p, agreed: "" })); }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div style={{ fontSize: 12, color: "#64748b", lineHeight: 1.5 }}>
{t("register.agreementPrefix", "我已阅读并同意")}
<a href="/agreement" style={{ color: "#818cf8", textDecoration: "none" }} onClick={(e) => e.stopPropagation()}>
{t("register.userAgreement", "用户协议")}
</a>
{t("register.and", "和")}
<a href="/privacy" style={{ color: "#818cf8", textDecoration: "none" }} onClick={(e) => e.stopPropagation()}>
{t("register.privacyPolicy", "隐私政策")}
</a>
</div>
</div>
{errors.agreed && <div className="register-error-msg" style={{ marginTop: -12 }}>{errors.agreed}</div>}
{/* 注册按钮 */}
<button
className="register-btn-primary reg-animate-delay2"
disabled={loading}
onClick={handleSubmit}
>
{loading ? t("register.submitting", "注册中...") : t("register.submit", "创建账号")}
</button>
{/* 分割线 */}
<div className="register-divider reg-animate-delay2">
{t("register.orDivider", "或")}
</div>
{/* 社交登录 */}
<div className="register-social-row reg-animate-delay2">
<div className="register-social-btn" style={{ width: 48, height: 48, borderRadius: 14 }}
onClick={() => message.info(t("loginModal.qqNotAvailable", "QQ登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-8h2v8zm-4-8h-2V7h2v2z" /></svg>
</div>
<div className="register-social-btn" style={{ width: 48, height: 48, borderRadius: 14 }}
onClick={() => message.info(t("loginModal.wechatNotAvailable", "微信登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-8h2v8zm-4-8h-2V7h2v2z" /></svg>
</div>
<div className="register-social-btn" style={{ width: 48, height: 48, borderRadius: 14 }}
onClick={() => message.info(t("loginModal.weiboNotAvailable", "微博登录暂未开放"))}>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>
</div>
</div>
{/* 已有账号 */}
<div className="register-agreement reg-animate-delay2" style={{ fontSize: 13 }}>
{t("register.hasAccount", "已有账号?")}
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate("/"); }}
style={{ fontWeight: 500 }}
>
{t("register.goLogin", "立即登录")}
</a>
</div>
</div>
<Spin spinning={loading} percent="auto" fullscreen />
</div>
);
};
export default RegisterMobile;