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": "丢个链接"
}
}