s'
This commit is contained in:
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal 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
60
app/api/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
32
app/api/auth/sync-session/route.ts
Normal file
32
app/api/auth/sync-session/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
55
app/api/vip/check/route.ts
Normal file
55
app/api/vip/check/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
93
app/lib/pocketbase/auth.ts
Normal file
93
app/lib/pocketbase/auth.ts
Normal 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);
|
||||
}
|
||||
1
app/lib/pocketbase/config.ts
Normal file
1
app/lib/pocketbase/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getPocketBaseConfig } from "@/config/services.config";
|
||||
5
app/lib/pocketbase/constants.ts
Normal file
5
app/lib/pocketbase/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** localStorage 存储 key */
|
||||
export const PB_STORAGE_KEYS = {
|
||||
token: "pb_token",
|
||||
user: "pb_user",
|
||||
} as const;
|
||||
11
app/lib/pocketbase/index.ts
Normal file
11
app/lib/pocketbase/index.ts
Normal 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";
|
||||
127
app/page.tsx
127
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<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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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}>
|
||||
登录
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
147
app/vip/components/landing/AuthForm.tsx
Normal file
147
app/vip/components/landing/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
66
app/vip/components/landing/PayAuthModal.tsx
Normal file
66
app/vip/components/landing/PayAuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user