From e5d080c202d00f641219ef72a2d0a0f8ff8a3e75 Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 12 Mar 2026 19:52:34 -0500 Subject: [PATCH] 's' --- app/api/auth/link-email/route.ts | 148 +++++++++++++ app/api/auth/logout/route.ts | 11 +- app/api/auth/me/route.ts | 20 +- app/api/auth/sync-session/route.ts | 15 +- app/api/pay/route.ts | 7 +- app/lib/auth-cookie-domain.ts | 11 - app/lib/auth-cookie.ts | 33 +++ app/page.tsx | 61 ++++-- app/vip/components/Copyable.tsx | 85 ++++++++ app/vip/components/DashboardPage.tsx | 8 - app/vip/components/VipHeader.tsx | 63 +++++- app/vip/components/VipLayout.tsx | 5 +- app/vip/components/dashboard/WelcomeBlock.tsx | 204 ++++++++++++++++-- app/vip/components/landing/LoginForm.tsx | 2 +- app/vip/components/landing/LoginModal.tsx | 46 +--- app/vip/components/vip.module.css | 57 +++++ config/services.config.ts | 15 +- package.json | 2 +- 18 files changed, 635 insertions(+), 158 deletions(-) create mode 100644 app/api/auth/link-email/route.ts delete mode 100644 app/lib/auth-cookie-domain.ts create mode 100644 app/lib/auth-cookie.ts create mode 100644 app/vip/components/Copyable.tsx diff --git a/app/api/auth/link-email/route.ts b/app/api/auth/link-email/route.ts new file mode 100644 index 0000000..af70e2c --- /dev/null +++ b/app/api/auth/link-email/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; + +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; +} + +/** 将匿名 user_id 的支付记录关联到新创建的 PB 用户 */ +async function linkPaymentsToUser( + adminToken: string, + fromUserId: string, + toUserId: string +): Promise { + const pb = getPocketBaseConfig(); + const filter = `user_id = "${fromUserId}"`; + const res = await fetch( + `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(filter)}&perPage=500`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + if (!res.ok) return; + const data = await res.json(); + const items = data?.items ?? []; + for (const item of items) { + await fetch(`${pb.url}/api/collections/payments/records/${item.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ user_id: toUserId }), + }); + } +} + +async function linkSiteVipToUser( + adminToken: string, + fromUserId: string, + toUserId: string +): Promise { + const pb = getPocketBaseConfig(); + const filter = `user_id = "${fromUserId}" && site_id = "${SITE_ID}"`; + const res = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=500`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + if (!res.ok) return; + const data = await res.json(); + const items = data?.items ?? []; + for (const item of items) { + await fetch(`${pb.url}/api/collections/site_vip/records/${item.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ user_id: toUserId }), + }); + } +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const email = String(body?.email ?? "").trim(); + const password = String(body?.password ?? "").trim(); + const anonymousUserId = String(body?.anonymousUserId ?? "").trim(); + + if (!email || !password) { + return NextResponse.json({ ok: false, error: "请填写邮箱和密码" }, { status: 400 }); + } + if (!anonymousUserId || !anonymousUserId.startsWith("user")) { + return NextResponse.json({ ok: false, error: "无效的匿名用户ID" }, { status: 400 }); + } + if (password.length < 8) { + return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 }); + } + + const pb = getPocketBaseConfig(); + + // 1. 尝试注册,若已存在则登录 + let token: string; + let record: { id: string; email?: string }; + try { + const registerRes = await fetch(`${pb.url}/api/collections/users/records`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, passwordConfirm: password }), + }); + if (registerRes.ok) { + const regData = await registerRes.json(); + const loginRes = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!loginRes.ok) throw new Error("登录失败"); + const loginData = await loginRes.json(); + token = loginData.token; + record = loginData.record; + } else { + const loginRes = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!loginRes.ok) { + const err = await loginRes.json().catch(() => ({})); + return NextResponse.json( + { ok: false, error: err?.message || "邮箱或密码错误" }, + { status: 401 } + ); + } + const loginData = await loginRes.json(); + token = loginData.token; + record = loginData.record; + } + } catch (e) { + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : "注册/登录失败" }, + { status: 500 } + ); + } + + const newUserId = record.id; + + // 2. 用管理员 token 将 payments、site_vip 的 user_id 从匿名改为新用户 + const adminToken = await getAdminToken(); + if (adminToken) { + await linkPaymentsToUser(adminToken, anonymousUserId, newUserId); + await linkSiteVipToUser(adminToken, anonymousUserId, newUserId); + } + + return NextResponse.json({ + ok: true, + token, + record: { id: record.id, email: record.email }, + }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 938680d..ededd1c 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,15 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain"; - -const COOKIE_NAME = "pb_session"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; export async function POST(request: NextRequest) { - const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, "", { - ...(domain && { domain }), - path: "/", - maxAge: 0, - }); + res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 22e3efe..1e88800 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,13 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain"; - -const COOKIE_NAME = "pb_session"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; export async function GET(request: NextRequest) { const cookie = request.cookies.get(COOKIE_NAME)?.value; - const domain = getAuthCookieDomain(request); if (!cookie) { return NextResponse.json({ ok: true, user: null }); } @@ -17,12 +13,13 @@ export async function GET(request: NextRequest) { if (!token || !userId) return NextResponse.json({ ok: true, user: null }); const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/users/refresh`, { + const res = await fetch(`${pb.url}/api/collections/users/auth-refresh`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) { const r = NextResponse.json({ ok: true, user: null }); - r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 }); + r.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); return r; } const data = await res.json(); @@ -40,14 +37,7 @@ export async function GET(request: NextRequest) { user: { id: newRecord.id, email: newRecord.email ?? email }, token: newToken, }); - r.cookies.set(COOKIE_NAME, encoded, { - ...(domain && { domain }), - path: "/", - maxAge: COOKIE_MAX_AGE, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - httpOnly: true, - }); + r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); return r; } return NextResponse.json({ diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index 23018db..4c1dac3 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -1,8 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain"; - -const COOKIE_NAME = "pb_session"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); @@ -19,15 +16,7 @@ export async function POST(request: NextRequest) { }); const encoded = Buffer.from(payload, "utf-8").toString("base64url"); - const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, encoded, { - ...(domain && { domain }), - path: "/", - maxAge: COOKIE_MAX_AGE, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - httpOnly: true, - }); + res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); return res; } diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index b695561..c85024b 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -3,7 +3,7 @@ * PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单 */ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig } from "@/config/services.config"; +import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config"; const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"]; @@ -68,7 +68,7 @@ async function createPayOrder( ...(provider && { provider }), }; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15000); + const timeout = setTimeout(() => controller.abort(), 30000); const res = await fetch(`${apiUrl}/nomadvip/payh5`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -104,7 +104,8 @@ export async function POST(request: NextRequest) { } const payConfig = getPaymentConfig(); - const apiUrl = payConfig.apiUrl.replace(/\/$/, ""); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = (getApiUrlFromRequest(hostHeader) || payConfig.apiUrl).replace(/\/$/, ""); let user_id = String(body?.user_id || "").trim(); if (user_id.startsWith("{") && user_id.includes("data")) { try { diff --git a/app/lib/auth-cookie-domain.ts b/app/lib/auth-cookie-domain.ts deleted file mode 100644 index 555d48a..0000000 --- a/app/lib/auth-cookie-domain.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 根据请求 host 返回 Cookie domain。 - * localhost 时不设 domain,生产环境用根域实现跨站 SSO。 - */ -export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined { - const envDomain = process.env.AUTH_COOKIE_DOMAIN; - if (envDomain) return envDomain; - const host = request.headers.get("host") ?? ""; - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined; - return process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn"; -} diff --git a/app/lib/auth-cookie.ts b/app/lib/auth-cookie.ts new file mode 100644 index 0000000..88c15d4 --- /dev/null +++ b/app/lib/auth-cookie.ts @@ -0,0 +1,33 @@ +/** + * 认证 Cookie 配置 + * 生产: AUTH_COOKIE_DOMAIN 实现跨子域 SSO + * 本地 IP: 自动设置 domain 实现多端口共享(3000/3001/3002) + */ + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +export { COOKIE_NAME, COOKIE_MAX_AGE }; + +export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string { + const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || ""; + return host.split(",")[0].trim().replace(/:\d+$/, ""); +} + +export function getCookieOptions(clear = false, requestHost?: string) { + const isProd = process.env.NODE_ENV === "production"; + const domain = process.env.AUTH_COOKIE_DOMAIN?.trim(); + const opts: Record = { + path: "/", + maxAge: clear ? 0 : COOKIE_MAX_AGE, + secure: isProd, + sameSite: "lax" as const, + httpOnly: true, + }; + if (domain && isProd) { + opts.domain = domain; + } else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) { + opts.domain = requestHost; + } + return opts; +} diff --git a/app/page.tsx b/app/page.tsx index 02926f2..10fe315 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -155,8 +155,10 @@ function removeStorage(key: string): void { export default function Home() { const [paidType, setPaidType] = useState(null); const [userName, setUserName] = useState(null); + const [userEmail, setUserEmail] = useState(null); const [loading, setLoading] = useState(true); const [payPendingOrderId, setPayPendingOrderId] = useState(null); + const [payAuthModalOpen, setPayAuthModalOpen] = useState(false); const savePaidType = useCallback((type: string | null) => { if (type) setStorage("paidType", type); @@ -281,8 +283,6 @@ export default function Home() { } }, []); - const [authForPayOpen, setAuthForPayOpen] = useState(false); - const doPay = useCallback(async () => { const userId = await getOrCreateUserId(); const returnUrl = `${window.location.origin}/paid`; @@ -317,7 +317,12 @@ export default function Home() { } catch { /* ignore */ } - setAuthForPayOpen(true); + setPayAuthModalOpen(true); + }, [doPay]); + + const handlePayAuthSuccess = useCallback(() => { + setPayAuthModalOpen(false); + doPay(); }, [doPay]); const handleVerifySuccess = useCallback(() => { @@ -358,24 +363,36 @@ export default function Home() { } const init = async () => { - await getOrCreateUserId(); - + // 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖 const localType = getStorage("paidType"); const localUserName = getStorage("userName"); - if (localType && localUserName) { + const localUserId = getStorage("userId"); + if (localType === "vip" && localUserName && localUserId) { setPaidType(localType); setUserName(localUserName); setLoading(false); return; } - // SSO: 优先检查根域 Cookie(跨站登录态,如从 digital 登录后跳转过来) + await getOrCreateUserId(); + + const afterLocalType = getStorage("paidType"); + const afterLocalUserName = getStorage("userName"); + if (afterLocalType && afterLocalUserName) { + setPaidType(afterLocalType); + setUserName(afterLocalUserName); + setLoading(false); + return; + } + + // 检查本站 Cookie 登录态 try { const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); 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 }); + setUserEmail(meData.user.email || null); const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); const vipData = await vipRes.json(); if (vipData?.vip) { @@ -429,6 +446,7 @@ export default function Home() { const handleLogout = useCallback(async () => { const { pbLogout } = await import("@/app/lib/pocketbase"); await pbLogout(); + setUserEmail(null); clearAllStorage(); }, [clearAllStorage]); @@ -445,26 +463,25 @@ export default function Home() { return ( <> - + {isLoggedIn ? ( ) : ( - + <> + + setPayAuthModalOpen(false)} + onSuccess={handlePayAuthSuccess} + /> + )} - setAuthForPayOpen(false)} - onSuccess={() => { - setAuthForPayOpen(false); - doPay(); - }} - /> ); } diff --git a/app/vip/components/Copyable.tsx b/app/vip/components/Copyable.tsx new file mode 100644 index 0000000..a980327 --- /dev/null +++ b/app/vip/components/Copyable.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState, useCallback } from "react"; + +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +type CopyableProps = { + text: string; + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}; + +export function Copyable({ text, children, className, style }: CopyableProps) { + const [copied, setCopied] = useState(false); + + const handleClick = useCallback(async () => { + const fallbackCopy = () => { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + ta.style.top = "0"; + ta.setAttribute("readonly", ""); + document.body.appendChild(ta); + ta.select(); + ta.setSelectionRange(0, text.length); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(ta); + } + }; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(); + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + try { + fallbackCopy(); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* ignore */ + } + } + }, [text]); + + return ( + e.key === "Enter" && handleClick()} + className={className} + style={{ + cursor: "pointer", + userSelect: "none", + display: "inline-flex", + alignItems: "center", + gap: "0.35rem", + ...style, + }} + title={copied ? "已复制" : "点击复制"} + > + {children} + {copied ? : } + + ); +} diff --git a/app/vip/components/DashboardPage.tsx b/app/vip/components/DashboardPage.tsx index e6fe818..c8c6632 100644 --- a/app/vip/components/DashboardPage.tsx +++ b/app/vip/components/DashboardPage.tsx @@ -2,11 +2,7 @@ import { WelcomeBlock } from "./dashboard/WelcomeBlock"; import { UnlockedStats } from "./dashboard/UnlockedStats"; -import { RecentUpdates } from "./dashboard/RecentUpdates"; -import { ContinueLearning } from "./dashboard/ContinueLearning"; import { TopicEbookShowcase } from "./landing/TopicEbookShowcase"; -import { CommunityHighlights } from "./dashboard/CommunityHighlights"; -import { WeeklyActions } from "./dashboard/WeeklyActions"; type DashboardPageProps = { userName: string | null; @@ -17,11 +13,7 @@ export function DashboardPage({ userName }: DashboardPageProps) { <> - - - - ); } diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index 7a47a71..84ba27f 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -1,16 +1,46 @@ "use client"; +import { useState, useEffect, useRef } from "react"; import Link from "next/link"; import { ThemeToggle } from "@/app/components/ThemeToggle"; +import { Copyable } from "./Copyable"; +import { pbLogout } from "@/app/lib/pocketbase"; import styles from "./vip.module.css"; +function getAvatarLetter(email: string): string { + const local = email.split("@")[0]; + if (!local) return "?"; + return local.slice(0, 1).toUpperCase(); +} + type VipHeaderProps = { isLoggedIn: boolean; userName?: string | null; + userEmail?: string | null; onLogout?: () => void; }; -export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) { +export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHeaderProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleLogout = async () => { + await pbLogout(); + setOpen(false); + onLogout?.(); + }; + + const showUser = isLoggedIn || userEmail; + const displayName = userName || userEmail || "会员"; + return (
diff --git a/app/vip/components/VipLayout.tsx b/app/vip/components/VipLayout.tsx index 8989d62..cae05a5 100644 --- a/app/vip/components/VipLayout.tsx +++ b/app/vip/components/VipLayout.tsx @@ -8,13 +8,14 @@ type VipLayoutProps = { children: ReactNode; isLoggedIn: boolean; userName?: string | null; + userEmail?: string | null; onLogout?: () => void; }; -export function VipLayout({ children, isLoggedIn, userName, onLogout }: VipLayoutProps) { +export function VipLayout({ children, isLoggedIn, userName, userEmail, onLogout }: VipLayoutProps) { return (
- +
{children}
); diff --git a/app/vip/components/dashboard/WelcomeBlock.tsx b/app/vip/components/dashboard/WelcomeBlock.tsx index 0c874f6..cfa8b34 100644 --- a/app/vip/components/dashboard/WelcomeBlock.tsx +++ b/app/vip/components/dashboard/WelcomeBlock.tsx @@ -1,6 +1,7 @@ "use client"; -import Link from "next/link"; +import { useState, type FormEvent } from "react"; +import { Copyable } from "../Copyable"; import styles from "../vip.module.css"; type WelcomeBlockProps = { @@ -12,8 +13,174 @@ function getStorage(key: string): string | null { return localStorage.getItem(key); } +/** 匿名 user_id 格式:user + 数字 */ +function isAnonymousUserId(val: string | null): boolean { + return !!val && /^user\d+$/.test(val); +} + export function WelcomeBlock({ userName }: WelcomeBlockProps) { const displayName = userName || getStorage("userName") || "会员"; + const userId = getStorage("userId"); + const needLinkEmail = isAnonymousUserId(userId); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [linked, setLinked] = useState(false); + + const handleLinkSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + if (password !== passwordConfirm) { + setError("两次密码不一致"); + setLoading(false); + return; + } + if (password.length < 8) { + setError("密码至少 8 位"); + setLoading(false); + return; + } + const res = await fetch("/api/auth/link-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim(), + password, + anonymousUserId: userId, + }), + }); + const data = await res.json(); + if (!data?.ok) { + setError(data?.error || "操作失败"); + setLoading(false); + return; + } + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, { id: data.record.id, email: data.record.email }); + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: data.token, record: data.record }), + credentials: "include", + }); + if (typeof window !== "undefined") { + localStorage.setItem("userId", data.record.id); + localStorage.setItem("userName", data.record.email || data.record.id); + } + setLinked(true); + window.location.reload(); + } catch { + setError("操作失败"); + } finally { + setLoading(false); + } + }; + + if (needLinkEmail && !linked) { + return ( +
+

+ 欢迎加入,请补充邮箱 +

+

+ 补充邮箱后可在各站点通用登录,Memos 账号密码为 12345678 +

+
+ setEmail(e.target.value)} + placeholder="your@email.com" + 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", + }} + /> + setPassword(e.target.value)} + placeholder="至少 8 位" + required + minLength={8} + 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", + }} + /> + setPasswordConfirm(e.target.value)} + placeholder="再次输入密码" + required + minLength={8} + 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}

+ )} + +
+
+ ); + } + return (

- 欢迎回来,{displayName} + 欢迎回来,{displayName}

以下是您的会员资产与推荐动作

- - 🌐 进入星球 qun.hackrobot.cn → - +
+
星球登录信息
+
+ 网址 + qun.hackrobot.cn + 打开 +
+
+ 账号 + {displayName} +
+
+ 密码 + 12345678 +
+
); } diff --git a/app/vip/components/landing/LoginForm.tsx b/app/vip/components/landing/LoginForm.tsx index 8432536..1bc61fa 100644 --- a/app/vip/components/landing/LoginForm.tsx +++ b/app/vip/components/landing/LoginForm.tsx @@ -83,7 +83,7 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L 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 + 账号 -
- - -
- {tab === "email" ? ( -

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

- ) : ( -

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

- )} - {tab === "email" ? ( - - ) : ( - - )} + ); diff --git a/app/vip/components/vip.module.css b/app/vip/components/vip.module.css index 8b2d9d5..6463c5b 100644 --- a/app/vip/components/vip.module.css +++ b/app/vip/components/vip.module.css @@ -164,6 +164,63 @@ border-color: rgba(245, 158, 11, 0.3); } +/* 星球登录信息卡片 */ +.loginCard { + margin-top: 1.25rem; + padding: 1.25rem 1.5rem; + background: linear-gradient(135deg, rgba(245, 158, 11, 0.06) 0%, rgba(245, 158, 11, 0.02) 100%); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: 10px; + max-width: 380px; +} + +.loginCardTitle { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 1rem; +} + +.loginCardRow { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} + +.loginCardRow:last-of-type { + border-bottom: none; +} + +.loginCardLabel { + flex-shrink: 0; + width: 2.5em; + color: var(--muted-foreground); +} + +.loginCardValue { + font-family: ui-monospace, monospace; + font-size: 0.9rem; + padding: 0.15rem 0.5rem; + background: var(--muted); + border-radius: 6px; +} + +.loginCardLink { + margin-left: auto; + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} + +.loginCardLink:hover { + text-decoration: underline; +} + /* 大数字 */ .bigNumber { font-size: 2rem; diff --git a/config/services.config.ts b/config/services.config.ts index 03fc41a..70e106d 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -39,27 +39,30 @@ export function getPocketBaseConfig(): PocketBaseConfig { }; } +const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007"; + /** - * 根据请求 Host 推断 payjsapi 地址(同主机:8700) - * 访问 192.168.41.222:3000 时自动用 192.168.41.222:8700,无需在 .env 写死 IP + * 根据请求 Host 推断 payjsapi 地址(同主机:8007) + * 访问 192.168.41.222:3000 时自动用 192.168.41.222:8007 */ export function getApiUrlFromRequest(hostHeader: string | null): string { - if (!hostHeader) return isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"; + if (!hostHeader) return isDev ? `http://127.0.0.1:${PAYJSAPI_PORT}` : "https://api.hackrobot.cn"; const hostname = hostHeader.split(":")[0]; - return isDev ? `http://${hostname}:8700` : "https://api.hackrobot.cn"; + return isDev ? `http://${hostname}:${PAYJSAPI_PORT}` : "https://api.hackrobot.cn"; } export function getPaymentConfig(): PaymentConfig { const apiUrl = process.env.PAYMENT_API_URL || process.env.NEXT_PUBLIC_API_URL || - (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"); + (isDev ? `http://127.0.0.1:${PAYJSAPI_PORT}` : "https://api.hackrobot.cn"); return { apiUrl, provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", - defaultAmount: 8800, + /** 支付测试价格 1 元(生产可改 PAYMENT_DEFAULT_AMOUNT=8800) */ + defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 100, nomadvipPayPath: "/nomadvip/payh5", }; } diff --git a/package.json b/package.json index d4d156c..e7fb4ef 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -H 0.0.0.0", + "dev": "next dev -p 3002 -H 0.0.0.0", "build": "next build", "start": "next start", "lint": "eslint"