From bf10b5d7e4421bbef5c1a26bf2d548a8204f15ad Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 18 Mar 2026 02:01:48 -0500 Subject: [PATCH] 's' --- app/api/join/route.ts | 7 ++ app/api/join/stats/route.ts | 66 +++++++++++++++--- app/components/HeroSection.tsx | 79 ++++++++++++--------- app/components/HomeContent.tsx | 25 +++++-- app/components/SessionCard.tsx | 105 ++++++++++++++++------------ app/hooks/useXhClickOpener.ts | 29 ++++++++ app/join/page.tsx | 41 ++++++++++- app/lib/dates.ts | 123 +++++++++++++++++++++++---------- app/lib/joiners.ts | 55 +++++++++++++++ config/home.config.ts | 22 +++--- next.config.ts | 1 + 11 files changed, 415 insertions(+), 138 deletions(-) create mode 100644 app/hooks/useXhClickOpener.ts create mode 100644 app/lib/joiners.ts diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 25c4835..b75523c 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -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"; @@ -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 仅存自我介绍,不合并其他字段 */ @@ -107,6 +109,7 @@ export async function POST(request: NextRequest) { is_volunteer: isVolunteer, qualify, userInfo: userInfo || "", + meetupdate: meetupdate || "", }; if (phone.trim()) { record.phone = phone.trim(); @@ -199,6 +202,10 @@ async function tryPocketBaseDirect( 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; } diff --git a/app/api/join/stats/route.ts b/app/api/join/stats/route.ts index 8019f3a..398dfb5 100644 --- a/app/api/join/stats/route.ts +++ b/app/api/join/stats/route.ts @@ -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 }; + 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,26 +36,52 @@ export async function GET(request: NextRequest) { const stats: Record = {}; + const { start: weekStartCn, end: weekEndCn } = getThisWeekRangeChinaStr(); + const { start: weekStartUtc, end: weekEndUtc } = getThisWeekRangeISO(); + const results = await Promise.all( SESSIONS.map(async (s) => { try { - const filter = encodeURIComponent(`type="${s.id}"`); - const res = await fetch( - `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`, - { + // 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(); + 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) 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; + } + 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.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({ + 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, diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index c1bf26a..b6911ed 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -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 ( + + {showAvatar ? ( + + ) : ( + {nickname.slice(0, 1) || "?"} + )} + + ); +} 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 (
- {(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 ( - { - e.preventDefault(); - e.stopPropagation(); - if (xhUrl) window.open(xhUrl, "_blank", "noopener,noreferrer"); - }} - > - {showAvatar ? ( - - ) : ( - {nickname.slice(0, 1) || "?"} - )} - - ); - })} + {mergeJoiners(MOCK_JOINERS_SESSION1, stats?.joiners).map((j, i) => ( + + ))}
- {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} + {3 + getUniqueDbCount(stats?.joiners)} 人已报名
diff --git a/app/components/HomeContent.tsx b/app/components/HomeContent.tsx index f7d9c76..2690aaf 100644 --- a/app/components/HomeContent.tsx +++ b/app/components/HomeContent.tsx @@ -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"; @@ -36,6 +37,7 @@ function getPendingOrderId(): string | null { } export default function HomeContent({ initialVolunteer }: HomeContentProps) { + const pathname = usePathname(); const [volunteer, setVolunteer] = useState(initialVolunteer); const [stats, setStats] = useState(null); const [pendingOrderId] = useState(() => @@ -49,13 +51,28 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { .catch(() => {}); }, []); - useEffect(() => { - fetch("/api/join/stats") + 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], @@ -66,7 +83,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
-
+
{/* 是什么 / 不是什么 - 小红书风格 */}
diff --git a/app/components/SessionCard.tsx b/app/components/SessionCard.tsx index 5c6d67b..178cb78 100644 --- a/app/components/SessionCard.tsx +++ b/app/components/SessionCard.tsx @@ -1,18 +1,48 @@ "use client"; +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 ( + + {showAvatar ? ( + setImgError(true)} /> + ) : ( + + {nickname.slice(0, 1) || "?"} + + )} + + ); +} type SessionTag = keyof typeof SESSION_TAGS; @@ -39,18 +69,15 @@ export default function SessionCard({ }) { return ( - -
+
+
{session.coverImage ? ( ) : ( @@ -60,7 +87,7 @@ export default function SessionCard({ )}
-
+
{session.tags?.map((tag) => ( ))} - {index === 0 && ( + {!session.tags?.includes("light-topic") && ( + + {SESSION_TAGS["light-topic"]} + + )} + {index === 0 ? ( 主推 + ) : ( + + 主推 + )}
@@ -82,47 +118,28 @@ export default function SessionCard({ {session.title}

- {session.date} {session.time} · + {session.date} {session.time}

- +

+ +

+ 报名这场 → -
-
+ +
- {(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 ( - { - if (xhUrl) { - e.preventDefault(); - e.stopPropagation(); - window.open(xhUrl, "_blank", "noopener,noreferrer"); - } else { - e.preventDefault(); - } - }} - > - {showAvatar ? ( - - ) : ( - {nickname.slice(0, 1) || "?"} - )} - - ); - })} + {mergeJoiners(getMockForSession(session.id), stats?.joiners).map((j, i) => ( + + ))}
- {stats?.count ? `${stats.count} 人已报名` : "有人报名中"} + {3 + getUniqueDbCount(stats?.joiners)} 人已报名
- +
); } diff --git a/app/hooks/useXhClickOpener.ts b/app/hooks/useXhClickOpener.ts new file mode 100644 index 0000000..9807d7a --- /dev/null +++ b/app/hooks/useXhClickOpener.ts @@ -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>(); + + 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] + ); +} diff --git a/app/join/page.tsx b/app/join/page.tsx index a2d6c99..0efc04c 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -5,7 +5,7 @@ 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, @@ -16,6 +16,15 @@ 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_-]*$/; @@ -137,6 +146,11 @@ export default function JoinPage() { 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(null); const [profile, setProfile] = useState<{ @@ -319,6 +333,10 @@ export default function JoinPage() { setError("请选择性别"); return; } + if (!meetupdate) { + setError("请选择要参加的活动场次"); + return; + } submittingRef.current = true; lastSubmitTime.current = now; @@ -340,6 +358,8 @@ export default function JoinPage() { const qualify = formGenderFemale && postCountOk; const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无"; 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", @@ -375,7 +395,8 @@ export default function JoinPage() { : "", age_range: ageRange, city: "深圳", - session: fromVolunteer ? "volunteer" : "digital-nomad", + session: sessionType, + meetupdate: meetupdate.trim(), education: "", graduation_year: "", agree_rules: true, @@ -435,6 +456,7 @@ export default function JoinPage() { setProfile(null); setValidating(false); setSubmitted(false); + setMeetupdate(meetupdateOptions[0]?.value ?? ""); setSubmitting(false); setQualify(false); setError(null); @@ -482,6 +504,12 @@ 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(); @@ -941,6 +969,15 @@ export default function JoinPage() { setProfession(e.target.value)} required className="form-input" />
+
+ + +