Skip to content

Commit cd14662

Browse files
fix(community): ShareLink 按钮改跳 /feed/submit + Join 区加 /feed 入口 + feed SSR 抗 CF 挑战 (#315)
按钮语义修正(用户反馈): - 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(徽章已删) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 9ceee8a commit cd14662

6 files changed

Lines changed: 148 additions & 95 deletions

File tree

app/components/Hero.tsx

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,31 @@ export async function Hero() {
9494
<p className="font-body text-sm mb-6 opacity-80">
9595
{t("join.body")}
9696
</p>
97-
<Link
98-
href="/docs/learn/ai"
99-
className="block w-full"
100-
data-umami-event="navigation_click"
101-
data-umami-event-region="hero_cta"
102-
data-umami-event-label="Access Articles"
103-
>
104-
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
105-
{t("cta.access")}
106-
</button>
107-
</Link>
97+
{/* 双阅读入口:严肃文档 + 社区随手分享,视觉同构;投稿动作已在 Hero 左侧 Contribute/ShareLink */}
98+
<div className="flex flex-col gap-3">
99+
<Link
100+
href="/docs/learn/ai"
101+
className="block w-full"
102+
data-umami-event="navigation_click"
103+
data-umami-event-region="hero_cta"
104+
data-umami-event-label="Access Articles"
105+
>
106+
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
107+
{t("cta.access")}
108+
</button>
109+
</Link>
110+
<Link
111+
href="/feed"
112+
className="block w-full"
113+
data-umami-event="navigation_click"
114+
data-umami-event-region="hero_cta"
115+
data-umami-event-label="Community Feed"
116+
>
117+
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
118+
{t("cta.feed")}
119+
</button>
120+
</Link>
121+
</div>
108122
</div>
109123
</div>
110124
</div>

app/components/ShareLink.tsx

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,44 @@
33
/**
44
* Hero 区的"分享链接"按钮。
55
*
6-
* 视觉与样式**完全复制** Contribute 主 CTA,和它并排形成双 CTA
6+
* 视觉与 Contribute 主 CTA 完全同构,并排形成双投稿入口
77
* - Contribute → 正式投稿 Fumadocs 知识库(走 GitHub PR)
8-
* - ShareLink → 随手分享公众号/知乎等文章到社区墙(/feed)
8+
* - ShareLink → 随手丢一篇外部文章到社区分享墙(/feed/submit
99
*
10-
* 两者语义平级,视觉也平级——这是用户拍板的设计(之前尝试的次级文字链 UI 不够突出)。
11-
* 按钮点击跳 /feed(先看一眼再决定是否提交),右上角徽章保留与 Contribute 对称的图标位。
10+
* 两者语义平级:都是"投稿"动作。对应的"阅读"入口在右侧 Join the Resistance
11+
* 卡片里(访问文章 / 看看最近在读),不放在 Hero 主 CTA 区,避免混淆。
12+
*
13+
* 之前本按钮跳 /feed 并带一个 "+" 徽章跳 /feed/submit——语义错位,已修正。
1214
*/
1315

1416
import Link from "next/link";
1517
import { useTranslations } from "next-intl";
1618
import { Button } from "@/components/ui/button";
17-
import { Link2, Plus } from "lucide-react";
19+
import { Link2 } from "lucide-react";
1820

1921
export function ShareLink() {
2022
const t = useTranslations("shareLink");
2123

2224
return (
23-
<div className="relative inline-flex w-full sm:w-auto">
24-
{/* 主按钮跳 /feed(社区分享墙),样式与 Contribute 主按钮完全同构 */}
25-
<Link
26-
href="/feed"
27-
className="w-full sm:w-auto"
28-
data-umami-event="share_link_trigger"
29-
data-umami-event-location="hero"
30-
>
31-
<Button
32-
variant="hero"
33-
size="lg"
34-
className="relative isolate w-full sm:w-auto h-20 px-14 rounded-none
35-
text-2xl font-serif font-black uppercase italic tracking-tighter
36-
bg-[var(--foreground)] text-[var(--background)] border border-[var(--foreground)]
37-
hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all duration-300"
38-
>
39-
<span className="relative z-10 flex items-center gap-4">
40-
<Link2 className="h-6 w-6" />
41-
<span>{t("button")}</span>
42-
</span>
43-
</Button>
44-
</Link>
45-
{/* 右上角徽章:跳 /feed/submit 直接开提交表单,对应 Contribute 的指南 "?" 徽章 */}
46-
<Link
47-
href="/feed/submit"
48-
aria-label={t("submitAriaLabel")}
49-
title={t("submitAriaLabel")}
50-
className="absolute top-0 right-0 flex h-10 w-10 translate-x-1/2 -translate-y-1/2 items-center justify-center border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono hover:bg-[#CC0000] hover:text-white transition-colors z-20"
51-
data-umami-event="share_link_submit_shortcut"
25+
<Link
26+
href="/feed/submit"
27+
className="inline-flex w-full sm:w-auto"
28+
data-umami-event="share_link_trigger"
29+
data-umami-event-location="hero"
30+
>
31+
<Button
32+
variant="hero"
33+
size="lg"
34+
className="relative isolate w-full sm:w-auto h-20 px-14 rounded-none
35+
text-2xl font-serif font-black uppercase italic tracking-tighter
36+
bg-[var(--foreground)] text-[var(--background)] border border-[var(--foreground)]
37+
hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all duration-300"
5238
>
53-
<Plus className="h-4 w-4" strokeWidth={3} />
54-
<span className="sr-only">{t("submitAriaLabel")}</span>
55-
</Link>
56-
</div>
39+
<span className="relative z-10 flex items-center gap-4">
40+
<Link2 className="h-6 w-6" />
41+
<span>{t("button")}</span>
42+
</span>
43+
</Button>
44+
</Link>
5745
);
5846
}

app/feed/components/FeedAuthWrapper.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard,
88
* 使举报按钮可以区分已登录 / 未登录行为。
99
*
10-
* 接收 server 端已预计算好的 links 和 categoryLabel 函数,
11-
* 只负责登录态桥接,不做额外数据请求。
10+
* 接收 server 端已预计算好的 links 和**分类标签映射表**(纯数据),
11+
* 不接收函数 prop —— Next 16 对 server→client 边界严格禁止函数 prop
12+
* (会报 "Functions cannot be passed directly to Client Components")。
1213
*/
1314

1415
import { useAuth } from "@/lib/use-auth";
@@ -17,13 +18,13 @@ import type { SharedLinkView, CategorySlug } from "@/app/feed/types";
1718

1819
interface FeedAuthWrapperProps {
1920
links: SharedLinkView[];
20-
/** server 端传入的分类标签计算函数(已含 i18n 翻译) */
21-
getCategoryLabel: (slug: CategorySlug | null) => string;
21+
/** server 端预翻译好的 slug → 中文显示名 map */
22+
categoryLabels: Partial<Record<CategorySlug, string>>;
2223
}
2324

2425
export function FeedAuthWrapper({
2526
links,
26-
getCategoryLabel,
27+
categoryLabels,
2728
}: FeedAuthWrapperProps) {
2829
const { status } = useAuth();
2930
// loading 阶段默认视为未登录,避免 UI 闪烁
@@ -36,7 +37,7 @@ export function FeedAuthWrapper({
3637
<LinkCard
3738
key={link.id}
3839
link={link}
39-
categoryLabel={getCategoryLabel(link.category)}
40+
categoryLabel={(link.category && categoryLabels[link.category]) ?? ""}
4041
isLoggedIn={isLoggedIn}
4142
/>
4243
))}

app/feed/page.tsx

Lines changed: 84 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,40 +28,93 @@ export const metadata: Metadata = {
2828
};
2929

3030
/**
31-
* 从后端拉取 APPROVED 的链接列表。
32-
* category 为空时拉全部,否则按 slug 过滤。
31+
* 从后端拉取 APPROVED 的链接列表,带 Cloudflare Managed Challenge 重试。
32+
*
33+
* 背景:Vercel SSR 出口偶发被 CF 403 挑战(同 fetchProfile 的坑)。
34+
* 单次失败就 throw 会让首页/feed 显示 500。
35+
*
36+
* 策略(对齐 fetchProfile):
37+
* - 第 1 次:走 Next Data Cache(revalidate: 120),命中快
38+
* - 第 2/3 次:cache: no-store 绕过缓存,分别退避 300ms / 800ms
39+
* - 全败返回 [] 而非抛错——让页面降级展示空态,不崩
40+
* - 每次失败记录 status / cf-ray,便于 Vercel 日志定位
3341
*/
3442
async function fetchLinks(category?: string): Promise<SharedLinkView[]> {
3543
const backendUrl = process.env.BACKEND_URL;
3644
if (!backendUrl) {
37-
// 配置缺失时给清晰错误,而非静默空列表
38-
throw new Error("BACKEND_URL is not configured");
45+
console.error("[feed/page] BACKEND_URL is not configured");
46+
return [];
3947
}
4048

41-
// 构造查询参数
4249
const params = new URLSearchParams({ limit: "50", offset: "0" });
4350
if (category) params.set("category", category);
51+
const url = `${backendUrl}/api/community/links?${params.toString()}`;
52+
53+
const attempts: Array<{ revalidate: number } | { noStore: true }> = [
54+
{ revalidate: 120 },
55+
{ noStore: true },
56+
{ noStore: true },
57+
];
58+
59+
for (let i = 0; i < attempts.length; i++) {
60+
const attempt = attempts[i];
61+
const init: RequestInit & { next?: { revalidate: number } } =
62+
"noStore" in attempt
63+
? { cache: "no-store" }
64+
: { next: { revalidate: attempt.revalidate } };
65+
// 显式 UA 降低被 Cloudflare 误判 bot 的概率
66+
init.headers = {
67+
accept: "application/json",
68+
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
69+
};
70+
71+
let res: Response;
72+
try {
73+
res = await fetch(url, init);
74+
} catch (err) {
75+
console.warn("[feed/page] fetch network error", {
76+
attempt: i,
77+
error: String(err),
78+
});
79+
if (i === attempts.length - 1) return [];
80+
await sleep(i === 0 ? 300 : 800);
81+
continue;
82+
}
4483

45-
const res = await fetch(
46-
`${backendUrl}/api/community/links?${params.toString()}`,
47-
{
48-
next: { revalidate: 120 },
49-
headers: {
50-
accept: "application/json",
51-
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
52-
},
53-
},
54-
);
84+
if (res.ok) {
85+
try {
86+
const json = (await res.json()) as ApiResponse<SharedLinkView[]>;
87+
return json.success && json.data ? json.data : [];
88+
} catch (err) {
89+
// 2xx 但非 JSON(例如 CF 偶发返回 200 的 challenge HTML)
90+
console.warn("[feed/page] non-JSON 2xx response", {
91+
attempt: i,
92+
cfRay: res.headers.get("cf-ray"),
93+
contentType: res.headers.get("content-type"),
94+
error: String(err),
95+
});
96+
if (i === attempts.length - 1) return [];
97+
await sleep(i === 0 ? 300 : 800);
98+
continue;
99+
}
100+
}
55101

56-
if (!res.ok) {
57-
// 后端 5xx / 网络错误才抛,前端会走 error.tsx(如果有的话)
58-
throw new Error(
59-
`/api/community/links backend ${res.status} ${res.statusText}`,
60-
);
102+
// 非 2xx(含 403 CF challenge / 5xx):记录 + 重试
103+
console.warn("[feed/page] backend non-2xx", {
104+
attempt: i,
105+
status: res.status,
106+
cfRay: res.headers.get("cf-ray"),
107+
cfMitigated: res.headers.get("cf-mitigated"),
108+
});
109+
if (i === attempts.length - 1) return [];
110+
await sleep(i === 0 ? 300 : 800);
61111
}
62112

63-
const json = (await res.json()) as ApiResponse<SharedLinkView[]>;
64-
return json.success && json.data ? json.data : [];
113+
return [];
114+
}
115+
116+
function sleep(ms: number): Promise<void> {
117+
return new Promise((r) => setTimeout(r, ms));
65118
}
66119

67120
interface FeedPageProps {
@@ -85,16 +138,16 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
85138
console.error("[feed/page] fetchLinks failed:", err);
86139
}
87140

88-
/**
89-
* 预计算每条链接的分类显示名(i18n)。
90-
* 在 server 端翻译,避免 LinkCard(server component)里调 useTranslations(client hook)
91-
*/
92-
function getCategoryLabel(slug: CategorySlug | null): string {
93-
if (!slug) return "";
141+
// Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client)
142+
// 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to
143+
// Client Components")。8 个 slug 一次翻译完毕,零额外开销
144+
const { CATEGORY_SLUGS } = await import("@/app/feed/types");
145+
const categoryLabels: Partial<Record<CategorySlug, string>> = {};
146+
for (const slug of CATEGORY_SLUGS) {
94147
try {
95-
return tCategory(slug);
148+
categoryLabels[slug] = tCategory(slug);
96149
} catch {
97-
return slug;
150+
categoryLabels[slug] = slug;
98151
}
99152
}
100153

@@ -148,10 +201,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
148201
</div>
149202
) : (
150203
// FeedAuthWrapper 是 client 组件,负责读取登录态后注入到 LinkCard
151-
<FeedAuthWrapper
152-
links={links}
153-
getCategoryLabel={getCategoryLabel}
154-
/>
204+
<FeedAuthWrapper links={links} categoryLabels={categoryLabels} />
155205
)}
156206
</div>
157207
</main>

messages/en.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"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.",
1212
"cta": {
1313
"access": "Access Articles",
14-
"guideAriaLabel": "Contribution Guide"
14+
"guideAriaLabel": "Contribution Guide",
15+
"feed": "What we're reading lately"
1516
},
1617
"archivesLabel": "Classified Archives",
1718
"join": {
@@ -444,7 +445,6 @@
444445
}
445446
},
446447
"shareLink": {
447-
"button": "Share Link",
448-
"submitAriaLabel": "Quick submit"
448+
"button": "Share a Link"
449449
}
450450
}

messages/zh.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"mission": "一个由开发者自发组织、免费开放的学习社区。降低门槛,避免无意义内卷,专注真实进步与乐趣。我们相信知识不应成为枷锁,而应是通往自由的阶梯。",
1212
"cta": {
1313
"access": "访问文章",
14-
"guideAriaLabel": "查看投稿指南"
14+
"guideAriaLabel": "查看投稿指南",
15+
"feed": "看看我们最近在读什么"
1516
},
1617
"archivesLabel": "归档分类",
1718
"join": {
@@ -444,7 +445,6 @@
444445
}
445446
},
446447
"shareLink": {
447-
"button": "丢个链接",
448-
"submitAriaLabel": "快速提交"
448+
"button": "丢个链接"
449449
}
450450
}

0 commit comments

Comments
 (0)