feat(website): 独立注册页 /register(邮箱/邀请码预填+验证码注册+平台匹配下载)+ 主页/落地页跳转接入 + 登录态隐藏注册入口
ci-pangolin / Lint — shellcheck (pull_request) Successful in 13s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 23s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 34s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m34s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 12:20:32 +08:00
parent 254db4f40d
commit 65ce2664ee
12 changed files with 789 additions and 43 deletions
+5 -2
View File
@@ -1,9 +1,12 @@
# Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。
# 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。
# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'
# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故大部分 *-src 收敛到 'self'
# 唯一例外 connect-src 放行 api.yanmeiai.com(控制面 APICloudflare 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
+14 -3
View File
@@ -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 (
<div class="ann" id="ann">
<div class="wrap ann-in">
<Gift />
<span>{text}</span>
<a href="#signup">{cta}</a>
<a href="/register">{cta}</a>
<button class="ann-x" onClick={() => setClosed(true)} aria-label="close">
<X />
</button>
+6 -14
View File
@@ -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.jsAnnouncementBar/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;
+1 -1
View File
@@ -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')}
/>
<div class="trust">
+28 -6
View File
@@ -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/I2-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() {
</button>
)}
{loggedIn ? (
<div class="invite-loggedin-note">{t.ctaLoggedIn}</div>
) : (
<a class="btn btn-primary btn-lg invite-register-btn" href={registerHref}>
<UserPlus />
<span>{t.ctaRegister}</span>
<ArrowRight />
</a>
)}
<div class="invite-rewards">
<div class="invite-rewards-title">
<Gift />
@@ -161,10 +184,9 @@ export default function InviteCard() {
</ol>
</div>
<a class="btn btn-primary btn-lg invite-dl-btn" href={dlHref}>
<a class="btn btn-secondary invite-dl-btn" href={dlHref}>
<Download />
<span>{t.dl}</span>
<ArrowRight />
</a>
</div>
</div>
+443
View File
@@ -0,0 +1,443 @@
/**
* RegisterCard.jsx — 独立注册页 `/register` 正文(React island)。
*
* 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页
* `/i/<code>`「注册领取 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 = (
<div class="langseg invite-langseg" role="group" aria-label="Language">
<button type="button" class={lang === 'zh' ? 'on' : undefined} onClick={() => setLang('zh')}>中文</button>
<button type="button" class={lang === 'en' ? 'on' : undefined} onClick={() => setLang('en')}>EN</button>
</div>
);
// 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染,
// 与「无 JS 时默认展示注册表单」的渐进增强保持一致。
if (checkedAuth && loggedIn) {
return (
<section class="invite-page register-page">
<div class="wrap">
<div class="invite-card register-card">
{LangSeg}
<div class="register-loggedin">
<CheckCircle2 class="register-success-icon" />
<h1 class="h-sec center register-hero">{t.loggedInTitle}</h1>
<p class="sub-sec center">{t.loggedInSub}</p>
<div class="register-loggedin-actions">
<a class="btn btn-primary btn-lg" href={homeHref}>{t.loggedInHome}</a>
<a class="btn btn-secondary btn-lg" href={SITE.usercenter}>{t.loggedInCenter}</a>
</div>
</div>
</div>
</div>
</section>
);
}
if (success) {
return (
<section class="invite-page register-page">
<div class="wrap">
<div class="invite-card register-card">
{LangSeg}
<div class="register-success">
<CheckCircle2 class="register-success-icon" />
<h1 class="h-sec center register-hero">{t.successTitle}</h1>
<p class="sub-sec center">{invite ? t.successSubInvite : t.successSubPlain}</p>
<a class="btn btn-primary btn-lg invite-dl-btn" href={dl.href}>
<Download />
<span>{dl.label}</span>
<ArrowRight />
</a>
<a class="register-small" href={homeHref}>{t.backHome}</a>
<div class="register-countdown">{t.redirecting(redirectIn)}</div>
</div>
</div>
</div>
</section>
);
}
return (
<section class="invite-page register-page">
<div class="wrap">
<div class="invite-card register-card">
{LangSeg}
<div class="eyebrow center">{t.eyebrow}</div>
<h1 class="h-sec center register-hero">{invite ? t.titleInvite : t.titlePlain}</h1>
<p class="sub-sec center">{t.sub}</p>
<form class="register-form" onSubmit={codeSent ? onRegister : onSendCode}>
<div>
<div class="register-field-label">{t.emailLabel}</div>
<div class="register-row">
<div class="su-field">
<Mail />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder={t.emailPh}
autoComplete="email"
/>
</div>
{!codeSent && (
<button
type="button"
class="btn btn-secondary register-resend-btn"
disabled={sendingCode || cooldown > 0}
onClick={onSendCode}
>
{sendingCode ? <Loader2 class="register-spin" /> : null}
<span>{sendingCode ? t.sending : cooldown > 0 ? t.resendIn(cooldown) : t.sendCode}</span>
</button>
)}
</div>
</div>
{codeErr && (
<div class="register-err">
<AlertCircle />
<span>{codeErr}</span>
</div>
)}
{codeSent && (
<>
<div>
<div class="register-field-label">{t.codeLabel}</div>
<div class="register-row">
<div class="su-field">
<KeyRound />
<input
ref={codeInputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
placeholder={t.codePh}
/>
</div>
<button
type="button"
class="btn btn-secondary register-resend-btn"
disabled={sendingCode || cooldown > 0}
onClick={onSendCode}
>
{sendingCode ? <Loader2 class="register-spin" /> : null}
<span>{sendingCode ? t.sending : cooldown > 0 ? t.resendIn(cooldown) : t.resend}</span>
</button>
</div>
</div>
<div>
<div class="register-field-label">{t.pwLabel}</div>
<div class="su-field">
<Lock />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onBlur={() => setPwTouched(true)}
placeholder={t.pwPh}
autoComplete="new-password"
/>
</div>
{pwInvalid && <div class="register-hint register-hint--warn">{t.pwHint}</div>}
</div>
{regErr && (
<div class="register-err">
<AlertCircle />
<span>{regErr}</span>
</div>
)}
<button type="submit" class="btn btn-primary btn-lg invite-dl-btn" disabled={!canSubmit}>
{submitting ? <Loader2 class="register-spin" /> : null}
<span>{submitting ? t.submitting : invite ? t.submitInvite : t.submitPlain}</span>
</button>
</>
)}
</form>
</div>
</div>
</section>
);
}
+33 -17
View File
@@ -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 (
<div class="signup" id="signup">
<a class="btn btn-primary btn-lg su-btn" href={SITE.usercenter}>
<span>{centerLabel}</span>
</a>
</div>
);
}
return (
<form class="signup" id="signup" onSubmit={onSubmit}>
<div class="su-row">
<div class="su-field">
<Mail />
<input type="email" id="su-email" name="su-email" placeholder={ph} required />
<input type="email" id="su-email" name="su-email" placeholder={ph} />
</div>
<button class="btn btn-primary btn-lg su-btn" type="submit">
<span>{btn}</span>
</button>
</div>
<div class="su-ok" id="su-ok" hidden={!done}>
<CheckCircle />
<span>{ok}</span>
</div>
<div class="su-hint">
<span>{hint}</span>
<a class="su-dl" href="#download">
+7
View File
@@ -11,6 +11,13 @@ export const SITE = {
/** 联系邮箱。Cloudflare Email Routing 转发到个人 Gmail。 */
email: 'pangolin@yanmeiai.com',
/**
* 控制面 API 基址(Cloudflare Tunnel 出口,见 docs/control-plane-tls-tunnel.html)。
* 官网/落地页/注册页浏览器端直连(跨域,CORS 已放行 pangolin.yanmeiai.com,见
* server/internal/httpapi/cors.go);PUBLIC_API_BASE 环境变量可覆盖,供本地联调改指向。
*/
api: 'https://api.yanmeiai.com',
/** Telegram 公开频道(群为私有,公开页不放)。 */
telegram: { handle: '@pangolin_app', url: 'https://t.me/pangolin_app' },
+42
View File
@@ -0,0 +1,42 @@
/**
* authState.js — 官网判断「用户中心登录态」的单一实现。
*
* usercenterweb/usercenter/lib/api/session.ts)与官网同源部署
* pangolin.yanmeiai.com 根路径 + /user/ 子路径共享同一个源),localStorage 按源
* 共享,无需跨域请求即可判断登录态:有 pg_uc_refreshrefresh token)即视为已登录
* (仅存在性判断,不校验有效期/签名——过期由用户中心自己的静默续期/登出收敛,官网
* 只做「有没有」这层粗粒度判断,用来决定要不要展示注册入口)。
*
* SSR/构建期没有 window,一律回退「未登录」(渐进增强:默认显示注册相关 UI,
* 客户端水合后各处 useEffect 再按真实登录态切换/隐藏,无 JS 时行为不变)。
*/
const REFRESH_KEY = 'pg_uc_refresh';
const EMAIL_KEY = 'pg_uc_email';
export function isLoggedIn() {
if (typeof window === 'undefined') return false;
try {
return !!window.localStorage.getItem(REFRESH_KEY);
} catch {
return false;
}
}
export function getStoredEmail() {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(EMAIL_KEY) || '';
} catch {
return '';
}
}
export function clearStoredSession() {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(REFRESH_KEY);
window.localStorage.removeItem(EMAIL_KEY);
} catch {
/* ignore quota / privacy mode */
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* inviteCode.js — 邀请码格式校验单一实现。
*
* 字母表须与 server/internal/reward/service.go 的 inviteAlphabet 一致:
* A-Z 去 O/I2-9 去 0/1,固定 8 位。供 InviteCard.jsx(落地页从 URL 路径解析)与
* RegisterCard.jsx/register 页从 ?invite= 查询参数解析)共用,避免两处定义漂移。
* 仅做「像不像邀请码」的格式校验,不联网核验(是否真实存在/已核销由服务端注册接口判定)。
*/
export const INVITE_CODE_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/;
/** 规范化并校验;不合法返回 null。 */
export function normalizeInviteCode(raw) {
if (!raw) return null;
const code = String(raw).trim().toUpperCase();
return INVITE_CODE_RE.test(code) ? code : null;
}
+70
View File
@@ -0,0 +1,70 @@
---
/**
* register.astro — 独立注册页 `/register`(产出 dist/register/index.html)。
*
* 两处入口跳转到这里:① 首页 hero 免费注册表单(?email= 预填,见 SignupForm.jsx);
* ② 邀请落地页 /i/<code> 的「注册领取 3 天」CTA?invite= 预填,见 InviteCard.jsx)。
* 二者共用同一套「验证码 + 设密码」注册流程(RegisterCard.jsx)。
*
* 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式,与 i.astro 同一路数;
* Header/Footer 走全站 i18n 惯例整页单显中文,正文卡片(RegisterCard.jsx)另有独立的
* zh/en 运行时切换,理由见该组件顶部注释。
*/
import '@fontsource/sora/500.css';
import '@fontsource/sora/600.css';
import '@fontsource/sora/700.css';
import '@fontsource/manrope/400.css';
import '@fontsource/manrope/500.css';
import '@fontsource/manrope/600.css';
import '@fontsource/manrope/700.css';
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/500.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '../styles/tokens.gen.css';
import '../styles/website.css';
import '../styles/site-extra.css';
import { createT } from '../i18n/strings';
import Header from '../components/Header.jsx';
import Footer from '../components/Footer.astro';
import RegisterCard from '../components/RegisterCard.jsx';
const t = createT('zh');
const headerT = {
product: t('nav.product'),
pricing: t('nav.pricing'),
download: t('nav.download'),
docs: t('nav.docs'),
blog: t('nav.blog'),
login: t('nav.login'),
center: t('nav.center'),
brand: t('nav.brand'),
mcenter: t('menu.center'),
mswitch: t('menu.switch'),
mlogout: t('menu.logout'),
get: t('nav.get'),
suBtn: t('su.btn'),
};
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>注册 · {t('nav.brand')}</title>
<meta name="description" content="创建你的穿山甲账户:一个邮箱即可开始,免费注册,无需任何付款信息。" />
<meta name="robots" content="noindex,follow" />
<meta name="theme-color" content="#B96A3D" />
</head>
<body>
<Header client:load lang="zh" t={headerT} />
<main>
<RegisterCard client:load />
</main>
<Footer t={t} />
</body>
</html>
+124
View File
@@ -331,6 +331,123 @@
justify-content: center;
margin-top: 30px;
}
.invite-register-btn {
width: 100%;
justify-content: center;
margin-top: 20px;
}
.invite-loggedin-note {
width: 100%;
margin-top: 20px;
text-align: center;
font-size: 13px;
color: var(--fg3);
}
/* 全局:无障碍禁用态视觉反馈(此前没有产品化按钮用到过 disabled,注册页倒计时/
提交中会用到,顺手补成通用规则,不影响任何既有按钮——目前没有生产页在 disabled
态下渲染过 .btn)。 */
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.register-spin {
animation: register-spin 0.9s linear infinite;
}
@keyframes register-spin {
to { transform: rotate(360deg); }
}
/* ---------- register page (/register) ----------
复用上面 .invite-page/.invite-card 的「白卡」容器视觉;字段挪用首页 signup 表单
现成的 .su-field 胶囊输入(website.css),错误提示挪用 /buy 联调页 .buy-err 的
配色语汇(var(--danger),非新造),本节只补步骤展开/倒计时/成功态/已登录态专属的
最小必要样式。 */
.register-hero {
margin-top: 8px;
max-width: 420px;
}
.register-form {
width: 100%;
margin-top: 26px;
display: flex;
flex-direction: column;
gap: 14px;
}
.register-field-label {
font-size: 12.5px;
font-weight: 700;
color: var(--fg3);
margin-bottom: 6px;
}
.register-row {
display: flex;
gap: 10px;
align-items: center;
}
.register-row .su-field {
flex: 1;
}
.register-resend-btn {
flex-shrink: 0;
white-space: nowrap;
}
.register-hint {
font-size: 12.5px;
color: var(--fg3);
margin-top: 6px;
}
.register-hint--warn {
color: var(--danger);
}
.register-err {
display: flex;
align-items: center;
gap: 8px;
color: var(--danger);
background: var(--danger-subtle);
border-radius: var(--radius-md);
padding: 10px 14px;
font-size: 13.5px;
}
.register-err svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.register-small {
margin-top: 18px;
font-size: 13px;
font-weight: 600;
color: var(--accent);
}
.register-success,
.register-loggedin {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4px;
}
.register-success-icon {
width: 48px;
height: 48px;
color: var(--success);
margin-bottom: 4px;
}
.register-countdown {
margin-top: 10px;
font-size: 12.5px;
color: var(--fg3);
}
.register-loggedin-actions {
display: flex;
gap: 10px;
margin-top: 22px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 640px) {
.invite-card {
@@ -339,4 +456,11 @@
.invite-code-value {
font-size: 24px;
}
.register-row {
flex-direction: column;
align-items: stretch;
}
.register-resend-btn {
justify-content: center;
}
}