This commit is contained in:
eric
2026-03-12 03:55:17 -05:00
parent 7e8f0e3e40
commit 2a9bb8330c
18 changed files with 767 additions and 72 deletions

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
const COOKIE_NAME = "pb_session";
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: COOKIE_DOMAIN, path: "/", maxAge: 0 });
return res;
}

60
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
const COOKIE_NAME = "pb_session";
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, user: null });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const { token, userId, email } = payload;
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/users/refresh`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", { domain: COOKIE_DOMAIN, path: "/", maxAge: 0 });
return r;
}
const data = await res.json();
const newToken = data.token;
const newRecord = data.record;
if (newToken && newRecord?.id) {
const newPayload = JSON.stringify({
token: newToken,
userId: newRecord.id,
email: newRecord.email ?? email,
});
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
const r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, {
domain: COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return r;
}
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
token: data.token,
});
} catch {
return NextResponse.json({ ok: true, user: null });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const token = body?.token;
const record = body?.record;
if (!token || !record?.id) {
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
}
const payload = JSON.stringify({
token,
userId: record.id,
email: record.email ?? "",
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, {
domain: COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return res;
}

View File

@@ -114,7 +114,7 @@ export async function POST(request: NextRequest) {
}
}
const return_url = String(body?.return_url || "").trim();
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 8800;
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 100;
const type = String(body?.type || "vip");
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
const COOKIE_NAME = "pb_session";
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.token ?? null;
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, vip: false });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const userId = payload?.userId;
if (!userId) return NextResponse.json({ ok: true, vip: false });
const token = await getAdminToken();
if (!token) return NextResponse.json({ ok: true, vip: false });
const pb = getPocketBaseConfig();
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
const res = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return NextResponse.json({ ok: true, vip: false });
const data = await res.json();
const items = data?.items ?? [];
const record = items[0];
const now = new Date().toISOString();
const isExpired = record?.expires_at && record.expires_at < now;
const vip = !!record && !isExpired;
return NextResponse.json({
ok: true,
vip,
expires_at: record?.expires_at ?? null,
});
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}

View File

@@ -0,0 +1,93 @@
/**
* PocketBase 认证服务 - 邮箱登录、注册
*/
import { getPocketBaseConfig } from "@/config/services.config";
import { PB_STORAGE_KEYS } from "./constants";
export interface AuthUser {
id: string;
email: string;
[key: string]: unknown;
}
export interface AuthResult {
token: string;
record: AuthUser;
}
function getPBUrl(): string {
return getPocketBaseConfig().url;
}
export async function pbLogin(
identity: string,
password: string
): Promise<AuthResult> {
const url = getPBUrl();
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "登录失败");
}
const data = await res.json();
if (!data?.token) throw new Error("登录响应异常");
return { token: data.token, record: data.record };
}
export async function pbRegister(
email: string,
password: string,
passwordConfirm: string
): Promise<AuthResult> {
const url = getPBUrl();
const res = await fetch(`${url}/api/collections/users/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirm }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "注册失败");
}
return pbLogin(email, password);
}
export function pbSaveAuth(token: string, record: AuthUser): void {
if (typeof window === "undefined") return;
localStorage.setItem(PB_STORAGE_KEYS.token, token);
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
/** 登出 - 清除本地存储和根域 Cookie */
export async function pbLogout(): Promise<void> {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
}
export function getStoredUserEmail(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(PB_STORAGE_KEYS.token);
}

View File

@@ -0,0 +1 @@
export { getPocketBaseConfig } from "@/config/services.config";

View File

@@ -0,0 +1,5 @@
/** localStorage 存储 key */
export const PB_STORAGE_KEYS = {
token: "pb_token",
user: "pb_user",
} as const;

View File

@@ -0,0 +1,11 @@
export {
pbLogin,
pbRegister,
pbSaveAuth,
pbLogout,
getStoredUserEmail,
getStoredToken,
} from "./auth";
export type { AuthUser, AuthResult } from "./auth";
export { PB_STORAGE_KEYS } from "./constants";
export { getPocketBaseConfig } from "./config";

View File

@@ -8,6 +8,7 @@ import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
import { VipLayout } from "@/app/vip/components/VipLayout";
import { LandingPage } from "@/app/vip/components/LandingPage";
import { DashboardPage } from "@/app/vip/components/DashboardPage";
import { PayAuthModal } from "@/app/vip/components/landing/PayAuthModal";
import styles from "@/app/vip/components/vip.module.css";
const PB_URL =
@@ -168,7 +169,19 @@ export default function Home() {
setUserName(name);
}, []);
const getOrCreateUserId = useCallback(() => {
const getOrCreateUserId = useCallback(async () => {
// 优先使用 PocketBase 用户 ID已登录时
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user?.id) {
const pbUserId = meData.user.id;
setStorage("userId", pbUserId);
return pbUserId;
}
} catch {
/* ignore */
}
let userId = getStorage("userId");
if (userId?.startsWith("{") && userId.includes("data")) {
try {
@@ -215,6 +228,37 @@ export default function Home() {
[savePaidType, saveUserName]
);
/** 会员账号恢复:通过邮箱+密码验证,关联 users 后检查 site_vip / payments */
const checkVerifyByEmail = useCallback(
async (email: string, password: string): Promise<boolean> => {
try {
const { pbLogin, pbSaveAuth } = await import("@/app/lib/pocketbase");
const result = await pbLogin(email, password);
pbSaveAuth(result.token, result.record);
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
});
const userId = result.record.id;
const vipRes = await fetch("/api/vip/check");
const vipData = await vipRes.json();
if (vipData?.vip) {
setStorage("userId", userId);
savePaidType("vip");
saveUserName(email);
return true;
}
const found = await checkPaymentFromPB(userId);
if (found) return true;
return false;
} catch {
return false;
}
},
[checkPaymentFromPB, savePaidType, saveUserName]
);
const clearAllStorage = useCallback(() => {
try {
if (typeof window === "undefined") return;
@@ -236,8 +280,10 @@ export default function Home() {
}
}, []);
const handlePay = useCallback(async () => {
const userId = getOrCreateUserId();
const [authForPayOpen, setAuthForPayOpen] = useState(false);
const doPay = useCallback(async () => {
const userId = await getOrCreateUserId();
const returnUrl = `${window.location.origin}/paid`;
const env = getPayEnv();
const device = getDeviceFromEnv(env);
@@ -259,6 +305,20 @@ export default function Home() {
}
}, [getOrCreateUserId]);
const handlePay = useCallback(async () => {
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user?.id) {
doPay();
return;
}
} catch {
/* ignore */
}
setAuthForPayOpen(true);
}, [doPay]);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
@@ -297,7 +357,7 @@ export default function Home() {
}
const init = async () => {
getOrCreateUserId();
await getOrCreateUserId();
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
@@ -308,6 +368,26 @@ export default function Home() {
return;
}
// SSO: 优先检查根域 Cookie跨站登录态
try {
const meRes = await fetch("/api/auth/me");
const meData = await meRes.json();
if (meData?.user && meData?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
const vipRes = await fetch("/api/vip/check");
const vipData = await vipRes.json();
if (vipData?.vip) {
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
setLoading(false);
return;
}
}
} catch {
/* ignore */
}
const userId = getStorage("userId");
if (userId) {
const found = await checkPaymentFromPB(userId);
@@ -355,17 +435,34 @@ export default function Home() {
);
}
const handleLogout = useCallback(async () => {
const { pbLogout } = await import("@/app/lib/pocketbase");
await pbLogout();
clearAllStorage();
}, [clearAllStorage]);
return (
<VipLayout isLoggedIn={isLoggedIn} userName={userName}>
{isLoggedIn ? (
<DashboardPage userName={userName} />
) : (
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifySuccess={handleVerifySuccess}
/>
)}
</VipLayout>
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} onLogout={handleLogout}>
{isLoggedIn ? (
<DashboardPage userName={userName} />
) : (
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
/>
)}
</VipLayout>
<PayAuthModal
open={authForPayOpen}
onClose={() => setAuthForPayOpen(false)}
onSuccess={() => {
setAuthForPayOpen(false);
doPay();
}}
/>
</>
);
}

View File

@@ -13,12 +13,14 @@ import { LoginModal } from "./landing/LoginModal";
type LandingPageProps = {
onJoin: () => void;
onVerify: (userId: string) => Promise<boolean>;
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
onVerifySuccess: () => void;
};
export function LandingPage({
onJoin,
onVerify,
onVerifyByEmail,
onVerifySuccess,
}: LandingPageProps) {
const [loginModalOpen, setLoginModalOpen] = useState(false);
@@ -36,6 +38,7 @@ export function LandingPage({
open={loginModalOpen}
onClose={() => setLoginModalOpen(false)}
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={onVerifySuccess}
/>
</>

View File

@@ -7,9 +7,10 @@ import styles from "./vip.module.css";
type VipHeaderProps = {
isLoggedIn: boolean;
userName?: string | null;
onLogout?: () => void;
};
export function VipHeader({ isLoggedIn, userName }: VipHeaderProps) {
export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) {
return (
<header className={styles.header}>
<Link
@@ -23,7 +24,19 @@ export function VipHeader({ isLoggedIn, userName }: VipHeaderProps) {
<nav className={styles.nav}>
<ThemeToggle />
{isLoggedIn ? (
<span className={styles.userBadge}>{userName || "会员"}</span>
<div className={styles.userBadge} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span>{userName || "会员"}</span>
{onLogout && (
<button
type="button"
onClick={onLogout}
className={styles.loginLink}
style={{ background: "none", border: "none", cursor: "pointer", padding: 0, fontSize: "inherit" }}
>
退
</button>
)}
</div>
) : (
<Link href="/#login" className={styles.loginLink}>

View File

@@ -8,12 +8,13 @@ type VipLayoutProps = {
children: ReactNode;
isLoggedIn: boolean;
userName?: string | null;
onLogout?: () => void;
};
export function VipLayout({ children, isLoggedIn, userName }: VipLayoutProps) {
export function VipLayout({ children, isLoggedIn, userName, onLogout }: VipLayoutProps) {
return (
<div className={styles.root}>
<VipHeader isLoggedIn={isLoggedIn} userName={userName} />
<VipHeader isLoggedIn={isLoggedIn} userName={userName} onLogout={onLogout} />
<main className={styles.main}>{children}</main>
</div>
);

View File

@@ -0,0 +1,147 @@
"use client";
import { useState, type FormEvent } from "react";
import { pbLogin, pbRegister, pbSaveAuth } from "@/app/lib/pocketbase";
import styles from "../vip.module.css";
type AuthFormProps = {
onSuccess: () => void;
autoFocus?: boolean;
};
export function AuthForm({ onSuccess, autoFocus }: AuthFormProps) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
let result;
if (mode === "register") {
if (password !== passwordConfirm) {
setError("两次密码不一致");
setLoading(false);
return;
}
if (password.length < 8) {
setError("密码至少 8 位");
setLoading(false);
return;
}
result = await pbRegister(email, password, passwordConfirm);
} else {
result = await pbLogin(email, password);
}
pbSaveAuth(result.token, result.record);
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
});
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
autoFocus={autoFocus}
disabled={loading}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: "0.75rem",
}}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
required
minLength={mode === "register" ? 8 : 1}
disabled={loading}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: mode === "register" ? "0.75rem" : 0,
}}
/>
{mode === "register" && (
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder="再次输入密码"
required
disabled={loading}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: "0.75rem",
}}
/>
)}
{error && (
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
style={{ width: "100%", marginBottom: "0.75rem" }}
>
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
</button>
<p style={{ fontSize: "0.85rem", color: "var(--muted-foreground)", textAlign: "center" }}>
{mode === "login" ? (
<>
{" "}
<button type="button" onClick={() => { setMode("register"); setError(null); }} className="text-[var(--primary)]">
</button>
</>
) : (
<>
{" "}
<button type="button" onClick={() => { setMode("login"); setError(null); }} className="text-[var(--primary)]">
</button>
</>
)}
</p>
</form>
);
}

View File

@@ -1,25 +1,29 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import styles from "../vip.module.css";
type LoginFormProps = {
onVerify: (userId: string) => Promise<boolean>;
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
onSuccess: () => void;
autoFocus?: boolean;
};
export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: LoginFormProps) {
const [mode, setMode] = useState<"user_id" | "email">("user_id");
const [userId, setUserId] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
}, [autoFocus, mode]);
const handleSubmit = async (e: React.FormEvent) => {
const handleUserIdSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmed = userId.trim();
if (!trimmed) {
@@ -30,11 +34,8 @@ export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
setLoading(true);
try {
const ok = await onVerify(trimmed);
if (ok) {
onSuccess();
} else {
setError("未找到该账号的会员记录,请确认账号正确");
}
if (ok) onSuccess();
else setError("未找到该账号的会员记录,请确认账号正确");
} catch {
setError("验证失败,请稍后重试");
} finally {
@@ -42,39 +43,103 @@ export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
}
};
const handleEmailSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!onVerifyByEmail) return;
const trimmedEmail = email.trim();
if (!trimmedEmail || !password) {
setError("请输入邮箱和密码");
return;
}
setError(null);
setLoading(true);
try {
const ok = await onVerifyByEmail(trimmedEmail, password);
if (ok) onSuccess();
else setError("未找到该邮箱关联的会员记录,请确认账号正确");
} catch {
setError("验证失败,请稍后重试");
} finally {
setLoading(false);
}
};
const inputStyle = {
width: "100%" as const,
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: "0.75rem",
};
return (
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="例如user1234567890"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem 1rem",
fontSize: "1rem",
border: "1px solid var(--border)",
borderRadius: "8px",
background: "var(--background)",
color: "var(--foreground)",
marginBottom: "0.75rem",
}}
/>
{error && (
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
{error}
</p>
<>
<div className="mb-3 flex gap-2">
<button
type="button"
onClick={() => { setMode("user_id"); setError(null); }}
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "user_id" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
>
user_id
</button>
<button
type="button"
onClick={() => { setMode("email"); setError(null); }}
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "email" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
>
</button>
</div>
{mode === "user_id" ? (
<form onSubmit={handleUserIdSubmit}>
<input
ref={inputRef}
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="例如user1234567890"
disabled={loading}
style={inputStyle}
/>
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
) : onVerifyByEmail ? (
<form onSubmit={handleEmailSubmit}>
<input
ref={inputRef}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={loading}
style={inputStyle}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
disabled={loading}
style={inputStyle}
/>
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
) : (
<form onSubmit={handleUserIdSubmit}>
<input ref={inputRef} type="text" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="例如user1234567890" disabled={loading} style={inputStyle} />
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>{loading ? "验证中..." : "验证并登录"}</button>
</form>
)}
<button
type="submit"
disabled={loading}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
style={{ width: "100%" }}
>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
</>
);
}

View File

@@ -1,17 +1,21 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import styles from "../vip.module.css";
import { LoginForm } from "./LoginForm";
import { AuthForm } from "./AuthForm";
type LoginModalProps = {
open: boolean;
onClose: () => void;
onVerify: (userId: string) => Promise<boolean>;
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
onSuccess: () => void;
};
export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalProps) {
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
const [tab, setTab] = useState<"email" | "legacy">("email");
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
@@ -46,19 +50,17 @@ export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalPro
aria-modal="true"
aria-labelledby="login-modal-title"
>
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
</h2>
<button
type="button"
@@ -71,10 +73,41 @@ export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalPro
</svg>
</button>
</div>
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
使 user_id
</p>
<LoginForm onVerify={onVerify} onSuccess={handleSuccess} autoFocus />
<div className="mb-4 flex gap-2 border-b border-[var(--border)]">
<button
type="button"
onClick={() => setTab("email")}
className={`pb-2 text-sm font-medium ${tab === "email" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
>
</button>
<button
type="button"
onClick={() => setTab("legacy")}
className={`pb-2 text-sm font-medium ${tab === "legacy" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
>
</button>
</div>
{tab === "email" ? (
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
使 digital / meetup
</p>
) : (
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
user_id users
</p>
)}
{tab === "email" ? (
<AuthForm onSuccess={handleSuccess} autoFocus />
) : (
<LoginForm
onVerify={onVerify}
onVerifyByEmail={onVerifyByEmail}
onSuccess={handleSuccess}
autoFocus
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,66 @@
"use client";
import { useEffect } from "react";
import styles from "../vip.module.css";
import { AuthForm } from "./AuthForm";
type PayAuthModalProps = {
open: boolean;
onClose: () => void;
onSuccess: () => void;
};
/** 支付前强制补充邮箱:注册/登录以关联 users 记录 */
export function PayAuthModal({ open, onClose, onSuccess }: PayAuthModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
const handleSuccess = () => {
onClose();
onSuccess();
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className={styles.sectionTitle} style={{ margin: 0 }}>
/
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
</p>
<AuthForm onSuccess={handleSuccess} autoFocus />
</div>
</div>
);
}

View File

@@ -5,6 +5,9 @@
const isDev = process.env.NODE_ENV === "development";
/** 站点标识,用于 VIP 按站点隔离 */
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "vip";
export interface PocketBaseConfig {
url: string;
}
@@ -13,7 +16,7 @@ export interface PaymentConfig {
apiUrl: string;
provider: "zpay" | "xorpay";
zpaySubmitUrl: string;
/** 默认金额(分),88 元 = 8800 */
/** 默认金额(分),测试用1元 */
defaultAmount?: number;
/** nomadvip 专用接口路径 */
nomadvipPayPath: string;
@@ -56,7 +59,7 @@ export function getPaymentConfig(): PaymentConfig {
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
zpaySubmitUrl:
process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
defaultAmount: 8800,
defaultAmount: 100,
nomadvipPayPath: "/nomadvip/payh5",
};
}