From 790a58b47d932dd7b28c83e78322283c3fa7dc5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:49:43 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E8=BD=BB=E9=87=8F=E5=8F=91?= =?UTF-8?q?=E6=96=87=20posts=20=E6=A8=A1=E5=9D=97=EF=BC=88=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=9B=B4=E5=8F=91=20+=20/feed=20=E5=8E=9F?= =?UTF-8?q?=E5=88=9B=20Tab=20+=20=E4=B8=AA=E4=BA=BA=E4=B8=BB=E9=A1=B5=20+?= =?UTF-8?q?=20=E8=AF=A6=E6=83=85=E9=A1=B5=20+=20=E8=BD=AC=E6=AD=A3=20PR?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PostContent UGC Markdown 渲染器(react-markdown + rehype-sanitize,XSS 防护), EditorPageClient 改造为直发 POST /api/posts,/feed 加原创文章默认 Tab, /u/[username]/posts 列表页和 /u/[username]/posts/[slug] 详情页, PromoteToDocsButton 三态按钮支持一键转正 PR,个人主页追加文章入口。 --- app/[locale]/editor/EditorPageClient.tsx | 231 ++++++++--------- .../feed/components/FeedTabSwitcher.tsx | 52 ++++ app/[locale]/feed/components/PostCard.tsx | 99 ++++++++ app/[locale]/feed/page.tsx | 232 +++++++++++++----- .../u/[username]/PostsLinkOnProfile.tsx | 80 ++++++ app/[locale]/u/[username]/page.tsx | 7 + .../posts/[slug]/PostDetailOwnerActions.tsx | 107 ++++++++ .../u/[username]/posts/[slug]/page.tsx | 140 +++++++++++ app/[locale]/u/[username]/posts/layout.tsx | 18 ++ app/[locale]/u/[username]/posts/page.tsx | 126 ++++++++++ app/components/PostContent.tsx | 107 ++++++++ app/components/PromoteToDocsButton.tsx | 146 +++++++++++ app/robots.ts | 2 + app/types/post.ts | 70 ++++++ next.config.mjs | 9 + package.json | 2 + pnpm-lock.yaml | 37 ++- 17 files changed, 1266 insertions(+), 199 deletions(-) create mode 100644 app/[locale]/feed/components/FeedTabSwitcher.tsx create mode 100644 app/[locale]/feed/components/PostCard.tsx create mode 100644 app/[locale]/u/[username]/PostsLinkOnProfile.tsx create mode 100644 app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx create mode 100644 app/[locale]/u/[username]/posts/[slug]/page.tsx create mode 100644 app/[locale]/u/[username]/posts/layout.tsx create mode 100644 app/[locale]/u/[username]/posts/page.tsx create mode 100644 app/components/PostContent.tsx create mode 100644 app/components/PromoteToDocsButton.tsx create mode 100644 app/types/post.ts diff --git a/app/[locale]/editor/EditorPageClient.tsx b/app/[locale]/editor/EditorPageClient.tsx index c442edf1..af8e42e3 100644 --- a/app/[locale]/editor/EditorPageClient.tsx +++ b/app/[locale]/editor/EditorPageClient.tsx @@ -2,16 +2,16 @@ import { useEditorStore } from "@/lib/editor-store"; import { EditorMetadataForm } from "@/app/components/EditorMetadataForm"; -import { DocsDestinationForm } from "@/app/components/DocsDestinationForm"; import { MarkdownEditor, type MarkdownEditorHandle, } from "@/app/components/MarkdownEditor"; import { Button } from "@/app/components/ui/button"; import { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import type { UserView } from "@/lib/use-auth"; -import { buildDocsNewUrl } from "@/lib/github"; +import type { PostRequest, ApiResponse, PostView } from "@/app/types/post"; import { FILENAME_PATTERN, normalizeMarkdownFilename, @@ -22,7 +22,24 @@ interface EditorPageClientProps { user: UserView; } -function buildFrontmatter({ +/** + * 从文章标题生成 slug 候选值,和后端生成逻辑保持一致(kebab-case,纯 ASCII)。 + * 后端会做唯一性去重,前端只是提前填充 filename input 用,不是最终 slug。 + */ +function titleToSlug(title: string): string { + return title + .toLowerCase() + .trim() + .replace(/[\s_]+/g, "-") + .replace(/[^\p{L}\p{N}-]/gu, "") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 128); +} + +// buildFrontmatter 仅在「收录进知识库」(PromoteToDocsButton)路径使用, +// 这里为 PromoteToDocsButton 单独导出,editor 直发不再拼 frontmatter。 +export function buildFrontmatter({ title, description, tags, @@ -58,46 +75,36 @@ function buildFrontmatter({ return lines.join("\n"); } -/** - * 编辑器页面客户端组件 - * 包含表单、编辑器和发布按钮 - */ export function EditorPageClient({ user }: EditorPageClientProps) { + const router = useRouter(); const [isPublishing, setIsPublishing] = useState(false); const [imageCount, setImageCount] = useState(0); - const [destinationPath, setDestinationPath] = useState(""); const editorRef = useRef(null); const { title, description, tags, filename, markdown, setFilename } = useEditorStore(); const handleImageCountChange = useCallback((count: number) => { setImageCount(count); }, []); - const previewFilename = filename ? normalizeMarkdownFilename(filename) : ""; + const previewSlug = filename + ? stripMarkdownExtension(normalizeMarkdownFilename(filename)) + : ""; - /** - * 上传单个图片到 R2 - */ + // 上传单个图片到 R2,返回 { blobUrl, publicUrl } const uploadImage = async ( blobUrl: string, file: File, articleSlug: string, ): Promise<{ blobUrl: string; publicUrl: string }> => { // 规范化 Content-Type:只取主 MIME(分号前)+ trim + 小写。 - // 服务端预签名 URL 绑的是这个规范化后的 ContentType,客户端 PUT 时的 - // Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。 - // 浏览器 file.type 在极少见情况下可能是 "Image/JPEG" 或 "image/jpeg; foo=bar", - // 不能直接原样透传。 + // 服务端预签名 URL 绑的是规范化后的 ContentType,客户端 PUT 时必须 byte-exact 一致, + // 否则 R2 返 403 SignatureDoesNotMatch。 const primaryMime = file.type.split(";")[0]!.trim().toLowerCase(); if (!primaryMime) { - // 浏览器识别不出 MIME(某些冷门类型会给空串)。此时继续走会被服务端 MIME_PATTERN - // 正则直接 400,给个本地报错更清晰,和 editor 里其它 throw -> handlePublish alert 的 - // 链路一致。 throw new Error( `无法识别图片类型:${file.name}(浏览器未给出 MIME),请另存为 PNG/JPG/WebP 后重试`, ); } - // 1. 获取预签名 URL(带 x-satoken 请求头,供服务端验证身份) const token = localStorage.getItem("satoken") ?? ""; const response = await fetch("/api/upload", { method: "POST", @@ -120,12 +127,10 @@ export function EditorPageClient({ user }: EditorPageClientProps) { const { uploadUrl, publicUrl } = await response.json(); - // 2. 上传文件到 R2 —— Content-Type 必须和签名时服务端绑的 primaryMime byte-exact 一致 + // Content-Type 必须和签名时绑的 primaryMime byte-exact 一致,否则 R2 返 403 const uploadResponse = await fetch(uploadUrl, { method: "PUT", - headers: { - "Content-Type": primaryMime, - }, + headers: { "Content-Type": primaryMime }, body: file, }); @@ -145,117 +150,92 @@ export function EditorPageClient({ user }: EditorPageClientProps) { return; } - if (!filename.trim()) { - alert("请输入文件名"); - return; - } - - if (!destinationPath) { - alert("请选择投稿目录"); - return; - } + // filename 字段作为 slug 来源;为空时用 title 自动生成 + const rawSlug = filename.trim() + ? stripMarkdownExtension(normalizeMarkdownFilename(filename)) + : titleToSlug(title); - const normalizedFilename = normalizeMarkdownFilename(filename); - const filenameBase = stripMarkdownExtension(normalizedFilename); - if (!filenameBase || !FILENAME_PATTERN.test(filenameBase)) { + if (rawSlug && !FILENAME_PATTERN.test(rawSlug)) { alert( "文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头(已自动清洗空格和特殊符号)。", ); return; } - if (normalizedFilename !== filename) { - setFilename(normalizedFilename); + if (filename.trim()) { + const normalized = normalizeMarkdownFilename(filename); + if (normalized !== filename) setFilename(normalized); } - let githubDraftWindow: Window | null = null; - try { - githubDraftWindow = window.open("", "_blank"); - if (githubDraftWindow) { - githubDraftWindow.document.title = "正在生成稿件…"; - githubDraftWindow.document.body.innerHTML = - '

正在生成 GitHub 草稿,请稍候…

'; - githubDraftWindow.opener = null; - } - } catch { - githubDraftWindow = null; - } - - console.group("发布流程:上传图片并生成 GitHub 草稿"); - console.log("文章标题:", title); - console.log("文件名:", normalizedFilename); - console.log("投稿目录:", destinationPath); - console.log("图片数量:", imageCount); - let finalMarkdown = markdown; - const articleSlug = filenameBase; + const articleSlug = rawSlug || "draft"; - // 如果有图片,上传到 R2 并替换 URL const editorHandle = editorRef.current; if (!editorHandle) { throw new Error("编辑器尚未就绪,无法上传图片"); } + // 清理编辑器中未被 Markdown 正文引用的孤儿图片 const removedImages = editorHandle.removeUnreferencedImages(markdown); if (removedImages > 0) { - console.log(`已清理 ${removedImages} 个未在 Markdown 中引用的图片`); + console.log(`已清理 ${removedImages} 个未引用的图片`); } const imageEntries = Array.from(editorHandle.getImages().entries()); if (imageEntries.length > 0) { - console.log("开始上传图片..."); - - // 并发上传所有图片 const uploadPromises = imageEntries.map(([blobUrl, file]) => uploadImage(blobUrl, file, articleSlug), ); - const uploadResults = await Promise.all(uploadPromises); - console.log("所有图片上传完成!"); - console.group("图片 URL 映射"); - uploadResults.forEach(({ blobUrl, publicUrl }) => { - console.log(`${blobUrl} -> ${publicUrl}`); - }); - console.groupEnd(); - - // 替换 Markdown 中的 blob URL 为公开 URL + // 用 R2 公开 URL 替换 blob URL uploadResults.forEach(({ blobUrl, publicUrl }) => { finalMarkdown = finalMarkdown.replaceAll(blobUrl, publicUrl); }); - - console.log("Markdown 中的 blob URL 已替换为公开 URL"); } - console.group("最终 Markdown 内容"); - console.log(finalMarkdown); - console.groupEnd(); + // POST /api/posts 直发落库 + // TODO(backend-contract): 等后端 #2 完成后确认 BACKEND_URL 路由 rewrite 情况; + // 目前后端路由走 next.config.mjs rewrites 同源代理(参考 /api/community/links), + // 若 posts 同样走代理则直接 fetch "/api/posts",否则需要带 BACKEND_URL。 + const token = localStorage.getItem("satoken") ?? ""; + const postRequest: PostRequest = { + title: title.trim(), + description: description.trim() || undefined, + tags: tags.filter((t) => t.trim().length > 0), + contentMd: finalMarkdown, + // 有用户填的 slug 就带上,后端会去重;没有则不传,后端从 title 自动生成 + ...(rawSlug ? { slug: rawSlug } : {}), + }; + + const res = await fetch("/api/posts", { + method: "POST", + headers: { + "Content-Type": "application/json", + // rewrite 透传:后端 sa-token.token-name=satoken,需用 satoken 而非 x-satoken + satoken: token, + }, + body: JSON.stringify(postRequest), + signal: AbortSignal.timeout(30_000), + }); - console.groupEnd(); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + (body as { message?: string }).message ?? + `发布失败(HTTP ${res.status})`, + ); + } - const frontmatter = buildFrontmatter({ - title, - description, - tags, - }); - const markdownBody = finalMarkdown.trimStart(); - const finalContent = - markdownBody.length > 0 - ? `${frontmatter}\n\n${markdownBody}` - : `${frontmatter}\n`; - - const params = new URLSearchParams({ - filename: normalizedFilename, - value: finalContent, - }); - const githubUrl = buildDocsNewUrl(destinationPath, params); - if (githubDraftWindow) { - githubDraftWindow.location.href = githubUrl; - } else { - window.open(githubUrl, "_blank", "noopener,noreferrer"); + const body = (await res.json()) as ApiResponse; + if (!body.success || !body.data) { + throw new Error(body.message ?? "发布失败,请重试"); } - alert("图片已上传并生成 GitHub 草稿,请在新标签页完成提交。"); + + const { slug: finalSlug } = body.data; + // 跳到文章详情页 + router.push(`/u/${user.username}/posts/${finalSlug}`); } catch (error) { console.error("发布失败:", error); alert(`发布失败:${error instanceof Error ? error.message : "未知错误"}`); @@ -264,14 +244,16 @@ export function EditorPageClient({ user }: EditorPageClientProps) { } }; + const canPublish = title.trim().length > 0 && !isPublishing; + return (
{/* 头部 */}
-

创作新文章

+

写篇文章

- 欢迎,{user.displayName || user.username} + 写完直接发布,想进知识库再一键投稿。

@@ -281,9 +263,8 @@ export function EditorPageClient({ user }: EditorPageClientProps) { {/* 主要内容区域 */}
- {/* 元数据表单 */} + {/* 元数据表单(标题/描述/标签/文件名) */} - {/* Markdown 编辑器 */}
@@ -299,24 +280,20 @@ export function EditorPageClient({ user }: EditorPageClientProps) { />
- {/* 操作按钮 */} + {/* 操作区 */}
- {!title.trim() || !filename.trim() ? ( - 请填写标题和文件名 - ) : !destinationPath ? ( - 请选择投稿目录 - ) : ( + {!title.trim() ? ( + 请填写标题 + ) : previewSlug ? ( - 将在{" "} + 将发布到{" "} - {destinationPath} - {" "} - 下创建{" "} - - {previewFilename} + /u/{user.username}/posts/{previewSlug} + ) : ( + 发布后 slug 由标题自动生成 )}
@@ -333,29 +310,19 @@ export function EditorPageClient({ user }: EditorPageClientProps) { 清空 -
- {/* 提示信息 */} + {/* 流程提示 */}
-

发布流程提示

+

写完直接发

    -
  • 填写标题、描述、标签与文件名,自动补全 .md 后缀
  • -
  • 选择或新建投稿目录,目录结构与现有投稿机制一致
  • -
  • 点击“发布文章”将自动上传图片并替换为线上 URL
  • -
  • 系统会生成标准 Frontmatter,并打开 GitHub 新建页面
  • -
  • 在 GitHub 页面确认内容后提交 PR 即可完成投稿
  • +
  • 图片粘贴后自动上传到 CDN,发布时无需额外处理
  • +
  • 发布即可见,链接可直接分享,不等 review
  • +
  • 想进知识库?发布后点「收录进知识库」一键投稿
diff --git a/app/[locale]/feed/components/FeedTabSwitcher.tsx b/app/[locale]/feed/components/FeedTabSwitcher.tsx new file mode 100644 index 00000000..b8d05703 --- /dev/null +++ b/app/[locale]/feed/components/FeedTabSwitcher.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +/** + * 「原创文章 / 分享链接」顶级 Tab 切换。 + * + * 通过 ?tab=posts / ?tab=links 控制当前 Tab,保持 SSR 可读且可书签化。 + * 切换 Tab 时清除 ?category query,避免分类筛选残留到原创文章 Tab。 + */ +export function FeedTabSwitcher({ + currentTab, +}: { + currentTab: "posts" | "links"; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + + function switchTab(tab: "posts" | "links") { + const params = new URLSearchParams(); + params.set("tab", tab); + // 切到 links 时保留 category;切到 posts 时清除(posts 暂无分类筛选) + if (tab === "links") { + const category = searchParams.get("category"); + if (category) params.set("category", category); + } + router.replace(`/feed?${params.toString()}`); + } + + const base = + "px-5 py-2.5 font-mono text-xs uppercase tracking-widest transition-colors"; + const active = + "border-b-2 border-[var(--foreground)] text-[var(--foreground)] -mb-px"; + const inactive = "text-neutral-400 hover:text-[var(--foreground)]"; + + return ( +
+ + +
+ ); +} diff --git a/app/[locale]/feed/components/PostCard.tsx b/app/[locale]/feed/components/PostCard.tsx new file mode 100644 index 00000000..6b54b262 --- /dev/null +++ b/app/[locale]/feed/components/PostCard.tsx @@ -0,0 +1,99 @@ +import Link from "next/link"; +import type { PostSummaryView } from "@/app/types/post"; +import { Badge } from "@/components/ui/badge"; + +interface PostCardProps { + post: PostSummaryView; + /** 是否显示作者头像和用户名(feed 页显示,个人主页隐藏) */ + showAuthor?: boolean; +} + +/** 格式化 ISO-8601 时间为本地日期字符串,仅用 YYYY-MM-DD */ +function formatDate(iso: string): string { + return iso.slice(0, 10); +} + +/** + * 原创文章卡片。 + * /feed 原创 Tab 和 /u/[username]/posts 列表均复用此组件。 + * 点击整卡跳转到 /u/{authorUsername}/posts/{slug}。 + */ +export function PostCard({ post, showAuthor = false }: PostCardProps) { + const href = `/u/${post.authorUsername}/posts/${post.slug}`; + + return ( +
  • + {/* 已收录角标:绝对定位右上角,不占内容流 */} + {post.promoted && ( + + 已收录 /docs + + )} + + {/* 封面图:有 coverUrl 时展示 16:9,无则跳过不留空白 */} + {post.coverUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {post.title} + )} + + {/* 卡片内容区 */} +
    + {/* 第一行:发布时间 + 作者(showAuthor=true 时) */} +
    +
    + + {formatDate(post.createdAt)} + +
    + {showAuthor && ( +
    + {post.authorAvatar && ( + // eslint-disable-next-line @next/next/no-img-element + {post.authorDisplayName + )} + + @{post.authorUsername} + +
    + )} +
    + + {/* 标题 */} +

    + {post.title} +

    + + {/* 摘要 */} + {post.description && ( +

    + {post.description} +

    + )} + + {/* tags */} + {post.tags.length > 0 && ( +
    + {post.tags.slice(0, 4).map((tag) => ( + + {tag} + + ))} +
    + )} +
    + +
  • + ); +} diff --git a/app/[locale]/feed/page.tsx b/app/[locale]/feed/page.tsx index b1a7e432..83110bae 100644 --- a/app/[locale]/feed/page.tsx +++ b/app/[locale]/feed/page.tsx @@ -1,12 +1,11 @@ /** - * /feed 社区分享墙列表页。 + * /feed 社区页:顶部「原创文章 / 分享链接」Tab 切换。 * - * SSR 直连后端拉已审核通过(APPROVED)的链接,revalidate: 120 减少 DB 压力。 - * 分类过滤通过 URL searchParam ?category= 实现,SSR 可直接读取。 + * 默认 Tab:原创文章(无 ?tab query 等同 ?tab=posts)。 + * posts Tab:SSR 拉 GET /api/posts/feed,走相同的三次退避降级策略。 + * links Tab:维持原有 SSR + CategoryTabs 逻辑不变。 * - * 登录态检测:由于认证走 localStorage(client-only),isLoggedIn 无法在 SSR 层知道, - * 故 LinkCard 中的举报按钮默认以未登录态渲染,由 ReportButton 内部在 client 端 - * 补充真实登录判断。这里通过 FeedAuthWrapper 传递 client 端的 isLoggedIn 状态。 + * revalidate 统一 120s,与原 links Tab 一致。 */ import type { Metadata } from "next"; @@ -16,15 +15,16 @@ import { Footer } from "@/app/components/Footer"; import { Suspense } from "react"; import { CategoryTabs } from "@/app/[locale]/feed/components/CategoryTabs"; import { FeedAuthWrapper } from "@/app/[locale]/feed/components/FeedAuthWrapper"; +import { FeedTabSwitcher } from "@/app/[locale]/feed/components/FeedTabSwitcher"; +import { PostCard } from "@/app/[locale]/feed/components/PostCard"; import type { SharedLinkView, CategorySlug } from "@/app/[locale]/feed/types"; import type { ApiResponse } from "@/app/[locale]/feed/types"; +import type { PostSummaryView } from "@/app/types/post"; import Link from "next/link"; import { ensureSeoDescription } from "@/lib/seo-description"; export const revalidate = 120; -// 原 description 只有 24 字符(远低于 Bing 推荐的 150-160),统一走 ensureSeoDescription -// 兜底到 80+ 字符。社区分享墙是公开 SEO 页,搜索摘要质量直接影响 CTR。 export const metadata: Metadata = { title: "社区分享墙 · Involution Hell", description: ensureSeoDescription({ @@ -35,18 +35,7 @@ export const metadata: Metadata = { }), }; -/** - * 从后端拉取 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 日志定位 - */ +// 三次退避策略(镜像原 fetchLinks,防 Cloudflare 误判 bot 导致 SSR 崩溃) async function fetchLinks(category?: string): Promise { const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { @@ -70,7 +59,6 @@ async function fetchLinks(category?: string): Promise { "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)", @@ -94,11 +82,9 @@ async function fetchLinks(category?: string): Promise { 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 []; @@ -107,12 +93,79 @@ async function fetchLinks(category?: string): Promise { } } - // 非 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); + } + + return []; +} + +// 拉取 posts feed,三次退避策略同上 +async function fetchPosts(): Promise { + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) { + console.error("[feed/page] BACKEND_URL is not configured"); + return []; + } + + const url = `${backendUrl}/api/posts/feed?limit=50&offset=0`; + + 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 } }; + 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] posts fetch error", { + attempt: i, + error: String(err), + }); + if (i === attempts.length - 1) return []; + await sleep(i === 0 ? 300 : 800); + continue; + } + + if (res.ok) { + try { + const json = (await res.json()) as { + success: boolean; + data?: PostSummaryView[]; + }; + return json.success && json.data ? json.data : []; + } catch (err) { + console.warn("[feed/page] posts non-JSON 2xx", { + attempt: i, + error: String(err), + }); + if (i === attempts.length - 1) return []; + await sleep(i === 0 ? 300 : 800); + continue; + } + } + + console.warn("[feed/page] posts backend non-2xx", { + attempt: i, + status: res.status, }); if (i === attempts.length - 1) return []; await sleep(i === 0 ? 300 : 800); @@ -126,36 +179,47 @@ function sleep(ms: number): Promise { } interface FeedPageProps { - searchParams: Promise<{ category?: string }>; + searchParams: Promise<{ tab?: string; category?: string }>; } export default async function FeedPage({ searchParams }: FeedPageProps) { const t = await getTranslations("feed"); const tCategory = await getTranslations("feed.category"); - // Next.js 15+ searchParams 是 Promise,需要 await const resolvedParams = await searchParams; + // 默认 Tab 是 posts(无 query 或 ?tab=posts) + const tab: "posts" | "links" = + resolvedParams.tab === "links" ? "links" : "posts"; const category = resolvedParams.category ?? ""; - // 获取链接列表,出错时降级为空数组(不让整页崩溃) + // 按 Tab 并发拉数据,失败时降级为空数组 let links: SharedLinkView[] = []; - try { - links = await fetchLinks(category || undefined); - } catch (err) { - // SSR 拉取失败时记录日志,降级展示空状态,不崩溃整页 - console.error("[feed/page] fetchLinks failed:", err); + let posts: PostSummaryView[] = []; + + if (tab === "links") { + try { + links = await fetchLinks(category || undefined); + } catch (err) { + console.error("[feed/page] fetchLinks failed:", err); + } + } else { + try { + posts = await fetchPosts(); + } catch (err) { + console.error("[feed/page] fetchPosts failed:", err); + } } - // Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client) - // 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to - // Client Components")。8 个 slug 一次翻译完毕,零额外开销。 + // links Tab 需要分类标签翻译(传给 client 组件) const { CATEGORY_SLUGS } = await import("@/app/[locale]/feed/types"); const categoryLabels: Partial> = {}; - for (const slug of CATEGORY_SLUGS) { - try { - categoryLabels[slug] = tCategory(slug); - } catch { - categoryLabels[slug] = slug; + if (tab === "links") { + for (const slug of CATEGORY_SLUGS) { + try { + categoryLabels[slug] = tCategory(slug); + } catch { + categoryLabels[slug] = slug; + } } } @@ -164,7 +228,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
    - {/* 页面头部,风格对齐 /events */} + {/* 页面头部 */}
    Community · Feed @@ -178,37 +242,73 @@ export default async function FeedPage({ searchParams }: FeedPageProps) { {t("subtitle")}

    - {/* 提交按钮 */} - - + 丢个链接 - + {tab === "links" ? ( + + + 丢个链接 + + ) : ( + + + 写篇文章 + + )}
    - {/* 分类 tab(client component,依赖 router/searchParams) */} -
    - {/* Suspense 包裹是因为 CategoryTabs 内部调用 useSearchParams, - Next.js 要求 useSearchParams 的父级有 Suspense 边界 */} - - } - > - - -
    - - {/* 链接卡片瀑布流 */} - {links.length === 0 ? ( - // 空状态提示 + {/* 一级 Tab(需要 useSearchParams,Suspense 包裹) */} + + } + > + + + + {/* links Tab:分类筛选 */} + {tab === "links" && ( +
    + + } + > + + +
    + )} + + {/* 内容区 */} + {tab === "posts" ? ( + posts.length === 0 ? ( +
    +

    社区里还没有原创文章。

    +

    + 你的第一篇,可能会成为别人的路标。 +

    + + 开始写 → + +
    + ) : ( +
      + {posts.map((post) => ( + + ))} +
    + ) + ) : links.length === 0 ? (
    {t("empty")}
    ) : ( - // FeedAuthWrapper 是 client 组件,负责读取登录态后注入到 LinkCard )} diff --git a/app/[locale]/u/[username]/PostsLinkOnProfile.tsx b/app/[locale]/u/[username]/PostsLinkOnProfile.tsx new file mode 100644 index 00000000..4646a8af --- /dev/null +++ b/app/[locale]/u/[username]/PostsLinkOnProfile.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/lib/use-auth"; +import type { PostSummaryView, ApiResponse } from "@/app/types/post"; + +interface Props { + ownerGithubId: number | null; + ownerUsername: string; + /** URL 上的标识符(/u/),posts 链接用相同 identifier */ + identifier: string; +} + +/** + * 个人主页文章区块入口。 + * + * 本人访问:显示文章数量 + 跳 /u/{identifier}/posts。 + * 他人访问:只显示跳转链接,数量由公开 feed 近似(为避免多余请求, + * 初期 MVP 统一显示「查看文章 →」不带数量)。 + * + * 样式:在 stats 三列下方追加独立链接行,对齐 Task #1 设计说明。 + */ +export function PostsLinkOnProfile({ + ownerGithubId, + ownerUsername, + identifier, +}: Props) { + const { user, status } = useAuth(); + const [postCount, setPostCount] = useState(null); + + const isOwner = useMemo(() => { + if (status !== "authenticated" || !user) return false; + if (ownerGithubId != null && user.githubId === ownerGithubId) return true; + if (user.username === ownerUsername) return true; + return false; + }, [status, user, ownerGithubId, ownerUsername]); + + // 本人才拉 mine 接口以获取文章数(含草稿),他人不请求 + useEffect(() => { + if (!isOwner) return; + let aborted = false; + (async () => { + try { + const token = localStorage.getItem("satoken") ?? ""; + const res = await fetch("/api/posts/mine", { + cache: "no-store", + // rewrite 透传:后端读 satoken,不是 x-satoken + headers: token ? { satoken: token } : {}, + }); + if (!res.ok) return; + const body = (await res.json()) as ApiResponse; + if (!aborted && body.success && body.data) { + setPostCount(body.data.length); + } + } catch { + // 静默失败,不影响主页主体展示 + } + })(); + return () => { + aborted = true; + }; + }, [isOwner]); + + const href = `/u/${identifier}/posts`; + + return ( +
    + + 文章 + + + {isOwner && postCount !== null ? `${postCount} 篇 →` : "查看文章 →"} + +
    + ); +} diff --git a/app/[locale]/u/[username]/page.tsx b/app/[locale]/u/[username]/page.tsx index 04b496ca..38934ea9 100644 --- a/app/[locale]/u/[username]/page.tsx +++ b/app/[locale]/u/[username]/page.tsx @@ -11,6 +11,7 @@ import { AdminLinkIfOwnerAdmin } from "./AdminLinkIfOwnerAdmin"; import { DeveloperToolsIfOwner } from "./DeveloperToolsIfOwner"; import { SharesLinkIfOwner } from "./SharesLinkIfOwner"; import { SharesOnProfile } from "./SharesOnProfile"; +import { PostsLinkOnProfile } from "./PostsLinkOnProfile"; import { ActivityHeatmap } from "./ActivityHeatmap"; import { FollowButton } from "./FollowButton"; import { GithubRepos, GithubReposSkeleton } from "./GithubRepos"; @@ -460,6 +461,12 @@ export default async function UserProfilePage({ params }: Param) { + {/* 文章入口:stats 下方独立链接行,client 组件动态拉计数 */} + {/* 关注按钮 + 粉丝/关注数,客户端动态拉 */} {links.length > 0 && ( diff --git a/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx b/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx new file mode 100644 index 00000000..f3b1aa94 --- /dev/null +++ b/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useAuth } from "@/lib/use-auth"; +import { PromoteToDocsButton } from "@/app/components/PromoteToDocsButton"; +import type { ApiResponse } from "@/app/types/post"; + +interface Props { + postId: number; + postSlug: string; + authorUsername: string; + promotedAt: string | null; + title: string; + description: string | null; + tags: string[]; + contentMd: string; +} + +/** + * 详情页 owner 按钮组(编辑 | 删除 | 收录进知识库)。 + * 仅在当前登录用户是文章作者时渲染。 + */ +export function PostDetailOwnerActions({ + postId, + postSlug: _postSlug, + authorUsername, + promotedAt, + title, + description, + tags, + contentMd, +}: Props) { + const { user, status } = useAuth(); + const router = useRouter(); + const params = useParams<{ username: string }>(); + const [deleting, setDeleting] = useState(false); + + // 判定是否为作者(githubId 优先,username 兜底) + const isOwner = useMemo(() => { + if (status !== "authenticated" || !user) return false; + const urlUsername = params?.username ?? authorUsername; + if (user.githubId != null && String(user.githubId) === urlUsername) + return true; + if (user.username === authorUsername) return true; + return false; + }, [status, user, params, authorUsername]); + + if (!isOwner) return null; + + async function handleDelete() { + if (!confirm("确定要删除这篇文章吗?删除后不可恢复。")) return; + setDeleting(true); + try { + const token = localStorage.getItem("satoken") ?? ""; + const res = await fetch(`/api/posts/${postId}`, { + method: "DELETE", + // rewrite 透传:后端读 satoken,不是 x-satoken + headers: { satoken: token }, + }); + const body = (await res.json()) as ApiResponse; + if (res.ok && body.success) { + router.replace(`/u/${authorUsername}/posts`); + } else { + alert(body.message ?? `删除失败(HTTP ${res.status})`); + } + } catch { + alert("网络错误,请稍后重试"); + } finally { + setDeleting(false); + } + } + + const btnBase = + "font-mono text-[11px] uppercase tracking-widest transition-colors"; + + return ( +
    + {/* 编辑(预留,后续迭代实现) */} + + 编辑 + + + {/* 删除 */} + + + {/* 收录进知识库:三态按钮(idle / pending / promoted) */} + +
    + ); +} diff --git a/app/[locale]/u/[username]/posts/[slug]/page.tsx b/app/[locale]/u/[username]/posts/[slug]/page.tsx new file mode 100644 index 00000000..0bf11971 --- /dev/null +++ b/app/[locale]/u/[username]/posts/[slug]/page.tsx @@ -0,0 +1,140 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { Header } from "@/app/components/Header"; +import { Footer } from "@/app/components/Footer"; +import PostContent from "@/app/components/PostContent"; +import { PostDetailOwnerActions } from "./PostDetailOwnerActions"; +import type { PostView } from "@/app/types/post"; +import { Badge } from "@/components/ui/badge"; + +// noindex:posts 是 UGC 直发,不走 SEO 收录,双重保险(metadata + robots.ts) +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + +async function fetchPost( + username: string, + slug: string, +): Promise { + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) { + throw new Error("BACKEND_URL is not configured"); + } + + const url = `${backendUrl}/api/posts/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`; + let res: Response; + try { + // 详情页走 SSR(cache: "no-store"),内容可能随时更新 + res = await fetch(url, { + cache: "no-store", + headers: { + accept: "application/json", + "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", + }, + }); + } catch (err) { + throw new Error(`fetch post failed: ${String(err)}`); + } + + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`post backend ${res.status} for ${username}/${slug}`); + } + + const json = (await res.json()) as { success: boolean; data?: PostView }; + if (!json.success || !json.data) return null; + return json.data; +} + +function formatDate(iso: string): string { + return iso.slice(0, 10); +} + +interface PageProps { + params: Promise<{ username: string; slug: string }>; +} + +export default async function PostDetailPage({ params }: PageProps) { + const { username, slug } = await params; + const post = await fetchPost(username, slug); + if (!post) notFound(); + + return ( + <> +
    +
    +
    + {/* 页面头部(border-t-4 对齐 /feed 和 /u/[username] 页风格) */} +
    +
    + Community · Posts +
    +
    +

    + {post.title} +

    + + {/* owner 按钮组:client 组件,内部判定是否为作者 */} + +
    +
    + + {/* 作者 + 时间 */} +
    + {post.authorAvatar && ( + // eslint-disable-next-line @next/next/no-img-element + {post.authorDisplayName + )} + + @{post.authorUsername} + + · + + {formatDate(post.createdAt)} + +
    + + {/* 正文:PostContent 带 prose 样式 */} + + + {/* tags */} + {post.tags.length > 0 && ( +
    + {post.tags.map((tag) => ( + + {tag} + + ))} +
    + )} +
    +
    +