's'调试支付

This commit is contained in:
eric
2026-03-08 06:43:48 -05:00
parent b99fba1c5a
commit 1f4473cb04
35 changed files with 1794 additions and 178 deletions

View File

@@ -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);
};

View File

@@ -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";

View File

@@ -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 跳转 payurlH5/微信 用 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 : "网络错误,请重试");

View 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>
);
}

View File

@@ -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 (
<LocaleProvider locale={locale as Locale} messages={messages}>

View File

@@ -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 (
<>
<Header />
@@ -14,7 +17,7 @@ export default function HomePage() {
<Hero />
<Features />
<Roadmap />
<Tools />
<Tools data={toolsData} />
<Community />
</main>
<Footer />

View File

@@ -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<string, string>)["media"] = mediaUrl;
}
const theme = getThemeConfig();
const pb = getPocketBaseConfig({
joinCollection: theme.services?.pocketbaseJoinCollection,
});
const collection = pb.joinCollection;
const doCreate = async (authToken?: string) => {
const headers: Record<string, string> = {
"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),

View File

@@ -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<string, string>;
}
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<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) {
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 {

View File

@@ -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(() => ({}));

View File

@@ -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";
// 公开访问 URLhttps 无端口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) {

View File

@@ -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("");

View File

@@ -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<string | null>(null);
useEffect(() => {
setUserEmail(getStoredUser());
setUserEmail(getStoredUserEmail());
}, []);
const handleLogout = () => {
localStorage.removeItem("pb_token");
localStorage.removeItem("pb_user");
pbLogout();
setUserEmail(null);
};

View File

@@ -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 = [

View File

@@ -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 (
<html lang="zh-CN" suppressHydrationError>
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

67
app/lib/minio/client.ts Normal file
View 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
View 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
View 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
View 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";
}

View 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
View 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
View 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";
}

View 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`;
}
/** 解析 cookieorder_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;
}

View 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);
}

View File

@@ -0,0 +1,2 @@
export { getPocketBaseConfig, getServicesConfig } from "@/config/services.config";
export type { PocketBaseConfig } from "@/config/services.config";

View File

@@ -0,0 +1,5 @@
/** localStorage 存储 key */
export const PB_STORAGE_KEYS = {
token: "pb_token",
user: "pb_user",
} as const;

View 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
View 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
View 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
View 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
View 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];
}

View 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" }
]
}
]
}

View 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"
}
}

View 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
View 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
View 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 条件渲染待接入)