公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。

后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 00:11:24 +08:00
parent 2b3d122e56
commit 40bbad2c82
51 changed files with 3042 additions and 1318 deletions

View File

@@ -43,6 +43,10 @@ import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { discussionJsonLd, questionJsonLd, type JsonLdVisibleComment } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface PageProps {
params: Promise<{ id: string }>;
@@ -50,8 +54,9 @@ interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const { id } = await params;
const sp = await searchParams;
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, postViewerCookieHeader(await cookies()));
@@ -66,6 +71,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const desc = publicBody
? publicBody.slice(0, 160)
: post.access_hint || "部分内容需满足条件后可见";
const page = canonicalPage(sp.page);
return {
title: post.title,
description: desc,
@@ -74,6 +80,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
description: desc,
type: "article",
},
...(await requestCanonical(`/post/${post.id}`, page ? { page } : undefined)),
};
} catch {
return { title: "帖子不存在" };
@@ -505,39 +512,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
}
: null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
// 仅公开片段进 JSON-LD,避免 SEO 泄露隐藏块原文
articleBody: publicBody || undefined,
datePublished: post.created_at,
dateModified: post.updated_at,
author: { "@type": "Person", name: post.user.nickname },
...(allowComments ? { commentCount: post.comment_count } : {}),
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "ReadAction" },
userInteractionCount: post.view_count,
},
...(allowComments &&
acceptedAnswer &&
!acceptedAnswer.deleted &&
!!publicBody && {
acceptedAnswer: {
"@type": "Comment",
text: acceptedAnswer.content,
dateCreated: acceptedAnswer.created_at,
const entityUrl = absoluteCanonical(`/post/${post.id}`);
const pageUrl =
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
entityUrl;
const authorName = post.user.nickname || post.user.username;
const visibleFloors: JsonLdVisibleComment[] = allowComments
? comments
.filter((c) => !c.deleted && (c.content || "").trim())
.map((c) => ({
id: c.id,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}))
: [];
const acceptedForLd: JsonLdVisibleComment | null =
allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
? {
id: acceptedAnswer.id,
content: acceptedAnswer.content,
created_at: acceptedAnswer.created_at,
author: acceptedAnswer.user
? {
"@type": "Person",
id: acceptedAnswer.user.id,
name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
}
: undefined,
url: `#comment-${acceptedAnswer.id}`,
},
}),
};
}
: null;
// 问答帖才是「一问多答」;悬赏/投票仍是讨论帖,不套 QAPage,也不把采纳标成 Question.acceptedAnswer
const jsonLd =
postType === "question"
? questionJsonLd({
title: post.title,
text: publicBody || undefined,
createdAt: post.created_at,
updatedAt: edited ? post.updated_at : undefined,
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
accepted: acceptedForLd,
answers: visibleFloors,
})
: discussionJsonLd({
title: post.title,
text: publicBody || undefined,
createdAt: post.created_at,
updatedAt: edited ? post.updated_at : undefined,
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
commentCount: allowComments ? post.comment_count : undefined,
viewCount: post.view_count,
comments: visibleFloors,
});
// 仅正文 Markdown 标题与 headingRenderer 按序对应(时间线日锚点另有 tl-* id)
let headingIndex = 0;
@@ -557,7 +588,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
{/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */}
<MobilePostBar
postId={id}
@@ -678,8 +709,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</span>
</div>
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
发布于 {formatDateTime(post.created_at)}
{edited && <> · 更新于 {formatDateTime(post.updated_at)}</>}
发布于 <time dateTime={post.created_at}>{formatDateTime(post.created_at)}</time>
{edited && (
<>
{" "}
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
</>
)}
</p>
</div>
</header>