diff --git a/backend/service/post.go b/backend/service/post.go index 38a42bb..ccd5aa4 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -43,23 +43,100 @@ func toPostListItems(posts []model.Post) []PostListItem { return items } +// LastReplyUser 最后回复人(仅列表展示所需字段) +type LastReplyUser struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// LastReplyInfo 帖子最后一条已发布评论的摘要 +type LastReplyInfo struct { + User LastReplyUser `json:"user"` + CreatedAt time.Time `json:"created_at"` +} + // PostListItem 帖子列表项(不含正文) type PostListItem struct { - ID uint `json:"id"` - BoardID uint `json:"board_id"` - UserID uint `json:"user_id"` - Title string `json:"title"` - Tags string `json:"tags"` - PostType string `json:"post_type"` - Pinned int `json:"pinned"` - Recommended bool `json:"recommended"` - LikeCount int `json:"like_count"` - ViewCount int `json:"view_count"` - CommentCount int `json:"comment_count"` - Liked bool `json:"liked"` - CreatedAt time.Time `json:"created_at"` - Board model.Board `json:"board"` - User model.User `json:"user"` + ID uint `json:"id"` + BoardID uint `json:"board_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Tags string `json:"tags"` + PostType string `json:"post_type"` + Pinned int `json:"pinned"` + Recommended bool `json:"recommended"` + LikeCount int `json:"like_count"` + ViewCount int `json:"view_count"` + CommentCount int `json:"comment_count"` + Liked bool `json:"liked"` + CreatedAt time.Time `json:"created_at"` + LastReply *LastReplyInfo `json:"last_reply,omitempty"` + Board model.Board `json:"board"` + User model.User `json:"user"` +} + +// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间), +// 单条 SQL 取每帖最新评论,再批量取用户,避免 N+1 +func (s *PostService) fillLastReply(items []PostListItem) { + if len(items) == 0 { + return + } + postIDs := make([]uint, 0, len(items)) + for _, it := range items { + postIDs = append(postIDs, it.ID) + } + + type latestRow struct { + PostID uint + UserID uint + CreatedAt time.Time + } + var rows []latestRow + if err := s.db.Raw(` + SELECT DISTINCT ON (post_id) post_id, user_id, created_at + FROM comments + WHERE post_id IN ? AND status = 'published' AND deleted_at IS NULL + ORDER BY post_id, created_at DESC`, postIDs).Scan(&rows).Error; err != nil || len(rows) == 0 { + return + } + + userIDs := make([]uint, 0, len(rows)) + seen := map[uint]bool{} + for _, r := range rows { + if !seen[r.UserID] { + seen[r.UserID] = true + userIDs = append(userIDs, r.UserID) + } + } + var users []model.User + if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", userIDs).Find(&users).Error; err != nil { + return + } + userMap := make(map[uint]model.User, len(users)) + for _, u := range users { + userMap[u.ID] = u + } + + replyMap := make(map[uint]latestRow, len(rows)) + for _, r := range rows { + replyMap[r.PostID] = r + } + for i := range items { + r, ok := replyMap[items[i].ID] + if !ok { + continue + } + u, ok := userMap[r.UserID] + if !ok { + continue + } + items[i].LastReply = &LastReplyInfo{ + User: LastReplyUser{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}, + CreatedAt: r.CreatedAt, + } + } } // List 获取帖子列表 @@ -104,7 +181,9 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { return nil, 0, err } - return toPostListItems(posts), total, nil + items := toPostListItems(posts) + s.fillLastReply(items) + return items, total, nil } // ListByUser 获取指定用户发布的帖子(公开已发布) @@ -126,7 +205,9 @@ func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, i Preload("Board").Preload("User").Find(&posts).Error; err != nil { return nil, 0, err } - return toPostListItems(posts), total, nil + items := toPostListItems(posts) + s.fillLastReply(items) + return items, total, nil } // GetAuthorID 获取帖子作者 ID(不增加浏览量) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2f08886..f74b736 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -253,6 +253,54 @@ h1, h2, h3, h4 { .btn-sm { padding: 7px 14px; font-size: 13px; } + /* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */ + .j13-like { + border-color: var(--line-2); + color: var(--ink-2); + background: var(--panel); + transition: + border-color 0.15s ease, + color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; + } + .j13-like svg { + transition: transform 0.15s ease; + } + .j13-like:hover:not(:disabled), + .j13-like.is-liked { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); + } + /* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */ + .j13-like:hover:not(:disabled) { + box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent); + } + .j13-like.is-liked:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 17%, transparent); + } + .j13-like:hover:not(:disabled) svg { + transform: scale(1.15); + } + .j13-like:active:not(:disabled) svg { + transform: scale(0.95); + } + .j13-like:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + @media (prefers-reduced-motion: reduce) { + .j13-like, + .j13-like svg { + transition: none; + } + .j13-like:hover:not(:disabled) svg, + .j13-like:active:not(:disabled) svg { + transform: none; + } + } + /* ----- 表单 ----- */ .field-label { display: block; @@ -570,6 +618,26 @@ h1, h2, h3, h4 { border-color: var(--nav-active-line); color: var(--nav-active-ink); } + + /* 帖子详情侧栏内的滚动承载区(目录 / 作者帖子列表): + 配合栏容器的 max-height 独立滚动,细滚动条、不产生滚动链 */ + .j13-rail-scroll { + min-height: 3.5rem; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + } + .j13-rail-scroll::-webkit-scrollbar { + width: 6px; + } + .j13-rail-scroll::-webkit-scrollbar-thumb { + background: var(--line-2); + border-radius: 99px; + border: none; + } + .j13-rail-scroll::-webkit-scrollbar-track { + background: transparent; + } } /* ============================================================ diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 84a00cb..2eaae69 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -2,15 +2,34 @@ import type { Metadata } from "next"; import Link from "next/link"; import { cookies } from "next/headers"; import ReactMarkdown from "react-markdown"; -import { fetchPostDetail, fetchComments } from "@/lib/api"; +import { + Clock, + Heart, + MessageCircle, + Flame, + Layers, + User, + PenLine, + ArrowRight, + ChevronRight, +} from "lucide-react"; +import { + fetchPostDetail, + fetchComments, + fetchPosts, + fetchUserProfile, + type UserProfile, + type PostListItem, + type Board, +} from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import CommentSection from "@/components/CommentSection"; import PostActions from "@/components/PostActions"; import LikeButton from "@/components/LikeButton"; import ReadingProgress from "@/components/ReadingProgress"; +import PostToc, { type TocItem } from "@/components/PostToc"; import Avatar from "@/components/Avatar"; import { IconComment, IconEye } from "@/components/Icons"; -import { ChevronRight } from "lucide-react"; interface PageProps { params: Promise<{ id: string }>; @@ -34,11 +53,285 @@ export async function generateMetadata({ params }: PageProps): Promise } } +/** + * 从 Markdown 提取二三级标题生成目录项,id 按出现顺序编号; + * 代码围栏内的 # 不计入 + */ +function extractToc(content: string): TocItem[] { + const items: TocItem[] = []; + let inCode = false; + for (const raw of content.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("```")) { + inCode = !inCode; + continue; + } + if (inCode) continue; + const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line); + if (m) items.push({ level: m[1].length as 2 | 3, text: m[2], id: `h-${items.length}` }); + } + return items; +} + +function formatYearMonth(dateStr: string) { + const d = new Date(dateStr); + return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function formatYMD(dateStr: string) { + const d = new Date(dateStr); + return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String( + d.getDate() + ).padStart(2, "0")}`; +} + +function formatDateTime(dateStr: string) { + return new Date(dateStr).toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +/* ============================================================ + 左栏模块(仅 xl 三栏时展示) + ============================================================ */ + +function BoardCard({ board }: { board: Board }) { + const colorIdx = Math.max(0, board.color_index ?? 0) % 8; + return ( +
+

+ 所属板块 +

+ + + {board.name.slice(0, 1)} + + + {board.name} + + + {board.description && ( +

+ {board.description} +

+ )} + + 进入板块 + +
+ ); +} + +function AuthorRailCard({ profile }: { profile: UserProfile }) { + const u = profile.user; + return ( +
+

+ 作者 +

+
+ + + +
+ + {u.nickname} + + {u.role === "admin" && ( + + 管理员 + + )} +
+
+

{formatYearMonth(u.created_at)} 加入

+
+
+ {profile.stats.post_count} + 发帖 +
+
+ {profile.stats.comment_count} + 评论 +
+
+ + 访问主页 + +
+ ); +} + +function AuthorPosts({ posts }: { posts: PostListItem[] }) { + if (posts.length === 0) return null; + return ( +
+

+ TA 的其他帖子 +

+ +
+ ); +} + +/* ============================================================ + 右栏模块(lg 起展示;移动端下沉到评论之后) + ============================================================ */ + +function RelatedPosts({ + posts, + boardId, + uid = "related", +}: { + posts: PostListItem[]; + boardId: number; + // 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本 + uid?: string; +}) { + if (posts.length === 0) return null; + const titleId = `${uid}-title`; + return ( +
+

+ 同板块热议 +

+
    + {posts.map((p, i) => ( +
  1. + + {i + 1} + + + {p.title} + + + + {p.comment_count} + + + {p.like_count} + + + + +
  2. + ))} +
+ + 查看全部 + +
+ ); +} + +/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */ +function AuthorCardStacked({ profile }: { profile: UserProfile }) { + return ( +
+

+ 关于作者 +

+
+ + + +
+
+ + {profile.user.nickname} + + {profile.user.role === "admin" && ( + + 管理员 + + )} +
+

+ {formatYearMonth(profile.user.created_at)} 加入 · 发帖 {profile.stats.post_count} · 评论{" "} + {profile.stats.comment_count} +

+
+ + 访问主页 + +
+
+ ); +} + export default async function PostDetailPage({ params }: PageProps) { const { id } = await params; const cookie = authCookieHeader(await cookies()); const { post } = await fetchPostDetail(id, cookie); - const { comments } = await fetchComments(id); + + // 评论 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文 + const [commentsData, profile, relatedData] = await Promise.all([ + fetchComments(id), + fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), + fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null), + ]); + const comments = commentsData.comments; + 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 edited = + new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000; const jsonLd = { "@context": "https://schema.org", @@ -56,84 +349,162 @@ export default async function PostDetailPage({ params }: PageProps) { }, }; + // 目录项与 Markdown 标题按渲染顺序一一对应(extractToc 顺序 == 文档顺序) + let headingIndex = 0; + const headingRenderer = (Tag: "h2" | "h3") => + function Heading({ children }: { children?: React.ReactNode }) { + const item = toc[headingIndex++]; + return ( + + {children} + + ); + }; + + const tags = post.tags + ? [...new Set(post.tags.split(",").map((t) => t.trim()).filter(Boolean))] + : []; + return ( -
+ <>