From f670a35b7e194d2e5b17856189f5f3d998b923a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:48:29 +0000 Subject: [PATCH] =?UTF-8?q?fix(community):=20ShareLink=20=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9=E8=B7=B3=20/feed/submit=20+=20Join=20?= =?UTF-8?q?=E5=8C=BA=E5=8A=A0=20/feed=20=E5=85=A5=E5=8F=A3=20+=20feed=20SS?= =?UTF-8?q?R=20=E6=8A=97=20CF=20=E6=8C=91=E6=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按钮语义修正(用户反馈): - Hero ShareLink 主按钮 /feed → /feed/submit(语义:投稿动作,与 Contribute 平级) - 去掉 ShareLink 右上角 "+" 徽章(主按钮已经是投稿,徽章冗余) - Join the Resistance 卡片里"访问文章"按钮下方加同构"看看我们最近在读什么"→ /feed (阅读入口从 Hero 主 CTA 挪到 Join 区,避免与投稿动作混淆) Next 16 严格模式修正: - FeedAuthWrapper 之前收 getCategoryLabel 函数 prop 会触发 "Functions cannot be passed directly to Client Components" → /feed 500 - 改传 server 端预计算的 slug → 中文 map(纯数据),client 组件自己查表 生产 500 修复(生产症状:/feed 显示 "server error"): - fetchLinks 之前单次失败就抛错,Cloudflare Managed Challenge 403 时直接崩 - 加重试 + UA 头 + cf-ray 日志,对齐 fetchProfile 的防御策略 - 全败时返回 [] 而非抛错,页面降级展示空态不崩 i18n: - 新增 hero.cta.feed("看看我们最近在读什么" / "What we're reading lately") - 移除失效的 shareLink.submitAriaLabel(徽章已删) --- app/components/Hero.tsx | 36 +++++--- app/components/ShareLink.tsx | 64 ++++++------- app/feed/components/FeedAuthWrapper.tsx | 13 +-- app/feed/page.tsx | 118 +++++++++++++++++------- messages/en.json | 6 +- messages/zh.json | 6 +- 6 files changed, 148 insertions(+), 95 deletions(-) diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index fccd89b..fa1fa2d 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -94,17 +94,31 @@ export async function Hero() {

{t("join.body")}

- - - + {/* 双阅读入口:严肃文档 + 社区随手分享,视觉同构;投稿动作已在 Hero 左侧 Contribute/ShareLink */} +
+ + + + + + +
diff --git a/app/components/ShareLink.tsx b/app/components/ShareLink.tsx index b28917d..61cfe6b 100644 --- a/app/components/ShareLink.tsx +++ b/app/components/ShareLink.tsx @@ -3,56 +3,44 @@ /** * Hero 区的"分享链接"按钮。 * - * 视觉与样式**完全复制** Contribute 主 CTA,和它并排形成双 CTA: + * 视觉与 Contribute 主 CTA 完全同构,并排形成双投稿入口: * - Contribute → 正式投稿 Fumadocs 知识库(走 GitHub PR) - * - ShareLink → 随手分享公众号/知乎等文章到社区墙(/feed) + * - ShareLink → 随手丢一篇外部文章到社区分享墙(/feed/submit) * - * 两者语义平级,视觉也平级——这是用户拍板的设计(之前尝试的次级文字链 UI 不够突出)。 - * 按钮点击跳 /feed(先看一眼再决定是否提交),右上角徽章保留与 Contribute 对称的图标位。 + * 两者语义平级:都是"投稿"动作。对应的"阅读"入口在右侧 Join the Resistance + * 卡片里(访问文章 / 看看最近在读),不放在 Hero 主 CTA 区,避免混淆。 + * + * 之前本按钮跳 /feed 并带一个 "+" 徽章跳 /feed/submit——语义错位,已修正。 */ import Link from "next/link"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { Link2, Plus } from "lucide-react"; +import { Link2 } from "lucide-react"; export function ShareLink() { const t = useTranslations("shareLink"); return ( -
- {/* 主按钮跳 /feed(社区分享墙),样式与 Contribute 主按钮完全同构 */} - - - - {/* 右上角徽章:跳 /feed/submit 直接开提交表单,对应 Contribute 的指南 "?" 徽章 */} - +
+ + + {t("button")} + + + ); } diff --git a/app/feed/components/FeedAuthWrapper.tsx b/app/feed/components/FeedAuthWrapper.tsx index f27187b..27cee6f 100644 --- a/app/feed/components/FeedAuthWrapper.tsx +++ b/app/feed/components/FeedAuthWrapper.tsx @@ -7,8 +7,9 @@ * 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard, * 使举报按钮可以区分已登录 / 未登录行为。 * - * 接收 server 端已预计算好的 links 和 categoryLabel 函数, - * 只负责登录态桥接,不做额外数据请求。 + * 接收 server 端已预计算好的 links 和**分类标签映射表**(纯数据), + * 不接收函数 prop —— Next 16 对 server→client 边界严格禁止函数 prop + * (会报 "Functions cannot be passed directly to Client Components")。 */ import { useAuth } from "@/lib/use-auth"; @@ -17,13 +18,13 @@ import type { SharedLinkView, CategorySlug } from "@/app/feed/types"; interface FeedAuthWrapperProps { links: SharedLinkView[]; - /** 由 server 端传入的分类标签计算函数(已含 i18n 翻译) */ - getCategoryLabel: (slug: CategorySlug | null) => string; + /** server 端预翻译好的 slug → 中文显示名 map */ + categoryLabels: Partial>; } export function FeedAuthWrapper({ links, - getCategoryLabel, + categoryLabels, }: FeedAuthWrapperProps) { const { status } = useAuth(); // loading 阶段默认视为未登录,避免 UI 闪烁 @@ -36,7 +37,7 @@ export function FeedAuthWrapper({ ))} diff --git a/app/feed/page.tsx b/app/feed/page.tsx index 51bb3ba..0c3356f 100644 --- a/app/feed/page.tsx +++ b/app/feed/page.tsx @@ -28,40 +28,93 @@ export const metadata: Metadata = { }; /** - * 从后端拉取 APPROVED 的链接列表。 - * category 为空时拉全部,否则按 slug 过滤。 + * 从后端拉取 APPROVED 的链接列表,带 Cloudflare Managed Challenge 重试。 + * + * 背景:Vercel SSR 出口偶发被 CF 403 挑战(同 fetchProfile 的坑)。 + * 单次失败就 throw 会让首页/feed 显示 500。 + * + * 策略(对齐 fetchProfile): + * - 第 1 次:走 Next Data Cache(revalidate: 120),命中快 + * - 第 2/3 次:cache: no-store 绕过缓存,分别退避 300ms / 800ms + * - 全败返回 [] 而非抛错——让页面降级展示空态,不崩 + * - 每次失败记录 status / cf-ray,便于 Vercel 日志定位 */ async function fetchLinks(category?: string): Promise { const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { - // 配置缺失时给清晰错误,而非静默空列表 - throw new Error("BACKEND_URL is not configured"); + console.error("[feed/page] BACKEND_URL is not configured"); + return []; } - // 构造查询参数 const params = new URLSearchParams({ limit: "50", offset: "0" }); if (category) params.set("category", category); + const url = `${backendUrl}/api/community/links?${params.toString()}`; + + const attempts: Array<{ revalidate: number } | { noStore: true }> = [ + { revalidate: 120 }, + { noStore: true }, + { noStore: true }, + ]; + + for (let i = 0; i < attempts.length; i++) { + const attempt = attempts[i]; + const init: RequestInit & { next?: { revalidate: number } } = + "noStore" in attempt + ? { cache: "no-store" } + : { next: { revalidate: attempt.revalidate } }; + // 显式 UA 降低被 Cloudflare 误判 bot 的概率 + init.headers = { + accept: "application/json", + "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", + }; + + let res: Response; + try { + res = await fetch(url, init); + } catch (err) { + console.warn("[feed/page] fetch network error", { + attempt: i, + error: String(err), + }); + if (i === attempts.length - 1) return []; + await sleep(i === 0 ? 300 : 800); + continue; + } - const res = await fetch( - `${backendUrl}/api/community/links?${params.toString()}`, - { - next: { revalidate: 120 }, - headers: { - accept: "application/json", - "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", - }, - }, - ); + if (res.ok) { + try { + const json = (await res.json()) as ApiResponse; + return json.success && json.data ? json.data : []; + } catch (err) { + // 2xx 但非 JSON(例如 CF 偶发返回 200 的 challenge HTML) + console.warn("[feed/page] non-JSON 2xx response", { + attempt: i, + cfRay: res.headers.get("cf-ray"), + contentType: res.headers.get("content-type"), + error: String(err), + }); + if (i === attempts.length - 1) return []; + await sleep(i === 0 ? 300 : 800); + continue; + } + } - if (!res.ok) { - // 后端 5xx / 网络错误才抛,前端会走 error.tsx(如果有的话) - throw new Error( - `/api/community/links backend ${res.status} ${res.statusText}`, - ); + // 非 2xx(含 403 CF challenge / 5xx):记录 + 重试 + console.warn("[feed/page] backend non-2xx", { + attempt: i, + status: res.status, + cfRay: res.headers.get("cf-ray"), + cfMitigated: res.headers.get("cf-mitigated"), + }); + if (i === attempts.length - 1) return []; + await sleep(i === 0 ? 300 : 800); } - const json = (await res.json()) as ApiResponse; - return json.success && json.data ? json.data : []; + return []; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); } interface FeedPageProps { @@ -85,16 +138,16 @@ export default async function FeedPage({ searchParams }: FeedPageProps) { console.error("[feed/page] fetchLinks failed:", err); } - /** - * 预计算每条链接的分类显示名(i18n)。 - * 在 server 端翻译,避免 LinkCard(server component)里调 useTranslations(client hook)。 - */ - function getCategoryLabel(slug: CategorySlug | null): string { - if (!slug) return ""; + // Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client) + // 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to + // Client Components")。8 个 slug 一次翻译完毕,零额外开销。 + const { CATEGORY_SLUGS } = await import("@/app/feed/types"); + const categoryLabels: Partial> = {}; + for (const slug of CATEGORY_SLUGS) { try { - return tCategory(slug); + categoryLabels[slug] = tCategory(slug); } catch { - return slug; + categoryLabels[slug] = slug; } } @@ -148,10 +201,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) { ) : ( // FeedAuthWrapper 是 client 组件,负责读取登录态后注入到 LinkCard - + )} diff --git a/messages/en.json b/messages/en.json index a28c9af..4d326c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11,7 +11,8 @@ "mission": "A free, open learning community built by developers, for developers. No gatekeeping, no pointless grind — just real progress and the joy of building. Knowledge is a ladder to freedom, not a cage.", "cta": { "access": "Access Articles", - "guideAriaLabel": "Contribution Guide" + "guideAriaLabel": "Contribution Guide", + "feed": "What we're reading lately" }, "archivesLabel": "Classified Archives", "join": { @@ -444,7 +445,6 @@ } }, "shareLink": { - "button": "Share Link", - "submitAriaLabel": "Quick submit" + "button": "Share a Link" } } diff --git a/messages/zh.json b/messages/zh.json index f8c61c6..c3f8598 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -11,7 +11,8 @@ "mission": "一个由开发者自发组织、免费开放的学习社区。降低门槛,避免无意义内卷,专注真实进步与乐趣。我们相信知识不应成为枷锁,而应是通往自由的阶梯。", "cta": { "access": "访问文章", - "guideAriaLabel": "查看投稿指南" + "guideAriaLabel": "查看投稿指南", + "feed": "看看我们最近在读什么" }, "archivesLabel": "归档分类", "join": { @@ -444,7 +445,6 @@ } }, "shareLink": { - "button": "丢个链接", - "submitAriaLabel": "快速提交" + "button": "丢个链接" } }