's'调试支付
This commit is contained in:
@@ -4,18 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { VideoCard } from "./VideoCard";
|
import { VideoCard } from "./VideoCard";
|
||||||
import { useVideoProgress } from "./useVideoProgress";
|
import { useVideoProgress } from "./useVideoProgress";
|
||||||
import AuthModal from "../../components/AuthModal";
|
import AuthModal from "../../components/AuthModal";
|
||||||
|
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||||
function getStoredUser(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("pb_user");
|
|
||||||
if (!raw) return null;
|
|
||||||
const user = JSON.parse(raw);
|
|
||||||
return user?.email ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── data ─── */
|
/* ─── data ─── */
|
||||||
|
|
||||||
@@ -166,12 +155,11 @@ export default function CoursePage() {
|
|||||||
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
|
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserEmail(getStoredUser());
|
setUserEmail(getStoredUserEmail());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("pb_token");
|
pbLogout();
|
||||||
localStorage.removeItem("pb_user");
|
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import type { EbookData } from "../lib/ebook";
|
import type { EbookData } from "@/app/lib/ebook";
|
||||||
import "github-markdown-css/github-markdown.css";
|
import "github-markdown-css/github-markdown.css";
|
||||||
import styles from "./ebook.module.css";
|
import styles from "./ebook.module.css";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
|
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { getPayEnv, getRecommendedChannel, mustUseWxPay, type PayChannel, type PayEnv } from "../../lib/payEnv";
|
import {
|
||||||
|
getPayEnv,
|
||||||
|
getRecommendedChannel,
|
||||||
|
mustUseWxPay,
|
||||||
|
redirectToPay,
|
||||||
|
getDeviceFromEnv,
|
||||||
|
} from "@/app/lib/payment";
|
||||||
|
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||||
|
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||||
|
|
||||||
const genderOptions = ["男", "女"];
|
const genderOptions = ["男", "女"];
|
||||||
const singleOptions = ["单身", "恋爱中", "已婚"];
|
const singleOptions = ["单身", "恋爱中", "已婚"];
|
||||||
@@ -55,6 +63,9 @@ export default function JoinPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 微信 H5 支付完成后可能不跳转,后台静默轮询,检测到已支付则显示成功页
|
||||||
|
usePayStatusPoll(() => setSubmitted(true));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const env = getPayEnv();
|
const env = getPayEnv();
|
||||||
@@ -86,33 +97,18 @@ export default function JoinPage() {
|
|||||||
}
|
}
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setPayRedirecting(true);
|
setPayRedirecting(true);
|
||||||
const returnUrl = `${window.location.origin}${window.location.pathname}?paid=1`;
|
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
|
||||||
const payForm = document.createElement("form");
|
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
|
||||||
payForm.method = "POST";
|
|
||||||
payForm.action = "/api/pay";
|
|
||||||
payForm.target = "_self";
|
|
||||||
payForm.style.display = "none";
|
|
||||||
const env = getPayEnv();
|
const env = getPayEnv();
|
||||||
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
|
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
|
||||||
const device = env === "pc" ? "pc" : "h5"; // PC 跳转 payurl,H5/微信 用 payurl/payurl2
|
const device = getDeviceFromEnv(env);
|
||||||
const fields: [string, string][] = [
|
redirectToPay({
|
||||||
["user_id", userId],
|
user_id: userId,
|
||||||
["total_fee", "80"],
|
return_url: returnUrl,
|
||||||
["type", "meetup"],
|
channel,
|
||||||
["channel", channel],
|
device,
|
||||||
["device", device],
|
useJoinConfig: true,
|
||||||
["return_url", returnUrl],
|
|
||||||
["html", "1"],
|
|
||||||
];
|
|
||||||
fields.forEach(([k, v]) => {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "hidden";
|
|
||||||
input.name = k;
|
|
||||||
input.value = v;
|
|
||||||
payForm.appendChild(input);
|
|
||||||
});
|
});
|
||||||
document.body.appendChild(payForm);
|
|
||||||
payForm.submit();
|
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
||||||
|
|||||||
31
app/[locale]/join/paid/page.tsx
Normal file
31
app/[locale]/join/paid/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付完成回跳页
|
||||||
|
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
||||||
|
*/
|
||||||
|
export default function JoinPaidPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
|
||||||
|
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||||
|
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">申请已提交</h2>
|
||||||
|
<p className="mt-3 text-slate-500">
|
||||||
|
我们会尽快审核你的信息
|
||||||
|
<br />
|
||||||
|
审核通过后将通过微信邀请你入群
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||||
|
>
|
||||||
|
← 返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { LocaleProvider } from "../context/LocaleContext";
|
import { LocaleProvider } from "../context/LocaleContext";
|
||||||
import type { Locale } from "../context/LocaleContext";
|
import type { Locale } from "../context/LocaleContext";
|
||||||
|
import { getThemeConfig } from "@/config";
|
||||||
|
import { getThemeMessages } from "@/app/lib/theme-data";
|
||||||
|
|
||||||
const LOCALES: Locale[] = ["zh", "en"];
|
const LOCALES: Locale[] = ["zh", "en"];
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = (() => {
|
||||||
title: "数字游民指南 | Digital Nomad Guide",
|
const theme = getThemeConfig();
|
||||||
description:
|
return {
|
||||||
"从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
|
title: theme.meta.title,
|
||||||
};
|
description: theme.meta.description,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return LOCALES.map((locale) => ({ locale }));
|
return LOCALES.map((locale) => ({ locale }));
|
||||||
@@ -27,7 +31,7 @@ export default async function LocaleLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = (await import(`../../messages/${locale}.json`)).default;
|
const messages = await getThemeMessages(locale as Locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import Roadmap from "../components/Roadmap";
|
|||||||
import Tools from "../components/Tools";
|
import Tools from "../components/Tools";
|
||||||
import Community from "../components/Community";
|
import Community from "../components/Community";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
|
import { getThemeToolsData } from "../lib/theme-data";
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const toolsData = await getThemeToolsData();
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -14,7 +17,7 @@ export default function HomePage() {
|
|||||||
<Hero />
|
<Hero />
|
||||||
<Features />
|
<Features />
|
||||||
<Roadmap />
|
<Roadmap />
|
||||||
<Tools />
|
<Tools data={toolsData} />
|
||||||
<Community />
|
<Community />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
const PB_URL = process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
|
import { getThemeConfig } from "@/config";
|
||||||
const COLLECTION = "solan";
|
|
||||||
|
|
||||||
function getOrCreateUserId(): string {
|
function getOrCreateUserId(): string {
|
||||||
return `user${Date.now()}`;
|
return `user${Date.now()}`;
|
||||||
@@ -62,13 +61,19 @@ export async function POST(request: NextRequest) {
|
|||||||
(record as Record<string, string>)["media"] = mediaUrl;
|
(record as Record<string, string>)["media"] = mediaUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const theme = getThemeConfig();
|
||||||
|
const pb = getPocketBaseConfig({
|
||||||
|
joinCollection: theme.services?.pocketbaseJoinCollection,
|
||||||
|
});
|
||||||
|
const collection = pb.joinCollection;
|
||||||
|
|
||||||
const doCreate = async (authToken?: string) => {
|
const doCreate = async (authToken?: string) => {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
if (authToken) headers["Authorization"] = authToken;
|
if (authToken) headers["Authorization"] = authToken;
|
||||||
|
|
||||||
return fetch(`${PB_URL}/api/collections/${COLLECTION}/records`, {
|
return fetch(`${pb.url}/api/collections/${collection}/records`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(record),
|
body: JSON.stringify(record),
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { PAYMENT_API_URL, SITE_URL } from "../../lib/env";
|
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
|
||||||
|
import { SITE_URL } from "../../lib/env";
|
||||||
const ZPAY_SUBMIT = "https://zpayz.cn/submit.php";
|
|
||||||
|
|
||||||
async function createPayOrder(
|
async function createPayOrder(
|
||||||
user_id: string,
|
user_id: string,
|
||||||
return_url: string,
|
return_url: string,
|
||||||
total_fee = 80,
|
total_fee?: number,
|
||||||
type = "meetup",
|
type?: string,
|
||||||
channel = "alipay"
|
channel = "alipay"
|
||||||
) {
|
) {
|
||||||
if (!PAYMENT_API_URL) throw new Error("未配置 PAYMENT_API_URL");
|
const joinConfig = getJoinPaymentConfig();
|
||||||
|
const fee = total_fee ?? joinConfig.amount;
|
||||||
|
const orderType = type ?? joinConfig.type;
|
||||||
|
const config = getPaymentConfig();
|
||||||
|
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
user_id,
|
user_id,
|
||||||
total_fee: Number(total_fee) || 80,
|
total_fee: Number(fee) || joinConfig.amount,
|
||||||
type,
|
type: orderType,
|
||||||
channel,
|
channel,
|
||||||
return_url,
|
return_url,
|
||||||
};
|
};
|
||||||
@@ -23,7 +26,7 @@ async function createPayOrder(
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
const res = await fetch(`${PAYMENT_API_URL}/payh5`, {
|
const res = await fetch(`${config.apiUrl}/payh5`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -36,7 +39,7 @@ async function createPayOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let params = data.params || data.xorpay_params || {};
|
let params = data.params || data.xorpay_params || {};
|
||||||
const payUrl = data.pay_url || data.xorpay_url || ZPAY_SUBMIT;
|
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
|
||||||
if (!params || typeof params !== "object") {
|
if (!params || typeof params !== "object") {
|
||||||
throw new Error("payjsapi 未返回支付参数");
|
throw new Error("payjsapi 未返回支付参数");
|
||||||
}
|
}
|
||||||
@@ -103,12 +106,14 @@ export async function POST(request: NextRequest) {
|
|||||||
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const joinConfig = getJoinPaymentConfig();
|
||||||
|
const payConfig = getPaymentConfig();
|
||||||
const user_id = String(body?.user_id || "").trim();
|
const user_id = String(body?.user_id || "").trim();
|
||||||
const return_url = String(body?.return_url || "").trim();
|
const return_url = String(body?.return_url || "").trim();
|
||||||
const total_fee = Number(body?.total_fee) || 80;
|
const total_fee = Number(body?.total_fee) || joinConfig.amount;
|
||||||
const type = String(body?.type || "meetup");
|
const type = String(body?.type || joinConfig.type);
|
||||||
const channel = String(body?.channel || "alipay");
|
const channel = String(body?.channel || "alipay");
|
||||||
const device = String(body?.device || "pc"); // pc | h5,前端根据 UA 传入
|
const device = String(body?.device || "pc"); // pc | h5 | wechat,微信内传 wechat
|
||||||
|
|
||||||
if (!user_id) {
|
if (!user_id) {
|
||||||
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||||||
@@ -118,7 +123,8 @@ export async function POST(request: NextRequest) {
|
|||||||
request.headers.get("x-forwarded-host")
|
request.headers.get("x-forwarded-host")
|
||||||
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||||
: request.headers.get("origin") || SITE_URL;
|
: request.headers.get("origin") || SITE_URL;
|
||||||
const finalReturnUrl = return_url || `${origin}/join?paid=1`;
|
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
|
||||||
|
const finalReturnUrl = return_url || `${origin}/zh/join/paid`;
|
||||||
|
|
||||||
const { params, payUrl } = await createPayOrder(
|
const { params, payUrl } = await createPayOrder(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -151,7 +157,7 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
const redirectRes = await fetch(`${PAYMENT_API_URL}/payh5/redirect`, {
|
const redirectRes = await fetch(`${payConfig.apiUrl}/payh5/redirect`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(redirectPayload),
|
body: JSON.stringify(redirectPayload),
|
||||||
@@ -160,9 +166,38 @@ export async function POST(request: NextRequest) {
|
|||||||
}).finally(() => clearTimeout(timeout));
|
}).finally(() => clearTimeout(timeout));
|
||||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||||
const location = redirectRes.headers.get("location");
|
const location = redirectRes.headers.get("location");
|
||||||
if (location) return NextResponse.redirect(location);
|
if (location) {
|
||||||
|
const res = NextResponse.redirect(location);
|
||||||
|
// payh5/redirect 创建的是新订单,必须用其返回的 order_id(非 createPayOrder 的 params)
|
||||||
|
const orderId =
|
||||||
|
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||||
|
String(params?.out_trade_no || "").trim();
|
||||||
|
if (orderId) {
|
||||||
|
// 附带时间戳,前端只对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
|
||||||
|
const payload = `${orderId}|${Date.now()}`;
|
||||||
|
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const resData = await redirectRes.json().catch(() => ({}));
|
const resData = await redirectRes.json().catch(() => ({}));
|
||||||
|
// 微信内:payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA
|
||||||
|
if (redirectRes.status === 200 && resData?.use_form) {
|
||||||
|
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
|
||||||
|
const res = new NextResponse(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
const orderId =
|
||||||
|
resData?.order_id?.trim() || String(params?.out_trade_no || "").trim();
|
||||||
|
if (orderId) {
|
||||||
|
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 900,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
|
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||||
@@ -247,7 +282,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const zpayRes = await fetch(ZPAY_SUBMIT, {
|
const zpayRes = await fetch(payConfig.zpaySubmitUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: formParams.toString(),
|
body: formParams.toString(),
|
||||||
@@ -255,7 +290,13 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
const location = zpayRes.headers.get("location");
|
const location = zpayRes.headers.get("location");
|
||||||
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
|
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
|
||||||
return NextResponse.redirect(location);
|
const res = NextResponse.redirect(location);
|
||||||
|
const outTradeNo = String(params?.out_trade_no || "").trim();
|
||||||
|
if (outTradeNo) {
|
||||||
|
const payload = `${outTradeNo}|${Date.now()}`;
|
||||||
|
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
const text = await zpayRes.text();
|
const text = await zpayRes.text();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { PAYMENT_API_URL } from "../../../lib/env";
|
import { getPaymentConfig } from "@/config/services.config";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const orderId = request.nextUrl.searchParams.get("order_id");
|
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||||
if (!orderId) {
|
if (!orderId) {
|
||||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!PAYMENT_API_URL) {
|
const config = getPaymentConfig();
|
||||||
|
if (!config.apiUrl) {
|
||||||
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${PAYMENT_API_URL}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||||
{ cache: "no-store" }
|
{ cache: "no-store" }
|
||||||
);
|
);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as Minio from "minio";
|
import { uploadBuffer, generateObjectKey } from "@/app/lib/minio";
|
||||||
|
|
||||||
// mc alias list myminio: http://minioweb.hackrobot.cn:9035
|
const MAX_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
|
|
||||||
const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9035", 10);
|
|
||||||
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === "true";
|
|
||||||
const MINIO_BUCKET = process.env.MINIO_BUCKET || "hackrobot";
|
|
||||||
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp";
|
|
||||||
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q";
|
|
||||||
// 公开访问 URL(https 无端口),API 上传用 9035
|
|
||||||
const MINIO_PUBLIC_URL =
|
|
||||||
process.env.MINIO_PUBLIC_URL ||
|
|
||||||
`https://${MINIO_ENDPOINT}/${MINIO_BUCKET}`;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -27,38 +17,23 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileObj.size > 20 * 1024 * 1024) {
|
if (fileObj.size > MAX_SIZE) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ ok: false, error: "文件大小不能超过 20MB" },
|
{ ok: false, error: "文件大小不能超过 20MB" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new Minio.Client({
|
|
||||||
endPoint: MINIO_ENDPOINT,
|
|
||||||
port: MINIO_PORT,
|
|
||||||
useSSL: MINIO_USE_SSL,
|
|
||||||
accessKey: MINIO_ACCESS_KEY,
|
|
||||||
secretKey: MINIO_SECRET_KEY,
|
|
||||||
pathStyle: true,
|
|
||||||
region: process.env.MINIO_REGION || "china",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ext =
|
const ext =
|
||||||
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
|
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
|
||||||
(fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
|
(fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
|
||||||
const objectKey = `joins/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
const objectKey = generateObjectKey(ext);
|
||||||
|
|
||||||
const buffer = Buffer.from(await fileObj.arrayBuffer());
|
const buffer = Buffer.from(await fileObj.arrayBuffer());
|
||||||
const contentType =
|
const contentType =
|
||||||
(fileObj as File).type || "application/octet-stream";
|
(fileObj as File).type || "application/octet-stream";
|
||||||
|
|
||||||
await client.putObject(MINIO_BUCKET, objectKey, buffer, buffer.length, {
|
const { url } = await uploadBuffer(buffer, objectKey, contentType);
|
||||||
"Content-Type": contentType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const base = MINIO_PUBLIC_URL.replace(/\/$/, "");
|
|
||||||
const url = `${base}/${objectKey}`;
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, url });
|
return NextResponse.json({ ok: true, url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import {
|
||||||
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
|
pbLogin,
|
||||||
|
pbRegister,
|
||||||
|
pbSaveAuth,
|
||||||
|
} from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -24,6 +27,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let result;
|
||||||
if (mode === "register") {
|
if (mode === "register") {
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
setError("两次密码不一致");
|
setError("两次密码不一致");
|
||||||
@@ -35,49 +39,11 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${PB_URL}/api/collections/users/records`, {
|
result = await pbRegister(email, password, passwordConfirm);
|
||||||
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 || "注册失败");
|
|
||||||
}
|
|
||||||
// 注册成功后自动登录
|
|
||||||
const authRes = await fetch(`${PB_URL}/api/collections/users/auth-with-password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ identity: email, password }),
|
|
||||||
});
|
|
||||||
if (!authRes.ok) {
|
|
||||||
throw new Error("注册成功,请手动登录");
|
|
||||||
}
|
|
||||||
const authData = await authRes.json();
|
|
||||||
if (authData?.token) {
|
|
||||||
localStorage.setItem("pb_token", authData.token);
|
|
||||||
localStorage.setItem("pb_user", JSON.stringify(authData.record));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(`${PB_URL}/api/collections/users/auth-with-password`, {
|
result = await pbLogin(email, password);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ identity: email, password }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
throw new Error(err?.message || "登录失败");
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.token) {
|
|
||||||
localStorage.setItem("pb_token", data.token);
|
|
||||||
localStorage.setItem("pb_user", JSON.stringify(data.record));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
pbSaveAuth(result.token, result.record);
|
||||||
onSuccess(email);
|
onSuccess(email);
|
||||||
onClose();
|
onClose();
|
||||||
setEmail("");
|
setEmail("");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||||
|
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||||
import AuthModal from "./AuthModal";
|
import AuthModal from "./AuthModal";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
|
|
||||||
@@ -11,18 +12,6 @@ const navLinks = [
|
|||||||
{ labelKey: "community" as const, href: "#community" },
|
{ labelKey: "community" as const, href: "#community" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getStoredUser(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("pb_user");
|
|
||||||
if (!raw) return null;
|
|
||||||
const user = JSON.parse(raw);
|
|
||||||
return user?.email ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const { t: tNav } = useTranslation("nav");
|
const { t: tNav } = useTranslation("nav");
|
||||||
@@ -35,12 +24,11 @@ export default function Header() {
|
|||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserEmail(getStoredUser());
|
setUserEmail(getStoredUserEmail());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("pb_token");
|
pbLogout();
|
||||||
localStorage.removeItem("pb_user");
|
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslation } from "@/i18n/navigation";
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
import toolsData from "../data/tools.json";
|
import type { ToolsCategory } from "@/app/lib/theme-data";
|
||||||
|
|
||||||
const categories = toolsData.categories as Array<{
|
type ToolsProps = {
|
||||||
icon: string;
|
data: { categories: ToolsCategory[] };
|
||||||
title: string;
|
};
|
||||||
color: string;
|
|
||||||
bg: string;
|
|
||||||
text: string;
|
|
||||||
tools: Array<{ name: string; desc: string; href: string }>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function truncateDesc(desc: string, maxLen = 5) {
|
function truncateDesc(desc: string, maxLen = 5) {
|
||||||
if (!desc || desc.length <= maxLen) return desc;
|
if (!desc || desc.length <= maxLen) return desc;
|
||||||
return desc.slice(0, maxLen) + "...";
|
return desc.slice(0, maxLen) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tools() {
|
export default function Tools({ data }: ToolsProps) {
|
||||||
|
const categories = data.categories;
|
||||||
const { t } = useTranslation("tools");
|
const { t } = useTranslation("tools");
|
||||||
const total = categories.reduce((s, c) => s + c.tools.length, 0);
|
const total = categories.reduce((s, c) => s + c.tools.length, 0);
|
||||||
const toolStats = [
|
const toolStats = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
|
import { getThemeConfig } from "@/config";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,11 +14,13 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = (() => {
|
||||||
title: "数字游民指南 | Digital Nomad Guide",
|
const theme = getThemeConfig();
|
||||||
description:
|
return {
|
||||||
"从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
|
title: theme.meta.title,
|
||||||
};
|
description: theme.meta.description,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
function ThemeScript() {
|
function ThemeScript() {
|
||||||
return (
|
return (
|
||||||
@@ -46,7 +49,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationError>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
67
app/lib/minio/client.ts
Normal file
67
app/lib/minio/client.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* MinIO 客户端封装 - 上传、下载等操作
|
||||||
|
* 通过 getMinioConfig() 获取配置,支持主题覆盖
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Minio from "minio";
|
||||||
|
import { getMinioConfig } from "./config";
|
||||||
|
import { getThemeConfig } from "@/config/site.config";
|
||||||
|
|
||||||
|
/** 获取 MinIO 客户端实例(使用主题配置) */
|
||||||
|
export function getMinioClient(): Minio.Client {
|
||||||
|
const theme = getThemeConfig();
|
||||||
|
const cfg = getMinioConfig(theme.services?.minio);
|
||||||
|
|
||||||
|
return new Minio.Client({
|
||||||
|
endPoint: cfg.endPoint,
|
||||||
|
port: cfg.port,
|
||||||
|
useSSL: cfg.useSSL,
|
||||||
|
accessKey: cfg.accessKey,
|
||||||
|
secretKey: cfg.secretKey,
|
||||||
|
pathStyle: true,
|
||||||
|
region: cfg.region,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件到 MinIO
|
||||||
|
* @param buffer 文件内容
|
||||||
|
* @param objectKey 对象键(含路径,如 joins/xxx.jpg)
|
||||||
|
* @param contentType MIME 类型
|
||||||
|
*/
|
||||||
|
export async function uploadBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
objectKey: string,
|
||||||
|
contentType: string = "application/octet-stream"
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const theme = getThemeConfig();
|
||||||
|
const cfg = getMinioConfig(theme.services?.minio);
|
||||||
|
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
throw new Error("MinIO 存储未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getMinioClient();
|
||||||
|
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${cfg.publicUrl}/${objectKey}`;
|
||||||
|
return { url, objectKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成上传用的对象键
|
||||||
|
* @param ext 文件扩展名
|
||||||
|
*/
|
||||||
|
export function generateObjectKey(ext: string): string {
|
||||||
|
const theme = getThemeConfig();
|
||||||
|
const cfg = getMinioConfig(theme.services?.minio);
|
||||||
|
const prefix = cfg.uploadPrefix.replace(/\/$/, "");
|
||||||
|
return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||||
|
}
|
||||||
2
app/lib/minio/config.ts
Normal file
2
app/lib/minio/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getMinioConfig } from "@/config/services.config";
|
||||||
|
export type { MinioConfig } from "@/config/services.config";
|
||||||
13
app/lib/minio/index.ts
Normal file
13
app/lib/minio/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* MinIO 存储组件化封装
|
||||||
|
* 通过 config/services.config 和主题配置,支持多环境、多主题
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { getMinioConfig } from "./config";
|
||||||
|
export type { MinioConfig } from "./config";
|
||||||
|
export {
|
||||||
|
getMinioClient,
|
||||||
|
uploadBuffer,
|
||||||
|
generateObjectKey,
|
||||||
|
} from "./client";
|
||||||
|
export type { UploadResult } from "./client";
|
||||||
57
app/lib/payment/client.ts
Normal file
57
app/lib/payment/client.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 支付客户端 - 封装支付 API 调用
|
||||||
|
* 用于前端发起支付、跳转支付页
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
|
||||||
|
import type { PayChannel, PayEnv } from "./types";
|
||||||
|
|
||||||
|
/** 发起支付并跳转(表单 POST 到 /api/pay) */
|
||||||
|
export function redirectToPay(params: {
|
||||||
|
user_id: string;
|
||||||
|
return_url: string;
|
||||||
|
total_fee?: number;
|
||||||
|
type?: string;
|
||||||
|
channel: PayChannel;
|
||||||
|
device: "pc" | "h5" | "wechat";
|
||||||
|
/** 是否使用「加入」场景配置(主题覆盖) */
|
||||||
|
useJoinConfig?: boolean;
|
||||||
|
}): void {
|
||||||
|
const baseConfig = getPaymentConfig();
|
||||||
|
const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
|
||||||
|
const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
|
||||||
|
const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
|
||||||
|
|
||||||
|
const payForm = document.createElement("form");
|
||||||
|
payForm.method = "POST";
|
||||||
|
payForm.action = "/api/pay";
|
||||||
|
payForm.target = "_self";
|
||||||
|
payForm.style.display = "none";
|
||||||
|
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
["user_id", params.user_id],
|
||||||
|
["return_url", params.return_url],
|
||||||
|
["total_fee", String(total_fee)],
|
||||||
|
["type", type],
|
||||||
|
["channel", params.channel],
|
||||||
|
["device", params.device],
|
||||||
|
["html", "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.forEach(([k, v]) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = k;
|
||||||
|
input.value = v;
|
||||||
|
payForm.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(payForm);
|
||||||
|
payForm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 PayEnv 确定 device:微信内用 wechat 走收银台表单,payurl2 仅支持微信外 */
|
||||||
|
export function getDeviceFromEnv(env: PayEnv): "pc" | "h5" | "wechat" {
|
||||||
|
if (env === "wechat") return "wechat";
|
||||||
|
return env === "pc" ? "pc" : "h5";
|
||||||
|
}
|
||||||
2
app/lib/payment/config.ts
Normal file
2
app/lib/payment/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getPaymentConfig, getServicesConfig } from "@/config/services.config";
|
||||||
|
export type { PaymentConfig } from "@/config/services.config";
|
||||||
4
app/lib/payment/index.ts
Normal file
4
app/lib/payment/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { redirectToPay, getDeviceFromEnv } from "./client";
|
||||||
|
export { getPayEnv, getRecommendedChannel, mustUseWxPay } from "../payEnv";
|
||||||
|
export type { PayChannel, PayEnv, CreatePayParams, PayRedirectParams } from "./types";
|
||||||
|
export { getPaymentConfig } from "./config";
|
||||||
21
app/lib/payment/types.ts
Normal file
21
app/lib/payment/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type PayChannel = "alipay" | "wxpay";
|
||||||
|
export type PayEnv = "pc" | "h5" | "wechat";
|
||||||
|
|
||||||
|
export interface CreatePayParams {
|
||||||
|
user_id: string;
|
||||||
|
return_url: string;
|
||||||
|
total_fee?: number;
|
||||||
|
type?: string;
|
||||||
|
channel?: PayChannel;
|
||||||
|
device?: "pc" | "h5" | "wechat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayRedirectParams {
|
||||||
|
user_id: string;
|
||||||
|
total_fee: number;
|
||||||
|
type: string;
|
||||||
|
channel: PayChannel;
|
||||||
|
return_url: string;
|
||||||
|
device: "pc" | "h5" | "wechat";
|
||||||
|
html?: "1";
|
||||||
|
}
|
||||||
76
app/lib/payment/usePayStatusPoll.ts
Normal file
76
app/lib/payment/usePayStatusPoll.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
|
||||||
|
* cookie 格式:order_id|timestamp,仅对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "join_pay_order";
|
||||||
|
const POLL_INTERVAL = 2000;
|
||||||
|
const MAX_POLLS = 150; // 约 5 分钟
|
||||||
|
const MAX_AGE_MS = 15 * 60 * 1000; // 15 分钟
|
||||||
|
|
||||||
|
function getCookie(name: string): string | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookie(name: string): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.cookie = `${name}=; path=/; max-age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 cookie:order_id|timestamp,返回 [orderId, isValid]。无时间戳视为过期并清除 */
|
||||||
|
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
|
||||||
|
if (!raw?.trim()) return [null, false];
|
||||||
|
const parts = raw.split("|");
|
||||||
|
const orderId = parts[0]?.trim() || raw.trim();
|
||||||
|
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
|
||||||
|
if (!orderId) return [null, false];
|
||||||
|
if (Number.isNaN(ts)) return [orderId, false]; // 旧格式无时间戳,视为过期
|
||||||
|
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false]; // 超过 15 分钟
|
||||||
|
return [orderId, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePayStatusPoll(onPaid: () => void): boolean {
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
const onPaidRef = useRef(onPaid);
|
||||||
|
onPaidRef.current = onPaid;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = getCookie(COOKIE_NAME);
|
||||||
|
const [orderId, isValid] = parsePayOrderCookie(raw);
|
||||||
|
if (!orderId || !isValid) {
|
||||||
|
if (raw) clearCookie(COOKIE_NAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolling(true);
|
||||||
|
let count = 0;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
count++;
|
||||||
|
if (count > MAX_POLLS) {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearCookie(COOKIE_NAME);
|
||||||
|
setPolling(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok && data?.paid) {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearCookie(COOKIE_NAME);
|
||||||
|
setPolling(false);
|
||||||
|
onPaidRef.current();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 继续轮询
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return polling;
|
||||||
|
}
|
||||||
94
app/lib/pocketbase/auth.ts
Normal file
94
app/lib/pocketbase/auth.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 PocketBase 基础 URL(客户端用 NEXT_PUBLIC_ 前缀) */
|
||||||
|
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 config = getPocketBaseConfig();
|
||||||
|
const res = await fetch(`${url}/api/collections/${config.usersCollection}/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存认证信息到 localStorage */
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登出 - 清除本地存储 */
|
||||||
|
export function pbLogout(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前存储的用户邮箱 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取存储的 token(用于 API 请求) */
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(PB_STORAGE_KEYS.token);
|
||||||
|
}
|
||||||
2
app/lib/pocketbase/config.ts
Normal file
2
app/lib/pocketbase/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getPocketBaseConfig, getServicesConfig } from "@/config/services.config";
|
||||||
|
export type { PocketBaseConfig } 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";
|
||||||
86
app/lib/theme-data.ts
Normal file
86
app/lib/theme-data.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 主题数据加载器 - 根据 CURRENT_THEME 加载对应主题的内容
|
||||||
|
* 新增主题:1. 在 config/site.config.ts 添加 THEME_CONFIGS
|
||||||
|
* 2. 在 content/themes/[theme-id]/ 下添加 messages 和 data
|
||||||
|
* 3. 在本文件 switch 中添加 case
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CURRENT_THEME, THEME_IDS, type ThemeId } from "@/config";
|
||||||
|
|
||||||
|
export type Locale = "zh" | "en";
|
||||||
|
|
||||||
|
export type ToolsCategory = {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
text: string;
|
||||||
|
tools: Array<{ name: string; desc: string; href: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 加载当前主题的 messages */
|
||||||
|
export async function getThemeMessages(
|
||||||
|
locale: Locale
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const theme = CURRENT_THEME;
|
||||||
|
switch (theme) {
|
||||||
|
case "digital-nomad": {
|
||||||
|
const mod =
|
||||||
|
locale === "zh"
|
||||||
|
? await import("@/content/themes/digital-nomad/messages/zh.json")
|
||||||
|
: await import("@/content/themes/digital-nomad/messages/en.json");
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
case "solo-company":
|
||||||
|
case "tiktok-ops":
|
||||||
|
case "indie-dev":
|
||||||
|
// 新主题暂无内容时回退到 digital-nomad
|
||||||
|
return getThemeMessagesFallback(locale);
|
||||||
|
default:
|
||||||
|
return getThemeMessagesFallback(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getThemeMessagesFallback(
|
||||||
|
locale: Locale
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const mod =
|
||||||
|
locale === "zh"
|
||||||
|
? await import("@/content/themes/digital-nomad/messages/zh.json")
|
||||||
|
: await import("@/content/themes/digital-nomad/messages/en.json");
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载当前主题的 tools 数据 */
|
||||||
|
export async function getThemeToolsData(): Promise<{
|
||||||
|
categories: ToolsCategory[];
|
||||||
|
}> {
|
||||||
|
const theme = CURRENT_THEME;
|
||||||
|
switch (theme) {
|
||||||
|
case "digital-nomad": {
|
||||||
|
const mod = await import(
|
||||||
|
"@/content/themes/digital-nomad/data/tools.json"
|
||||||
|
);
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
case "solo-company":
|
||||||
|
case "tiktok-ops":
|
||||||
|
case "indie-dev":
|
||||||
|
return getThemeToolsDataFallback();
|
||||||
|
default:
|
||||||
|
return getThemeToolsDataFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getThemeToolsDataFallback(): Promise<{
|
||||||
|
categories: ToolsCategory[];
|
||||||
|
}> {
|
||||||
|
const mod = await import(
|
||||||
|
"@/content/themes/digital-nomad/data/tools.json"
|
||||||
|
);
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CURRENT_THEME, THEME_IDS };
|
||||||
|
export type { ThemeId };
|
||||||
16
config/index.ts
Normal file
16
config/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
CURRENT_THEME,
|
||||||
|
THEME_IDS,
|
||||||
|
THEME_CONFIGS,
|
||||||
|
getThemeConfig,
|
||||||
|
type ThemeId,
|
||||||
|
type ThemeConfig,
|
||||||
|
} from "./site.config";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getPocketBaseConfig,
|
||||||
|
getPaymentConfig,
|
||||||
|
getJoinPaymentConfig,
|
||||||
|
getServicesConfig,
|
||||||
|
} from "./services.config";
|
||||||
|
export type { PocketBaseConfig, PaymentConfig, ServicesConfig } from "./services.config";
|
||||||
137
config/services.config.ts
Normal file
137
config/services.config.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 服务配置 - PocketBase、支付等第三方服务
|
||||||
|
* 可通过环境变量覆盖,不同主题可配置不同集合/金额
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getThemeConfig } from "./site.config";
|
||||||
|
|
||||||
|
export interface PocketBaseConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** PocketBase 服务地址 */
|
||||||
|
url: string;
|
||||||
|
/** 用户认证集合 */
|
||||||
|
usersCollection: string;
|
||||||
|
/** 申请/加入表单集合(如 solan、applications) */
|
||||||
|
joinCollection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** 支付 API 地址(payjsapi) */
|
||||||
|
apiUrl: string;
|
||||||
|
/** 支付提供商:zpay | xorpay */
|
||||||
|
provider: "zpay" | "xorpay";
|
||||||
|
/** 默认金额(分) */
|
||||||
|
defaultAmount: number;
|
||||||
|
/** 默认订单类型 */
|
||||||
|
defaultType: string;
|
||||||
|
/** ZPAY 提交地址 */
|
||||||
|
zpaySubmitUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MinioConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** MinIO 服务端点 */
|
||||||
|
endPoint: string;
|
||||||
|
/** 端口 */
|
||||||
|
port: number;
|
||||||
|
/** 是否使用 SSL */
|
||||||
|
useSSL: boolean;
|
||||||
|
/** 存储桶 */
|
||||||
|
bucket: string;
|
||||||
|
/** Access Key */
|
||||||
|
accessKey: string;
|
||||||
|
/** Secret Key */
|
||||||
|
secretKey: string;
|
||||||
|
/** 区域 */
|
||||||
|
region: string;
|
||||||
|
/** 公开访问 URL(用于生成文件链接) */
|
||||||
|
publicUrl: string;
|
||||||
|
/** 上传路径前缀(如 joins、uploads) */
|
||||||
|
uploadPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicesConfig {
|
||||||
|
pocketbase: PocketBaseConfig;
|
||||||
|
payment: PaymentConfig;
|
||||||
|
minio: MinioConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
/** 获取 PocketBase 配置(可传入主题覆盖) */
|
||||||
|
export function getPocketBaseConfig(themeOverride?: { joinCollection?: string }): PocketBaseConfig {
|
||||||
|
const joinCollection =
|
||||||
|
themeOverride?.joinCollection ||
|
||||||
|
process.env.POCKETBASE_JOIN_COLLECTION ||
|
||||||
|
"solan";
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn",
|
||||||
|
usersCollection: "users",
|
||||||
|
joinCollection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取支付配置(可传入主题覆盖,如 join 金额/类型) */
|
||||||
|
export function getPaymentConfig(themeOverride?: { amount?: number; type?: string }): PaymentConfig {
|
||||||
|
const base = {
|
||||||
|
enabled: true,
|
||||||
|
apiUrl:
|
||||||
|
process.env.PAYMENT_API_URL ||
|
||||||
|
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
|
||||||
|
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
||||||
|
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80,
|
||||||
|
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
|
||||||
|
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
||||||
|
};
|
||||||
|
if (themeOverride) {
|
||||||
|
if (themeOverride.amount != null) base.defaultAmount = themeOverride.amount;
|
||||||
|
if (themeOverride.type) base.defaultType = themeOverride.type;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 MinIO 配置(可传入主题覆盖 bucket/prefix) */
|
||||||
|
export function getMinioConfig(themeOverride?: { bucket?: string; uploadPrefix?: string }): MinioConfig {
|
||||||
|
const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
|
||||||
|
const port = parseInt(process.env.MINIO_PORT || "9035", 10);
|
||||||
|
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||||||
|
const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
|
||||||
|
const publicUrl =
|
||||||
|
process.env.MINIO_PUBLIC_URL ||
|
||||||
|
`https://${endPoint}/${bucket}`;
|
||||||
|
const uploadPrefix = themeOverride?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins";
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
endPoint,
|
||||||
|
port,
|
||||||
|
useSSL,
|
||||||
|
bucket,
|
||||||
|
accessKey: process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp",
|
||||||
|
secretKey: process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q",
|
||||||
|
region: process.env.MINIO_REGION || "china",
|
||||||
|
publicUrl: publicUrl.replace(/\/$/, ""),
|
||||||
|
uploadPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取完整服务配置 */
|
||||||
|
export function getServicesConfig(): ServicesConfig {
|
||||||
|
return {
|
||||||
|
pocketbase: getPocketBaseConfig(),
|
||||||
|
payment: getPaymentConfig(),
|
||||||
|
minio: getMinioConfig(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取「加入/申请」场景的支付配置(含主题覆盖) */
|
||||||
|
export function getJoinPaymentConfig(): { amount: number; type: string } {
|
||||||
|
const theme = getThemeConfig();
|
||||||
|
const payment = getPaymentConfig();
|
||||||
|
return {
|
||||||
|
amount: theme.services?.join?.amount ?? payment.defaultAmount,
|
||||||
|
type: theme.services?.join?.type ?? payment.defaultType,
|
||||||
|
};
|
||||||
|
}
|
||||||
165
config/site.config.ts
Normal file
165
config/site.config.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 站点模板配置 - 切换主题只需修改 CURRENT_THEME
|
||||||
|
* 克隆新主题:复制 themes/digital-nomad 为 themes/your-theme,修改配置和内容
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const THEME_IDS = [
|
||||||
|
"digital-nomad",
|
||||||
|
"solo-company",
|
||||||
|
"tiktok-ops",
|
||||||
|
"indie-dev",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ThemeId = (typeof THEME_IDS)[number];
|
||||||
|
|
||||||
|
export const CURRENT_THEME: ThemeId =
|
||||||
|
(process.env.NEXT_PUBLIC_THEME as ThemeId) || "digital-nomad";
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
id: ThemeId;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
tagline: string;
|
||||||
|
taglineEn: string;
|
||||||
|
/** SEO 元数据 */
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
/** 功能开关 */
|
||||||
|
features: {
|
||||||
|
roadmap: boolean;
|
||||||
|
tools: boolean;
|
||||||
|
community: boolean;
|
||||||
|
join: boolean;
|
||||||
|
course: boolean;
|
||||||
|
ebook: boolean;
|
||||||
|
};
|
||||||
|
/** 品牌色 - Tailwind 类名 */
|
||||||
|
colors: {
|
||||||
|
primary: string;
|
||||||
|
accent: string;
|
||||||
|
};
|
||||||
|
/** 服务配置覆盖(可选) */
|
||||||
|
services?: {
|
||||||
|
/** 加入/申请支付 */
|
||||||
|
join?: {
|
||||||
|
amount?: number;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
/** PocketBase 申请集合名 */
|
||||||
|
pocketbaseJoinCollection?: string;
|
||||||
|
/** MinIO 存储(可选) */
|
||||||
|
minio?: {
|
||||||
|
bucket?: string;
|
||||||
|
uploadPrefix?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_CONFIGS: Record<ThemeId, ThemeConfig> = {
|
||||||
|
"digital-nomad": {
|
||||||
|
id: "digital-nomad",
|
||||||
|
name: "数字游民指南",
|
||||||
|
nameEn: "Digital Nomad Guide",
|
||||||
|
tagline: "开源免费的数字游民资源平台",
|
||||||
|
taglineEn: "Open-source digital nomad resource platform",
|
||||||
|
meta: {
|
||||||
|
title: "数字游民指南 | Digital Nomad Guide",
|
||||||
|
description:
|
||||||
|
"从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
roadmap: true,
|
||||||
|
tools: true,
|
||||||
|
community: true,
|
||||||
|
join: true,
|
||||||
|
course: true,
|
||||||
|
ebook: true,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: "sky",
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
join: { amount: 80, type: "meetup" },
|
||||||
|
pocketbaseJoinCollection: "solan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"solo-company": {
|
||||||
|
id: "solo-company",
|
||||||
|
name: "一人公司指南",
|
||||||
|
nameEn: "Solo Company Guide",
|
||||||
|
tagline: "一人公司创业与运营指南",
|
||||||
|
taglineEn: "Guide to building and running a solo company",
|
||||||
|
meta: {
|
||||||
|
title: "一人公司指南 | Solo Company Guide",
|
||||||
|
description:
|
||||||
|
"从零到一,打造属于你的一人公司。产品、营销、运营 —— 一人公司全流程指南。",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
roadmap: true,
|
||||||
|
tools: true,
|
||||||
|
community: true,
|
||||||
|
join: false,
|
||||||
|
course: false,
|
||||||
|
ebook: true,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: "violet",
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tiktok-ops": {
|
||||||
|
id: "tiktok-ops",
|
||||||
|
name: "TikTok 运营指南",
|
||||||
|
nameEn: "TikTok Operations Guide",
|
||||||
|
tagline: "TikTok 出海运营与变现指南",
|
||||||
|
taglineEn: "TikTok operations and monetization guide",
|
||||||
|
meta: {
|
||||||
|
title: "TikTok 运营指南 | TikTok Operations Guide",
|
||||||
|
description:
|
||||||
|
"TikTok 账号运营、内容创作、广告投放、跨境变现 —— 一站式 TikTok 出海指南。",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
roadmap: true,
|
||||||
|
tools: true,
|
||||||
|
community: true,
|
||||||
|
join: false,
|
||||||
|
course: true,
|
||||||
|
ebook: true,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: "pink",
|
||||||
|
accent: "cyan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"indie-dev": {
|
||||||
|
id: "indie-dev",
|
||||||
|
name: "独立开发指南",
|
||||||
|
nameEn: "Indie Developer Guide",
|
||||||
|
tagline: "独立开发者产品与变现指南",
|
||||||
|
taglineEn: "Indie developer product and monetization guide",
|
||||||
|
meta: {
|
||||||
|
title: "独立开发指南 | Indie Developer Guide",
|
||||||
|
description:
|
||||||
|
"从想法到产品,独立开发全流程。技术选型、产品设计、上线运营 —— 独立开发者必备指南。",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
roadmap: true,
|
||||||
|
tools: true,
|
||||||
|
community: true,
|
||||||
|
join: false,
|
||||||
|
course: false,
|
||||||
|
ebook: true,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: "indigo",
|
||||||
|
accent: "orange",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getThemeConfig(): ThemeConfig {
|
||||||
|
return THEME_CONFIGS[CURRENT_THEME];
|
||||||
|
}
|
||||||
219
content/themes/digital-nomad/data/tools.json
Normal file
219
content/themes/digital-nomad/data/tools.json
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "nomad-community",
|
||||||
|
"icon": "👥",
|
||||||
|
"title": "游民社区",
|
||||||
|
"color": "from-sky-500 to-cyan-500",
|
||||||
|
"bg": "bg-sky-50",
|
||||||
|
"text": "text-sky-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "nomadro", "desc": "出海导航", "href": "https://nomadro.com" },
|
||||||
|
{ "name": "nomadlist", "desc": "海外最大的数字游民社区", "href": "https://nomads.com?ref=https://nomadro.com" },
|
||||||
|
{ "name": "nomadcna", "desc": "国内数字游民社区", "href": "https://meetup.hackrobot.cn" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "toolbox",
|
||||||
|
"icon": "🔧",
|
||||||
|
"title": "工具箱",
|
||||||
|
"color": "from-violet-500 to-purple-500",
|
||||||
|
"bg": "bg-violet-50",
|
||||||
|
"text": "text-violet-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "ChatGPT", "desc": "OpenAI开发的人工智能聊天机器人程序", "href": "https://chat.openai.com/" },
|
||||||
|
{ "name": "Grok", "desc": "xAI 免费 AI 助手", "href": "https://grok.com/" },
|
||||||
|
{ "name": "沉浸式翻译", "desc": "双语翻译插件", "href": "https://immersivetranslate.com/?via=nomad-vps" },
|
||||||
|
{ "name": "奈飞小铺", "desc": "代充值,外网账号合租平台", "href": "https://ihezu.art/CZhn6c" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "us-phone",
|
||||||
|
"icon": "📱",
|
||||||
|
"title": "美国手机号",
|
||||||
|
"color": "from-emerald-500 to-teal-500",
|
||||||
|
"bg": "bg-emerald-50",
|
||||||
|
"text": "text-emerald-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "1psim", "desc": "低成本:2.5$/m,支持esim和实体卡(邮寄中国)", "href": "https://1psim.com/7GARRL" },
|
||||||
|
{ "name": "yesim", "desc": "美国手机号,3$/m", "href": "https://yesim.app/?partner_id=1948" },
|
||||||
|
{ "name": "tello", "desc": "最低5$/m,支持esim", "href": "https://tello.com/" },
|
||||||
|
{ "name": "9esim", "desc": "让任何手机支持esim,可写数据的SIM实体卡", "href": "https://www.9esim.com/?coupon=XIAOSHUANGERIC2" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finance",
|
||||||
|
"icon": "💳",
|
||||||
|
"title": "金融收款",
|
||||||
|
"color": "from-amber-500 to-orange-500",
|
||||||
|
"bg": "bg-amber-50",
|
||||||
|
"text": "text-amber-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "wise", "desc": "线上开通境外银行账户(身份证即可)", "href": "https://wise.com/invite/amc/xiaoshuangh" },
|
||||||
|
{ "name": "OKX交易所", "desc": "加密货币交易所", "href": "https://okx.com/join/84999861" },
|
||||||
|
{ "name": "safepal (U卡)", "desc": "出海神卡,邀请码:104809", "href": "https://www.safepal.com/zh-cn/" },
|
||||||
|
{ "name": "paypal", "desc": "出海收款必备,tiktok提现", "href": "https://py.pl/1tyAwY" },
|
||||||
|
{ "name": "buy me coffee", "desc": "赞助收款平台", "href": "https://www.buymeacoffee.com/invite/xiaoshuangi" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vps",
|
||||||
|
"icon": "🖥️",
|
||||||
|
"title": "VPS服务器",
|
||||||
|
"color": "from-blue-500 to-indigo-500",
|
||||||
|
"bg": "bg-blue-50",
|
||||||
|
"text": "text-blue-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "vultr", "desc": "可按分钟计费的服务器 (可支付宝)", "href": "https://www.vultr.com/?ref=8985003" },
|
||||||
|
{ "name": "bandwagonhost", "desc": "针对中国线路优化的高质量vps", "href": "https://bandwagonhost.com/aff.php?aff=25463" },
|
||||||
|
{ "name": "RackNerd", "desc": "最便宜的美国vps服务器", "href": "https://my.racknerd.com/aff.php?aff=15630" },
|
||||||
|
{ "name": "腾讯云", "desc": "腾讯服务器,国内速度快", "href": "https://curl.qcloud.com/BZZ2MBTN" },
|
||||||
|
{ "name": "阿里云", "desc": "阿里巴巴旗下,vps服务器", "href": "https://www.aliyun.com/minisite/goods?userCode=dii0ofzg" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "residential-ip",
|
||||||
|
"icon": "🌐",
|
||||||
|
"title": "静态住宅IP",
|
||||||
|
"color": "from-pink-500 to-rose-500",
|
||||||
|
"bg": "bg-pink-50",
|
||||||
|
"text": "text-pink-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "iproyal", "desc": "静态住宅ip,出海必备", "href": "https://iproyal.cn/?r=nomadro" },
|
||||||
|
{ "name": "proxy-cheap", "desc": "便宜的住宅ip", "href": "https://app.proxy-cheap.com/r/bmcMoY" },
|
||||||
|
{ "name": "smartproxy", "desc": "静态住宅ip服务商", "href": "https://www.smartproxy.org/register/?invitation_code=7RXPLM" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "residential-vps",
|
||||||
|
"icon": "🏠",
|
||||||
|
"title": "住宅VPS服务器",
|
||||||
|
"color": "from-rose-500 to-pink-500",
|
||||||
|
"bg": "bg-rose-50",
|
||||||
|
"text": "text-rose-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "lisa", "desc": "住宅ip服务器,tiktok必备", "href": "https://lisahost.com/aff.php?aff=7566" },
|
||||||
|
{ "name": "voyracloud", "desc": "静态住宅ip服务器", "href": "https://www.voyracloud.com/?ref_code=2LJPRTE5" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hardware",
|
||||||
|
"icon": "🖲️",
|
||||||
|
"title": "硬件",
|
||||||
|
"color": "from-slate-500 to-gray-600",
|
||||||
|
"bg": "bg-slate-50",
|
||||||
|
"text": "text-slate-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "树莓派", "desc": "无与伦比的ARM开发板", "href": "https://uland.taobao.com/coupon/edetail?e=5hkWkiThOmClhHvvyUNXZfh8CuWt5YH5OVuOuRD5gLJMmdsrkidbOWBzzpT26idJPSbzO0ArF68ertbZl9wqRUbeJevxT02sHgNIxyFsmaMOsi2OxhyQb%2BhYUTTNN7GRRSHvQe2jOLZ9pbNCYX0I%2BPP%2BWUTgK%2F%2B0I%2BtaUgbudUxA%2B536asYsLWVfKa%2BhVnND4NyB1fwjO3bJLf667Yy8Q5jB6TX2HR3Qrq8LCw4Dujjqyaqc1rGLUwdQHHM8iY%2BUDMxLRSiIntldeBz90au3REAmOgZ0RtdnfeSBK3KXa0%2FA9snq37kKL6zL4bZ3EAS%2F2oYsLHTsIOxroXBFP6oz%2BA%3D%3D&traceId=213e032e17650447230113327e0c4f&union_lens=lensId%3APUB%401765044691%400b521400_0da4_19af4dca231_da77%400290EZjuZ2xbgOlH3NTYAyI%40eyJmbG9vcklkIjoyMDg3MTcxMDEwLCJzcG1CIjoiiX3BvcnRhbF92Ml90b29sX3NlbGVjdGlvbmxpc3RfZGV0YWlsX2luZGV4X2h0bSIsInNyY0Zsb29ySWQiiOiiI4MDY2NCJ9" },
|
||||||
|
{ "name": "迷你主机", "desc": "具有性价比的X86小主机", "href": "https://s.click.taobao.com/t?e=m%3D2%26s%3DCb1Quo%2F0Wb5w4vFB6t2Z2ueEDrYVVa64YUrQeSeIhnK53hKxp7mNFuhCZFSw9yq2woqx0EHqItb0JlhLk0Jl4eVWmNyHytKdTKJ80yOOA0Mtd0e5Ku7CIDk5ffgV5fRI1qrpxiwMoCNxc1AtbZGVS936JshS%2F%2FIdwgQgG5hIzyLNEPXytV9ALtCLThlbPuuZLb93Df8fOziGg7JVKZos%2BjLJJgRsfWlWtajntMnDLEHhuApO%2F8klJ62RdK%2B6%2FP7Z6k6s3rDgsC6jO9AJYjY8CXJ%2BwEVkOqHFPSJJXArlDuzso5nyPLY4NPiLqets6lIJ" },
|
||||||
|
{ "name": "GPU显卡", "desc": "AI时代的算力基础设施", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATUJK1olXwEDUFZVCkkRC18IGloWWwALV19aAUonRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoUBWkBGFoSVAcdDRsBVXsQB29pbiZRKGMHKlclCEgVWghcGV91UQoyVW5dCUoWAmYPHl4RbTYCU24fZp-esLulkIK0yd6n5V1eOEonA2kPH1oQWQUFV19VCnsQA2Y4El0dWQ4LU1hBC00SCmcJK2slXjYFVFdJDjlWUXsOaWslXTYBZF5cCEoQAGoLH1kRQQYHV1lbAFcXBWgMGl4RXgUKUllYOEkWAmsBK2vL05J3IB9Uajl8UStqSz4dFQ9jitDJGTlnA2sPH0olImF5ECFaSU0VVAdNQDxuW09KFiw8SyljbW1jcih3JWNxNlwaS0hqWBhoX2sQbQEEVW4" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "software",
|
||||||
|
"icon": "⚙️",
|
||||||
|
"title": "软件",
|
||||||
|
"color": "from-indigo-500 to-blue-500",
|
||||||
|
"bg": "bg-indigo-50",
|
||||||
|
"text": "text-indigo-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "3X-UI", "desc": "科学上网的正确姿势,仪表盘", "href": "https://github.com/MHSanaei/3x-ui" },
|
||||||
|
{ "name": "虚空终端mihomo", "desc": "代理软件以及配置wiki", "href": "https://wiki.metacubex.one/config/" },
|
||||||
|
{ "name": "frp", "desc": "高性能内网穿透", "href": "https://gofrp.org/zh-cn/docs/" },
|
||||||
|
{ "name": "wireguard", "desc": "P2P打洞组网", "href": "https://github.com/WireGuard/wireguard-go" },
|
||||||
|
{ "name": "lineageos (pi5)", "desc": "树莓派的安卓:linageos", "href": "https://konstakang.com/devices/rpi5/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "digital-gear",
|
||||||
|
"icon": "📷",
|
||||||
|
"title": "数码装备",
|
||||||
|
"color": "from-cyan-500 to-teal-500",
|
||||||
|
"bg": "bg-cyan-50",
|
||||||
|
"text": "text-cyan-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "AI眼镜", "desc": "第一视角拍摄", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BATkJK1olXw8AXVxfCU8UA18IGloVWwcKXFlZDUgnRzBQRQQlBENHFRxWFlVPRjtUBABAQlRcCEBdCUoXBW4AE1wRWAUdDRsBVXtwXw1pZzNKAmZgFFwlYUh8UzVBZyZlUQoyVW5dCUoWAmYPHl4RbTYCU24fZpO9hbeBtYKjxt-jwInikZ-fjV8JK1sTWgIDUVtVAEkVA2w4HFscbQ8EXFpVAUwRH2wOHlIdXDYyZF1tD0seF2l6WgkBW3QyZF5tC3sXAm8AGFsVXAEBXEJdDUMVB24UG10SWQcHUVZbAE8fCl8KGloRVDYyitPtY0JrQAYIYj0dXGZKMVc5T5Was35jYlMVXQUTZAQOch9SZTMNbC9gIGdQJi5VCzkRSCpfWDUXVAJlDQoCbw1gcG9qRwYdHnYyUW5aDEgn" },
|
||||||
|
{ "name": "运动相机", "desc": "骑行记录仪", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eCE0UB28ME1slGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWXQABUF5ZAEsIWipURmt3CFhXIQkpbChuRy8JGDpRJUJQDwkbBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVpaDEsUM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmsNHl8UXQQeVF1ZDE0UH28OHF8UWAIDUVddCkgnAW4JH1IlbdiMwDxcaAJfBTlNTRpCBHpHLhyDht8GcR8BHFIUTDZ6NSMpYRJweWtBfAUXC2JyXBkpfTNRWi1mGV4UAlgENFpeDA13cRRcTAJLbQMyU1peOA" },
|
||||||
|
{ "name": "无人机", "desc": "天空视角", "href": "https://union-click.jd.com/jdc?e=618%7Cpc%7C&p=JF8BASgJK1olXDYCVV9eAUIeB2YMH14lGVlaCgFtUQ5SQi0DBUVNGFJeSwUIFxlJX3EIGloWVA8LUFdZDE4IWipURmtCHHVEABsFVChPYRJAbVhlGRhbFRgLBEcnAl8IGloUXA8FUVtZOHsXBF9edVsUXAcDVV1fDEonAl8IHVwRXAMGVVdbC04VM2gIEmscWw4GXFdaDlcUBWoBE1olbTYBZFldAV8RcS5aD11nbTYCZF1tCEoXAmgLHlgRXgIeVFpfDU0UH28OHF8UWAIDUlteAUwnAW4JH1IlbdiMwCQvcipMBxJhfV9HGgF3FVaDht8GfxgBGVkRTDZUCjsVbzBTYRJbeRkWGG9WAx02D0JcUy5mGRNRFEJiUwgldStzfx1wTj1GbQMyU1peOA" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tiktok",
|
||||||
|
"icon": "🎵",
|
||||||
|
"title": "Tiktok",
|
||||||
|
"color": "from-fuchsia-500 to-pink-500",
|
||||||
|
"bg": "bg-fuchsia-50",
|
||||||
|
"text": "text-fuchsia-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "Tiktok", "desc": "全球直播平台", "href": "https://www.tiktok.com/" },
|
||||||
|
{ "name": "云手机", "desc": "用开源硬件打造云手机", "href": "https://www.hackrobot.cn/" },
|
||||||
|
{ "name": "vcam虚拟摄像头", "desc": "xposed模块,替换摄像头视频流", "href": "https://pan.baidu.com/s/1NW4GMGA3cxfUQNWZPD9vOg?pwd=live" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "youtube",
|
||||||
|
"icon": "▶️",
|
||||||
|
"title": "Youtube",
|
||||||
|
"color": "from-red-500 to-rose-500",
|
||||||
|
"bg": "bg-red-50",
|
||||||
|
"text": "text-red-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "Youtube", "desc": "全球最大的视频网站", "href": "https://www.youtube.com/" },
|
||||||
|
{ "name": "smmfollows", "desc": "刷粉刷量平台", "href": "https://smmfollows.com/ref/dfugq" },
|
||||||
|
{ "name": "YouTube Studio Auto Dismiss", "desc": "油猴脚本,无人直播辅助", "href": "https://greasyfork.org/zh-CN/scripts/557378-youtube-studio-auto-dismiss" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "web",
|
||||||
|
"icon": "🌍",
|
||||||
|
"title": "Web网站",
|
||||||
|
"color": "from-green-500 to-emerald-500",
|
||||||
|
"bg": "bg-green-50",
|
||||||
|
"text": "text-green-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "namesilo", "desc": "注册域名网址", "href": "https://www.namesilo.com/?rid=3bd4047mc" },
|
||||||
|
{ "name": "aapanel", "desc": "宝塔面板", "href": "https://www.aapanel.com?referral_code=wcn04vez" },
|
||||||
|
{ "name": "cloudflare", "desc": "互联网基础节点", "href": "https://www.cloudflare.com/" },
|
||||||
|
{ "name": "Google Analytics", "desc": "网站流量统计服务", "href": "https://analytics.google.com/" },
|
||||||
|
{ "name": "Google AdSense", "desc": "Google广告服务", "href": "https://www.google.com/adsense/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "live-stream",
|
||||||
|
"icon": "📺",
|
||||||
|
"title": "无人直播",
|
||||||
|
"color": "from-orange-500 to-amber-500",
|
||||||
|
"bg": "bg-orange-50",
|
||||||
|
"text": "text-orange-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "OBS", "desc": "免费开源的电脑客户端:录屏/推流直播", "href": "https://obsproject.com/download" },
|
||||||
|
{ "name": "ffmpeg", "desc": "命令行视频处理/推流工具", "href": "https://www.ffmpeg.org/download.html" },
|
||||||
|
{ "name": "SRS", "desc": "开源直播服务器,拉流推流中转", "href": "https://ossrs.io/" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "payment",
|
||||||
|
"icon": "💲",
|
||||||
|
"title": "支付网关",
|
||||||
|
"color": "from-lime-500 to-green-500",
|
||||||
|
"bg": "bg-lime-50",
|
||||||
|
"text": "text-lime-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "z-pay", "desc": "支持独角数卡-易支付", "href": "https://z-pay.cn/?uid=22119" },
|
||||||
|
{ "name": "xorpay", "desc": "支持收银台支付", "href": "https://xorpay.com?r=nomadro" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forum",
|
||||||
|
"icon": "📝",
|
||||||
|
"title": "论坛社区",
|
||||||
|
"color": "from-sky-500 to-blue-500",
|
||||||
|
"bg": "bg-sky-50",
|
||||||
|
"text": "text-sky-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "异度世界", "desc": "独立博客,hugo搭建", "href": "https://blog.hackrobot.cn" },
|
||||||
|
{ "name": "大师兄", "desc": "AI实践", "href": "https://dsx2016.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
155
content/themes/digital-nomad/messages/en.json
Normal file
155
content/themes/digital-nomad/messages/en.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"siteName": "Digital Nomad Guide",
|
||||||
|
"siteNameShort": "DN Guide",
|
||||||
|
"logout": "Logout",
|
||||||
|
"loginRegister": "Login / Sign up",
|
||||||
|
"explore": "Explore →",
|
||||||
|
"toggleMenu": "Toggle menu"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"roadmap": "Learning Path",
|
||||||
|
"tools": "Tools",
|
||||||
|
"community": "Community"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Open source · 200+ curated resources",
|
||||||
|
"title": "Digital Nomad",
|
||||||
|
"titleSuffix": "Life Guide",
|
||||||
|
"subtitle": "Start your digital nomad journey in 7 days from scratch",
|
||||||
|
"subtitleEn": "The open-source guide to becoming a digital nomad",
|
||||||
|
"ctaStart": "🚀 Start Learning",
|
||||||
|
"ctaResources": "📚 Browse Resources",
|
||||||
|
"stats": {
|
||||||
|
"resources": "Resources",
|
||||||
|
"path": "Learning Path",
|
||||||
|
"tools": "Remote Tools",
|
||||||
|
"destinations": "Destinations",
|
||||||
|
"free": "Free & Open"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "What is a",
|
||||||
|
"titleHighlight": "Digital Nomad",
|
||||||
|
"titleSuffix": "?",
|
||||||
|
"subtitle": "A digital nomad leverages internet technology to work remotely without geographic constraints.",
|
||||||
|
"subtitle2": "It lets you explore the world while creating value.",
|
||||||
|
"stat": "Over",
|
||||||
|
"statCount": "35 million",
|
||||||
|
"statSuffix": "digital nomads worldwide",
|
||||||
|
"items": {
|
||||||
|
"location": {
|
||||||
|
"title": "Location Freedom",
|
||||||
|
"desc": "Work from anywhere—cafés, beaches, coworking spaces. Your office is the whole world. Say goodbye to fixed desks and embrace infinite possibilities."
|
||||||
|
},
|
||||||
|
"skill": {
|
||||||
|
"title": "Monetize Skills",
|
||||||
|
"desc": "Programming, design, writing, marketing, consulting… Exchange your expertise for global income and build a diversified revenue stream."
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"title": "Work-Life Balance",
|
||||||
|
"desc": "Set your own schedule. Work and travel blend seamlessly. Live at your own pace and find the lifestyle that truly fits you."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roadmap": {
|
||||||
|
"title": "7-Day",
|
||||||
|
"titleHighlight": "Learning Path",
|
||||||
|
"subtitle": "One theme per day, from basics to launch—start your digital nomad journey step by step.",
|
||||||
|
"viewDetails": "View details →",
|
||||||
|
"days": {
|
||||||
|
"1": { "title": "Meet Digital Nomads", "desc": "Understand what digital nomadism really means, assess if it fits you, and debunk common myths." },
|
||||||
|
"2": { "title": "Discover Remote Skills", "desc": "Master the most in-demand remote skills—from coding and design to content creation—and find your direction." },
|
||||||
|
"3": { "title": "Build Income Streams", "desc": "Freelancing, remote jobs, passive income, online courses—build a sustainable, diversified income." },
|
||||||
|
"4": { "title": "Tool Up", "desc": "Productivity tools, communication, project management, cloud services—build your mobile office toolkit." },
|
||||||
|
"5": { "title": "Visa & Legal", "desc": "Digital nomad visas, tax planning, overseas insurance, bank accounts—get legal and compliant." },
|
||||||
|
"6": { "title": "Destination Guide", "desc": "Top digital nomad cities, cost of living, internet speed, community vibes—all at a glance." },
|
||||||
|
"7": { "title": "Launch", "desc": "Packing list, transition plan, first-stop tips—from planning to action." }
|
||||||
|
},
|
||||||
|
"ebook": {
|
||||||
|
"title": "Ebook",
|
||||||
|
"desc": "Digital nomad: geo-arbitrage & automation leverage. Low-cost studio, cloud phones, unmanned live streaming, overseas gold rush.",
|
||||||
|
"cta": "Read online →"
|
||||||
|
},
|
||||||
|
"course": {
|
||||||
|
"tag": "🎓 Bootcamp",
|
||||||
|
"title": "Digital Nomad Bootcamp",
|
||||||
|
"desc": "21 video lessons to plan your digital nomad route from zero and master the full remote workflow.",
|
||||||
|
"cta": "Learn more →"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"badge": "🚀 50+ Curated Tools",
|
||||||
|
"title": "Tool Recommendations",
|
||||||
|
"subtitle": "Essential tools for digital nomads: community, VPS, payments, overseas ventures—every link is clickable.",
|
||||||
|
"stats": {
|
||||||
|
"tools": "Tools",
|
||||||
|
"categories": "Categories",
|
||||||
|
"nomad": "Nomad",
|
||||||
|
"affiliate": "Affiliate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"community": {
|
||||||
|
"nomadCommunity": "Digital Nomad Community",
|
||||||
|
"joinTitle": "Join the Global Chinese Digital Nomad Network",
|
||||||
|
"joinDesc": "Apply to join · 1000+ nomads · Weekly online shares · City guides",
|
||||||
|
"applyForm": "Apply via form",
|
||||||
|
"approveJoin": "Join after approval",
|
||||||
|
"applyNow": "Apply now →",
|
||||||
|
"applyHint": "Simple form, quick review",
|
||||||
|
"openSource": "🤝 Open Source",
|
||||||
|
"contributeTitle": "Community &",
|
||||||
|
"contributeHighlight": "Contribution",
|
||||||
|
"contributeSubtitle": "Digital Nomad Guide is open source, building the best Chinese digital nomad resource hub.",
|
||||||
|
"contributeSubtitle2": "Welcome to add resources, improve content, and share experience.",
|
||||||
|
"links": {
|
||||||
|
"guide": { "title": "Getting Started", "desc": "Complete digital nomad tutorial", "label": "Start learning ↗" },
|
||||||
|
"discord": { "title": "Discord", "desc": "Chat with global digital nomads", "label": "Join community ↗" },
|
||||||
|
"tools": { "title": "Tool Marketplace", "desc": "Discover, review, and share remote tools", "label": "Browse tools ↗" },
|
||||||
|
"github": { "title": "GitHub", "desc": "Open source—Star and PR welcome", "label": "View repo ↗" },
|
||||||
|
"feishu": { "title": "Feishu Docs", "desc": "7-day guide · Chinese tutorials", "label": "View docs ↗" },
|
||||||
|
"destinations": { "title": "Destinations", "desc": "Global digital nomad city info", "label": "Explore cities ↗" }
|
||||||
|
},
|
||||||
|
"betterTitle": "🌟 Help Make Digital Nomad Guide Better",
|
||||||
|
"betterDesc": "Found a great resource? Wrote travel notes? Have tool recommendations?",
|
||||||
|
"betterDesc2": "Open a PR—your contribution helps more people.",
|
||||||
|
"starFork": "⭐ Star & Fork on GitHub"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"siteName": "Digital Nomad Guide",
|
||||||
|
"tagline": "Open-source digital nomad resource platform",
|
||||||
|
"tagline2": "Start your free work and travel lifestyle",
|
||||||
|
"sections": {
|
||||||
|
"guide": "Guide",
|
||||||
|
"resources": "Resources",
|
||||||
|
"community": "Community",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"roadmap": "7-Day Learning Path",
|
||||||
|
"tools": "Tools",
|
||||||
|
"destinations": "Destinations",
|
||||||
|
"remoteJobs": "Remote Jobs",
|
||||||
|
"visa": "Visa Policies",
|
||||||
|
"coliving": "Coliving Spaces",
|
||||||
|
"insurance": "Nomad Insurance",
|
||||||
|
"discord": "Discord",
|
||||||
|
"wechat": "WeChat",
|
||||||
|
"jike": "Jike",
|
||||||
|
"github": "GitHub",
|
||||||
|
"aboutUs": "About Us",
|
||||||
|
"contribute": "Contribute",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"madeBy": "Made with 🌍 by Digital Nomad Community | DN Guide",
|
||||||
|
"openSource": "Open source · Community driven"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Login / Sign up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Login",
|
||||||
|
"close": "Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
content/themes/digital-nomad/messages/zh.json
Normal file
155
content/themes/digital-nomad/messages/zh.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"siteName": "数字游民指南",
|
||||||
|
"siteNameShort": "DN Guide",
|
||||||
|
"logout": "退出",
|
||||||
|
"loginRegister": "登录 / 注册",
|
||||||
|
"explore": "开始探索 →",
|
||||||
|
"toggleMenu": "切换菜单"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"roadmap": "学习路径",
|
||||||
|
"tools": "工具推荐",
|
||||||
|
"community": "社区"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "开源免费 · 收录 200+ 篇实用资源",
|
||||||
|
"title": "数字游民",
|
||||||
|
"titleSuffix": "生活指南",
|
||||||
|
"subtitle": "从零开始,7天开启你的数字游民生活",
|
||||||
|
"subtitleEn": "The open-source guide to becoming a digital nomad",
|
||||||
|
"ctaStart": "🚀 开始学习",
|
||||||
|
"ctaResources": "📚 浏览资源",
|
||||||
|
"stats": {
|
||||||
|
"resources": "精选资源",
|
||||||
|
"path": "入门路径",
|
||||||
|
"tools": "远程工具",
|
||||||
|
"destinations": "目的地",
|
||||||
|
"free": "免费开源"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "什么是",
|
||||||
|
"titleHighlight": "数字游民",
|
||||||
|
"titleSuffix": "?",
|
||||||
|
"subtitle": "数字游民是一种利用互联网技术远程工作、不受地理位置限制的生活方式。",
|
||||||
|
"subtitle2": "它让你在探索世界的同时,持续创造价值。",
|
||||||
|
"stat": "全球已有超过",
|
||||||
|
"statCount": "3500 万",
|
||||||
|
"statSuffix": "数字游民",
|
||||||
|
"items": {
|
||||||
|
"location": {
|
||||||
|
"title": "地点自由",
|
||||||
|
"desc": "在世界任何角落工作——咖啡馆、海滩、共享空间,你的办公室就是整个地球。告别固定工位,拥抱无限可能。"
|
||||||
|
},
|
||||||
|
"skill": {
|
||||||
|
"title": "技能变现",
|
||||||
|
"desc": "编程、设计、写作、营销、咨询……用你的专业技能换取全球收入,构建多元化的收入体系。"
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"title": "工作生活平衡",
|
||||||
|
"desc": "自主安排时间,工作与旅行完美融合。按照自己的节奏生活,找到真正属于你的生活方式。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roadmap": {
|
||||||
|
"title": "7天",
|
||||||
|
"titleHighlight": "学习路径",
|
||||||
|
"subtitle": "从入门到出发,每天一个主题,循序渐进开启你的数字游民之旅。",
|
||||||
|
"viewDetails": "查看详情 →",
|
||||||
|
"days": {
|
||||||
|
"1": { "title": "认识数字游民", "desc": "了解数字游民的真正含义,评估这种生活方式是否适合你,破除常见误解。" },
|
||||||
|
"2": { "title": "发现远程技能", "desc": "掌握最受欢迎的远程工作技能,从编程、设计到内容创作,找到你的方向。" },
|
||||||
|
"3": { "title": "构建收入体系", "desc": "自由职业、远程全职、被动收入、在线课程,构建可持续的多元收入来源。" },
|
||||||
|
"4": { "title": "工具武装", "desc": "生产力工具、通讯协作、项目管理、云服务,打造你的移动办公装备库。" },
|
||||||
|
"5": { "title": "签证与法律", "desc": "数字游民签证政策、税务规划、海外保险、银行账户,搞定法律合规问题。" },
|
||||||
|
"6": { "title": "目的地指南", "desc": "热门数字游民城市推荐,生活成本对比、网速测评、社区活跃度一目了然。" },
|
||||||
|
"7": { "title": "启程出发", "desc": "打包清单、过渡计划、第一站选择建议,从规划到行动的最后一步。" }
|
||||||
|
},
|
||||||
|
"ebook": {
|
||||||
|
"title": "电子书",
|
||||||
|
"desc": "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
|
||||||
|
"cta": "在线阅读 →"
|
||||||
|
},
|
||||||
|
"course": {
|
||||||
|
"tag": "🎓 实战训练营",
|
||||||
|
"title": "数字游民实战课",
|
||||||
|
"desc": "21节视频课程,手把手带你从零规划专属数字游民路线,打通远程工作全流程",
|
||||||
|
"cta": "了解详情 →"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"badge": "🚀 50+ 精选工具",
|
||||||
|
"title": "工具推荐",
|
||||||
|
"subtitle": "数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。",
|
||||||
|
"stats": {
|
||||||
|
"tools": "精选工具",
|
||||||
|
"categories": "分类",
|
||||||
|
"nomad": "出海",
|
||||||
|
"affiliate": "推荐"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"community": {
|
||||||
|
"nomadCommunity": "数字游民社区",
|
||||||
|
"joinTitle": "加入全球华人数字游民网络",
|
||||||
|
"joinDesc": "填写申请表加入社区 · 1000+ 数字游民 · 每周线上分享 · 城市攻略",
|
||||||
|
"applyForm": "填写表单即可申请",
|
||||||
|
"approveJoin": "审核通过入群",
|
||||||
|
"applyNow": "立即申请加入 →",
|
||||||
|
"applyHint": "简单表单,快速审核",
|
||||||
|
"openSource": "🤝 开源共建",
|
||||||
|
"contributeTitle": "社区与",
|
||||||
|
"contributeHighlight": "贡献",
|
||||||
|
"contributeSubtitle": "数字游民指南是开源项目,致力于做最好的中文数字游民资源聚合站。",
|
||||||
|
"contributeSubtitle2": "欢迎补充资源、改进内容、分享经验。",
|
||||||
|
"links": {
|
||||||
|
"guide": { "title": "入门指南", "desc": "完整的数字游民入门教程", "label": "开始学习 ↗" },
|
||||||
|
"discord": { "title": "Discord 社区", "desc": "与全球数字游民实时交流", "label": "加入社区 ↗" },
|
||||||
|
"tools": { "title": "工具集市", "desc": "发现、评测和分享远程工具", "label": "浏览工具 ↗" },
|
||||||
|
"github": { "title": "GitHub", "desc": "开源项目,欢迎 Star 和 PR", "label": "访问仓库 ↗" },
|
||||||
|
"feishu": { "title": "飞书知识库", "desc": "7天入门指南 · 中文图文教程", "label": "查看文档 ↗" },
|
||||||
|
"destinations": { "title": "目的地数据库", "desc": "全球数字游民城市信息", "label": "探索城市 ↗" }
|
||||||
|
},
|
||||||
|
"betterTitle": "🌟 一起让数字游民指南变得更好",
|
||||||
|
"betterDesc": "发现了好资源?写了旅居心得?有工具推荐?",
|
||||||
|
"betterDesc2": "提一个 PR,你的贡献将帮助更多人。",
|
||||||
|
"starFork": "⭐ Star & Fork on GitHub"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"siteName": "数字游民指南",
|
||||||
|
"tagline": "开源免费的数字游民资源平台",
|
||||||
|
"tagline2": "助你开启自由工作与旅行的生活方式",
|
||||||
|
"sections": {
|
||||||
|
"guide": "指南",
|
||||||
|
"resources": "资源",
|
||||||
|
"community": "社区",
|
||||||
|
"about": "关于"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"roadmap": "7天学习路径",
|
||||||
|
"tools": "工具推荐",
|
||||||
|
"destinations": "目的地数据库",
|
||||||
|
"remoteJobs": "远程工作平台",
|
||||||
|
"visa": "签证政策汇总",
|
||||||
|
"coliving": "共居空间推荐",
|
||||||
|
"insurance": "数字游民保险",
|
||||||
|
"discord": "Discord",
|
||||||
|
"wechat": "微信群",
|
||||||
|
"jike": "即刻",
|
||||||
|
"github": "GitHub",
|
||||||
|
"aboutUs": "关于我们",
|
||||||
|
"contribute": "贡献指南",
|
||||||
|
"privacy": "隐私政策",
|
||||||
|
"contact": "联系我们"
|
||||||
|
},
|
||||||
|
"madeBy": "Made with 🌍 by 数字游民社区 | Digital Nomad Guide",
|
||||||
|
"openSource": "开源共享 · 社区驱动"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "登录 / 注册",
|
||||||
|
"email": "邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"submit": "登录",
|
||||||
|
"close": "关闭"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
docs/SERVICES.md
Normal file
178
docs/SERVICES.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# 服务配置说明
|
||||||
|
|
||||||
|
支付、PocketBase、MinIO 存储已组件化、配置化,可通过环境变量和主题配置覆盖。
|
||||||
|
|
||||||
|
## 配置入口
|
||||||
|
|
||||||
|
- **`config/services.config.ts`** - 服务配置
|
||||||
|
- **`config/site.config.ts`** - 主题可覆盖 `services` 字段
|
||||||
|
|
||||||
|
## PocketBase
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `NEXT_PUBLIC_POCKETBASE_URL` | 客户端可见的 PB 地址 | https://pocketbase.hackrobot.cn |
|
||||||
|
| `POCKETBASE_URL` | 服务端 PB 地址 | 同上 |
|
||||||
|
| `POCKETBASE_JOIN_COLLECTION` | 申请表单集合名 | solan |
|
||||||
|
| `POCKETBASE_EMAIL` | 管理员邮箱(创建记录用) | - |
|
||||||
|
| `POCKETBASE_PASSWORD` | 管理员密码 | - |
|
||||||
|
|
||||||
|
### 主题覆盖
|
||||||
|
|
||||||
|
在 `config/site.config.ts` 的 `THEME_CONFIGS[theme].services` 中:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
services: {
|
||||||
|
pocketbaseJoinCollection: "solan", // 申请/加入表单集合
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { pbLogin, pbRegister, pbSaveAuth, pbLogout, getStoredUserEmail } from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
const result = await pbLogin(email, password);
|
||||||
|
pbSaveAuth(result.token, result.record);
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
const result = await pbRegister(email, password, passwordConfirm);
|
||||||
|
pbSaveAuth(result.token, result.record);
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
pbLogout();
|
||||||
|
|
||||||
|
// 获取当前用户邮箱
|
||||||
|
const email = getStoredUserEmail();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支付
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `PAYMENT_API_URL` | payjsapi 地址 | 开发: 127.0.0.1:8700 / 生产: api.hackrobot.cn |
|
||||||
|
| `PAYMENT_PROVIDER` | 支付提供商 | zpay |
|
||||||
|
| `PAYMENT_DEFAULT_AMOUNT` | 默认金额(分) | 80 |
|
||||||
|
| `PAYMENT_DEFAULT_TYPE` | 默认订单类型 | meetup |
|
||||||
|
| `ZPAY_SUBMIT_URL` | ZPAY 提交地址 | https://zpayz.cn/submit.php |
|
||||||
|
|
||||||
|
### 主题覆盖
|
||||||
|
|
||||||
|
在 `config/site.config.ts` 的 `THEME_CONFIGS[theme].services` 中:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
services: {
|
||||||
|
join: {
|
||||||
|
amount: 80, // 加入费用(分)
|
||||||
|
type: "meetup", // 订单类型
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
getPayEnv,
|
||||||
|
getRecommendedChannel,
|
||||||
|
mustUseWxPay,
|
||||||
|
redirectToPay,
|
||||||
|
getDeviceFromEnv,
|
||||||
|
} from "@/app/lib/payment";
|
||||||
|
|
||||||
|
// 检测支付环境
|
||||||
|
const env = getPayEnv(); // "pc" | "h5" | "wechat"
|
||||||
|
const channel = getRecommendedChannel(env);
|
||||||
|
const device = getDeviceFromEnv(env);
|
||||||
|
|
||||||
|
// 发起支付跳转(加入场景,使用主题配置)
|
||||||
|
redirectToPay({
|
||||||
|
user_id: userId,
|
||||||
|
return_url: returnUrl,
|
||||||
|
channel,
|
||||||
|
device,
|
||||||
|
useJoinConfig: true, // 使用主题的 amount/type
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## MinIO 存储
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `MINIO_ENDPOINT` | 服务端点 | minioweb.hackrobot.cn |
|
||||||
|
| `MINIO_PORT` | 端口 | 9035 |
|
||||||
|
| `MINIO_USE_SSL` | 是否使用 SSL | false |
|
||||||
|
| `MINIO_BUCKET` | 存储桶 | hackrobot |
|
||||||
|
| `MINIO_ACCESS_KEY` | Access Key | - |
|
||||||
|
| `MINIO_SECRET_KEY` | Secret Key | - |
|
||||||
|
| `MINIO_REGION` | 区域 | china |
|
||||||
|
| `MINIO_PUBLIC_URL` | 公开访问 URL | https://{endpoint}/{bucket} |
|
||||||
|
| `MINIO_UPLOAD_PREFIX` | 上传路径前缀 | joins |
|
||||||
|
|
||||||
|
### 主题覆盖
|
||||||
|
|
||||||
|
在 `config/site.config.ts` 的 `THEME_CONFIGS[theme].services` 中:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
services: {
|
||||||
|
minio: {
|
||||||
|
bucket: "my-bucket", // 存储桶
|
||||||
|
uploadPrefix: "joins", // 上传路径前缀
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
getMinioClient,
|
||||||
|
uploadBuffer,
|
||||||
|
generateObjectKey,
|
||||||
|
} from "@/app/lib/minio";
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
const objectKey = generateObjectKey("jpg");
|
||||||
|
const { url } = await uploadBuffer(buffer, objectKey, "image/jpeg");
|
||||||
|
|
||||||
|
// 获取客户端(高级用法)
|
||||||
|
const client = getMinioClient();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 路由
|
||||||
|
|
||||||
|
- **`/api/join`** - 使用 `getPocketBaseConfig()` 和主题的 `pocketbaseJoinCollection`
|
||||||
|
- **`/api/pay`** - 使用 `getPaymentConfig()` 和 `getJoinPaymentConfig()`
|
||||||
|
- **`/api/pay/status`** - 使用 `getPaymentConfig()`
|
||||||
|
- **`/api/upload-media`** - 使用 `getMinioConfig()` 和主题的 `minio` 配置
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
services.config.ts # 服务配置
|
||||||
|
site.config.ts # 主题配置(含 services 覆盖)
|
||||||
|
|
||||||
|
app/lib/
|
||||||
|
pocketbase/
|
||||||
|
auth.ts # 认证:login/register/logout
|
||||||
|
constants.ts # 存储 key
|
||||||
|
config.ts # 配置导出
|
||||||
|
index.ts
|
||||||
|
payment/
|
||||||
|
client.ts # redirectToPay
|
||||||
|
config.ts # 配置导出
|
||||||
|
types.ts # PayChannel, PayEnv
|
||||||
|
index.ts # 聚合 payEnv
|
||||||
|
minio/
|
||||||
|
config.ts # 配置导出
|
||||||
|
client.ts # getMinioClient, uploadBuffer, generateObjectKey
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
154
docs/TEMPLATE.md
Normal file
154
docs/TEMPLATE.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# 站点模板使用指南
|
||||||
|
|
||||||
|
本项目已配置化为可复用模板,可快速克隆并替换为不同主题(数字游民、一人公司、TikTok 运营、独立开发等)。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
config/ # 配置层
|
||||||
|
site.config.ts # 主题配置(切换主题只需改 CURRENT_THEME)
|
||||||
|
index.ts
|
||||||
|
|
||||||
|
content/themes/ # 内容层 - 按主题组织
|
||||||
|
digital-nomad/ # 数字游民主题
|
||||||
|
messages/
|
||||||
|
zh.json # 中文文案
|
||||||
|
en.json # 英文文案
|
||||||
|
data/
|
||||||
|
tools.json # 工具数据
|
||||||
|
|
||||||
|
app/ # 应用层 - 组件与页面
|
||||||
|
components/ # 通用 UI 组件
|
||||||
|
[locale]/ # 多语言路由
|
||||||
|
lib/
|
||||||
|
theme-data.ts # 主题数据加载器
|
||||||
|
```
|
||||||
|
|
||||||
|
## 切换主题
|
||||||
|
|
||||||
|
### 方式一:环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
NEXT_PUBLIC_THEME=solo-company
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:修改配置
|
||||||
|
|
||||||
|
编辑 `config/site.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const CURRENT_THEME: ThemeId =
|
||||||
|
(process.env.NEXT_PUBLIC_THEME as ThemeId) || "solo-company"; // 改默认值
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增主题步骤
|
||||||
|
|
||||||
|
### 1. 添加主题配置
|
||||||
|
|
||||||
|
在 `config/site.config.ts` 的 `THEME_CONFIGS` 中添加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
"your-theme-id": {
|
||||||
|
id: "your-theme-id",
|
||||||
|
name: "你的主题名",
|
||||||
|
nameEn: "Your Theme Name",
|
||||||
|
tagline: "一句话描述",
|
||||||
|
taglineEn: "Tagline in English",
|
||||||
|
meta: {
|
||||||
|
title: "站点标题 | Site Title",
|
||||||
|
description: "SEO 描述",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
roadmap: true,
|
||||||
|
tools: true,
|
||||||
|
community: true,
|
||||||
|
join: false,
|
||||||
|
course: false,
|
||||||
|
ebook: true,
|
||||||
|
},
|
||||||
|
colors: { primary: "sky", accent: "amber" },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `THEME_IDS` 数组中添加 `"your-theme-id"`。
|
||||||
|
|
||||||
|
### 2. 创建主题内容
|
||||||
|
|
||||||
|
复制 `content/themes/digital-nomad/` 为 `content/themes/your-theme-id/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
content/themes/your-theme-id/
|
||||||
|
messages/
|
||||||
|
zh.json # 修改为你的中文文案
|
||||||
|
en.json # 修改为你的英文文案
|
||||||
|
data/
|
||||||
|
tools.json # 修改为你的工具数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 注册数据加载
|
||||||
|
|
||||||
|
在 `app/lib/theme-data.ts` 的 `getThemeMessages` 和 `getThemeToolsData` 的 switch 中添加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case "your-theme-id": {
|
||||||
|
const mod = locale === "zh"
|
||||||
|
? await import("@/content/themes/your-theme-id/messages/zh.json")
|
||||||
|
: await import("@/content/themes/your-theme-id/messages/en.json");
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文案结构 (messages)
|
||||||
|
|
||||||
|
参考 `content/themes/digital-nomad/messages/zh.json`:
|
||||||
|
|
||||||
|
- `common` - 站点名、导航、按钮等
|
||||||
|
- `hero` - 首页主视觉
|
||||||
|
- `features` - 特性介绍
|
||||||
|
- `roadmap` - 学习路径、天数、电子书、课程
|
||||||
|
- `tools` - 工具区块
|
||||||
|
- `community` - 社区区块
|
||||||
|
- `footer` - 页脚
|
||||||
|
- `auth` - 登录注册
|
||||||
|
|
||||||
|
## 工具数据 (tools.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "category-id",
|
||||||
|
"icon": "🔧",
|
||||||
|
"title": "分类名",
|
||||||
|
"color": "from-sky-500 to-cyan-500",
|
||||||
|
"bg": "bg-sky-50",
|
||||||
|
"text": "text-sky-700",
|
||||||
|
"tools": [
|
||||||
|
{ "name": "工具名", "desc": "描述", "href": "https://..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7 天学习路径 (days)
|
||||||
|
|
||||||
|
Day 内容当前在 `app/data/days.ts`,后续可迁移到 `content/themes/[theme]/data/days.json` 实现按主题切换。
|
||||||
|
|
||||||
|
## 服务配置(支付、PocketBase)
|
||||||
|
|
||||||
|
详见 [docs/SERVICES.md](./SERVICES.md)。支付和 PocketBase 已配置化,可通过环境变量和主题 `services` 覆盖。
|
||||||
|
|
||||||
|
## 功能开关 (features)
|
||||||
|
|
||||||
|
在主题配置中可关闭某些模块:
|
||||||
|
|
||||||
|
- `roadmap` - 7 天学习路径
|
||||||
|
- `tools` - 工具推荐
|
||||||
|
- `community` - 社区区块
|
||||||
|
- `join` - 加入社区页
|
||||||
|
- `course` - 课程页
|
||||||
|
- `ebook` - 电子书
|
||||||
|
|
||||||
|
(功能开关的 UI 条件渲染待接入)
|
||||||
Reference in New Issue
Block a user