feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown";
import {
Clock,
Heart,
@@ -33,8 +32,13 @@ import PostToc, { type TocItem } from "@/components/PostToc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import MarkdownBody from "@/components/MarkdownBody";
import ContentLockGate from "@/components/ContentLockGate";
import PostAttachments from "@/components/PostAttachments";
import PostInteractPanels from "@/components/PostInteractPanels";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
interface PageProps {
params: Promise<{ id: string }>;
@@ -47,12 +51,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
const desc = post.content_locked
? post.access_hint || "部分内容需满足条件后可见"
: post.content.slice(0, 160);
return {
title: post.title,
description: post.content.slice(0, 160),
description: desc,
openGraph: {
title: post.title,
description: post.content.slice(0, 160),
description: desc,
type: "article",
},
};
@@ -194,7 +201,7 @@ function AuthorPosts({ posts }: { posts: PostListItem[] }) {
<li key={p.id}>
<Link
href={`/post/${p.id}`}
title={p.title}
data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<span
@@ -236,7 +243,7 @@ function RelatedPosts({
<ol>
{posts.map((p, i) => (
<li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span
@@ -333,20 +340,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
const toc = extractToc(post.content);
const readingMinutes = Math.max(1, Math.ceil(post.content.length / 400));
const toc = post.content_locked ? [] : extractToc(post.content);
const readingMinutes = Math.max(
1,
Math.ceil((post.content_locked ? post.title.length : post.content.length) / 400)
);
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const canSeeStatus = !!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
const postType = normalizePostType(post.post_type);
const typeStatus = typeStatusLabel(postType, post.type_status);
const canManageTyped =
!!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
const canSeeStatus = canManageTyped;
const questionSolved = !!post.question?.solved;
const acceptMode =
postType === "question" && !questionSolved && canManageTyped
? ("question" as const)
: post.bounty?.can_accept
? ("bounty" as const)
: null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
articleBody: post.content,
// 锁定正文不进 JSON-LD,避免 SEO 泄露付费/回复可见内容
articleBody: post.content_locked ? undefined : post.content,
datePublished: post.created_at,
dateModified: post.updated_at,
author: { "@type": "Person", name: post.user.nickname },
@@ -409,11 +431,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<div className="panel p-6 sm:p-8">
<header className="mb-7">
<div className="flex items-center gap-2 mb-4 flex-wrap">
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
{post.recommended && <span className="badge-rec">推荐</span>}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{postType !== "discussion" && postType !== "question" && (
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>
)}
{typeStatus && (
<span className="badge-access shrink-0 mt-2 sm:mt-3">{typeStatus}</span>
)}
{post.content_access && post.content_access !== "public" && (
<span className="badge-access shrink-0 mt-2 sm:mt-3">
{contentAccessLabel(post.content_access)}
</span>
)}
{!isPublished && canSeeStatus && post.status === "pending" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
等待审核
@@ -421,19 +467,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
)}
{!isPublished && canSeeStatus && post.status === "rejected" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
未通过审核
</span>
)}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
{post.title}
</h1>
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
<div
@@ -487,11 +527,43 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div>
)}
<div className="prose-content">
<ReactMarkdown components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}>
{post.content}
</ReactMarkdown>
</div>
<PostInteractPanels
postId={post.id}
question={
postType === "question"
? post.question ?? {
solved: false,
accepted_comment_id: 0,
can_accept: canManageTyped,
can_solve: canManageTyped,
can_reopen: false,
}
: post.question
}
poll={post.poll}
bounty={post.bounty}
lottery={post.lottery}
loggedIn={!!me.user}
/>
{post.content_locked ? (
<ContentLockGate
postId={post.id}
hint={post.access_hint}
access={post.content_access}
points={post.access_points}
loggedIn={!!me.user}
/>
) : (
<MarkdownBody
content={post.content}
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
/>
)}
{!post.content_locked && (
<PostAttachments postId={post.id} items={post.attachments || []} />
)}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
<div
@@ -519,12 +591,19 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<CommentSection
postId={id}
boardId={post.board_id}
authorId={post.user.id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||
post.bounty?.accepted_comment_id ||
0
}
/>
) : (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">