's'
This commit is contained in:
148
app/api/auth/link-email/route.ts
Normal file
148
app/api/auth/link-email/route.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将匿名 user_id 的支付记录关联到新创建的 PB 用户 */
|
||||||
|
async function linkPaymentsToUser(
|
||||||
|
adminToken: string,
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain";
|
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(COOKIE_NAME, "", {
|
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain";
|
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
return NextResponse.json({ ok: true, user: null });
|
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 });
|
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
|
||||||
|
|
||||||
const pb = getPocketBaseConfig();
|
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}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const r = NextResponse.json({ ok: true, user: null });
|
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<string, string | number | boolean>);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -40,14 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
user: { id: newRecord.id, email: newRecord.email ?? email },
|
user: { id: newRecord.id, email: newRecord.email ?? email },
|
||||||
token: newToken,
|
token: newToken,
|
||||||
});
|
});
|
||||||
r.cookies.set(COOKIE_NAME, encoded, {
|
r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
|
||||||
maxAge: COOKIE_MAX_AGE,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: true,
|
|
||||||
});
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getAuthCookieDomain } from "@/app/lib/auth-cookie-domain";
|
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => ({}));
|
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 encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||||
|
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(COOKIE_NAME, encoded, {
|
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
|
||||||
maxAge: COOKIE_MAX_AGE,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: true,
|
|
||||||
});
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
|
* PC: 二维码 | H5: 302 跳转 | 微信内: 收银台表单
|
||||||
*/
|
*/
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
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"];
|
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 }),
|
...(provider && { provider }),
|
||||||
};
|
};
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
const res = await fetch(`${apiUrl}/nomadvip/payh5`, {
|
const res = await fetch(`${apiUrl}/nomadvip/payh5`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -104,7 +104,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payConfig = getPaymentConfig();
|
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();
|
let user_id = String(body?.user_id || "").trim();
|
||||||
if (user_id.startsWith("{") && user_id.includes("data")) {
|
if (user_id.startsWith("{") && user_id.includes("data")) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
33
app/lib/auth-cookie.ts
Normal file
33
app/lib/auth-cookie.ts
Normal file
@@ -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<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
61
app/page.tsx
61
app/page.tsx
@@ -155,8 +155,10 @@ function removeStorage(key: string): void {
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [paidType, setPaidType] = useState<string | null>(null);
|
const [paidType, setPaidType] = useState<string | null>(null);
|
||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
||||||
|
const [payAuthModalOpen, setPayAuthModalOpen] = useState(false);
|
||||||
|
|
||||||
const savePaidType = useCallback((type: string | null) => {
|
const savePaidType = useCallback((type: string | null) => {
|
||||||
if (type) setStorage("paidType", type);
|
if (type) setStorage("paidType", type);
|
||||||
@@ -281,8 +283,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [authForPayOpen, setAuthForPayOpen] = useState(false);
|
|
||||||
|
|
||||||
const doPay = useCallback(async () => {
|
const doPay = useCallback(async () => {
|
||||||
const userId = await getOrCreateUserId();
|
const userId = await getOrCreateUserId();
|
||||||
const returnUrl = `${window.location.origin}/paid`;
|
const returnUrl = `${window.location.origin}/paid`;
|
||||||
@@ -317,7 +317,12 @@ export default function Home() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setAuthForPayOpen(true);
|
setPayAuthModalOpen(true);
|
||||||
|
}, [doPay]);
|
||||||
|
|
||||||
|
const handlePayAuthSuccess = useCallback(() => {
|
||||||
|
setPayAuthModalOpen(false);
|
||||||
|
doPay();
|
||||||
}, [doPay]);
|
}, [doPay]);
|
||||||
|
|
||||||
const handleVerifySuccess = useCallback(() => {
|
const handleVerifySuccess = useCallback(() => {
|
||||||
@@ -358,24 +363,36 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await getOrCreateUserId();
|
// 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖
|
||||||
|
|
||||||
const localType = getStorage("paidType");
|
const localType = getStorage("paidType");
|
||||||
const localUserName = getStorage("userName");
|
const localUserName = getStorage("userName");
|
||||||
if (localType && localUserName) {
|
const localUserId = getStorage("userId");
|
||||||
|
if (localType === "vip" && localUserName && localUserId) {
|
||||||
setPaidType(localType);
|
setPaidType(localType);
|
||||||
setUserName(localUserName);
|
setUserName(localUserName);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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 {
|
try {
|
||||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||||
const meData = await meRes.json();
|
const meData = await meRes.json();
|
||||||
if (meData?.user && meData?.token) {
|
if (meData?.user && meData?.token) {
|
||||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||||
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
|
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 vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||||
const vipData = await vipRes.json();
|
const vipData = await vipRes.json();
|
||||||
if (vipData?.vip) {
|
if (vipData?.vip) {
|
||||||
@@ -429,6 +446,7 @@ export default function Home() {
|
|||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
const { pbLogout } = await import("@/app/lib/pocketbase");
|
const { pbLogout } = await import("@/app/lib/pocketbase");
|
||||||
await pbLogout();
|
await pbLogout();
|
||||||
|
setUserEmail(null);
|
||||||
clearAllStorage();
|
clearAllStorage();
|
||||||
}, [clearAllStorage]);
|
}, [clearAllStorage]);
|
||||||
|
|
||||||
@@ -445,26 +463,25 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} onLogout={handleLogout}>
|
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<DashboardPage userName={userName} />
|
<DashboardPage userName={userName} />
|
||||||
) : (
|
) : (
|
||||||
<LandingPage
|
<>
|
||||||
onJoin={handlePay}
|
<LandingPage
|
||||||
onVerify={checkPaymentFromPB}
|
onJoin={handlePay}
|
||||||
onVerifyByEmail={checkVerifyByEmail}
|
onVerify={checkPaymentFromPB}
|
||||||
onVerifySuccess={handleVerifySuccess}
|
onVerifyByEmail={checkVerifyByEmail}
|
||||||
/>
|
onVerifySuccess={handleVerifySuccess}
|
||||||
|
/>
|
||||||
|
<PayAuthModal
|
||||||
|
open={payAuthModalOpen}
|
||||||
|
onClose={() => setPayAuthModalOpen(false)}
|
||||||
|
onSuccess={handlePayAuthSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</VipLayout>
|
</VipLayout>
|
||||||
<PayAuthModal
|
|
||||||
open={authForPayOpen}
|
|
||||||
onClose={() => setAuthForPayOpen(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setAuthForPayOpen(false);
|
|
||||||
doPay();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
85
app/vip/components/Copyable.tsx
Normal file
85
app/vip/components/Copyable.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
const CopyIcon = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, opacity: 0.6 }}>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, color: "var(--accent)" }}>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleClick()}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
title={copied ? "已复制" : "点击复制"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
import { WelcomeBlock } from "./dashboard/WelcomeBlock";
|
import { WelcomeBlock } from "./dashboard/WelcomeBlock";
|
||||||
import { UnlockedStats } from "./dashboard/UnlockedStats";
|
import { UnlockedStats } from "./dashboard/UnlockedStats";
|
||||||
import { RecentUpdates } from "./dashboard/RecentUpdates";
|
|
||||||
import { ContinueLearning } from "./dashboard/ContinueLearning";
|
|
||||||
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
|
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
|
||||||
import { CommunityHighlights } from "./dashboard/CommunityHighlights";
|
|
||||||
import { WeeklyActions } from "./dashboard/WeeklyActions";
|
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
@@ -17,11 +13,7 @@ export function DashboardPage({ userName }: DashboardPageProps) {
|
|||||||
<>
|
<>
|
||||||
<WelcomeBlock userName={userName} />
|
<WelcomeBlock userName={userName} />
|
||||||
<UnlockedStats />
|
<UnlockedStats />
|
||||||
<ContinueLearning />
|
|
||||||
<RecentUpdates />
|
|
||||||
<TopicEbookShowcase />
|
<TopicEbookShowcase />
|
||||||
<CommunityHighlights />
|
|
||||||
<WeeklyActions />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||||
|
import { Copyable } from "./Copyable";
|
||||||
|
import { pbLogout } from "@/app/lib/pocketbase";
|
||||||
import styles from "./vip.module.css";
|
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 = {
|
type VipHeaderProps = {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
onLogout?: () => void;
|
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<HTMLDivElement>(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 (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Link
|
<Link
|
||||||
@@ -23,19 +53,34 @@ export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{isLoggedIn ? (
|
{showUser ? (
|
||||||
<div className={styles.userBadge} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div className={styles.userBadge} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span>{userName || "会员"}</span>
|
<div className="relative" ref={ref}>
|
||||||
{onLogout && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onLogout}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className={styles.loginLink}
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-500 text-sm font-semibold text-white shadow-sm hover:bg-amber-600"
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", padding: 0, fontSize: "inherit" }}
|
aria-label="用户菜单"
|
||||||
>
|
>
|
||||||
退出
|
{getAvatarLetter(displayName)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<span className="block px-4 py-2 text-sm text-slate-600 dark:text-slate-400 truncate max-w-[160px]">
|
||||||
|
<Copyable text={displayName}>{displayName}</Copyable>
|
||||||
|
</span>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline max-w-[120px] truncate text-sm"><Copyable text={displayName}>{displayName}</Copyable></span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ type VipLayoutProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VipLayout({ children, isLoggedIn, userName, onLogout }: VipLayoutProps) {
|
export function VipLayout({ children, isLoggedIn, userName, userEmail, onLogout }: VipLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<VipHeader isLoggedIn={isLoggedIn} userName={userName} onLogout={onLogout} />
|
<VipHeader isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={onLogout} />
|
||||||
<main className={styles.main}>{children}</main>
|
<main className={styles.main}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Copyable } from "../Copyable";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
|
|
||||||
type WelcomeBlockProps = {
|
type WelcomeBlockProps = {
|
||||||
@@ -12,8 +13,174 @@ function getStorage(key: string): string | null {
|
|||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 匿名 user_id 格式:user + 数字 */
|
||||||
|
function isAnonymousUserId(val: string | null): boolean {
|
||||||
|
return !!val && /^user\d+$/.test(val);
|
||||||
|
}
|
||||||
|
|
||||||
export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
||||||
const displayName = userName || getStorage("userName") || "会员";
|
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<string | null>(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 (
|
||||||
|
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||||
|
<h1
|
||||||
|
className="animate__animated animate__fadeInDown"
|
||||||
|
style={{
|
||||||
|
fontSize: "clamp(1.5rem, 4vw, 2rem)",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
animationDelay: "0.1s",
|
||||||
|
animationFillMode: "both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
欢迎加入,请补充邮箱
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="animate__animated animate__fadeInDown"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
margin: "0.25rem 0 1rem",
|
||||||
|
animationDelay: "0.15s",
|
||||||
|
animationFillMode: "both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
补充邮箱后可在各站点通用登录,Memos 账号密码为 12345678
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleLinkSubmit} className={styles.loginCard} style={{ maxWidth: "400px" }}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||||
<h1
|
<h1
|
||||||
@@ -26,7 +193,7 @@ export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
|||||||
animationFillMode: "both",
|
animationFillMode: "both",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
欢迎回来,{displayName}
|
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="animate__animated animate__fadeInDown"
|
className="animate__animated animate__fadeInDown"
|
||||||
@@ -40,23 +207,22 @@ export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
|||||||
>
|
>
|
||||||
以下是您的会员资产与推荐动作
|
以下是您的会员资产与推荐动作
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<div className={styles.loginCard}>
|
||||||
href="https://qun.hackrobot.cn"
|
<div className={styles.loginCardTitle}>星球登录信息</div>
|
||||||
target="_blank"
|
<div className={styles.loginCardRow}>
|
||||||
rel="noopener noreferrer"
|
<span className={styles.loginCardLabel}>网址</span>
|
||||||
className={styles.card}
|
<Copyable text="https://qun.hackrobot.cn">qun.hackrobot.cn</Copyable>
|
||||||
style={{
|
<a href="https://qun.hackrobot.cn" target="_blank" rel="noopener noreferrer" className={styles.loginCardLink}>打开</a>
|
||||||
display: "inline-flex",
|
</div>
|
||||||
alignItems: "center",
|
<div className={styles.loginCardRow}>
|
||||||
gap: "0.5rem",
|
<span className={styles.loginCardLabel}>账号</span>
|
||||||
marginTop: "1rem",
|
<Copyable text={displayName}><span className={styles.loginCardValue}>{displayName}</span></Copyable>
|
||||||
textDecoration: "none",
|
</div>
|
||||||
color: "var(--accent)",
|
<div className={styles.loginCardRow}>
|
||||||
fontWeight: 500,
|
<span className={styles.loginCardLabel}>密码</span>
|
||||||
}}
|
<Copyable text="12345678"><span className={styles.loginCardValue}>12345678</span></Copyable>
|
||||||
>
|
</div>
|
||||||
🌐 进入星球 qun.hackrobot.cn →
|
</div>
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L
|
|||||||
onClick={() => { setMode("user_id"); setError(null); }}
|
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)]"}`}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
import { LoginForm } from "./LoginForm";
|
import { LoginForm } from "./LoginForm";
|
||||||
import { AuthForm } from "./AuthForm";
|
|
||||||
|
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -14,8 +13,6 @@ type LoginModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
|
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
|
||||||
const [tab, setTab] = useState<"email" | "legacy">("email");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -73,41 +70,12 @@ export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex gap-2 border-b border-[var(--border)]">
|
<LoginForm
|
||||||
<button
|
onVerify={onVerify}
|
||||||
type="button"
|
onVerifyByEmail={onVerifyByEmail}
|
||||||
onClick={() => setTab("email")}
|
onSuccess={handleSuccess}
|
||||||
className={`pb-2 text-sm font-medium ${tab === "email" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
|
autoFocus
|
||||||
>
|
/>
|
||||||
邮箱登录
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -164,6 +164,63 @@
|
|||||||
border-color: rgba(245, 158, 11, 0.3);
|
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 {
|
.bigNumber {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|||||||
@@ -39,27 +39,30 @@ export function getPocketBaseConfig(): PocketBaseConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据请求 Host 推断 payjsapi 地址(同主机:8700)
|
* 根据请求 Host 推断 payjsapi 地址(同主机:8007)
|
||||||
* 访问 192.168.41.222:3000 时自动用 192.168.41.222:8700,无需在 .env 写死 IP
|
* 访问 192.168.41.222:3000 时自动用 192.168.41.222:8007
|
||||||
*/
|
*/
|
||||||
export function getApiUrlFromRequest(hostHeader: string | null): string {
|
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];
|
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 {
|
export function getPaymentConfig(): PaymentConfig {
|
||||||
const apiUrl =
|
const apiUrl =
|
||||||
process.env.PAYMENT_API_URL ||
|
process.env.PAYMENT_API_URL ||
|
||||||
process.env.NEXT_PUBLIC_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 {
|
return {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
||||||
zpaySubmitUrl:
|
zpaySubmitUrl:
|
||||||
process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
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",
|
nomadvipPayPath: "/nomadvip/payh5",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0",
|
"dev": "next dev -p 3002 -H 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
Reference in New Issue
Block a user