Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894150d1fa | ||
|
|
2f73b2ee23 | ||
|
|
5b26c3c907 | ||
|
|
6873bd8f70 | ||
|
|
bf10b5d7e4 | ||
|
|
742c293e14 | ||
|
|
4d4ca04167 | ||
|
|
fd2e424897 | ||
|
|
9d45fede7f | ||
|
|
473fa1a1cf | ||
|
|
c93eb7b2ee |
@@ -1 +1,2 @@
|
||||
PAYMENT_API_URL=https://api.nomadyt.com
|
||||
PAYMENT_API_URL=https://salonapi.hackrobot.cn
|
||||
PAYMENT_JOIN_AMOUNT=2900
|
||||
|
||||
@@ -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 地址
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(中国公历)或 created(UTC 兼容旧记录),两路查询合并
|
||||
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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
30
app/apple-icon.tsx
Normal 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 }
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
{[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
64
app/hooks/useCompleteOrderPolling.ts
Normal file
64
app/hooks/useCompleteOrderPolling.ts
Normal 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 {
|
||||
/** 待完成订单 ID,null 则不轮询 */
|
||||
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]);
|
||||
}
|
||||
29
app/hooks/useXhClickOpener.ts
Normal file
29
app/hooks/useXhClickOpener.ts
Normal 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
16
app/icon.svg
Normal 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 |
@@ -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,触发后端更新 solanRed(ZPAY 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"}`}>
|
||||
|
||||
@@ -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
51
app/lib/complete-order.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
123
app/lib/dates.ts
123
app/lib/dates.ts
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
55
app/lib/joiners.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** 报名者项(mock 用 name,DB 用 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;
|
||||
}
|
||||
10
app/page.tsx
10
app/page.tsx
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
/**
|
||||
* 域名配置
|
||||
* - salon 前端线上: nomadyt.com
|
||||
* - salonapi 后端线上: api.nomadyt.com(join、支付等)
|
||||
* 域名配置(腾讯云生产)
|
||||
* - 前端: https://salon.hackrobot.cn
|
||||
* - salonapi: https://salonapi.hackrobot.cn(join、支付等)
|
||||
*/
|
||||
|
||||
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(/\/$/, "");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
53
deploy/nginx.hackrobot.cn.conf
Normal file
53
deploy/nginx.hackrobot.cn.conf
Normal 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。
|
||||
# 需先申请 SSL(certbot 或腾讯云 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;
|
||||
}
|
||||
@@ -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: "/**" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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
313
scripts/deploy-neko-dual.sh
Normal 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}"
|
||||
205
scripts/deploy-neko-google-chrome-2.sh
Normal file
205
scripts/deploy-neko-google-chrome-2.sh
Normal 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
50
tcp_forward_qwen.py
Normal 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()
|
||||
Reference in New Issue
Block a user