11 Commits
test ... pro

Author SHA1 Message Date
eric
894150d1fa 's' 2026-03-23 02:18:43 -05:00
eric
2f73b2ee23 's' 2026-03-23 02:13:39 -05:00
eric
5b26c3c907 's' 2026-03-18 02:08:44 -05:00
eric
6873bd8f70 's' 2026-03-18 02:05:21 -05:00
eric
bf10b5d7e4 's' 2026-03-18 02:01:48 -05:00
eric
742c293e14 's' 2026-03-18 00:53:28 -05:00
eric
4d4ca04167 's' 2026-03-17 15:01:08 -05:00
eric
fd2e424897 's' 2026-03-16 20:44:14 -05:00
eric
9d45fede7f 's' 2026-03-16 20:39:19 -05:00
eric
473fa1a1cf 's' 2026-03-16 20:24:28 -05:00
eric
c93eb7b2ee 's' 2026-03-16 20:08:21 -05:00
33 changed files with 1609 additions and 385 deletions

View File

@@ -1 +1,2 @@
PAYMENT_API_URL=https://api.nomadyt.com
PAYMENT_API_URL=https://salonapi.hackrobot.cn
PAYMENT_JOIN_AMOUNT=2900

View File

@@ -13,9 +13,9 @@
### 前端 (salon)
- `PAYMENT_API_URL`: salonapi 公网地址,**生产环境必填**(如 `https://api.nomadyt.com`
- `ROOT_DOMAIN`: 根域名,未设 PAYMENT_API_URL 时用于推导 `https://api.${ROOT_DOMAIN}`,默认 `nomadyt.com`
- `NEXT_PUBLIC_SITE_URL`: 前端站点地址,线上 `https://nomadyt.com`
- `PAYMENT_API_URL`: salonapi 公网地址,**生产环境必填**(如 `https://salonapi.hackrobot.cn`
- `ROOT_DOMAIN`: 根域名(兼容变量),默认 `hackrobot.cn`;生产 salonapi 主机为 `salonapi.hackrobot.cn`
- `NEXT_PUBLIC_SITE_URL`: 前端站点地址,线上 `https://salon.hackrobot.cn`
- `PAYJSAPI_PORT`: 开发时 salonapi 端口,默认 `8007`
- `ZPAY_PID` / `ZPAY_KEY``XORPAY_AID` / `XORPAY_SECRET`:支付平台凭证,与 salonapi 一致。用于 pay/status 兜底直接查询,**本地无回调时依赖此检测支付**
- `NEXT_PUBLIC_POCKETBASE_URL` / `POCKETBASE_URL`: PocketBase 地址

View File

@@ -3,6 +3,15 @@ import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { getPocketBaseConfig } from "@/config/services.config";
import { getSalonApi } from "@/app/lib/salonApi";
/** 用户状态必须实时从数据库获取,禁止缓存 */
export const dynamic = "force-dynamic";
const NO_CACHE_HEADERS = {
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
};
const empty = {
hasRecord: false,
record: null,
@@ -65,17 +74,17 @@ async function getPocketBaseJoinByWechat(wechatId: string) {
export async function GET(request: NextRequest) {
const wechatId = request.nextUrl.searchParams.get("wechat_id")?.trim();
if (!wechatId) {
return NextResponse.json(empty);
return NextResponse.json(empty, { headers: NO_CACHE_HEADERS });
}
try {
const { status, data } = await getSalonApi(
request,
`/api/salon/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}`,
`/api/salon/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}&_=${Date.now()}`,
{ timeoutMs: 6000 }
);
if (status === 200 && data && typeof data === "object") {
return NextResponse.json(data);
return NextResponse.json(data, { headers: NO_CACHE_HEADERS });
}
} catch {
/* salonapi 不可用,回退直连 PocketBase */
@@ -83,8 +92,8 @@ export async function GET(request: NextRequest) {
const pbData = await getPocketBaseJoinByWechat(wechatId);
if (pbData) {
return NextResponse.json(pbData);
return NextResponse.json(pbData, { headers: NO_CACHE_HEADERS });
}
return NextResponse.json(empty);
return NextResponse.json(empty, { headers: NO_CACHE_HEADERS });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { postSalonApi } from "@/app/lib/salonApi";
import { getNowChinaStr } from "@/app/lib/dates";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { getPocketBaseConfig } from "@/config/services.config";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
@@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
const xiaohongshuUrl = String(formData.xiaohongshu_url || "").trim();
const xiaohongshuAvatarUrl = String(formData.xiaohongshu_avatar_url || formData.media || "").trim();
const gender = String(formData.gender || "").trim() || "\u65e0";
let intro = String(formData.intro || "").trim();
let intro = String(formData.intro || formData.reason || "").trim();
const firstTime = String(formData.first_time || "").trim();
const agreeRules = !!formData.agree_rules;
const status = String(formData.status || "").trim();
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
const whyJoin = String(formData.why_join || "").trim();
const region = String(formData.region || "").trim();
const availableTime = String(formData.available_time || "").trim();
const meetupdate = String(formData.meetupdate || "").trim();
const isVolunteer = !!formData.is_volunteer;
const qualify = !!formData.qualify; // 女性且小红书帖子>10 时自动通过审核
/** reason 仅存自我介绍,不合并其他字段 */
@@ -61,6 +63,24 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
if (!xiaohongshuUrl) {
return NextResponse.json(
{ ok: false, error: "请填写小红书主页链接或笔记链接" },
{ status: 400 }
);
}
if (!intro) {
return NextResponse.json(
{ ok: false, error: "请填写自我介绍" },
{ status: 400 }
);
}
if (intro.length < 20) {
return NextResponse.json(
{ ok: false, error: "自我介绍不少于20字" },
{ status: 400 }
);
}
const userId = getUserIdFromCookie(request) || generateAnonId();
@@ -68,6 +88,7 @@ export async function POST(request: NextRequest) {
/** occupation: 职业/城市reason: 仅自我介绍 */
const occupationValue = profession || city;
const userInfo = String(formData.userInfo || "").trim();
const record: Record<string, unknown> = {
user_id: userId,
nickname,
@@ -87,6 +108,8 @@ export async function POST(request: NextRequest) {
first_time: firstTime,
is_volunteer: isVolunteer,
qualify,
userInfo: userInfo || "",
meetupdate: meetupdate || "",
};
if (phone.trim()) {
record.phone = phone.trim();
@@ -177,7 +200,12 @@ async function tryPocketBaseDirect(
is_approved: qualify,
age_range: record.age_range ?? "",
first_time: record.first_time ?? "",
userInfo: record.userInfo ?? "",
};
if (record.meetupdate != null && String(record.meetupdate).trim()) {
body.meetupdate = String(record.meetupdate).trim();
}
body.submitted_at_cn = getNowChinaStr();
if (record.phone != null && String(record.phone).trim()) {
body.phone = record.phone;
}

View File

@@ -2,9 +2,27 @@ import { NextRequest, NextResponse } from "next/server";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { getPocketBaseConfig } from "@/config/services.config";
import { SESSIONS } from "@/config/home.config";
import { getThisWeekRangeChinaStr, getThisWeekRangeISO } from "@/app/lib/dates";
import { getSalonApi } from "@/app/lib/salonApi";
/** 按场次返回报名人数和最近报名者(昵称),头像由前端用 initials 或占位 */
export const dynamic = "force-dynamic";
/** 按场次返回报名人数和最近报名者,仅限中国日历本周(周一到周日)报名的记录。优先 salonapi失败时回退直连 PocketBase */
export async function GET(request: NextRequest) {
try {
const { status, data } = await getSalonApi(request, "/api/salon/join/stats", {
timeoutMs: 8000,
});
if (status === 200 && data && typeof data === "object") {
const payload = data as { ok?: boolean; stats?: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> };
if (payload?.stats && typeof payload.stats === "object") {
return NextResponse.json({ ok: true, stats: payload.stats });
}
}
} catch {
/* salonapi 不可用,回退直连 PocketBase */
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
@@ -18,34 +36,66 @@ export async function GET(request: NextRequest) {
const stats: Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }> = {};
for (const s of SESSIONS) {
try {
const filter = encodeURIComponent(`type="${s.id}"`);
const res = await fetch(
`${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
const { start: weekStartCn, end: weekEndCn } = getThisWeekRangeChinaStr();
const { start: weekStartUtc, end: weekEndUtc } = getThisWeekRangeISO();
const results = await Promise.all(
SESSIONS.map(async (s) => {
try {
// session-1 周六 / session-2 周日meetupdate 含关键词或为空(兼容旧记录)
const dayKeyword = s.id === "session-1" ? "周六" : "周日";
const meetupdatePart = s.id === "session-1"
? `(meetupdate ~ "${dayKeyword}" || meetupdate = "")`
: `meetupdate ~ "${dayKeyword}"`;
// 本周submitted_at_cn中国公历或 createdUTC 兼容旧记录),两路查询合并
const filterSubmitted = encodeURIComponent(
`${meetupdatePart} && submitted_at_cn >= "${weekStartCn}" && submitted_at_cn <= "${weekEndCn}"`
);
const filterCreated = encodeURIComponent(
`${meetupdatePart} && created >= "${weekStartUtc}" && created <= "${weekEndUtc}"`
);
const [res1, res2] = await Promise.all([
fetch(`${base}/api/collections/${pb.joinCollection}/records?filter=${filterSubmitted}&sort=-created&perPage=6`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
fetch(`${base}/api/collections/${pb.joinCollection}/records?filter=${filterCreated}&sort=-created&perPage=6`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}),
]);
const data1 = res1.ok ? await res1.json() : { items: [], totalItems: 0 };
const data2 = res2.ok ? await res2.json() : { items: [], totalItems: 0 };
const seen = new Set<string>();
const merged: typeof data1.items = [];
for (const r of [...(data1.items || []), ...(data2.items || [])]) {
if (r?.id && !seen.has(r.id)) {
seen.add(r.id);
merged.push(r);
}
}
);
if (!res.ok) {
stats[s.id] = { count: 0, joiners: [] };
continue;
merged.sort((a: { created?: string }, b: { created?: string }) => (b?.created || "").localeCompare(a?.created || ""));
const items = merged.slice(0, 6);
const total = Math.max(data1.totalItems || 0, data2.totalItems || 0, merged.length);
return {
id: s.id,
stat: {
count: total,
joiners: items.map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({
nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名",
avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined,
xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined,
})),
},
};
} catch {
return { id: s.id, stat: { count: 0, joiners: [] } };
}
const data = await res.json();
const items = Array.isArray(data?.items) ? data.items : [];
const total = typeof data?.totalItems === "number" ? data.totalItems : items.length;
stats[s.id] = {
count: total,
joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({
nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名",
avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined,
xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined,
})),
};
} catch {
stats[s.id] = { count: 0, joiners: [] };
}
})
);
for (const { id, stat } of results) {
stats[id] = stat;
}
return NextResponse.json({ ok: true, stats });

View File

@@ -13,16 +13,15 @@ function getSalonApiBase(): string {
return resolvePaymentApiUrl();
}
/** 代理到 salonapi 获取小红书用户资料 */
/** 代理到 salonapi 获取小红书用户资料,支持主页链接、笔记链接 xhslink.com/o/ */
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const url = String(body?.url || "").trim();
const url = String(body?.url || body?.id || "").trim();
if (!url) {
return NextResponse.json({ ok: false, error: "请输入小红书链接" }, { status: 400 });
}
const apiBase = getSalonApiBase();
const res = await fetch(`${apiBase}/api/salon/xiaohongshu/profile`, {
method: "POST",

View File

@@ -1,28 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
/** 支持xiaohongshu.com/user/profile/xxx、xhslink.com/user/profile/xxx、xhslink.com/m/xxx 短链 */
/** 支持:主页 xiaohongshu.com/user/profile/xxx、xhslink.com/m/xxx笔记 xhslink.com/o/xxx、xiaohongshu.com/explore/xxx */
const XH_PROFILE_PATTERN = /^https?:\/\/(www\.)?(xiaohongshu\.com|xhslink\.com)\/user\/profile\/[a-zA-Z0-9_-]+/i;
const XH_SHORT_PATTERN = /^https?:\/\/xhslink\.com\/m\/[a-zA-Z0-9_-]+/i;
const XH_NOTE_PATTERN = /^https?:\/\/(xhslink\.com\/o\/|(www\.)?xiaohongshu\.com\/explore\/)[a-zA-Z0-9_-]+/i;
function isValidUrl(url: string): boolean {
return XH_PROFILE_PATTERN.test(url) || XH_SHORT_PATTERN.test(url);
const s = url.trim();
if (!s) return false;
if (XH_PROFILE_PATTERN.test(s) || XH_SHORT_PATTERN.test(s) || XH_NOTE_PATTERN.test(s)) return true;
return false;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const url = String(body?.url || "").trim();
const url = String(body?.url || body?.id || "").trim();
if (!url) {
return NextResponse.json({ valid: false, error: "请输入小红书链接" }, { status: 400 });
}
const valid = isValidUrl(url);
if (!valid) {
return NextResponse.json({ valid: false, error: "小红书link异常" }, { status: 200 });
if (!isValidUrl(url)) {
return NextResponse.json({ valid: false, error: "请输入有效的主页链接或笔记链接xhslink.com/m/xxx 或 xhslink.com/o/xxx" }, { status: 200 });
}
return NextResponse.json({ valid: true });
return NextResponse.json({ valid: true, url });
} catch {
return NextResponse.json({ valid: false, error: "校验失败" }, { status: 500 });
}

30
app/apple-icon.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { ImageResponse } from "next/og";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #b45309 0%, #d97706 50%, #f59e0b 100%)",
borderRadius: 36,
}}
>
<div style={{ position: "relative", width: 100, height: 100, display: "flex" }}>
<div style={{ position: "absolute", left: 38, top: 28, width: 28, height: 28, borderRadius: "50%", background: "white", opacity: 0.95 }} />
<div style={{ position: "absolute", left: 18, top: 58, width: 22, height: 22, borderRadius: "50%", background: "white", opacity: 0.95 }} />
<div style={{ position: "absolute", left: 60, top: 58, width: 22, height: 22, borderRadius: "50%", background: "white", opacity: 0.95 }} />
<div style={{ position: "absolute", left: 43, top: 72, width: 16, height: 16, borderRadius: "50%", background: "white", opacity: 0.95 }} />
</div>
</div>
),
{ ...size }
);
}

View File

@@ -1,26 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import SessionCoverImage from "./SessionCoverImage";
import AddressLink from "./AddressLink";
import { getSessionsWithDates, RECENT_JOINERS } from "@/config/home.config";
import { getSessionsWithDates, MOCK_JOINERS_SESSION1 } from "@/config/home.config";
import { mergeJoiners, getUniqueDbCount } from "@/app/lib/joiners";
import { useXhClickOpener } from "@/app/hooks/useXhClickOpener";
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
function HeroAvatarItem({ joiner }: { joiner: { name?: string; nickname?: string; avatar?: string; xiaohongshu_url?: string } }) {
const nickname = ("name" in joiner ? joiner.name : joiner.nickname) ?? "";
const avatar = joiner.avatar;
const xhUrl = joiner.xiaohongshu_url;
const showAvatar = avatar && avatar.length > 0;
const handleClick = useXhClickOpener(xhUrl);
return (
<span
className={`relative w-6 h-6 sm:w-8 sm:h-8 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 shrink-0 inline-block transition-transform hover:scale-110 cursor-default ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-[10px] sm:text-xs font-medium text-amber-700 dark:text-amber-300"}`}
{...(xhUrl && { "data-xiaohongshu-url": xhUrl })}
onClick={handleClick}
>
{showAvatar ? (
<Image src={avatar} alt="" fill sizes="32px" className="object-cover" unoptimized />
) : (
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
)}
</span>
);
}
export default function HeroSection() {
const sessions = getSessionsWithDates();
const mainSession = sessions[0];
const [stats, setStats] = useState<{ count: number; joiners: { nickname: string }[] } | null>(null);
const [stats, setStats] = useState<{ count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] } | null>(null);
useEffect(() => {
const pathname = usePathname();
const fetchStats = useCallback(() => {
if (!mainSession) return;
fetch("/api/join/stats")
fetch(`/api/join/stats?_=${Date.now()}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => d?.stats?.[mainSession.id] && setStats(d.stats[mainSession.id]))
.catch(() => {});
}, [mainSession?.id]);
useEffect(() => {
if (!mainSession || pathname !== "/") return;
fetchStats();
const onVisible = () => fetchStats();
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) fetchStats();
};
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", onPageShow);
};
}, [pathname, mainSession?.id, fetchStats]);
return (
<section className="relative overflow-hidden bg-[#faf8f5] dark:bg-stone-950">
<div
@@ -117,33 +155,12 @@ export default function HeroSection() {
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-stone-100 dark:border-stone-800 flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="flex -space-x-2 sm:-space-x-2.5 shrink-0">
{(stats?.joiners?.length ? stats.joiners : MOCK_JOINERS).slice(0, 5).map((j, i) => {
const nickname = "name" in j ? (j as { name: string }).name : (j as { nickname: string }).nickname;
const avatar = "avatar" in j ? (j as { avatar: string }).avatar : (j as { avatar?: string }).avatar;
const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url;
const showAvatar = avatar && avatar.length > 0;
return (
<span
key={i}
className={`relative w-6 h-6 sm:w-8 sm:h-8 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 shrink-0 inline-block transition-transform hover:scale-110 ${xhUrl ? "cursor-pointer" : "cursor-default"} ${showAvatar ? "" : "bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-[10px] sm:text-xs font-medium text-amber-700 dark:text-amber-300"}`}
title={nickname}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (xhUrl) window.open(xhUrl, "_blank", "noopener,noreferrer");
}}
>
{showAvatar ? (
<Image src={avatar} alt="" fill sizes="32px" className="object-cover" unoptimized />
) : (
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
)}
</span>
);
})}
{mergeJoiners(MOCK_JOINERS_SESSION1, stats?.joiners).map((j, i) => (
<HeroAvatarItem key={`${i}-${("name" in j ? j.name : j.nickname) || i}`} joiner={j} />
))}
</div>
<span className="text-[10px] sm:text-xs text-stone-500 dark:text-stone-400 truncate">
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
{3 + getUniqueDbCount(stats?.joiners)}
</span>
</div>
<span className="shrink-0 inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md sm:rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 font-semibold text-xs sm:text-sm group-hover:bg-amber-100 dark:group-hover:bg-amber-900/30 transition-all group-hover:gap-2">

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import HeroSection from "./HeroSection";
import MyRegistrationStatus from "./MyRegistrationStatus";
@@ -9,8 +10,10 @@ import HostLink from "./HostLink";
import Footer from "./Footer";
import Header from "./Header";
import { getSessionsWithDates, HOSTS } from "@/config/home.config";
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
import { Check, X, ClipboardList, Calendar, Lightbulb, Users, HelpCircle } from "lucide-react";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
export type JoinStatsMap = Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }>;
interface HomeContentProps {
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
@@ -33,15 +36,13 @@ function getPendingOrderId(): string | null {
}
}
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
document.cookie = "join_pay_order=; path=/; max-age=0";
} catch {}
}
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
const pathname = usePathname();
const [volunteer, setVolunteer] = useState(initialVolunteer);
const [stats, setStats] = useState<JoinStatsMap | null>(null);
const [pendingOrderId] = useState<string | null>(() =>
typeof window !== "undefined" ? getPendingOrderId() : null
);
useEffect(() => {
fetch("/api/join/volunteers")
@@ -50,61 +51,39 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
.catch(() => {});
}, []);
// 支付完成后再次打开首页后台完成订单并刷新用户信息手机浏览器唤醒微信支付后用户可能直接回首页notify 可能未到,需轮询)
useEffect(() => {
const orderId = getPendingOrderId();
if (!orderId) return;
let cancelled = false;
const tryComplete = async (): Promise<boolean> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return false;
if (d?.token && d?.record) {
fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
}).catch(() => {});
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
if (d.record?.wechatId) {
try {
localStorage.setItem(WECHAT_STORAGE_KEY, d.record.wechatId);
} catch {}
}
}
clearPendingOrder();
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return true;
};
const run = async () => {
const retryDelays = [1500, 2500, 3500, 4500, 5500];
for (let i = 0; i <= retryDelays.length; i++) {
if (cancelled) return;
if (await tryComplete()) return;
if (i < retryDelays.length && !cancelled) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
}
};
void run();
return () => { cancelled = true; };
const fetchStats = useCallback(() => {
fetch(`/api/join/stats?_=${Date.now()}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => d?.stats && setStats(d.stats))
.catch(() => {});
}, []);
useEffect(() => {
if (pathname !== "/") return;
fetchStats();
const onVisible = () => fetchStats();
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) fetchStats();
};
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", onPageShow);
};
}, [pathname, fetchStats]);
useCompleteOrderPolling({
orderId: pendingOrderId,
retryDelays: [500, 1000, 1500, 2500, 4000],
});
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
<Header />
<HeroSection />
<main className="max-w-[900px] mx-auto px-4 sm:px-6 md:px-8 py-8 sm:py-12 md:py-14 space-y-10 sm:space-y-14">
<main className="max-w-[900px] md:max-w-[1024px] mx-auto px-4 sm:px-6 md:px-8 py-8 sm:py-12 md:py-14 space-y-10 sm:space-y-14">
<MyRegistrationStatus />
{/* 是什么 / 不是什么 - 小红书风格 */}
<section>
@@ -112,7 +91,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
<div className="grid sm:grid-cols-2 gap-6 sm:gap-8">
<div>
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
<span className="text-amber-600 dark:text-amber-400"></span> ·
<Check className="size-4 sm:size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> ·
</h3>
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
<li>· loft民宿见</li>
@@ -122,7 +101,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
</div>
<div>
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
<span className="text-stone-400 dark:text-stone-500"></span> ·
<X className="size-4 sm:size-5 text-stone-400 dark:text-stone-500 shrink-0" aria-hidden /> ·
</h3>
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
<li>· </li>
@@ -137,7 +116,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 报名流程 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<span>📋</span>
<ClipboardList className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
<div className="grid grid-cols-3 gap-3 sm:gap-6 md:gap-8">
@@ -168,11 +147,11 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 本周场次 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<span>📅</span>
<Calendar className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2">
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2 animate-fade-in-stagger">
{getSessionsWithDates().map((s, i) => (
<SessionCard key={s.id} session={s} index={i} />
<SessionCard key={s.id} session={s} index={i} stats={stats?.[s.id] ?? null} />
))}
</div>
</section>
@@ -180,7 +159,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 活动亮点 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<span>💡</span>
<Lightbulb className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
<div className="grid sm:grid-cols-2 gap-4 sm:gap-6">
@@ -205,7 +184,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 发起人 + 志愿者(有志愿者时替换 host-2 卡片) */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<span>👤</span>
<Users className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4 mb-4">
{HOSTS.map((host) => {
@@ -236,7 +215,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* FAQ */}
<section id="faq">
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<span></span>
<HelpCircle className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="space-y-2 sm:space-y-3">
{[

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
@@ -52,7 +53,7 @@ export default function MyRegistrationStatus() {
return;
}
setLoading(true);
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}`)
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}&_=${Date.now()}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => {
if (d?.hasRecord) {
@@ -76,6 +77,8 @@ export default function MyRegistrationStatus() {
}
};
const pathname = usePathname();
useEffect(() => {
if (typeof window === "undefined") return;
let cancelled = false;
@@ -83,13 +86,30 @@ export default function MyRegistrationStatus() {
const onUpdate = () => {
if (!cancelled) fetchStatus();
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible" && !cancelled) fetchStatus();
};
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted && !cancelled) fetchStatus();
};
window.addEventListener("vip:updated", onUpdate);
window.addEventListener("registration:refresh", onUpdate);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pageshow", onPageShow);
return () => {
cancelled = true;
window.removeEventListener("vip:updated", onUpdate);
window.removeEventListener("registration:refresh", onUpdate);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pageshow", onPageShow);
};
}, []);
// 每次进入首页时强制重新拉取,避免预取/缓存导致显示旧状态
useEffect(() => {
if (pathname === "/") fetchStatus();
}, [pathname]);
if (loading || !data?.hasRecord || !data.record) return null;
const rec = data.record;

View File

@@ -1,18 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { RefreshCw, Coffee } from "lucide-react";
import SessionCoverImage from "./SessionCoverImage";
import AddressLink from "./AddressLink";
import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
import { ACTIVITY_TYPES, SESSION_TAGS, MOCK_JOINERS_SESSION1, MOCK_JOINERS_SESSION2 } from "@/config/home.config";
import { mergeJoiners, getUniqueDbCount } from "@/app/lib/joiners";
import { useXhClickOpener } from "@/app/hooks/useXhClickOpener";
interface JoinStats {
count: number;
joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[];
}
const MOCK_JOINERS = RECENT_JOINERS.slice(0, 3);
function getMockForSession(sessionId: string) {
return sessionId === "session-1" ? MOCK_JOINERS_SESSION1 : MOCK_JOINERS_SESSION2;
}
function AvatarItem({ joiner }: { joiner: { name?: string; nickname?: string; avatar?: string; xiaohongshu_url?: string } }) {
const [imgError, setImgError] = useState(false);
const nickname = ("name" in joiner ? joiner.name : joiner.nickname) ?? "";
const avatar = joiner.avatar;
const xhUrl = joiner.xiaohongshu_url;
const showAvatar = avatar && avatar.length > 0 && !imgError;
const handleClick = useXhClickOpener(xhUrl);
return (
<span
className="relative w-7 h-7 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 ring-1 ring-stone-200/50 dark:ring-stone-700/50 shrink-0 inline-block cursor-default"
{...(xhUrl && { "data-xiaohongshu-url": xhUrl })}
onClick={handleClick}
>
{showAvatar ? (
<Image src={avatar} alt="" fill sizes="28px" className="object-cover" unoptimized onError={() => setImgError(true)} />
) : (
<span className="flex items-center justify-center w-full h-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
{nickname.slice(0, 1) || "?"}
</span>
)}
</span>
);
}
type SessionTag = keyof typeof SESSION_TAGS;
@@ -31,42 +61,33 @@ interface SessionItem {
export default function SessionCard({
session,
index,
stats,
}: {
session: SessionItem;
index: number;
stats: JoinStats | null;
}) {
const [stats, setStats] = useState<JoinStats | null>(null);
useEffect(() => {
fetch("/api/join/stats")
.then((r) => r.json())
.then((d) => d?.stats?.[session.id] && setStats(d.stats[session.id]))
.catch(() => {});
}, [session.id]);
return (
<Link
href={`/join?session=${encodeURIComponent(session.id)}`}
className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 transition-all"
>
<div className="relative w-full sm:w-32 sm:min-w-[128px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
<div className="group flex flex-col sm:flex-row gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg hover:shadow-amber-900/5 transition-all duration-200 ease-out">
<div className="relative w-full sm:w-36 md:w-40 sm:min-w-[144px] md:min-w-[160px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
{session.coverImage ? (
<SessionCoverImage
src={session.coverImage}
alt={session.title}
type={session.type}
fill
sizes="(max-width: 640px) 100vw, 96px"
sizes="(max-width: 640px) 100vw, (max-width: 768px) 144px, 160px"
className="object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-2xl">
{session.type === "skill-exchange" ? "🔄" : "☕"}
<div className="absolute inset-0 flex items-center justify-center text-amber-500/70 dark:text-amber-400/50">
{session.type === "skill-exchange" ? <RefreshCw className="size-8" aria-hidden /> : <Coffee className="size-8" aria-hidden />}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap gap-1 mb-1.5">
<div className="flex flex-wrap gap-1 mb-1.5 min-h-[1.5rem]">
{session.tags?.map((tag) => (
<span
key={tag}
@@ -75,10 +96,19 @@ export default function SessionCard({
{SESSION_TAGS[tag]}
</span>
))}
{index === 0 && (
{!session.tags?.includes("light-topic") && (
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
{SESSION_TAGS["light-topic"]}
</span>
)}
{index === 0 ? (
<span className="inline-block text-[9px] sm:text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-1.5 py-0.5 rounded">
</span>
) : (
<span className="inline-block text-[9px] sm:text-[10px] font-medium px-1.5 py-0.5 rounded opacity-0" aria-hidden>
</span>
)}
</div>
<span className="text-[10px] sm:text-xs text-amber-600 dark:text-amber-400/90">
@@ -88,47 +118,28 @@ export default function SessionCard({
{session.title}
</h3>
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
{session.date} {session.time} · <AddressLink place={session.place} city={session.city} />
{session.date} {session.time}
</p>
<span className="inline-flex items-center gap-1 mt-2 sm:mt-3 text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium">
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5">
<AddressLink place={session.place} city={session.city} />
</p>
<Link
href={`/join?session=${encodeURIComponent(session.id)}`}
className="inline-flex items-center gap-1 mt-2 sm:mt-3 text-amber-600 dark:text-amber-400 text-xs sm:text-sm font-medium hover:text-amber-700 dark:hover:text-amber-300 transition-colors"
>
</span>
<div className="mt-3 flex items-center gap-2">
</Link>
<div className="mt-3 flex items-center gap-2 min-w-0 overflow-hidden">
<div className="flex -space-x-2 shrink-0">
{(stats?.joiners?.length ? stats.joiners : MOCK_JOINERS).slice(0, 5).map((j, i) => {
const nickname = "name" in j ? (j as { name: string }).name : (j as { nickname: string }).nickname;
const avatar = "avatar" in j ? (j as { avatar: string }).avatar : (j as { avatar?: string }).avatar;
const xhUrl = (j as { xiaohongshu_url?: string }).xiaohongshu_url;
const showAvatar = avatar && avatar.length > 0;
return (
<span
key={i}
className={`relative w-7 h-7 rounded-full overflow-hidden border-2 border-white dark:border-stone-900 ring-1 ring-stone-200/50 dark:ring-stone-700/50 shrink-0 inline-block ${xhUrl ? "cursor-pointer" : "cursor-default"}`}
title={nickname}
onClick={(e) => {
if (xhUrl) {
e.preventDefault();
e.stopPropagation();
window.open(xhUrl, "_blank", "noopener,noreferrer");
} else {
e.preventDefault();
}
}}
>
{showAvatar ? (
<Image src={avatar} alt="" fill sizes="28px" className="object-cover" unoptimized />
) : (
<span className="flex items-center justify-center w-full h-full">{nickname.slice(0, 1) || "?"}</span>
)}
</span>
);
})}
{mergeJoiners(getMockForSession(session.id), stats?.joiners).map((j, i) => (
<AvatarItem key={`${i}-${(j.nickname || j.name) || i}`} joiner={j} />
))}
</div>
<span className="text-xs text-stone-500 dark:text-stone-400">
{stats?.count ? `${stats.count} 人已报名` : "有人报名中"}
{3 + getUniqueDbCount(stats?.joiners)}
</span>
</div>
</div>
</Link>
</div>
);
}

View File

@@ -134,5 +134,28 @@ body {
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* 卡片依次淡入,用于列表 */
.animate-fade-in-stagger > * {
opacity: 0;
animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.animate-fade-in-stagger > *:nth-child(1) { animation-delay: 0ms; }
.animate-fade-in-stagger > *:nth-child(2) { animation-delay: 80ms; }
.animate-fade-in-stagger > *:nth-child(3) { animation-delay: 160ms; }
.animate-fade-in-stagger > *:nth-child(4) { animation-delay: 240ms; }
.animate-fade-in-stagger > *:nth-child(5) { animation-delay: 320ms; }
.animate-fade-in-stagger > *:nth-child(6) { animation-delay: 400ms; }
/* 骨架屏脉冲 */
@keyframes pulse-subtle {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.animate-pulse-subtle {
animation: pulse-subtle 1.5s ease-in-out infinite;
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef } from "react";
import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order";
const DEFAULT_RETRY_DELAYS = [1500, 2500, 3500, 4500, 5500];
export interface UseCompleteOrderPollingOptions {
/** 待完成订单 IDnull 则不轮询 */
orderId: string | null;
/** 成功后的额外回调(用 ref 存储避免 deps 变化) */
onSuccess?: (data: { token: string; record?: { wechatId?: string } }) => void;
/** 重试间隔(毫秒) */
retryDelays?: number[];
}
/** 清除 pending order 的 cookie 和 localStorage */
export function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
} catch {
/* ignore */
}
document.cookie = "join_pay_order=; path=/; max-age=0";
}
/** 支付完成后轮询 complete-order成功后同步 session 并清除 pending */
export function useCompleteOrderPolling({
orderId,
onSuccess,
retryDelays = DEFAULT_RETRY_DELAYS,
}: UseCompleteOrderPollingOptions): void {
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
useEffect(() => {
if (!orderId || typeof window === "undefined") return;
let cancelled = false;
const run = async () => {
for (let i = 0; i <= retryDelays.length; i++) {
if (cancelled) return;
const result = await tryCompleteOrder(orderId);
if (result.ok) {
if (result.token && result.record) {
applyCompleteSuccess({ token: result.token, record: result.record });
} else if (result.record) {
applyCompleteSuccess({ token: "", record: result.record });
}
clearPendingOrder();
onSuccessRef.current?.({ token: result.token || "", record: result.record });
return;
}
if (i < retryDelays.length && !cancelled) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
}
};
void run();
return () => {
cancelled = true;
};
}, [orderId, retryDelays]);
}

View File

@@ -0,0 +1,29 @@
import { useRef, useCallback } from "react";
const RESET_MS = 1000;
const CLICK_THRESHOLD = 5;
/** 连续点击 5 次打开小红书主页 */
export function useXhClickOpener(xhUrl: string | undefined) {
const countRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
return useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!xhUrl) return;
clearTimeout(timerRef.current);
countRef.current++;
if (countRef.current >= CLICK_THRESHOLD) {
window.open(xhUrl, "_blank", "noopener,noreferrer");
countRef.current = 0;
} else {
timerRef.current = setTimeout(() => {
countRef.current = 0;
}, RESET_MS);
}
},
[xhUrl]
);
}

16
app/icon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#a)"/>
<g fill="white" opacity=".95">
<circle cx="16" cy="14" r="2.5"/>
<circle cx="10" cy="20" r="2"/>
<circle cx="22" cy="20" r="2"/>
<circle cx="16" cy="22" r="1.5"/>
</g>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#b45309"/>
<stop offset=".5" stop-color="#d97706"/>
<stop offset="1" stop-color="#f59e0b"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -1,9 +1,11 @@
"use client";
import { useState, useEffect, useRef, type FormEvent } from "react";
import { usePathname } from "next/navigation";
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
import Link from "next/link";
import ThemeToggle from "../components/ThemeToggle";
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
import { DIGITAL_NOMAD_WECHAT_QR, getSessionsWithDates } from "@/config/home.config";
import {
redirectToPay,
redirectToPayH5,
@@ -14,10 +16,19 @@ import {
const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
/** 活动场次选项:周六/周日,用于 meetupdate 下拉 */
function getMeetupdateOptions(): { value: string; label: string; type: string }[] {
return getSessionsWithDates().map((s) => ({
value: `${s.date} ${s.time}`,
label: `${s.date} ${s.time} · ${s.title}`,
type: s.id,
}));
}
/** 微信号仅允许英文字母、数字、下划线、连字符 */
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
/** 从粘贴文本中提取小红书 URL,如 "我在小红书收获了536次赞与收藏>> https://xhslink.com/m/9H50QYiVOVx" */
/** 从粘贴文本中提取小红书 URL(主页或笔记链接),清除前后空格 */
function extractXiaohongshuUrl(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
@@ -50,13 +61,6 @@ function getPendingOrderId(): string | null {
}
}
function clearPendingOrder(): void {
try {
localStorage.removeItem(ORDER_COOKIE);
document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`;
} catch {}
}
interface UserRecord {
hasRecord: boolean;
record: {
@@ -138,9 +142,15 @@ export default function JoinPage() {
const [checkingWechat, setCheckingWechat] = useState(false);
const [xiaohongshuUrl, setXiaohongshuUrl] = useState("");
const [intro, setIntro] = useState("");
const [profession, setProfession] = useState("");
const [gender, setGender] = useState("");
const [ageRange, setAgeRange] = useState("");
const meetupdateOptions = getMeetupdateOptions();
const [meetupdate, setMeetupdate] = useState(() => {
const opts = getMeetupdateOptions();
return opts[0]?.value ?? "";
});
const [agreeRules, setAgreeRules] = useState(true);
const [urlError, setUrlError] = useState<string | null>(null);
const [profile, setProfile] = useState<{
@@ -151,6 +161,14 @@ export default function JoinPage() {
ipLocation?: string;
avatarUrl?: string;
resolvedUrl?: string;
followers?: number;
followersText?: string;
following?: number;
followingText?: string;
likesAndCollects?: number;
likesAndCollectsText?: string;
redbookId?: string;
personalLink?: string;
} | null>(null);
const [validating, setValidating] = useState(false);
const [submitted, setSubmitted] = useState(false);
@@ -165,6 +183,7 @@ export default function JoinPage() {
const initialWechatCheckDone = useRef(false);
const lastSubmitTime = useRef(0);
const submittingRef = useRef(false);
const fetchByWechatRef = useRef<(w: string) => Promise<unknown>>(() => Promise.resolve(null));
const SUBMIT_THROTTLE_MS = 3000;
const fetchByWechat = async (w: string) => {
@@ -172,7 +191,7 @@ export default function JoinPage() {
setError(null);
setCheckingWechat(true);
try {
const res = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(w.trim())}`, {
const res = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(w.trim())}&_=${Date.now()}`, {
cache: "no-store",
});
const data = await res.json();
@@ -186,6 +205,7 @@ export default function JoinPage() {
setCheckingWechat(false);
}
};
fetchByWechatRef.current = fetchByWechat;
const handleWechatBlur = () => {
const w = wechat.trim();
@@ -213,7 +233,7 @@ export default function JoinPage() {
const handleUrlBlur = async () => {
const extracted = extractXiaohongshuUrl(xiaohongshuUrl);
if (extracted !== xiaohongshuUrl) {
if (extracted !== xiaohongshuUrl.trim()) {
setXiaohongshuUrl(extracted);
}
const url = extracted;
@@ -233,7 +253,7 @@ export default function JoinPage() {
});
const validateData = await validateRes.json();
if (!validateData.valid) {
setUrlError(validateData.error || "小红书link异常");
setUrlError(validateData.error || "小红书链接异常");
return;
}
const profileRes = await fetch("/api/xiaohongshu/profile", {
@@ -251,6 +271,14 @@ export default function JoinPage() {
ipLocation: profileData.ipLocation,
avatarUrl: profileData.avatarUrl,
resolvedUrl: profileData.resolvedUrl,
followers: profileData.followers,
followersText: profileData.followersText,
following: profileData.following,
followingText: profileData.followingText,
likesAndCollects: profileData.likesAndCollects,
likesAndCollectsText: profileData.likesAndCollectsText,
redbookId: profileData.redbookId,
personalLink: profileData.personalLink,
});
} else {
setUrlError(profileData.error || "获取资料失败");
@@ -273,10 +301,22 @@ export default function JoinPage() {
setError("该微信号已报名,请勿重复提交");
return;
}
if (!xiaohongshuUrl.trim()) {
setError("请填写小红书主页链接或笔记链接");
return;
}
if (xiaohongshuUrl.trim() && urlError) {
setError("请先修正小红书链接");
return;
}
if (!intro.trim()) {
setError("请填写自我介绍");
return;
}
if (intro.trim().length < 20) {
setError("自我介绍不少于20字");
return;
}
if (!profession.trim()) {
setError("请填写职业");
return;
@@ -293,13 +333,17 @@ export default function JoinPage() {
setError("请选择性别");
return;
}
if (!meetupdate) {
setError("请选择要参加的活动场次");
return;
}
submittingRef.current = true;
lastSubmitTime.current = now;
setSubmitting(true);
try {
const checkRes = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechat.trim())}`, { cache: "no-store" });
const checkRes = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechat.trim())}&_=${Date.now()}`, { cache: "no-store" });
const checkData = await checkRes.json();
if (checkData?.hasRecord) {
setError("该微信号已报名,请勿重复提交");
@@ -313,7 +357,9 @@ export default function JoinPage() {
const postCountOk = (profile?.postCount ?? 0) > 10;
const qualify = formGenderFemale && postCountOk;
const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无";
const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || "";
const submitXiaohongshuUrl = profile?.resolvedUrl || extractXiaohongshuUrl(xiaohongshuUrl) || "";
const selectedOption = meetupdateOptions.find((o) => o.value === meetupdate);
const sessionType = selectedOption?.type ?? "session-1";
const res = await fetch("/api/join", {
method: "POST",
@@ -326,10 +372,31 @@ export default function JoinPage() {
xiaohongshu_url: submitXiaohongshuUrl,
xiaohongshu_avatar_url: profile?.avatarUrl || "",
gender: genderLabel,
intro: "",
intro: intro.trim(),
reason: intro.trim(),
userInfo: profile
? JSON.stringify({
昵称: profile.nickname,
性别: profile.gender,
笔记数: profile.postCount,
笔记数文案: profile.postCountText,
IP属地: profile.ipLocation,
头像链接: profile.avatarUrl,
主页链接: profile.resolvedUrl,
粉丝数: profile.followers,
粉丝数文案: profile.followersText,
关注数: profile.following,
关注数文案: profile.followingText,
获赞与收藏: profile.likesAndCollects,
获赞与收藏文案: profile.likesAndCollectsText,
小红书号: profile.redbookId,
个人链接: profile.personalLink,
})
: "",
age_range: ageRange,
city: "深圳",
session: fromVolunteer ? "volunteer" : "digital-nomad",
session: sessionType,
meetupdate: meetupdate.trim(),
education: "",
graduation_year: "",
agree_rules: true,
@@ -380,6 +447,7 @@ export default function JoinPage() {
setUserRecord(null);
setWechat("");
setXiaohongshuUrl("");
setIntro("");
setProfession("");
setGender("");
setAgeRange("");
@@ -388,6 +456,7 @@ export default function JoinPage() {
setProfile(null);
setValidating(false);
setSubmitted(false);
setMeetupdate(meetupdateOptions[0]?.value ?? "");
setSubmitting(false);
setQualify(false);
setError(null);
@@ -435,14 +504,60 @@ export default function JoinPage() {
const params = new URLSearchParams(window.location.search);
setFromPaid(params.get("paid") === "1");
setFromVolunteer(params.get("volunteer") === "1");
const sessionParam = params.get("session");
if (sessionParam && (sessionParam === "session-1" || sessionParam === "session-2")) {
const opts = getMeetupdateOptions();
const match = opts.find((o) => o.type === sessionParam);
if (match) setMeetupdate(match.value);
}
const pendingId = getPendingOrderId();
const stored = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
// 有 pending 时先查 by-wechat若已支付(vip/amount)则直接展示报名成功,不跳转 /join/paid
if (pendingId && !params.has("paid") && stored && WECHAT_REGEX.test(stored)) {
setWechat(stored);
let cancelled = false;
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(stored)}&_=${Date.now()}`, { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
const isPaid = data?.vip || data?.isComplete || (data?.record && Number(data.record?.amount || 0) > 0);
if (isPaid) {
setWechat(stored);
setUserRecord(toUserRecord(data));
if (!initialWechatCheckDone.current) {
initialWechatCheckDone.current = true;
}
setHydrated(true);
return;
}
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
})
.catch(() => {
if (!cancelled) window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
});
setHydrated(true);
return () => {
cancelled = true;
};
}
if (pendingId && !params.has("paid")) {
try {
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
return;
} catch {
/* ignore */
}
}
if (!initialWechatCheckDone.current) {
initialWechatCheckDone.current = true;
try {
const stored = localStorage.getItem(WECHAT_STORAGE_KEY);
const w = (stored || "").trim();
if (w && WECHAT_REGEX.test(w)) {
setWechat(w);
fetchByWechat(w);
if (stored && WECHAT_REGEX.test(stored)) {
setWechat(stored);
fetchByWechat(stored);
}
} catch {
/* ignore */
@@ -451,65 +566,51 @@ export default function JoinPage() {
setHydrated(true);
}, []);
// 手机浏览器支付后若回到 /join有 pending order 时主动轮询 complete-order触发后端更新 solanRedZPAY notify 可能未到)
useEffect(() => {
if (typeof window === "undefined") return;
const orderId = getPendingOrderId();
if (!orderId) return;
const shouldComplete =
userRecord?.hasRecord &&
userRecord?.isWechatFriend &&
!userRecord?.vip &&
!userRecord?.isComplete;
if (!shouldComplete) return;
let cancelled = false;
const tryComplete = async (): Promise<boolean> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return false;
if (d?.token && d?.record) {
fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
}).catch(() => {});
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
}
clearPendingOrder();
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return true;
};
const run = async () => {
const retryDelays = [1500, 2500, 3500, 4500, 5500];
for (let i = 0; i <= retryDelays.length; i++) {
if (cancelled) return;
if (await tryComplete()) {
if (wechat.trim()) fetchByWechat(wechat.trim());
return;
}
if (i < retryDelays.length && !cancelled) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
const refreshOnReturn = () => {
try {
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
} catch {
/* ignore */
}
};
void run();
return () => { cancelled = true; };
}, [
userRecord?.hasRecord,
userRecord?.isWechatFriend,
userRecord?.vip,
userRecord?.isComplete,
wechat,
]);
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refreshOnReturn();
};
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) refreshOnReturn();
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pageshow", onPageShow);
};
}, []);
const [completeOrderId] = useState<string | null>(() =>
typeof window !== "undefined" ? getPendingOrderId() : null
);
useCompleteOrderPolling({
orderId: completeOrderId,
onSuccess: () => {
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
},
retryDelays: [500, 1000, 1500, 2500, 4000],
});
const pathname = usePathname();
// 每次进入报名页时强制重新拉取,避免预取/缓存导致显示旧状态
useEffect(() => {
if (pathname === "/join") {
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
}
}, [pathname]);
// 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功
// 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join故 h5 也需轮询(参考 nomadvip
@@ -629,6 +730,13 @@ export default function JoinPage() {
if (showPayPage) {
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
{paying && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/95 dark:bg-stone-950/95 backdrop-blur-sm">
<span className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
<p className="mt-4 text-base font-medium text-stone-700 dark:text-stone-300"></p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"></p>
</div>
)}
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400"> </Link>
@@ -639,7 +747,7 @@ export default function JoinPage() {
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-2xl p-6 sm:p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-50 dark:bg-amber-900/30 text-4xl">💰</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100"></h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">1/宿/</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">29/宿/</p>
<UserProfileCard record={userRecord?.record ?? null} showVolunteerTag />
{userRecord?.record?.is_volunteer && (
<p className="mt-3 text-xs text-emerald-600 dark:text-emerald-400">
@@ -783,24 +891,93 @@ export default function JoinPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 </label>
<p className="text-xs text-stone-500 dark:text-stone-400 mb-1.5">💡 </p>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 <span className="text-red-500">*</span></label>
<input
type="text"
value={xiaohongshuUrl}
onChange={(e) => { setXiaohongshuUrl(e.target.value); setUrlError(null); }}
onBlur={handleUrlBlur}
placeholder="https://www.xiaohongshu.com/user/profile/xxx 或粘贴分享文案"
required
className="form-input"
/>
{validating && <p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">🔄 </p>}
{urlError && !validating && <p className="mt-1.5 text-xs text-red-600 dark:text-red-400"> {urlError}</p>}
{profile && !urlError && <p className="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400"></p>}
{profile && !urlError && (
<>
<p className="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400"></p>
{process.env.NODE_ENV === "development" && (
<div className="mt-3 rounded-xl border border-stone-200 dark:border-stone-600 bg-stone-50 dark:bg-stone-800/50 p-4">
<div className="flex items-center gap-3">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="h-14 w-14 rounded-full object-cover border border-stone-200 dark:border-stone-600"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40 text-2xl">
👤
</div>
)}
<div>
<p className="font-medium text-stone-800 dark:text-stone-100">{profile.nickname}</p>
{profile.gender && <p className="text-xs text-stone-500 dark:text-stone-400"> {profile.gender}</p>}
{profile.redbookId && <p className="text-xs text-stone-500 dark:text-stone-400"> {profile.redbookId}</p>}
{profile.ipLocation && <p className="text-xs text-stone-500 dark:text-stone-400">IP属地 {profile.ipLocation}</p>}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600 dark:text-stone-400">
<span> {profile.followingText ?? profile.following ?? "—"}</span>
<span> {profile.followersText ?? profile.followers ?? "—"}</span>
<span> {profile.likesAndCollectsText ?? profile.likesAndCollects ?? "—"}</span>
<span> {profile.postCountText ?? profile.postCount ?? "—"}</span>
</div>
{profile.personalLink && (
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400 truncate"> {profile.personalLink}</p>
)}
{profile.resolvedUrl && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500 truncate">Profile {profile.resolvedUrl}</p>
)}
</div>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2"> <span className="text-red-500">*</span></label>
<div className="relative">
<textarea
rows={4}
maxLength={500}
minLength={20}
placeholder="不少于20字"
value={intro}
onChange={(e) => setIntro(e.target.value)}
required
className="form-input resize-y min-h-[80px] pr-14"
/>
<span
className={`absolute bottom-2 right-3 text-xs ${
intro.length < 20 ? "text-amber-600 dark:text-amber-400" : "text-stone-400 dark:text-stone-500"
}`}
>
{intro.length}/500
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💼 <span className="text-red-500">*</span></label>
<input type="text" maxLength={140} placeholder="您的职业或专业" value={profession} onChange={(e) => setProfession(e.target.value)} required className="form-input" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📅 <span className="text-red-500">*</span></label>
<select value={meetupdate} onChange={(e) => setMeetupdate(e.target.value)} required className={`form-input appearance-none ${meetupdate ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>
<option value=""></option>
{meetupdateOptions.map((o) => (
<option key={o.type} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">👤 <span className="text-red-500">*</span></label>
<select value={gender} onChange={(e) => setGender(e.target.value)} required className={`form-input appearance-none ${gender ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>

View File

@@ -2,16 +2,11 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order";
import { clearPendingOrder } from "@/app/hooks/useCompleteOrderPolling";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
} catch {}
document.cookie = "join_pay_order=; path=/; max-age=0";
}
export default function JoinPaidPage() {
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
@@ -21,31 +16,6 @@ export default function JoinPaidPage() {
useEffect(() => {
let cancelled = false;
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string; record?: { wechatId?: string } }> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` };
if (d?.token && d?.record) {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
});
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(d.token, d.record);
}
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return { ok: true, record: d?.record };
};
const run = async () => {
const urlOrderId = typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("order_id") || ""
@@ -68,13 +38,18 @@ export default function JoinPaidPage() {
}
const maxClientRetries = 5;
const retryDelays = [2000, 3000, 4000, 5000, 6000];
const retryDelays = [800, 1500, 2500, 4000, 6000];
let lastError = "";
for (let i = 0; i <= maxClientRetries; i++) {
if (cancelled) return;
try {
const result = await tryComplete(orderId);
const result = await tryCompleteOrder(orderId);
if (result.ok) {
if (result.token && result.record) {
applyCompleteSuccess({ token: result.token, record: result.record });
} else if (result.record) {
applyCompleteSuccess({ token: "", record: result.record });
}
clearPendingOrder();
if (!cancelled) {
setCompleteStatus("success");

51
app/lib/complete-order.ts Normal file
View File

@@ -0,0 +1,51 @@
/** 共享:完成订单 API 调用与成功后的 session 同步 */
import type { AuthUser } from "@/app/lib/pocketbase";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
export interface CompleteOrderResult {
ok: boolean;
token?: string;
record?: { wechatId?: string; [k: string]: unknown };
error?: string;
}
/** 调用 complete-order API */
export async function tryCompleteOrder(orderId: string): Promise<CompleteOrderResult> {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` };
return { ok: true, token: d.token, record: d.record };
}
/** 成功后的通用处理sync-session、pbSaveAuth、dispatch 事件、localStorage wechatId。token 为空时仅 dispatch 和保存 wechatId非 pending 用户) */
export function applyCompleteSuccess(data: { token?: string; record?: { wechatId?: string; [k: string]: unknown } }): void {
const { token, record } = data;
if (token) {
fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, record }),
credentials: "include",
}).catch(() => {});
if (record && "id" in record && "email" in record) {
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(token, record as AuthUser));
}
}
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
window.dispatchEvent(new CustomEvent("registration:refresh"));
if (record?.wechatId) {
try {
localStorage.setItem(WECHAT_STORAGE_KEY, record.wechatId);
} catch {
/* ignore */
}
}
}

View File

@@ -6,6 +6,7 @@ const TZ = "Asia/Shanghai";
/**
* 获取中国时区下的日期数字(月、日、星期)
* 无论服务器/客户端在何地,均返回中国公历
*/
function getChinaDateParts(): { year: number; month: number; day: number; weekday: number } {
const now = new Date();
@@ -28,6 +29,27 @@ function getChinaDateParts(): { year: number; month: number; day: number; weekda
};
}
/**
* 获取当前中国公历时间字符串,用于上传
* 格式YYYY-MM-DD HH:mm:ss中国时区
*/
export function getNowChinaStr(): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(new Date());
const pad = (s: string) => s.padStart(2, "0");
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "0";
return `${get("year")}-${pad(get("month"))}-${pad(get("day"))} ${pad(get("hour"))}:${pad(get("minute"))}:${pad(get("second"))}`;
}
/**
* 格式化为中文日期M月D日 周X
*/
@@ -36,48 +58,75 @@ function formatDateCN(month: number, day: number, weekday: number): string {
}
/**
* 获取下一个周六、周日的日期(中国历)
* session-1 对应周六session-2 对应周日
* 获取下一个周六、周日的日期(中国历)
* 使用 +08:00 显式构造,避免服务器/客户端时区影响
*/
export function getNextWeekendDates(): { saturday: string; sunday: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
let satM = month;
let satD = day;
let satW = 6;
let sunM = month;
let sunD = day;
let sunW = 0;
if (weekday === 0) {
// 今天周日:下周六 +6 天,下周日 +7 天
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + 6);
satM = d.getMonth() + 1;
satD = d.getDate();
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
} else if (weekday === 6) {
// 今天周六:明天周日
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
} else {
// 周一至周五
const daysToSat = 6 - weekday;
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() + daysToSat);
satM = d.getMonth() + 1;
satD = d.getDate();
d.setDate(d.getDate() + 1);
sunM = d.getMonth() + 1;
sunD = d.getDate();
}
const daysToSat = weekday === 0 ? 6 : weekday === 6 ? 0 : 6 - weekday;
const saturday = new Date(today);
saturday.setUTCDate(saturday.getUTCDate() + daysToSat);
const sunday = new Date(saturday);
sunday.setUTCDate(sunday.getUTCDate() + 1);
return {
saturday: formatDateCN(satM, satD, 6),
sunday: formatDateCN(sunM, sunD, 0),
saturday: formatDateCN(saturday.getUTCMonth() + 1, saturday.getUTCDate(), 6),
sunday: formatDateCN(sunday.getUTCMonth() + 1, sunday.getUTCDate(), 0),
};
}
/**
* 获取中国公历本周一 00:00 至周日 23:59:59 的 UTC 时间范围
* 用于 PocketBase created 过滤。PocketBase 要求 Y-m-d H:i:s.uZ 格式(空格非 T
*/
export function getThisWeekRangeISO(): { start: string; end: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
const daysToMonday = weekday === 0 ? 6 : weekday - 1;
const monday = new Date(today);
monday.setUTCDate(monday.getUTCDate() - daysToMonday);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
const mondayStr = `${monday.getUTCFullYear()}-${pad(monday.getUTCMonth() + 1)}-${pad(monday.getUTCDate())}T00:00:00+08:00`;
const sundayStr = `${sunday.getUTCFullYear()}-${pad(sunday.getUTCMonth() + 1)}-${pad(sunday.getUTCDate())}T23:59:59.999+08:00`;
const startDate = new Date(mondayStr);
const endDate = new Date(sundayStr);
const fmt = (d: Date, ms: string) => {
const y = d.getUTCFullYear();
const m = pad(d.getUTCMonth() + 1);
const day = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const min = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${y}-${m}-${day} ${h}:${min}:${s}.${ms}Z`;
};
return {
start: fmt(startDate, "000"),
end: fmt(endDate, "999"),
};
}
/**
* 获取中国公历本周一、周日的日期字符串YYYY-MM-DD用于 submitted_at_cn 过滤
*/
export function getThisWeekRangeChinaStr(): { start: string; end: string } {
const { year, month, day, weekday } = getChinaDateParts();
const pad = (n: number) => String(n).padStart(2, "0");
const todayStr = `${year}-${pad(month)}-${pad(day)}T12:00:00+08:00`;
const today = new Date(todayStr);
const daysToMonday = weekday === 0 ? 6 : weekday - 1;
const monday = new Date(today);
monday.setUTCDate(monday.getUTCDate() - daysToMonday);
const sunday = new Date(monday);
sunday.setUTCDate(sunday.getUTCDate() + 6);
return {
start: `${monday.getUTCFullYear()}-${pad(monday.getUTCMonth() + 1)}-${pad(monday.getUTCDate())} 00:00:00`,
end: `${sunday.getUTCFullYear()}-${pad(sunday.getUTCMonth() + 1)}-${pad(sunday.getUTCDate())} 23:59:59`,
};
}

View File

@@ -1,6 +1,6 @@
const isDev = process.env.NODE_ENV === "development";
/** 前端站点地址。线上 nomadyt.com,开发 localhost:3002 */
/** 前端站点地址。线上 salon.hackrobot.cn,开发 localhost:3002 */
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ||
(isDev ? "http://localhost:3002" : "https://nomadyt.com");
(isDev ? "http://localhost:3002" : "https://salon.hackrobot.cn");

55
app/lib/joiners.ts Normal file
View File

@@ -0,0 +1,55 @@
/** 报名者项mock 用 nameDB 用 nickname */
export type JoinerItem = {
name?: string;
nickname?: string;
avatar?: string;
xiaohongshu_url?: string;
};
/** 去重 key小红书 url 相同视为 1 人,无 url 时用 avatar再 fallback nickname */
function dedupeKey(j: JoinerItem, useAvatarForMock: boolean): string {
const xh = (j.xiaohongshu_url || "").trim();
if (xh && !useAvatarForMock) return `xh:${xh}`;
const avatar = (j.avatar || "").trim();
if (avatar) return `av:${avatar}`;
return `_${(j.nickname || j.name || "").trim()}`;
}
/**
* 合并 mock 与数据库报名者mock 固定 3 个在左DB 在右
* 小红书 url 相同视为 1 人avatar 相同也只显示 1 个
* 最多显示 8 个,保证 PC/H5 排版合理
*/
export function mergeJoiners(
mock: JoinerItem[],
db: JoinerItem[] | undefined
): JoinerItem[] {
const seen = new Set<string>();
const result: JoinerItem[] = [];
mock.forEach((j) => {
const key = dedupeKey(j, true);
if (!seen.has(key)) {
seen.add(key);
result.push(j);
}
});
(db || []).forEach((j) => {
const key = dedupeKey(j, false);
if (!seen.has(key)) {
seen.add(key);
result.push(j);
}
});
return result.slice(0, 8);
}
/** 数据库报名者按小红书 url 去重后的数量(用于显示报名人数) */
export function getUniqueDbCount(db: JoinerItem[] | undefined): number {
if (!db?.length) return 0;
const seen = new Set<string>();
for (const j of db) {
const key = dedupeKey(j, false);
seen.add(key);
}
return seen.size;
}

View File

@@ -1,10 +1,6 @@
import { getVolunteer } from "@/app/lib/volunteers";
import HomeContent from "./components/HomeContent";
export const dynamic = "force-dynamic";
/** 首页:服务端先查询第一个志愿者,替换发起人志愿者卡片 */
export default async function Home() {
const volunteer = await getVolunteer();
return <HomeContent initialVolunteer={volunteer} />;
/** 首页:志愿者由客户端加载,避免 SSR 阻塞首屏 */
export default function Home() {
return <HomeContent initialVolunteer={null} />;
}

View File

@@ -1,25 +1,28 @@
/**
* 域名配置
* - salon 前端线上: nomadyt.com
* - salonapi 后端线上: api.nomadyt.comjoin、支付等
* 域名配置(腾讯云生产)
* - 前端: https://salon.hackrobot.cn
* - salonapi: https://salonapi.hackrobot.cnjoin、支付等
*/
const isDev = process.env.NODE_ENV === "development";
/** 根域名。前端 nomadyt.com后端 api.nomadyt.com */
export const ROOT_DOMAIN =
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "nomadyt.com";
/** 生产环境 salonapi 主机(非 api.根域名 模式) */
const DEFAULT_PROD_PAYMENT_API_HOST = "salonapi.hackrobot.cn";
/** salonapi 默认地址。开发连本地 8007生产连 api.nomadyt.com */
/** 兼容旧逻辑:未单独设 PAYMENT_API_URL 时用于文档/兜底 */
export const ROOT_DOMAIN =
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
/** salonapi 默认地址。开发连本地 8007生产连 salonapi.hackrobot.cn */
export const DEFAULT_PAYMENT_API_URL = isDev
? "http://127.0.0.1:8007"
: `https://api.${ROOT_DOMAIN}`;
: `https://${DEFAULT_PROD_PAYMENT_API_HOST}`;
/** 解析 salonapi 地址api.nomadyt.com */
/** 解析 salonapi 公网地址 */
export function resolvePaymentApiUrl(): string {
const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
return `https://api.${ROOT_DOMAIN}`;
return `https://${DEFAULT_PROD_PAYMENT_API_HOST}`;
}
return url.replace(/\/$/, "");
}

View File

@@ -57,21 +57,25 @@ export const PAST_SESSIONS = [
/** 发起人 - 点击跳转小红书 */
export const HOSTS = [
{ id: "host-1", name: "发起人", avatar: "/images/11.webp", bio: "数字游民社区发起人,热爱连接同频的人。", link: "https://xhslink.com/m/9H50QYiVOVx" },
{ id: "host-1", name: "发起人", avatar: "/images/11.webp", bio: "数字游民社区发起人,热爱连接同频的人。", link: "https://xhslink.com/m/2GxEJ4eeTNS" },
{ id: "host-2", name: "志愿者", avatar: "/images/volunteer-mystery.svg", bio: "签到、茶歇准备、拍照摄像。", link: "/join?volunteer=1" },
];
/** 数字游民社区 - 符合条件时显示的微信二维码 */
export const DIGITAL_NOMAD_WECHAT_QR = "/images/QR.png";
/** 最近报名意向 - mock 数据,含小红书昵称与主页链接(悬浮显示昵称,点击不跳转) */
/** 最近报名意向 - mock 数据,中国女性头像,含小红书昵称与主页链接 */
export const RECENT_JOINERS = [
{ name: "晓琪", avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "雨晴", avatar: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "子豪", avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "林晓", avatar: "https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "佳琪", avatar: "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "浩然", avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "心怡", avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "阿杰", avatar: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "晓琪", avatar: "https://images.unsplash.com/photo-1589553009868-c7b2bb474531?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "雨晴", avatar: "https://images.unsplash.com/photo-1617187703472-9508e9d3d198?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "子豪", avatar: "https://images.unsplash.com/photo-1624803642337-2639de804c57?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "林晓", avatar: "https://images.unsplash.com/photo-1534751516642-a1af1ef26a56?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "佳琪", avatar: "https://images.unsplash.com/photo-1593519544233-57d84925abb4?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "浩然", avatar: "https://images.unsplash.com/photo-1623512083603-5068ca8290f4?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "心怡", avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
{ name: "阿杰", avatar: "https://images.unsplash.com/photo-1627008320558-9d59b27cb732?w=80&h=80&fit=crop&crop=face", xiaohongshu_url: "https://www.xiaohongshu.com/user/profile/xxx" },
];
/** 周六场 mock 头像(晓琪、雨晴、子豪) */
export const MOCK_JOINERS_SESSION1 = RECENT_JOINERS.slice(0, 3);
/** 周日场 mock 头像(林晓、佳琪、浩然),与周六完全不重复 */
export const MOCK_JOINERS_SESSION2 = RECENT_JOINERS.slice(3, 6);

View File

@@ -1,6 +1,6 @@
/**
* salon 服务配置 - PocketBase、支付
* 前端 nomadyt.com,后端 salonapi api.nomadyt.com
* 前端 salon.hackrobot.cn,后端 salonapi.hackrobot.cn
*/
import { resolvePaymentApiUrl } from "./domain.config";
@@ -58,8 +58,8 @@ export function getPaymentConfig(): PaymentConfig {
};
}
/** 茶歇费 1元/人(本地测试用,正式环境可改为 2900 */
export const JOIN_TEA_FEE = 100;
/** 茶歇费 29元/人(单位2900=29元 */
export const JOIN_TEA_FEE = 2900;
export function getJoinPaymentConfig(): { amount: number; type: string } {
return {

View File

@@ -0,0 +1,53 @@
# 腾讯云:将 salon.hackrobot.cn、salonapi.hackrobot.cn 解析到本机公网 IP 后,
# 复制到 /etc/nginx/sites-available/ 并 ln -s 到 sites-enabled再 nginx -t && systemctl reload nginx。
# 需先申请 SSLcertbot 或腾讯云 SSL 证书),把证书路径替换为实际路径。
# ---------- salon 前端Next.js 建议 Node 监听 127.0.0.1:3002由 Nginx 反代)----------
server {
listen 443 ssl http2;
server_name salon.hackrobot.cn;
ssl_certificate /etc/nginx/ssl/salon.hackrobot.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/salon.hackrobot.cn/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name salon.hackrobot.cn;
return 301 https://$host$request_uri;
}
# ---------- salonapi 后端uvicorn 建议 127.0.0.1:8007----------
server {
listen 443 ssl http2;
server_name salonapi.hackrobot.cn;
ssl_certificate /etc/nginx/ssl/salonapi.hackrobot.cn/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/salonapi.hackrobot.cn/privkey.pem;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:8007;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name salonapi.hackrobot.cn;
return 301 https://$host$request_uri;
}

View File

@@ -20,6 +20,7 @@ const nextConfig: NextConfig = {
{ protocol: "https", hostname: "i.pravatar.cc", pathname: "/**" },
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
{ protocol: "https", hostname: "sns-webpic-qc.xhscdn.com", pathname: "/**" },
{ protocol: "https", hostname: "sns-avatar-qc.xhscdn.com", pathname: "/**" },
],
},
};

View File

@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"pocketbase": "^0.26.8",

12
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.3)
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1466,6 +1469,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.577.0:
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3462,6 +3470,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.577.0(react@19.2.3):
dependencies:
react: 19.2.3
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

313
scripts/deploy-neko-dual.sh Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env bash
set -euo pipefail
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
require_cmd docker
require_cmd getent
require_cmd ip
require_cmd awk
require_cmd sudo
ufw_udp_range() {
echo "${1//-/:}/udp"
}
if [[ "${EUID}" -ne 0 ]]; then
sudo -v
fi
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
DEFAULT_HOME="$(getent passwd "$SUDO_USER" | awk -F: '{print $6}')"
else
DEFAULT_HOME="$HOME"
fi
HOME_DIR="${HOME_DIR:-$DEFAULT_HOME}"
LAN_IP="${LAN_IP:-$(ip route get 1 | awk '{print $7; exit}')}"
NEKO_USER_PASSWORD="${NEKO_USER_PASSWORD:-user123}"
NEKO_ADMIN_PASSWORD="${NEKO_ADMIN_PASSWORD:-admin123}"
PROFILE_UID="${PROFILE_UID:-1000}"
PROFILE_GID="${PROFILE_GID:-1000}"
CHROMIUM_DIR="${CHROMIUM_DIR:-$HOME_DIR/neko-browser}"
GOOGLE_CHROME_DIR="${GOOGLE_CHROME_DIR:-$HOME_DIR/neko-google-chrome}"
CHROMIUM_PROFILE_DIR="${CHROMIUM_PROFILE_DIR:-/root/neko-chromium-profile}"
GOOGLE_CHROME_PROFILE_DIR="${GOOGLE_CHROME_PROFILE_DIR:-/root/neko-google-chrome-profile}"
CHROMIUM_HTTP_PORT="${CHROMIUM_HTTP_PORT:-8080}"
GOOGLE_CHROME_HTTP_PORT="${GOOGLE_CHROME_HTTP_PORT:-8081}"
CHROMIUM_UDP_RANGE="${CHROMIUM_UDP_RANGE:-56000-56100}"
GOOGLE_CHROME_UDP_RANGE="${GOOGLE_CHROME_UDP_RANGE:-56200-56300}"
CHROMIUM_DISPLAY="${CHROMIUM_DISPLAY:-:99.0}"
GOOGLE_CHROME_DISPLAY="${GOOGLE_CHROME_DISPLAY:-:100.0}"
mkdir -p \
"$CHROMIUM_DIR/policies" \
"$GOOGLE_CHROME_DIR/policies"
sudo mkdir -p \
"$CHROMIUM_PROFILE_DIR" \
"$GOOGLE_CHROME_PROFILE_DIR"
sudo chown -R "${PROFILE_UID}:${PROFILE_GID}" \
"$CHROMIUM_PROFILE_DIR" \
"$GOOGLE_CHROME_PROFILE_DIR"
cat >"$CHROMIUM_DIR/docker-compose.yaml" <<EOF
services:
neko-browser:
image: ghcr.io/m1k1o/neko/chromium:latest
restart: unless-stopped
network_mode: host
shm_size: "8gb"
cap_add:
- SYS_ADMIN
environment:
DISPLAY: "${CHROMIUM_DISPLAY}"
NEKO_DESKTOP_SCREEN: "1920x1080@60"
NEKO_SERVER_BIND: ":${CHROMIUM_HTTP_PORT}"
NEKO_MEMBER_PROVIDER: "multiuser"
NEKO_MEMBER_MULTIUSER_USER_PASSWORD: "${NEKO_USER_PASSWORD}"
NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: "${NEKO_ADMIN_PASSWORD}"
NEKO_WEBRTC_EPR: "${CHROMIUM_UDP_RANGE}"
NEKO_WEBRTC_ICELITE: "1"
NEKO_WEBRTC_NAT1TO1: "${LAN_IP}"
NEKO_PLUGINS_ENABLED: "true"
NEKO_PLUGINS_DIR: "/etc/neko/plugins/"
volumes:
- ${CHROMIUM_PROFILE_DIR}:/home/neko/.config/chromium
- ${CHROMIUM_DIR}/policies:/etc/chromium/policies/managed:ro
- ${CHROMIUM_DIR}/chromium.conf:/etc/neko/supervisord/chromium.conf:ro
EOF
cat >"$CHROMIUM_DIR/chromium.conf" <<'EOF'
[program:chromium]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/chromium
--window-position=0,0
--display=%(ENV_DISPLAY)s
--user-data-dir=/home/neko/.config/chromium
--password-store=basic
--no-first-run
--start-maximized
--force-dark-mode
--disable-file-system
--disable-gpu
--disable-software-rasterizer
--disable-dev-shm-usage
--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding
stopsignal=INT
autorestart=true
priority=800
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/chromium.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
[program:openbox]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/openbox --config-file /etc/neko/openbox.xml
autorestart=true
priority=300
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/openbox.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
EOF
cat >"$CHROMIUM_DIR/policies/policies.json" <<'EOF'
{
"ExtensionInstallForcelist": [
"dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"*"
],
"ExtensionInstallBlocklist": [],
"DownloadRestrictions": 0,
"AllowFileSelectionDialogs": true,
"URLAllowlist": ["file:///home/neko/Downloads/*"],
"DefaultCookiesSetting": 1,
"RestoreOnStartup": 1
}
EOF
cat >"$GOOGLE_CHROME_DIR/docker-compose.yaml" <<EOF
services:
neko-google-chrome:
image: ghcr.io/m1k1o/neko/google-chrome:latest
restart: unless-stopped
network_mode: host
shm_size: "8gb"
cap_add:
- SYS_ADMIN
environment:
DISPLAY: "${GOOGLE_CHROME_DISPLAY}"
NEKO_DESKTOP_SCREEN: "1920x1080@60"
NEKO_SERVER_BIND: ":${GOOGLE_CHROME_HTTP_PORT}"
NEKO_MEMBER_PROVIDER: "multiuser"
NEKO_MEMBER_MULTIUSER_USER_PASSWORD: "${NEKO_USER_PASSWORD}"
NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: "${NEKO_ADMIN_PASSWORD}"
NEKO_WEBRTC_EPR: "${GOOGLE_CHROME_UDP_RANGE}"
NEKO_WEBRTC_ICELITE: "1"
NEKO_WEBRTC_NAT1TO1: "${LAN_IP}"
NEKO_PLUGINS_ENABLED: "true"
NEKO_PLUGINS_DIR: "/etc/neko/plugins/"
volumes:
- ${GOOGLE_CHROME_PROFILE_DIR}:/home/neko/.config/google-chrome
- ${GOOGLE_CHROME_DIR}/policies:/etc/opt/chrome/policies/managed:ro
- ${GOOGLE_CHROME_DIR}/google-chrome.conf:/etc/neko/supervisord/google-chrome.conf:ro
EOF
cat >"$GOOGLE_CHROME_DIR/google-chrome.conf" <<'EOF'
[program:google-chrome]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/google-chrome
--window-position=0,0
--display=%(ENV_DISPLAY)s
--user-data-dir=/home/neko/.config/google-chrome
--password-store=basic
--no-first-run
--start-maximized
--force-dark-mode
--disable-file-system
--disable-gpu
--disable-software-rasterizer
--disable-dev-shm-usage
--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding
stopsignal=INT
autorestart=true
priority=800
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/google-chrome.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
[program:openbox]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/openbox --config-file /etc/neko/openbox.xml
autorestart=true
priority=300
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/openbox.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
EOF
cat >"$GOOGLE_CHROME_DIR/policies/policies.json" <<'EOF'
{
"ExtensionInstallForcelist": [
"dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"*"
],
"ExtensionInstallBlocklist": [],
"DownloadRestrictions": 0,
"AllowFileSelectionDialogs": true,
"URLAllowlist": ["file:///home/neko/Downloads/*"],
"DefaultCookiesSetting": 1,
"RestoreOnStartup": 1
}
EOF
sudo ufw allow "${CHROMIUM_HTTP_PORT}" >/dev/null 2>&1 || true
sudo ufw allow "${GOOGLE_CHROME_HTTP_PORT}" >/dev/null 2>&1 || true
sudo ufw allow "$(ufw_udp_range "${CHROMIUM_UDP_RANGE}")" >/dev/null 2>&1 || true
sudo ufw allow "$(ufw_udp_range "${GOOGLE_CHROME_UDP_RANGE}")" >/dev/null 2>&1 || true
recreate_compose_service() {
local compose_dir="$1"
local service="$2"
local name_pattern="$3"
local existing_id=""
local pid=""
existing_id="$(cd "$compose_dir" && docker compose ps -q "$service" 2>/dev/null || true)"
if [[ -n "$existing_id" ]]; then
docker update --restart=no "$existing_id" >/dev/null 2>&1 || true
pid="$(docker inspect -f '{{.State.Pid}}' "$existing_id" 2>/dev/null || true)"
if [[ -n "$pid" && "$pid" != "0" ]]; then
sudo kill -TERM "$pid" >/dev/null 2>&1 || true
sleep 3
sudo kill -KILL "$pid" >/dev/null 2>&1 || true
sleep 2
fi
docker rm -f "$existing_id" >/dev/null 2>&1 || true
fi
docker ps -a --format '{{.ID}} {{.Names}}' \
| awk -v pat="$name_pattern" '$2 ~ pat {print $1}' \
| xargs -r docker rm -f >/dev/null 2>&1 || true
(cd "$compose_dir" && docker compose up -d)
}
wait_for_healthy() {
local compose_dir="$1"
local service="$2"
local container_id=""
local health_status=""
local state_status=""
for _ in $(seq 1 60); do
container_id="$(cd "$compose_dir" && docker compose ps -q "$service" 2>/dev/null || true)"
[[ -z "$container_id" ]] && sleep 2 && continue
health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)"
state_status="$(docker inspect -f '{{.State.Status}}' "$container_id" 2>/dev/null || true)"
if [[ "$state_status" == "running" && ( "$health_status" == "healthy" || "$health_status" == "none" ) ]]; then
echo "$container_id"
return 0
fi
if [[ "$health_status" == "unhealthy" ]]; then
echo "service ${service} became unhealthy" >&2
docker logs --tail 200 "$container_id" >&2 || true
return 1
fi
sleep 2
done
echo "timed out waiting for ${service}" >&2
[[ -n "$container_id" ]] && docker logs --tail 200 "$container_id" >&2 || true
return 1
}
recreate_compose_service "$CHROMIUM_DIR" "neko-browser" "neko-browser"
recreate_compose_service "$GOOGLE_CHROME_DIR" "neko-google-chrome" "neko-google-chrome"
CHROMIUM_ID="$(wait_for_healthy "$CHROMIUM_DIR" "neko-browser")"
GOOGLE_CHROME_ID="$(wait_for_healthy "$GOOGLE_CHROME_DIR" "neko-google-chrome")"
echo
echo "chromium:"
echo " url: http://${LAN_IP}:${CHROMIUM_HTTP_PORT}/"
echo " container: ${CHROMIUM_ID}"
echo " compose: ${CHROMIUM_DIR}"
echo
echo "google-chrome:"
echo " url: http://${LAN_IP}:${GOOGLE_CHROME_HTTP_PORT}/"
echo " container: ${GOOGLE_CHROME_ID}"
echo " compose: ${GOOGLE_CHROME_DIR}"

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env bash
set -euo pipefail
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
require_cmd docker
require_cmd getent
require_cmd ip
require_cmd awk
require_cmd sudo
ufw_udp_range() {
echo "${1//-/:}/udp"
}
if [[ "${EUID}" -ne 0 ]]; then
sudo -v
fi
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
DEFAULT_HOME="$(getent passwd "$SUDO_USER" | awk -F: '{print $6}')"
else
DEFAULT_HOME="$HOME"
fi
HOME_DIR="${HOME_DIR:-$DEFAULT_HOME}"
LAN_IP="${LAN_IP:-$(ip route get 1 | awk '{print $7; exit}')}"
NEKO_USER_PASSWORD="${NEKO_USER_PASSWORD:-user123}"
NEKO_ADMIN_PASSWORD="${NEKO_ADMIN_PASSWORD:-admin123}"
PROFILE_UID="${PROFILE_UID:-1000}"
PROFILE_GID="${PROFILE_GID:-1000}"
GOOGLE_CHROME_DIR="${GOOGLE_CHROME_DIR:-$HOME_DIR/neko-google-chrome-2}"
GOOGLE_CHROME_PROFILE_DIR="${GOOGLE_CHROME_PROFILE_DIR:-/root/neko-google-chrome-2-profile}"
GOOGLE_CHROME_HTTP_PORT="${GOOGLE_CHROME_HTTP_PORT:-8082}"
GOOGLE_CHROME_UDP_RANGE="${GOOGLE_CHROME_UDP_RANGE:-56400-56500}"
GOOGLE_CHROME_DISPLAY="${GOOGLE_CHROME_DISPLAY:-:101.0}"
mkdir -p "$GOOGLE_CHROME_DIR/policies"
sudo mkdir -p "$GOOGLE_CHROME_PROFILE_DIR"
sudo chown -R "${PROFILE_UID}:${PROFILE_GID}" "$GOOGLE_CHROME_PROFILE_DIR"
cat >"$GOOGLE_CHROME_DIR/docker-compose.yaml" <<EOF
services:
neko-google-chrome-2:
image: ghcr.io/m1k1o/neko/google-chrome:latest
restart: unless-stopped
network_mode: host
shm_size: "8gb"
cap_add:
- SYS_ADMIN
environment:
DISPLAY: "${GOOGLE_CHROME_DISPLAY}"
NEKO_DESKTOP_SCREEN: "1920x1080@60"
NEKO_SERVER_BIND: ":${GOOGLE_CHROME_HTTP_PORT}"
NEKO_MEMBER_PROVIDER: "multiuser"
NEKO_MEMBER_MULTIUSER_USER_PASSWORD: "${NEKO_USER_PASSWORD}"
NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: "${NEKO_ADMIN_PASSWORD}"
NEKO_WEBRTC_EPR: "${GOOGLE_CHROME_UDP_RANGE}"
NEKO_WEBRTC_ICELITE: "1"
NEKO_WEBRTC_NAT1TO1: "${LAN_IP}"
NEKO_PLUGINS_ENABLED: "true"
NEKO_PLUGINS_DIR: "/etc/neko/plugins/"
volumes:
- ${GOOGLE_CHROME_PROFILE_DIR}:/home/neko/.config/google-chrome
- ${GOOGLE_CHROME_DIR}/policies:/etc/opt/chrome/policies/managed:ro
- ${GOOGLE_CHROME_DIR}/google-chrome.conf:/etc/neko/supervisord/google-chrome.conf:ro
EOF
cat >"$GOOGLE_CHROME_DIR/google-chrome.conf" <<'EOF'
[program:google-chrome]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/google-chrome
--window-position=0,0
--display=%(ENV_DISPLAY)s
--user-data-dir=/home/neko/.config/google-chrome
--password-store=basic
--no-first-run
--start-maximized
--force-dark-mode
--disable-file-system
--disable-gpu
--disable-software-rasterizer
--disable-dev-shm-usage
--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding
stopsignal=INT
autorestart=true
priority=800
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/google-chrome.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
[program:openbox]
environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s"
command=/usr/bin/openbox --config-file /etc/neko/openbox.xml
autorestart=true
priority=300
user=%(ENV_USER)s
stdout_logfile=/var/log/neko/openbox.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
redirect_stderr=true
EOF
cat >"$GOOGLE_CHROME_DIR/policies/policies.json" <<'EOF'
{
"ExtensionInstallForcelist": [
"dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx"
],
"ExtensionInstallAllowlist": [
"*"
],
"ExtensionInstallBlocklist": [],
"DownloadRestrictions": 0,
"AllowFileSelectionDialogs": true,
"URLAllowlist": ["file:///home/neko/Downloads/*"],
"DefaultCookiesSetting": 1,
"RestoreOnStartup": 1
}
EOF
sudo ufw allow "${GOOGLE_CHROME_HTTP_PORT}" >/dev/null 2>&1 || true
sudo ufw allow "$(ufw_udp_range "${GOOGLE_CHROME_UDP_RANGE}")" >/dev/null 2>&1 || true
recreate_compose_service() {
local compose_dir="$1"
local service="$2"
local name_pattern="$3"
local existing_id=""
local pid=""
existing_id="$(cd "$compose_dir" && docker compose ps -q "$service" 2>/dev/null || true)"
if [[ -n "$existing_id" ]]; then
docker update --restart=no "$existing_id" >/dev/null 2>&1 || true
pid="$(docker inspect -f '{{.State.Pid}}' "$existing_id" 2>/dev/null || true)"
if [[ -n "$pid" && "$pid" != "0" ]]; then
sudo kill -TERM "$pid" >/dev/null 2>&1 || true
sleep 3
sudo kill -KILL "$pid" >/dev/null 2>&1 || true
sleep 2
fi
docker rm -f "$existing_id" >/dev/null 2>&1 || true
fi
docker ps -a --format '{{.ID}} {{.Names}}' \
| awk -v pat="$name_pattern" '$2 ~ pat {print $1}' \
| xargs -r docker rm -f >/dev/null 2>&1 || true
(cd "$compose_dir" && docker compose up -d)
}
wait_for_healthy() {
local compose_dir="$1"
local service="$2"
local container_id=""
local health_status=""
local state_status=""
for _ in $(seq 1 60); do
container_id="$(cd "$compose_dir" && docker compose ps -q "$service" 2>/dev/null || true)"
[[ -z "$container_id" ]] && sleep 2 && continue
health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)"
state_status="$(docker inspect -f '{{.State.Status}}' "$container_id" 2>/dev/null || true)"
if [[ "$state_status" == "running" && ( "$health_status" == "healthy" || "$health_status" == "none" ) ]]; then
echo "$container_id"
return 0
fi
if [[ "$health_status" == "unhealthy" ]]; then
echo "service ${service} became unhealthy" >&2
docker logs --tail 200 "$container_id" >&2 || true
return 1
fi
sleep 2
done
echo "timed out waiting for ${service}" >&2
[[ -n "$container_id" ]] && docker logs --tail 200 "$container_id" >&2 || true
return 1
}
recreate_compose_service "$GOOGLE_CHROME_DIR" "neko-google-chrome-2" "neko-google-chrome-2"
GOOGLE_CHROME_ID="$(wait_for_healthy "$GOOGLE_CHROME_DIR" "neko-google-chrome-2")"
echo
echo "google-chrome-2:"
echo " url: http://${LAN_IP}:${GOOGLE_CHROME_HTTP_PORT}/"
echo " container: ${GOOGLE_CHROME_ID}"
echo " compose: ${GOOGLE_CHROME_DIR}"

50
tcp_forward_qwen.py Normal file
View File

@@ -0,0 +1,50 @@
import socket
import threading
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 18007
TARGET_HOST = "192.168.41.184"
TARGET_PORT = 8007
def pipe(src: socket.socket, dst: socket.socket) -> None:
try:
while True:
data = src.recv(65536)
if not data:
break
dst.sendall(data)
except OSError:
pass
finally:
for sock in (src, dst):
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
def handle_client(client: socket.socket) -> None:
upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
upstream.connect((TARGET_HOST, TARGET_PORT))
threading.Thread(target=pipe, args=(client, upstream), daemon=True).start()
threading.Thread(target=pipe, args=(upstream, client), daemon=True).start()
def main() -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((LISTEN_HOST, LISTEN_PORT))
server.listen()
while True:
client, _ = server.accept()
threading.Thread(target=handle_client, args=(client,), daemon=True).start()
if __name__ == "__main__":
main()