diff --git a/app/[locale]/course/page.tsx b/app/[locale]/course/page.tsx
index 16551d5..9b551b2 100644
--- a/app/[locale]/course/page.tsx
+++ b/app/[locale]/course/page.tsx
@@ -4,18 +4,7 @@ import { useState, useEffect } from "react";
import { VideoCard } from "./VideoCard";
import { useVideoProgress } from "./useVideoProgress";
import AuthModal from "../../components/AuthModal";
-
-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;
- }
-}
+import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
/* ─── data ─── */
@@ -166,12 +155,11 @@ export default function CoursePage() {
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
useEffect(() => {
- setUserEmail(getStoredUser());
+ setUserEmail(getStoredUserEmail());
}, []);
const handleLogout = () => {
- localStorage.removeItem("pb_token");
- localStorage.removeItem("pb_user");
+ pbLogout();
setUserEmail(null);
};
diff --git a/app/[locale]/ebook/EbookClient.tsx b/app/[locale]/ebook/EbookClient.tsx
index d6119b7..6c22e16 100644
--- a/app/[locale]/ebook/EbookClient.tsx
+++ b/app/[locale]/ebook/EbookClient.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
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 styles from "./ebook.module.css";
diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx
index b475ee6..d9fe8a6 100644
--- a/app/[locale]/join/page.tsx
+++ b/app/[locale]/join/page.tsx
@@ -2,7 +2,15 @@
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
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 singleOptions = ["单身", "恋爱中", "已婚"];
@@ -55,6 +63,9 @@ export default function JoinPage() {
}
}, []);
+ // 微信 H5 支付完成后可能不跳转,后台静默轮询,检测到已支付则显示成功页
+ usePayStatusPoll(() => setSubmitted(true));
+
useEffect(() => {
if (typeof window === "undefined") return;
const env = getPayEnv();
@@ -86,33 +97,18 @@ export default function JoinPage() {
}
setSubmitting(false);
setPayRedirecting(true);
- const returnUrl = `${window.location.origin}${window.location.pathname}?paid=1`;
- const payForm = document.createElement("form");
- payForm.method = "POST";
- payForm.action = "/api/pay";
- payForm.target = "_self";
- payForm.style.display = "none";
+ // Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
+ const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
- const device = env === "pc" ? "pc" : "h5"; // PC 跳转 payurl,H5/微信 用 payurl/payurl2
- const fields: [string, string][] = [
- ["user_id", userId],
- ["total_fee", "80"],
- ["type", "meetup"],
- ["channel", channel],
- ["device", device],
- ["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);
+ const device = getDeviceFromEnv(env);
+ redirectToPay({
+ user_id: userId,
+ return_url: returnUrl,
+ channel,
+ device,
+ useJoinConfig: true,
});
- document.body.appendChild(payForm);
- payForm.submit();
return;
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx
new file mode 100644
index 0000000..4cab2ea
--- /dev/null
+++ b/app/[locale]/join/paid/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Link } from "@/i18n/navigation";
+
+/**
+ * 支付完成回跳页
+ * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
+ */
+export default function JoinPaidPage() {
+ return (
+
+
+
+ ✅
+
+
申请已提交
+
+ 我们会尽快审核你的信息
+
+ 审核通过后将通过微信邀请你入群
+
+
+ ← 返回首页
+
+
+
+ );
+}
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 42a44b8..6e28e81 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -2,14 +2,18 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { LocaleProvider } 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"];
-export const metadata: Metadata = {
- title: "数字游民指南 | Digital Nomad Guide",
- description:
- "从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
-};
+export const metadata: Metadata = (() => {
+ const theme = getThemeConfig();
+ return {
+ title: theme.meta.title,
+ description: theme.meta.description,
+ };
+})();
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
@@ -27,7 +31,7 @@ export default async function LocaleLayout({
notFound();
}
- const messages = (await import(`../../messages/${locale}.json`)).default;
+ const messages = await getThemeMessages(locale as Locale);
return (
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 0cbb240..1610e14 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -5,8 +5,11 @@ import Roadmap from "../components/Roadmap";
import Tools from "../components/Tools";
import Community from "../components/Community";
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 (
<>
@@ -14,7 +17,7 @@ export default function HomePage() {
-
+
diff --git a/app/api/join/route.ts b/app/api/join/route.ts
index 0bc5b60..9f8e3dd 100644
--- a/app/api/join/route.ts
+++ b/app/api/join/route.ts
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-
-const PB_URL = process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
-const COLLECTION = "solan";
+import { getPocketBaseConfig } from "@/config/services.config";
+import { getThemeConfig } from "@/config";
function getOrCreateUserId(): string {
return `user${Date.now()}`;
@@ -62,13 +61,19 @@ export async function POST(request: NextRequest) {
(record as Record)["media"] = mediaUrl;
}
+ const theme = getThemeConfig();
+ const pb = getPocketBaseConfig({
+ joinCollection: theme.services?.pocketbaseJoinCollection,
+ });
+ const collection = pb.joinCollection;
+
const doCreate = async (authToken?: string) => {
const headers: Record = {
"Content-Type": "application/json",
};
if (authToken) headers["Authorization"] = authToken;
- return fetch(`${PB_URL}/api/collections/${COLLECTION}/records`, {
+ return fetch(`${pb.url}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(record),
diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts
index 10b9456..6f58bde 100644
--- a/app/api/pay/route.ts
+++ b/app/api/pay/route.ts
@@ -1,21 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
-import { PAYMENT_API_URL, SITE_URL } from "../../lib/env";
-
-const ZPAY_SUBMIT = "https://zpayz.cn/submit.php";
+import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
+import { SITE_URL } from "../../lib/env";
async function createPayOrder(
user_id: string,
return_url: string,
- total_fee = 80,
- type = "meetup",
+ total_fee?: number,
+ type?: string,
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 = {
user_id,
- total_fee: Number(total_fee) || 80,
- type,
+ total_fee: Number(fee) || joinConfig.amount,
+ type: orderType,
channel,
return_url,
};
@@ -23,7 +26,7 @@ async function createPayOrder(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
- const res = await fetch(`${PAYMENT_API_URL}/payh5`, {
+ const res = await fetch(`${config.apiUrl}/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -36,7 +39,7 @@ async function createPayOrder(
}
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") {
throw new Error("payjsapi 未返回支付参数");
}
@@ -103,12 +106,14 @@ export async function POST(request: NextRequest) {
body = Object.fromEntries(form.entries()) as Record;
}
+ const joinConfig = getJoinPaymentConfig();
+ const payConfig = getPaymentConfig();
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim();
- const total_fee = Number(body?.total_fee) || 80;
- const type = String(body?.type || "meetup");
+ const total_fee = Number(body?.total_fee) || joinConfig.amount;
+ const type = String(body?.type || joinConfig.type);
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) {
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-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
: 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(
user_id,
@@ -151,7 +157,7 @@ export async function POST(request: NextRequest) {
};
const controller = new AbortController();
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(redirectPayload),
@@ -160,9 +166,38 @@ export async function POST(request: NextRequest) {
}).finally(() => clearTimeout(timeout));
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
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(() => ({}));
+ // 微信内:payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA
+ if (redirectRes.status === 200 && resData?.use_form) {
+ const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record);
+ 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) {
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
@@ -247,7 +282,7 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
- const zpayRes = await fetch(ZPAY_SUBMIT, {
+ const zpayRes = await fetch(payConfig.zpaySubmitUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formParams.toString(),
@@ -255,7 +290,13 @@ export async function POST(request: NextRequest) {
});
const location = zpayRes.headers.get("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();
try {
diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts
index a40f0da..1a52f85 100644
--- a/app/api/pay/status/route.ts
+++ b/app/api/pay/status/route.ts
@@ -1,17 +1,18 @@
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) {
const orderId = request.nextUrl.searchParams.get("order_id");
if (!orderId) {
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 });
}
try {
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" }
);
const data = await res.json().catch(() => ({}));
diff --git a/app/api/upload-media/route.ts b/app/api/upload-media/route.ts
index 7d1528f..dfbaf1b 100644
--- a/app/api/upload-media/route.ts
+++ b/app/api/upload-media/route.ts
@@ -1,17 +1,7 @@
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 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}`;
+const MAX_SIZE = 20 * 1024 * 1024; // 20MB
export async function POST(request: NextRequest) {
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(
{ ok: false, error: "文件大小不能超过 20MB" },
{ 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 =
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
(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 contentType =
(fileObj as File).type || "application/octet-stream";
- await client.putObject(MINIO_BUCKET, objectKey, buffer, buffer.length, {
- "Content-Type": contentType,
- });
-
- const base = MINIO_PUBLIC_URL.replace(/\/$/, "");
- const url = `${base}/${objectKey}`;
+ const { url } = await uploadBuffer(buffer, objectKey, contentType);
return NextResponse.json({ ok: true, url });
} catch (e) {
diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx
index c5b2f8f..bd21a5a 100644
--- a/app/components/AuthModal.tsx
+++ b/app/components/AuthModal.tsx
@@ -1,8 +1,11 @@
"use client";
import { useState, type FormEvent } from "react";
-
-const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
+import {
+ pbLogin,
+ pbRegister,
+ pbSaveAuth,
+} from "@/app/lib/pocketbase";
interface AuthModalProps {
isOpen: boolean;
@@ -24,6 +27,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
setLoading(true);
try {
+ let result;
if (mode === "register") {
if (password !== passwordConfirm) {
setError("两次密码不一致");
@@ -35,49 +39,11 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
setLoading(false);
return;
}
- const res = await fetch(`${PB_URL}/api/collections/users/records`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- email,
- password,
- passwordConfirm,
- }),
- });
- if (!res.ok) {
- const err = await res.json();
- throw new Error(err?.message || "注册失败");
- }
- // 注册成功后自动登录
- 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));
- }
+ result = await pbRegister(email, password, passwordConfirm);
} else {
- const res = await fetch(`${PB_URL}/api/collections/users/auth-with-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));
- }
+ result = await pbLogin(email, password);
}
+ pbSaveAuth(result.token, result.record);
onSuccess(email);
onClose();
setEmail("");
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index e772b5c..baf425c 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
+import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
@@ -11,18 +12,6 @@ const navLinks = [
{ 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() {
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
@@ -35,12 +24,11 @@ export default function Header() {
const [userEmail, setUserEmail] = useState(null);
useEffect(() => {
- setUserEmail(getStoredUser());
+ setUserEmail(getStoredUserEmail());
}, []);
const handleLogout = () => {
- localStorage.removeItem("pb_token");
- localStorage.removeItem("pb_user");
+ pbLogout();
setUserEmail(null);
};
diff --git a/app/components/Tools.tsx b/app/components/Tools.tsx
index 193ad85..91723ed 100644
--- a/app/components/Tools.tsx
+++ b/app/components/Tools.tsx
@@ -1,23 +1,19 @@
"use client";
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<{
- icon: string;
- title: string;
- color: string;
- bg: string;
- text: string;
- tools: Array<{ name: string; desc: string; href: string }>;
-}>;
+type ToolsProps = {
+ data: { categories: ToolsCategory[] };
+};
function truncateDesc(desc: string, maxLen = 5) {
if (!desc || desc.length <= maxLen) return desc;
return desc.slice(0, maxLen) + "...";
}
-export default function Tools() {
+export default function Tools({ data }: ToolsProps) {
+ const categories = data.categories;
const { t } = useTranslation("tools");
const total = categories.reduce((s, c) => s + c.tools.length, 0);
const toolStats = [
diff --git a/app/layout.tsx b/app/layout.tsx
index 2593fad..b0746a8 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "./context/ThemeContext";
+import { getThemeConfig } from "@/config";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,11 +14,13 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
-export const metadata: Metadata = {
- title: "数字游民指南 | Digital Nomad Guide",
- description:
- "从零开始,7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
-};
+export const metadata: Metadata = (() => {
+ const theme = getThemeConfig();
+ return {
+ title: theme.meta.title,
+ description: theme.meta.description,
+ };
+})();
function ThemeScript() {
return (
@@ -46,7 +49,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
diff --git a/app/lib/minio/client.ts b/app/lib/minio/client.ts
new file mode 100644
index 0000000..d8b355b
--- /dev/null
+++ b/app/lib/minio/client.ts
@@ -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 {
+ 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}`;
+}
diff --git a/app/lib/minio/config.ts b/app/lib/minio/config.ts
new file mode 100644
index 0000000..95650e4
--- /dev/null
+++ b/app/lib/minio/config.ts
@@ -0,0 +1,2 @@
+export { getMinioConfig } from "@/config/services.config";
+export type { MinioConfig } from "@/config/services.config";
diff --git a/app/lib/minio/index.ts b/app/lib/minio/index.ts
new file mode 100644
index 0000000..510b2d9
--- /dev/null
+++ b/app/lib/minio/index.ts
@@ -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";
diff --git a/app/lib/payment/client.ts b/app/lib/payment/client.ts
new file mode 100644
index 0000000..7116f98
--- /dev/null
+++ b/app/lib/payment/client.ts
@@ -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";
+}
diff --git a/app/lib/payment/config.ts b/app/lib/payment/config.ts
new file mode 100644
index 0000000..5f5ce8c
--- /dev/null
+++ b/app/lib/payment/config.ts
@@ -0,0 +1,2 @@
+export { getPaymentConfig, getServicesConfig } from "@/config/services.config";
+export type { PaymentConfig } from "@/config/services.config";
diff --git a/app/lib/payment/index.ts b/app/lib/payment/index.ts
new file mode 100644
index 0000000..fcf0ef2
--- /dev/null
+++ b/app/lib/payment/index.ts
@@ -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";
diff --git a/app/lib/payment/types.ts b/app/lib/payment/types.ts
new file mode 100644
index 0000000..36d0c97
--- /dev/null
+++ b/app/lib/payment/types.ts
@@ -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";
+}
diff --git a/app/lib/payment/usePayStatusPoll.ts b/app/lib/payment/usePayStatusPoll.ts
new file mode 100644
index 0000000..c6527e3
--- /dev/null
+++ b/app/lib/payment/usePayStatusPoll.ts
@@ -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;
+}
diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts
new file mode 100644
index 0000000..245df10
--- /dev/null
+++ b/app/lib/pocketbase/auth.ts
@@ -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 {
+ const url = getPBUrl();
+ const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity, password }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err?.message || "登录失败");
+ }
+ const data = await res.json();
+ if (!data?.token) throw new Error("登录响应异常");
+ return { token: data.token, record: data.record };
+}
+
+/** 注册 */
+export async function pbRegister(
+ email: string,
+ password: string,
+ passwordConfirm: string
+): Promise {
+ const url = getPBUrl();
+ const 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);
+}
diff --git a/app/lib/pocketbase/config.ts b/app/lib/pocketbase/config.ts
new file mode 100644
index 0000000..bafb2e3
--- /dev/null
+++ b/app/lib/pocketbase/config.ts
@@ -0,0 +1,2 @@
+export { getPocketBaseConfig, getServicesConfig } from "@/config/services.config";
+export type { PocketBaseConfig } from "@/config/services.config";
diff --git a/app/lib/pocketbase/constants.ts b/app/lib/pocketbase/constants.ts
new file mode 100644
index 0000000..fad70d0
--- /dev/null
+++ b/app/lib/pocketbase/constants.ts
@@ -0,0 +1,5 @@
+/** localStorage 存储 key */
+export const PB_STORAGE_KEYS = {
+ token: "pb_token",
+ user: "pb_user",
+} as const;
diff --git a/app/lib/pocketbase/index.ts b/app/lib/pocketbase/index.ts
new file mode 100644
index 0000000..48fd101
--- /dev/null
+++ b/app/lib/pocketbase/index.ts
@@ -0,0 +1,11 @@
+export {
+ pbLogin,
+ pbRegister,
+ pbSaveAuth,
+ pbLogout,
+ getStoredUserEmail,
+ getStoredToken,
+} from "./auth";
+export type { AuthUser, AuthResult } from "./auth";
+export { PB_STORAGE_KEYS } from "./constants";
+export { getPocketBaseConfig } from "./config";
diff --git a/app/lib/theme-data.ts b/app/lib/theme-data.ts
new file mode 100644
index 0000000..c6209f0
--- /dev/null
+++ b/app/lib/theme-data.ts
@@ -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> {
+ 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> {
+ 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 };
diff --git a/config/index.ts b/config/index.ts
new file mode 100644
index 0000000..045d8ef
--- /dev/null
+++ b/config/index.ts
@@ -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";
diff --git a/config/services.config.ts b/config/services.config.ts
new file mode 100644
index 0000000..be7a875
--- /dev/null
+++ b/config/services.config.ts
@@ -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,
+ };
+}
diff --git a/config/site.config.ts b/config/site.config.ts
new file mode 100644
index 0000000..887824f
--- /dev/null
+++ b/config/site.config.ts
@@ -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 = {
+ "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];
+}
diff --git a/content/themes/digital-nomad/data/tools.json b/content/themes/digital-nomad/data/tools.json
new file mode 100644
index 0000000..46ffc35
--- /dev/null
+++ b/content/themes/digital-nomad/data/tools.json
@@ -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" }
+ ]
+ }
+ ]
+}
diff --git a/content/themes/digital-nomad/messages/en.json b/content/themes/digital-nomad/messages/en.json
new file mode 100644
index 0000000..dc87d1e
--- /dev/null
+++ b/content/themes/digital-nomad/messages/en.json
@@ -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"
+ }
+}
diff --git a/content/themes/digital-nomad/messages/zh.json b/content/themes/digital-nomad/messages/zh.json
new file mode 100644
index 0000000..17e8a2a
--- /dev/null
+++ b/content/themes/digital-nomad/messages/zh.json
@@ -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": "关闭"
+ }
+}
diff --git a/docs/SERVICES.md b/docs/SERVICES.md
new file mode 100644
index 0000000..d0c4fcf
--- /dev/null
+++ b/docs/SERVICES.md
@@ -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
+```
diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md
new file mode 100644
index 0000000..229ac5d
--- /dev/null
+++ b/docs/TEMPLATE.md
@@ -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 条件渲染待接入)