From 2a9bb8330c946a86eee39bfd7d96903d787c79b2 Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 12 Mar 2026 03:55:17 -0500 Subject: [PATCH] s' --- app/api/auth/logout/route.ts | 10 ++ app/api/auth/me/route.ts | 60 ++++++++ app/api/auth/sync-session/route.ts | 32 +++++ app/api/pay/route.ts | 2 +- app/api/vip/check/route.ts | 55 ++++++++ app/lib/pocketbase/auth.ts | 93 +++++++++++++ app/lib/pocketbase/config.ts | 1 + app/lib/pocketbase/constants.ts | 5 + app/lib/pocketbase/index.ts | 11 ++ app/page.tsx | 127 +++++++++++++++-- app/vip/components/LandingPage.tsx | 3 + app/vip/components/VipHeader.tsx | 17 ++- app/vip/components/VipLayout.tsx | 5 +- app/vip/components/landing/AuthForm.tsx | 147 ++++++++++++++++++++ app/vip/components/landing/LoginForm.tsx | 147 ++++++++++++++------ app/vip/components/landing/LoginModal.tsx | 51 +++++-- app/vip/components/landing/PayAuthModal.tsx | 66 +++++++++ config/services.config.ts | 7 +- 18 files changed, 767 insertions(+), 72 deletions(-) create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/sync-session/route.ts create mode 100644 app/api/vip/check/route.ts create mode 100644 app/lib/pocketbase/auth.ts create mode 100644 app/lib/pocketbase/config.ts create mode 100644 app/lib/pocketbase/constants.ts create mode 100644 app/lib/pocketbase/index.ts create mode 100644 app/vip/components/landing/AuthForm.tsx create mode 100644 app/vip/components/landing/PayAuthModal.tsx diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..12fd2af --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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; +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..89569eb --- /dev/null +++ b/app/api/auth/me/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts new file mode 100644 index 0000000..0578d90 --- /dev/null +++ b/app/api/auth/sync-session/route.ts @@ -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; +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index ba83448..c1500b1 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -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"); diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts new file mode 100644 index 0000000..9bdc5f9 --- /dev/null +++ b/app/api/vip/check/route.ts @@ -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 { + 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 }); + } +} diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts new file mode 100644 index 0000000..f3c1c4e --- /dev/null +++ b/app/lib/pocketbase/auth.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/app/lib/pocketbase/config.ts b/app/lib/pocketbase/config.ts new file mode 100644 index 0000000..75f9115 --- /dev/null +++ b/app/lib/pocketbase/config.ts @@ -0,0 +1 @@ +export { getPocketBaseConfig } from "@/config/services.config"; diff --git a/app/lib/pocketbase/constants.ts b/app/lib/pocketbase/constants.ts new file mode 100644 index 0000000..fad70d0 --- /dev/null +++ b/app/lib/pocketbase/constants.ts @@ -0,0 +1,5 @@ +/** localStorage 存储 key */ +export const PB_STORAGE_KEYS = { + token: "pb_token", + user: "pb_user", +} as const; diff --git a/app/lib/pocketbase/index.ts b/app/lib/pocketbase/index.ts new file mode 100644 index 0000000..48fd101 --- /dev/null +++ b/app/lib/pocketbase/index.ts @@ -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"; diff --git a/app/page.tsx b/app/page.tsx index b24951b..6d32cfa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 => { + 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 ( - - {isLoggedIn ? ( - - ) : ( - - )} - + <> + + {isLoggedIn ? ( + + ) : ( + + )} + + setAuthForPayOpen(false)} + onSuccess={() => { + setAuthForPayOpen(false); + doPay(); + }} + /> + ); } diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index e0f9653..bfdd4b0 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -13,12 +13,14 @@ import { LoginModal } from "./landing/LoginModal"; type LandingPageProps = { onJoin: () => void; onVerify: (userId: string) => Promise; + onVerifyByEmail?: (email: string, password: string) => Promise; 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} /> diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index 74eb7a3..fc866ab 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -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 (
{isLoggedIn ? ( - {userName || "会员"} +
+ {userName || "会员"} + {onLogout && ( + + )} +
) : ( 登录 diff --git a/app/vip/components/VipLayout.tsx b/app/vip/components/VipLayout.tsx index 2a76e08..8989d62 100644 --- a/app/vip/components/VipLayout.tsx +++ b/app/vip/components/VipLayout.tsx @@ -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 (
- +
{children}
); diff --git a/app/vip/components/landing/AuthForm.tsx b/app/vip/components/landing/AuthForm.tsx new file mode 100644 index 0000000..be18d9b --- /dev/null +++ b/app/vip/components/landing/AuthForm.tsx @@ -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(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 ( +
+ 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", + }} + /> + 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" && ( + 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 && ( +

+ {error} +

+ )} + +

+ {mode === "login" ? ( + <> + 还没有账号?{" "} + + + ) : ( + <> + 已有账号?{" "} + + + )} +

+
+ ); +} diff --git a/app/vip/components/landing/LoginForm.tsx b/app/vip/components/landing/LoginForm.tsx index ae49a7e..8432536 100644 --- a/app/vip/components/landing/LoginForm.tsx +++ b/app/vip/components/landing/LoginForm.tsx @@ -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; + onVerifyByEmail?: (email: string, password: string) => Promise; 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(null); const inputRef = useRef(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 ( -
- 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 && ( -

- {error} -

+ <> +
+ + +
+ {mode === "user_id" ? ( + + setUserId(e.target.value)} + placeholder="例如:user1234567890" + disabled={loading} + style={inputStyle} + /> + {error &&

{error}

} + +
+ ) : onVerifyByEmail ? ( +
+ setEmail(e.target.value)} + placeholder="your@email.com" + disabled={loading} + style={inputStyle} + /> + setPassword(e.target.value)} + placeholder="密码" + disabled={loading} + style={inputStyle} + /> + {error &&

{error}

} + +
+ ) : ( +
+ setUserId(e.target.value)} placeholder="例如:user1234567890" disabled={loading} style={inputStyle} /> + {error &&

{error}

} + +
)} - - + ); } diff --git a/app/vip/components/landing/LoginModal.tsx b/app/vip/components/landing/LoginModal.tsx index cb16048..c0eb5a2 100644 --- a/app/vip/components/landing/LoginModal.tsx +++ b/app/vip/components/landing/LoginModal.tsx @@ -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; + onVerifyByEmail?: (email: string, password: string) => Promise; 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" > - {/* 背景遮罩 */}
- {/* 弹窗内容 */}
e.stopPropagation()} >

- 已有账号登录 + 登录

-

- 请输入您的会员账号(支付时使用的 user_id)以恢复权益 -

- +
+ + +
+ {tab === "email" ? ( +

+ 使用邮箱登录,可与 digital / meetup 等站点共享账号 +

+ ) : ( +

+ 支持 user_id 或关联 users 的邮箱验证 +

+ )} + {tab === "email" ? ( + + ) : ( + + )}
); diff --git a/app/vip/components/landing/PayAuthModal.tsx b/app/vip/components/landing/PayAuthModal.tsx new file mode 100644 index 0000000..8518249 --- /dev/null +++ b/app/vip/components/landing/PayAuthModal.tsx @@ -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 ( +
+
+
e.stopPropagation()} + > +
+

+ 请先注册/登录 +

+ +
+

+ 补充邮箱用于关联会员权益,支付后可在各站点通用 +

+ +
+
+ ); +} diff --git a/config/services.config.ts b/config/services.config.ts index 4d8d27d..7456780 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -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", }; }