From 65ce2664eea5361cd443b4b2534821a69a3d67e8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 12:20:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(website):=20=E7=8B=AC=E7=AB=8B=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E9=A1=B5=20/register(=E9=82=AE=E7=AE=B1/=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E9=A2=84=E5=A1=AB+=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E6=B3=A8=E5=86=8C+=E5=B9=B3=E5=8F=B0=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E4=B8=8B=E8=BD=BD)+=20=E4=B8=BB=E9=A1=B5/=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=A1=B5=E8=B7=B3=E8=BD=AC=E6=8E=A5=E5=85=A5=20+=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81=E9=9A=90=E8=97=8F=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu --- web/website/public/_headers | 7 +- .../src/components/AnnouncementBar.jsx | 17 +- web/website/src/components/Header.jsx | 20 +- web/website/src/components/Hero.astro | 2 +- web/website/src/components/InviteCard.jsx | 34 +- web/website/src/components/RegisterCard.jsx | 443 ++++++++++++++++++ web/website/src/components/SignupForm.jsx | 50 +- web/website/src/config/site.ts | 7 + web/website/src/lib/authState.js | 42 ++ web/website/src/lib/inviteCode.js | 16 + web/website/src/pages/register.astro | 70 +++ web/website/src/styles/site-extra.css | 124 +++++ 12 files changed, 789 insertions(+), 43 deletions(-) create mode 100644 web/website/src/components/RegisterCard.jsx create mode 100644 web/website/src/lib/authState.js create mode 100644 web/website/src/lib/inviteCode.js create mode 100644 web/website/src/pages/register.astro diff --git a/web/website/public/_headers b/web/website/public/_headers index 48f0400..4ea85ee 100644 --- a/web/website/public/_headers +++ b/web/website/public/_headers @@ -1,9 +1,12 @@ # Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。 # 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。 -# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'。 +# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故大部分 *-src 收敛到 'self'; +# 唯一例外 connect-src 放行 api.yanmeiai.com(控制面 API,Cloudflare Tunnel 出口, +# CORS 已放行本站 origin)——落地页/注册页/首页注册入口浏览器端直连该域发验证码、 +# 注册(见 src/components/RegisterCard.jsx),不经站内代理。 /* - Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.yanmeiai.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Content-Type-Options: nosniff X-Frame-Options: DENY diff --git a/web/website/src/components/AnnouncementBar.jsx b/web/website/src/components/AnnouncementBar.jsx index aa2cde2..8a19c3b 100644 --- a/web/website/src/components/AnnouncementBar.jsx +++ b/web/website/src/components/AnnouncementBar.jsx @@ -1,18 +1,29 @@ /** * AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。 + * + * CTA 直接跳独立注册页 /register(不再锚点滚动到 hero 的 #signup —— 那边现在本身也只是 + * 跳 /register 的一层转发,直达更省一跳)。已登录(见 lib/authState.js)时整条隐藏: + * 老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。 */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Gift, X } from 'lucide-react'; +import { isLoggedIn } from '../lib/authState'; export default function AnnouncementBar({ text, cta }) { const [closed, setClosed] = useState(false); - if (closed) return null; + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + setLoggedIn(isLoggedIn()); + }, []); + + if (closed || loggedIn) return null; return (
{text} - {cta} + {cta} diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index d7ecbd0..4134fc5 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react'; import { Download, Menu } from 'lucide-react'; import { SITE } from '../config/site'; +import { isLoggedIn, getStoredEmail, clearStoredSession } from '../lib/authState'; function Mark() { return ( @@ -42,24 +43,15 @@ export default function Header({ lang = 'zh', t = {} }) { const displayName = (email && email.split('@')[0]) || 'Account'; // 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。 + // 判断逻辑单一实现见 lib/authState.js(AnnouncementBar/SignupForm/RegisterCard/InviteCard 同源复用)。 useEffect(() => { - try { - setLoggedIn(!!localStorage.getItem('pg_uc_refresh')); - setEmail(localStorage.getItem('pg_uc_email') || ''); - } catch { - setLoggedIn(false); - } + setLoggedIn(isLoggedIn()); + setEmail(getStoredEmail()); }, []); // 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。 - const clearSession = () => { - try { - localStorage.removeItem('pg_uc_refresh'); - localStorage.removeItem('pg_uc_email'); - } catch { /* ignore */ } - }; - const onSwitch = () => { clearSession(); window.location.href = loginHref; }; - const onLogout = () => { clearSession(); window.location.href = '/'; }; + const onSwitch = () => { clearStoredSession(); window.location.href = loginHref; }; + const onLogout = () => { clearStoredSession(); window.location.href = '/'; }; useEffect(() => { if (!langOpen) return; diff --git a/web/website/src/components/Hero.astro b/web/website/src/components/Hero.astro index 42a0651..0dc6026 100644 --- a/web/website/src/components/Hero.astro +++ b/web/website/src/components/Hero.astro @@ -17,9 +17,9 @@ const { t } = Astro.props; client:idle ph={t('su.ph')} btn={t('su.btn')} - ok={t('su.ok')} hint={t('su.hint')} dl={t('su.dl')} + centerLabel={t('menu.center')} />
diff --git a/web/website/src/components/InviteCard.jsx b/web/website/src/components/InviteCard.jsx index d1d6755..7d08ed3 100644 --- a/web/website/src/components/InviteCard.jsx +++ b/web/website/src/components/InviteCard.jsx @@ -9,13 +9,19 @@ * 开关,此前只有原型用过、构建期路由化后闲置)做「单显 + 切换」:任意时刻只渲染 * 一种语言的文本,不并排堆叠,语义上与全站单显原则一致,只是切换时机在运行时。 * Header/Footer 仍按全站惯例整页单显中文(面向主力市场:中国大陆用户)。 + * + * 主 CTA 是「注册领取 3 天」按钮,跳去独立注册页 /register(?invite=<码> 预填,见 + * RegisterCard.jsx),本页不再内联注册表单。已登录(同源 localStorage 里有 + * pg_uc_refresh,见 lib/authState.js)时把 CTA 换成提示文案——邀请码只在新账号 + * 注册时生效,老账号无法补绑。 */ import { useEffect, useMemo, useState } from 'react'; import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react'; +import { isLoggedIn } from '../lib/authState'; +import { INVITE_CODE_RE } from '../lib/inviteCode'; -// 邀请码字母表:A-Z 去 O/I,2-9 去 0/1(与 server/internal/reward/service.go -// 的 inviteAlphabet 一致),固定 8 位。仅做「像不像邀请码」的格式校验,不联网核验。 -const CODE_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/; +// 邀请码字母表定义单一实现在 lib/inviteCode.js(与 RegisterCard.jsx 的 ?invite= +// 解析共用,避免两处定义漂移)。 function extractCode(pathname) { const m = /\/i\/([^/]+)/i.exec(pathname || ''); @@ -27,7 +33,7 @@ function extractCode(pathname) { raw = m[1]; } raw = raw.trim().toUpperCase(); - return CODE_RE.test(raw) ? raw : null; + return INVITE_CODE_RE.test(raw) ? raw : null; } const COPY = { @@ -47,6 +53,8 @@ const COPY = { step2: '注册时在「邀请码(选填)」填入此码', step3: '双方到账,天数自动加到账户', dl: '免费下载', + ctaRegister: '注册领取 3 天', + ctaLoggedIn: '你已登录,邀请码仅新账号注册时可用', }, en: { eyebrow: 'Invite', @@ -64,6 +72,8 @@ const COPY = { step2: 'Enter this code under “Invite code (optional)” at sign-up', step3: 'Days are credited to both accounts automatically', dl: 'Download free', + ctaRegister: 'Register & claim +3 days', + ctaLoggedIn: "You're already signed in — invite codes only apply at sign-up", }, }; @@ -71,9 +81,11 @@ export default function InviteCard() { const [code, setCode] = useState(null); const [lang, setLang] = useState('zh'); const [copied, setCopied] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); useEffect(() => { setCode(extractCode(window.location.pathname)); + setLoggedIn(isLoggedIn()); }, []); useEffect(() => { @@ -82,6 +94,7 @@ export default function InviteCard() { const t = COPY[lang]; const dlHref = useMemo(() => `${lang === 'zh' ? '/zh/' : '/'}#download`, [lang]); + const registerHref = useMemo(() => (code ? `/register?invite=${code}` : '/register'), [code]); async function onCopy() { if (!code) return; @@ -126,6 +139,16 @@ export default function InviteCard() { )} + {loggedIn ? ( +
{t.ctaLoggedIn}
+ ) : ( + + + {t.ctaRegister} + + + )} +
@@ -161,10 +184,9 @@ export default function InviteCard() {
- + {t.dl} -
diff --git a/web/website/src/components/RegisterCard.jsx b/web/website/src/components/RegisterCard.jsx new file mode 100644 index 0000000..ee2fd17 --- /dev/null +++ b/web/website/src/components/RegisterCard.jsx @@ -0,0 +1,443 @@ +/** + * RegisterCard.jsx — 独立注册页 `/register` 正文(React island)。 + * + * 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页 + * `/i/`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」 + * 注册流程,行为与后端契约见 server/internal/auth/handler.go: + * + * POST /v1/auth/code body {email} → 204 + * POST /v1/auth/register body {email,code,password,invite_code?} + * (device 整个省略——web 无设备指纹,后端零值容忍) + * → 200 {access_token,refresh_token,...}(网页不存,注册成功即弃) + * + * 错误统一 {code,message_zh,message_en},按当前语言态取对应文案;网络错用通用双语兜底。 + * + * 双语处理与 InviteCard.jsx 同一路数:不走全站 i18n/strings.ts 的「构建期按路由单显」 + * (那个模式要求语言在构建期已知),而是本组件内嵌 zh/en 两份文案、用 `.langseg` + * 单显 + 运行时切换 —— 任意时刻只渲染一种语言。 + * + * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)直接短路成 + * 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。 + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Mail, KeyRound, Lock, Loader2, AlertCircle, CheckCircle2, Download, ArrowRight } from 'lucide-react'; +import { SITE } from '../config/site'; +import { isLoggedIn } from '../lib/authState'; +import { normalizeInviteCode } from '../lib/inviteCode'; + +const API_BASE = import.meta.env.PUBLIC_API_BASE || SITE.api; +const RESEND_SECS = 60; +const REDIRECT_SECS = 3; + +const COPY = { + zh: { + eyebrow: '注册', + titleInvite: '注册穿山甲,双方各得 3 天', + titlePlain: '创建你的穿山甲账户', + sub: '一个邮箱即可开始,免费注册,无需任何付款信息。', + emailLabel: '邮箱', + emailPh: '你的邮箱', + sendCode: '发送验证码', + resend: '重新发送', + resendIn: (n) => `${n}s 后可重发`, + sending: '发送中…', + codeLabel: '验证码', + codePh: '6 位验证码', + pwLabel: '设置密码', + pwPh: '至少 8 位', + pwHint: '密码至少 8 位', + submitInvite: '注册并领取 3 天', + submitPlain: '注册', + submitting: '注册中…', + netErr: '网络异常,请检查网络后重试', + emailInvalid: '请输入正确的邮箱地址', + formIncomplete: '请填写完整的验证码与密码(密码至少 8 位)', + successTitle: '注册成功', + successSubInvite: '3 天已到账,用刚注册的邮箱登录穿山甲即可查收。', + successSubPlain: '请下载穿山甲,用刚注册的邮箱登录。', + dlMac: '下载 macOS 版', + dlWin: '下载 Windows 版', + dlAndroid: '下载 Android 版', + dlGeneric: '前往下载', + backHome: '返回首页', + redirecting: (n) => `${n} 秒后自动返回首页`, + loggedInTitle: '你已登录', + loggedInSub: '无需重复注册,直接返回首页或前往用户中心即可。', + loggedInHome: '返回首页', + loggedInCenter: '打开用户中心', + }, + en: { + eyebrow: 'Register', + titleInvite: 'Sign up — you and your friend each get +3 days', + titlePlain: 'Create your Pangolin account', + sub: 'Just an email to start. Free to join, no payment details.', + emailLabel: 'Email', + emailPh: 'Your email', + sendCode: 'Send code', + resend: 'Resend', + resendIn: (n) => `Resend in ${n}s`, + sending: 'Sending…', + codeLabel: 'Verification code', + codePh: '6-digit code', + pwLabel: 'Set a password', + pwPh: 'At least 8 characters', + pwHint: 'Password must be at least 8 characters', + submitInvite: 'Register & claim +3 days', + submitPlain: 'Register', + submitting: 'Registering…', + netErr: 'Network error, please check your connection and retry', + emailInvalid: 'Please enter a valid email address', + formIncomplete: 'Please fill in the code and a password (at least 8 characters)', + successTitle: 'Registration complete', + successSubInvite: 'Your +3 days are credited. Sign in to the app with this email to see them.', + successSubPlain: 'Download Pangolin and sign in with this email.', + dlMac: 'Download for macOS', + dlWin: 'Download for Windows', + dlAndroid: 'Download for Android', + dlGeneric: 'Go to downloads', + backHome: 'Back to home', + redirecting: (n) => `Returning to home in ${n}s`, + loggedInTitle: "You're already signed in", + loggedInSub: 'No need to register again — head back home or open the account center.', + loggedInHome: 'Back to home', + loggedInCenter: 'Open account center', + }, +}; + +function detectPlatform() { + if (typeof navigator === 'undefined') return 'unknown'; + const ua = navigator.userAgent || ''; + const platform = navigator.platform || ''; + // iPadOS 13+ 把 UA 伪装成 Mac,靠触点数区分(真 Mac 无多点触控)。 + const isIOS = /iPhone|iPad|iPod/.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1); + if (isIOS) return 'ios'; + if (/Android/.test(ua)) return 'android'; + if (/Win(dows)?|Win32|Win64/.test(ua) || /Win32|Win64/.test(platform)) return 'windows'; + if (/Mac/.test(platform) || /Macintosh/.test(ua)) return 'macos'; + return 'unknown'; +} + +async function callApi(path, body) { + let res; + try { + res = await fetch(API_BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + return { ok: false, network: true }; + } + let data = null; + try { + const text = await res.text(); + if (text) data = JSON.parse(text); + } catch { + data = null; + } + if (!res.ok) return { ok: false, network: false, error: data }; + return { ok: true, data }; +} + +export default function RegisterCard() { + const [lang, setLang] = useState('zh'); + const [loggedIn, setLoggedIn] = useState(false); + const [checkedAuth, setCheckedAuth] = useState(false); + + const [email, setEmail] = useState(''); + const [invite, setInvite] = useState(null); // 规范化后的邀请码,或 null + + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); + const [pwTouched, setPwTouched] = useState(false); + + const [codeSent, setCodeSent] = useState(false); + const [sendingCode, setSendingCode] = useState(false); + const [codeErr, setCodeErr] = useState(''); + const [cooldown, setCooldown] = useState(0); + const cooldownRef = useRef(null); + + const [submitting, setSubmitting] = useState(false); + const [regErr, setRegErr] = useState(''); + const [success, setSuccess] = useState(false); + const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS); + const redirectRef = useRef(null); + const codeInputRef = useRef(null); + + const t = COPY[lang]; + const msgKey = lang === 'zh' ? 'message_zh' : 'message_en'; + const homeHref = lang === 'zh' ? '/zh/' : '/'; + const dlAnchorHref = `${homeHref}#download`; + + // 读 query 参数(?email=&invite=) + 登录态;均只在挂载时读一次。 + useEffect(() => { + try { + const qs = new URLSearchParams(window.location.search); + const qEmail = qs.get('email'); + if (qEmail) setEmail(qEmail); + setInvite(normalizeInviteCode(qs.get('invite'))); + } catch { + /* ignore malformed query */ + } + document.documentElement.lang = 'zh-CN'; + setLoggedIn(isLoggedIn()); + setCheckedAuth(true); + }, []); + + useEffect(() => { + document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; + }, [lang]); + + useEffect(() => () => clearInterval(cooldownRef.current), []); + useEffect(() => () => clearInterval(redirectRef.current), []); + + useEffect(() => { + if (codeSent && codeInputRef.current) codeInputRef.current.focus(); + }, [codeSent]); + + // 成功后自动倒计时返回首页(点「返回首页」等价立即触发)。 + useEffect(() => { + if (!success) return undefined; + setRedirectIn(REDIRECT_SECS); + redirectRef.current = setInterval(() => { + setRedirectIn((s) => { + if (s <= 1) { + clearInterval(redirectRef.current); + window.location.href = homeHref; + return 0; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(redirectRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [success]); + + function startCooldown() { + setCooldown(RESEND_SECS); + clearInterval(cooldownRef.current); + cooldownRef.current = setInterval(() => { + setCooldown((s) => { + if (s <= 1) { + clearInterval(cooldownRef.current); + return 0; + } + return s - 1; + }); + }, 1000); + } + + async function onSendCode(e) { + e.preventDefault(); + setCodeErr(''); + const trimmed = email.trim(); + if (!/\S+@\S+\.\S+/.test(trimmed)) { + setCodeErr(t.emailInvalid); + return; + } + setSendingCode(true); + const r = await callApi('/v1/auth/code', { email: trimmed }); + setSendingCode(false); + if (!r.ok) { + setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr); + return; + } + setCodeSent(true); + startCooldown(); + } + + async function onRegister(e) { + e.preventDefault(); + setRegErr(''); + const trimmedEmail = email.trim(); + if (!/\S+@\S+\.\S+/.test(trimmedEmail) || code.length !== 6 || password.length < 8) { + setPwTouched(true); + setRegErr(t.formIncomplete); + return; + } + setSubmitting(true); + const body = { email: trimmedEmail, code, password }; + if (invite) body.invite_code = invite; + const r = await callApi('/v1/auth/register', body); + setSubmitting(false); + if (!r.ok) { + setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr); + return; + } + setSuccess(true); + } + + const plat = useMemo(() => detectPlatform(), []); + const dl = useMemo(() => { + if (plat === 'android') return { href: SITE.downloads.android, label: t.dlAndroid }; + if (plat === 'windows') return { href: SITE.downloads.windows, label: t.dlWin }; + if (plat === 'macos') return { href: SITE.downloads.macos, label: t.dlMac }; + return { href: dlAnchorHref, label: t.dlGeneric }; + }, [plat, t, dlAnchorHref]); + + const pwInvalid = pwTouched && password.length > 0 && password.length < 8; + const canSubmit = /\S+@\S+\.\S+/.test(email.trim()) && code.length === 6 && password.length >= 8 && !submitting; + + const LangSeg = ( +
+ + +
+ ); + + // 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染, + // 与「无 JS 时默认展示注册表单」的渐进增强保持一致。 + if (checkedAuth && loggedIn) { + return ( +
+
+
+ {LangSeg} +
+ +

{t.loggedInTitle}

+

{t.loggedInSub}

+ +
+
+
+
+ ); + } + + if (success) { + return ( +
+
+
+ {LangSeg} +
+ +

{t.successTitle}

+

{invite ? t.successSubInvite : t.successSubPlain}

+ + + {dl.label} + + + {t.backHome} +
{t.redirecting(redirectIn)}
+
+
+
+
+ ); + } + + return ( +
+
+
+ {LangSeg} + +
{t.eyebrow}
+

{invite ? t.titleInvite : t.titlePlain}

+

{t.sub}

+ +
+
+
{t.emailLabel}
+
+
+ + setEmail(e.currentTarget.value)} + placeholder={t.emailPh} + autoComplete="email" + /> +
+ {!codeSent && ( + + )} +
+
+ + {codeErr && ( +
+ + {codeErr} +
+ )} + + {codeSent && ( + <> +
+
{t.codeLabel}
+
+
+ + setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))} + placeholder={t.codePh} + /> +
+ +
+
+ +
+
{t.pwLabel}
+
+ + setPassword(e.currentTarget.value)} + onBlur={() => setPwTouched(true)} + placeholder={t.pwPh} + autoComplete="new-password" + /> +
+ {pwInvalid &&
{t.pwHint}
} +
+ + {regErr && ( +
+ + {regErr} +
+ )} + + + + )} +
+
+
+
+ ); +} diff --git a/web/website/src/components/SignupForm.jsx b/web/website/src/components/SignupForm.jsx index 3e0b6d2..a7107fb 100644 --- a/web/website/src/components/SignupForm.jsx +++ b/web/website/src/components/SignupForm.jsx @@ -1,38 +1,54 @@ /** - * SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。 - * 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。 + * SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。 + * + * 不在这里发验证码 / 建账户:提交只是把邮箱透传给独立注册页 /register(?email= 预填), + * 真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx 承接(见该组件顶部注释)。 + * 空邮箱也允许跳转——/register 页留空即可,用户到那边再填。 + * + * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时,表单换成 + * 「打开用户中心」按钮:老用户不需要再看到注册入口。无 JS 时默认展示表单(渐进增强)。 */ -import { useState } from 'react'; -import { Mail, CheckCircle, Download } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Mail, Download } from 'lucide-react'; +import { SITE } from '../config/site'; +import { isLoggedIn } from '../lib/authState'; -export default function SignupForm({ ph, btn, ok, hint, dl }) { - const [done, setDone] = useState(false); +export default function SignupForm({ ph, btn, hint, dl, centerLabel }) { + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + setLoggedIn(isLoggedIn()); + }, []); const onSubmit = (e) => { e.preventDefault(); - const email = e.currentTarget.elements['su-email']; - if (!/\S+@\S+\.\S+/.test(email.value)) { - email.focus(); - return; - } - setDone(true); + const emailEl = e.currentTarget.elements['su-email']; + const email = (emailEl && emailEl.value ? emailEl.value : '').trim(); + const qs = email ? `?email=${encodeURIComponent(email)}` : ''; + window.location.href = `/register${qs}`; }; + if (loggedIn) { + return ( + + ); + } + return (