feat: 新增帖子最后回复展示、文章目录与三栏布局,优化点赞按钮与导航栏
这是一次包含多个功能升级的提交: 1. 后端新增批量查询帖子最后回复的逻辑,在前端帖子列表项中展示最后回复信息 2. 重构点赞按钮,新增文章样式变体并统一样式逻辑 3. 重写工作台导航栏,精简默认展示的菜单选项 4. 新增文章目录组件,为帖子详情页添加文章目录导航 5. 重构帖子详情页,实现三栏响应式布局,新增板块卡片、作者信息栏与同板块热议推荐 6. 重构帖子列表行组件,优化排版与信息展示逻辑 7. 重构帖子管理操作组件,使用下拉菜单整合所有管理功能 8. 新增全局通用的滚动容器与点赞按钮样式
This commit is contained in:
@@ -43,23 +43,100 @@ func toPostListItems(posts []model.Post) []PostListItem {
|
|||||||
return items
|
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 帖子列表项(不含正文)
|
// PostListItem 帖子列表项(不含正文)
|
||||||
type PostListItem struct {
|
type PostListItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
BoardID uint `json:"board_id"`
|
BoardID uint `json:"board_id"`
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Tags string `json:"tags"`
|
Tags string `json:"tags"`
|
||||||
PostType string `json:"post_type"`
|
PostType string `json:"post_type"`
|
||||||
Pinned int `json:"pinned"`
|
Pinned int `json:"pinned"`
|
||||||
Recommended bool `json:"recommended"`
|
Recommended bool `json:"recommended"`
|
||||||
LikeCount int `json:"like_count"`
|
LikeCount int `json:"like_count"`
|
||||||
ViewCount int `json:"view_count"`
|
ViewCount int `json:"view_count"`
|
||||||
CommentCount int `json:"comment_count"`
|
CommentCount int `json:"comment_count"`
|
||||||
Liked bool `json:"liked"`
|
Liked bool `json:"liked"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Board model.Board `json:"board"`
|
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||||||
User model.User `json:"user"`
|
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 获取帖子列表
|
// List 获取帖子列表
|
||||||
@@ -104,7 +181,9 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return toPostListItems(posts), total, nil
|
items := toPostListItems(posts)
|
||||||
|
s.fillLastReply(items)
|
||||||
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByUser 获取指定用户发布的帖子(公开已发布)
|
// 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 {
|
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return toPostListItems(posts), total, nil
|
items := toPostListItems(posts)
|
||||||
|
s.fillLastReply(items)
|
||||||
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||||||
|
|||||||
@@ -253,6 +253,54 @@ h1, h2, h3, h4 {
|
|||||||
|
|
||||||
.btn-sm { padding: 7px 14px; font-size: 13px; }
|
.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 {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -570,6 +618,26 @@ h1, h2, h3, h4 {
|
|||||||
border-color: var(--nav-active-line);
|
border-color: var(--nav-active-line);
|
||||||
color: var(--nav-active-ink);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
@@ -2,15 +2,34 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import ReactMarkdown from "react-markdown";
|
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 { authCookieHeader } from "@/lib/cookies";
|
||||||
import CommentSection from "@/components/CommentSection";
|
import CommentSection from "@/components/CommentSection";
|
||||||
import PostActions from "@/components/PostActions";
|
import PostActions from "@/components/PostActions";
|
||||||
import LikeButton from "@/components/LikeButton";
|
import LikeButton from "@/components/LikeButton";
|
||||||
import ReadingProgress from "@/components/ReadingProgress";
|
import ReadingProgress from "@/components/ReadingProgress";
|
||||||
|
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import { IconComment, IconEye } from "@/components/Icons";
|
import { IconComment, IconEye } from "@/components/Icons";
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -34,11 +53,285 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 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 (
|
||||||
|
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||||
|
<p className="j13-module-title mb-3" id="board-card-title">
|
||||||
|
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
||||||
|
</p>
|
||||||
|
<Link href={`/board/${board.id}`} className="flex items-center gap-3 group">
|
||||||
|
<span
|
||||||
|
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
|
||||||
|
>
|
||||||
|
{board.name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-bold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{board.description && (
|
||||||
|
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{board.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/board/${board.id}`}
|
||||||
|
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
||||||
|
>
|
||||||
|
进入板块 <ArrowRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorRailCard({ profile }: { profile: UserProfile }) {
|
||||||
|
const u = profile.user;
|
||||||
|
return (
|
||||||
|
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
|
||||||
|
<p className="j13-module-title mb-3" id="author-card-title">
|
||||||
|
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||||
|
<Avatar name={u.nickname || u.username} size={48} />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/u/${u.id}`}
|
||||||
|
className="block font-bold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{u.nickname}
|
||||||
|
</Link>
|
||||||
|
{u.role === "admin" && (
|
||||||
|
<span
|
||||||
|
className="chip mt-1.5"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
管理员
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="meta mt-3">{formatYearMonth(u.created_at)} 加入</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2.5">
|
||||||
|
<div className="j13-stat">
|
||||||
|
<span className="j13-stat-num">{profile.stats.post_count}</span>
|
||||||
|
<span className="j13-stat-lab">发帖</span>
|
||||||
|
</div>
|
||||||
|
<div className="j13-stat">
|
||||||
|
<span className="j13-stat-num">{profile.stats.comment_count}</span>
|
||||||
|
<span className="j13-stat-lab">评论</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3.5">
|
||||||
|
访问主页
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||||
|
if (posts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
||||||
|
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
||||||
|
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
|
||||||
|
</p>
|
||||||
|
<ul className="j13-rail-scroll">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
href={`/post/${p.id}`}
|
||||||
|
title={p.title}
|
||||||
|
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block text-[13px] font-semibold leading-snug line-clamp-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
右栏模块(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 (
|
||||||
|
<section className="j13-module shrink-0" aria-labelledby={titleId}>
|
||||||
|
<p className="j13-module-title mb-1.5" id={titleId}>
|
||||||
|
<Flame size={15} style={{ color: "var(--clay)" }} /> 同板块热议
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
{posts.map((p, i) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
|
||||||
|
<span className="j13-rank-no">{i + 1}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className="block text-[13px] font-semibold leading-snug line-clamp-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MessageCircle size={11.5} /> {p.comment_count}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Heart size={11.5} /> {p.like_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<Link
|
||||||
|
href={`/board/${boardId}`}
|
||||||
|
className="j13-raillink justify-center mt-1.5 text-[12.5px] font-semibold"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
查看全部 <ChevronRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
|
||||||
|
function AuthorCardStacked({ profile }: { profile: UserProfile }) {
|
||||||
|
return (
|
||||||
|
<div className="panel p-5 sm:p-6">
|
||||||
|
<p
|
||||||
|
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-3.5"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
关于作者
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<Link href={`/u/${profile.user.id}`} className="shrink-0" aria-label={profile.user.nickname}>
|
||||||
|
<Avatar name={profile.user.nickname || profile.user.username} size={52} />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Link
|
||||||
|
href={`/u/${profile.user.id}`}
|
||||||
|
className="font-bold text-[15px] hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{profile.user.nickname}
|
||||||
|
</Link>
|
||||||
|
{profile.user.role === "admin" && (
|
||||||
|
<span
|
||||||
|
className="chip"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)", borderColor: "transparent" }}
|
||||||
|
>
|
||||||
|
管理员
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="meta mt-1">
|
||||||
|
{formatYearMonth(profile.user.created_at)} 加入 · 发帖 {profile.stats.post_count} · 评论{" "}
|
||||||
|
{profile.stats.comment_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/u/${profile.user.id}`} className="btn btn-line btn-sm shrink-0">
|
||||||
|
访问主页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PostDetailPage({ params }: PageProps) {
|
export default async function PostDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const { post } = await fetchPostDetail(id, cookie);
|
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 = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@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 (
|
||||||
|
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tags = post.tags
|
||||||
|
? [...new Set(post.tags.split(",").map((t) => t.trim()).filter(Boolean))]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="max-w-3xl mx-auto">
|
<>
|
||||||
<ReadingProgress />
|
<ReadingProgress />
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
<div className="grid grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_260px] xl:grid-cols-[240px_minmax(0,1fr)_280px] gap-x-6 xl:gap-x-7">
|
||||||
<ChevronRight size={13} />
|
{/* 左栏:板块 / 作者 / 作者其他帖 */}
|
||||||
<Link href={`/board/${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
|
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
||||||
{post.board.name}
|
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
||||||
</Link>
|
<aside
|
||||||
</nav>
|
className="hidden xl:flex w-[240px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||||
|
aria-label="帖子导航"
|
||||||
|
>
|
||||||
|
<BoardCard board={post.board} />
|
||||||
|
{profile && <AuthorRailCard profile={profile} />}
|
||||||
|
{profile && <AuthorPosts posts={authorPosts} />}
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="panel p-6 sm:p-9">
|
{/* 中栏:正文主体 */}
|
||||||
<header className="mb-7">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
<article className="w-full max-w-[760px] mx-auto">
|
||||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
||||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||||
<Link href={`/board/${post.board.id}`} className="chip">
|
<ChevronRight size={13} />
|
||||||
{post.board.name}
|
<Link href={`/board/${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
|
||||||
</Link>
|
{post.board.name}
|
||||||
</div>
|
|
||||||
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
|
||||||
{post.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between gap-4 flex-wrap pb-5"
|
|
||||||
style={{ borderBottom: "1px solid var(--line)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<Link
|
|
||||||
href={`/u/${post.user.id}`}
|
|
||||||
className="flex items-center gap-2.5 group"
|
|
||||||
>
|
|
||||||
<Avatar name={post.user.nickname || post.user.username} size={40} />
|
|
||||||
<span className="text-sm font-semibold group-hover:text-[var(--accent)] transition-colors" style={{ color: "var(--ink)" }}>
|
|
||||||
{post.user.nickname}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className="meta">{new Date(post.created_at).toLocaleString("zh-CN")}</span>
|
</nav>
|
||||||
<span className="inline-flex items-center gap-1.5 meta">
|
|
||||||
<IconEye size={14} /> {post.view_count}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 meta">
|
|
||||||
<IconComment size={14} /> {post.comment_count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LikeButton postId={id} liked={post.liked} likeCount={post.like_count} />
|
|
||||||
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{post.tags && (
|
<div className="panel p-6 sm:p-8">
|
||||||
<div className="flex flex-wrap gap-2 mb-7">
|
<header className="mb-7">
|
||||||
{[
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
...new Set(
|
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||||
post.tags
|
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||||
.split(",")
|
<Link href={`/board/${post.board.id}`} className="chip">
|
||||||
.map((t) => t.trim())
|
{post.board.name}
|
||||||
.filter(Boolean)
|
</Link>
|
||||||
),
|
</div>
|
||||||
].map((t, i) => (
|
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||||
<span key={`${t}-${i}`} className="chip">
|
{post.title}
|
||||||
# {t}
|
</h1>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="prose-content">
|
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
|
||||||
<ReactMarkdown>{post.content}</ReactMarkdown>
|
<div
|
||||||
|
className="flex items-center justify-between gap-4 flex-wrap pb-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}>
|
||||||
|
<Avatar name={post.user.nickname || post.user.username} size={40} />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/u/${post.user.id}`}
|
||||||
|
className="min-w-0 truncate text-[15px] font-bold hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{post.user.nickname}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
|
||||||
|
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 元信息条:阅读数据居左、发布/更新时间居右(窄屏自动折行);
|
||||||
|
原右栏“帖子数据”卡已移除,点赞数由点赞按钮承载,此处不重复 */}
|
||||||
|
<div className="mt-3.5 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center gap-4 meta">
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label={`浏览 ${post.view_count} 次`}>
|
||||||
|
<IconEye size={14} /> {post.view_count} 浏览
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
|
||||||
|
<IconComment size={14} /> {post.comment_count} 评论
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
|
||||||
|
<Clock size={14} /> 约 {readingMinutes} 分钟读完
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
|
||||||
|
发布于 {formatDateTime(post.created_at)}
|
||||||
|
{edited && <> · 更新于 {formatDateTime(post.updated_at)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-7">
|
||||||
|
{tags.map((t, i) => (
|
||||||
|
<span key={`${t}-${i}`} className="chip">
|
||||||
|
# {t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose-content">
|
||||||
|
<ReactMarkdown components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}>
|
||||||
|
{post.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
|
||||||
|
<div
|
||||||
|
className="mt-9 pt-7 flex justify-center"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<LikeButton
|
||||||
|
postId={id}
|
||||||
|
liked={post.liked}
|
||||||
|
likeCount={post.like_count}
|
||||||
|
variant="article"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 窄屏作者卡(xl 起由左栏承载,避免重复展示) */}
|
||||||
|
{profile && (
|
||||||
|
<div className="xl:hidden mt-6">
|
||||||
|
<AuthorCardStacked profile={profile} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CommentSection postId={id} comments={comments} />
|
||||||
|
|
||||||
|
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
||||||
|
<div className="lg:hidden mt-6">
|
||||||
|
<RelatedPosts posts={related} boardId={post.board_id} uid="related-inline" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommentSection postId={id} comments={comments} />
|
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
||||||
</article>
|
<aside
|
||||||
|
className="hidden lg:flex w-[260px] xl:w-[280px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||||
|
aria-label="阅读辅助"
|
||||||
|
>
|
||||||
|
{toc.length > 0 && <PostToc items={toc} />}
|
||||||
|
<RelatedPosts posts={related} boardId={post.board_id} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,33 +142,37 @@ export default function Header({
|
|||||||
WebkitBackdropFilter: "blur(14px)",
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] flex items-center justify-between gap-4">
|
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[68px] flex items-center gap-4 lg:gap-7">
|
||||||
{/* 品牌 */}
|
{/* 左组:品牌 + 主导航 */}
|
||||||
<Link href="/" className="flex items-center gap-2.5 shrink-0 group">
|
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||||||
<span
|
<Link href="/" className="flex items-center gap-2.5 group">
|
||||||
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
<span
|
||||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||||
>
|
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
>
|
||||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
</svg>
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
</span>
|
</svg>
|
||||||
<span className="leading-none">
|
|
||||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
|
||||||
姜十三
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
|
<span className="leading-none">
|
||||||
J13 社区
|
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||||
|
姜十三
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||||
|
J13 社区
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</Link>
|
||||||
</Link>
|
<nav className="hidden lg:flex items-center gap-1 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||||
|
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||||
|
首页
|
||||||
|
</NavPill>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 桌面端 */}
|
{/* 中组:搜索居中 */}
|
||||||
<nav className="hidden lg:flex items-center gap-1">
|
<form onSubmit={handleSearch} className="hidden lg:flex flex-1 justify-center">
|
||||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
<div className="relative w-full max-w-[380px] focus-within:max-w-[440px] transition-[max-width] duration-200">
|
||||||
首页
|
|
||||||
</NavPill>
|
|
||||||
<form onSubmit={handleSearch} className="relative ml-2">
|
|
||||||
<Search
|
<Search
|
||||||
size={15}
|
size={15}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||||||
@@ -182,7 +186,7 @@ export default function Header({
|
|||||||
onKeyDown={onSearchKeyDown}
|
onKeyDown={onSearchKeyDown}
|
||||||
placeholder="搜索帖子..."
|
placeholder="搜索帖子..."
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
className="w-48 focus:w-64 transition-[width] duration-200 pl-10 pr-10 py-2.5 text-sm rounded-full"
|
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--panel)",
|
background: "var(--panel)",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
@@ -195,8 +199,8 @@ export default function Header({
|
|||||||
>
|
>
|
||||||
/
|
/
|
||||||
</kbd>
|
</kbd>
|
||||||
</form>
|
</div>
|
||||||
</nav>
|
</form>
|
||||||
|
|
||||||
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||||
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
|
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface LikeButtonProps {
|
|||||||
postId: string;
|
postId: string;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
variant?: "default" | "compact";
|
variant?: "default" | "compact" | "article";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LikeButton({
|
export default function LikeButton({
|
||||||
@@ -60,10 +60,13 @@ export default function LikeButton({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const compact =
|
const sizeCls =
|
||||||
variant === "compact"
|
variant === "compact"
|
||||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||||
: "px-4 py-2 gap-1.5 text-sm";
|
: variant === "article"
|
||||||
|
? "px-7 py-3 gap-2 text-[15px] min-w-[148px]"
|
||||||
|
: "px-4 py-2 gap-1.5 text-sm";
|
||||||
|
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -71,17 +74,13 @@ export default function LikeButton({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
title={liked ? "取消点赞" : "点赞"}
|
title={liked ? "取消点赞" : "点赞"}
|
||||||
aria-pressed={liked}
|
aria-pressed={liked}
|
||||||
className={`inline-flex items-center font-semibold tabular-nums rounded-full border transition-colors duration-150 hover:border-[var(--accent)] disabled:opacity-50 ${compact}`}
|
className={`j13-like inline-flex items-center justify-center font-semibold tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||||
style={{
|
|
||||||
borderColor: liked ? "var(--accent)" : "var(--line-2)",
|
|
||||||
color: liked ? "var(--accent)" : "var(--ink-2)",
|
|
||||||
background: liked ? "var(--accent-soft)" : "var(--panel)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
||||||
<IconHeart size={variant === "compact" ? 13 : 15} filled={liked} />
|
<IconHeart size={iconSize} filled={liked} />
|
||||||
</span>
|
</span>
|
||||||
<span>{count}</span>
|
<span>{count}</span>
|
||||||
|
{variant === "article" && <span>{liked ? "已赞" : "点赞"}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Pencil, Trash2, Pin, Star } from "lucide-react";
|
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check } from "lucide-react";
|
||||||
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
|
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -17,10 +17,12 @@ interface PostActionsProps {
|
|||||||
export default function PostActions({ postId, authorId, pinned, recommended }: PostActionsProps) {
|
export default function PostActions({ postId, authorId, pinned, recommended }: PostActionsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [isPinned, setIsPinned] = useState(pinned > 0);
|
const [isPinned, setIsPinned] = useState(pinned > 0);
|
||||||
const [isRecommended, setIsRecommended] = useState(recommended);
|
const [isRecommended, setIsRecommended] = useState(recommended);
|
||||||
const [acting, setActing] = useState(false);
|
const [acting, setActing] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiMe()
|
apiMe()
|
||||||
@@ -28,11 +30,28 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
|||||||
.catch(() => setUser(null));
|
.catch(() => setUser(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 点击外部 / Esc 关闭菜单(与通知铃铛同一交互模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const canManage = user && (user.id === authorId || user.role === "admin");
|
const canManage = user && (user.id === authorId || user.role === "admin");
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
if (!canManage) return null;
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
setOpen(false);
|
||||||
if (!confirm("确定要删除这篇帖子吗?此操作不可恢复。")) return;
|
if (!confirm("确定要删除这篇帖子吗?此操作不可恢复。")) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +71,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
|||||||
|
|
||||||
const handlePin = async () => {
|
const handlePin = async () => {
|
||||||
setActing(true);
|
setActing(true);
|
||||||
|
setOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await apiTogglePin(postId);
|
const res = await apiTogglePin(postId);
|
||||||
setIsPinned(res.pinned > 0);
|
setIsPinned(res.pinned > 0);
|
||||||
@@ -66,6 +86,7 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
|||||||
|
|
||||||
const handleRecommend = async () => {
|
const handleRecommend = async () => {
|
||||||
setActing(true);
|
setActing(true);
|
||||||
|
setOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await apiToggleRecommend(postId);
|
const res = await apiToggleRecommend(postId);
|
||||||
setIsRecommended(res.recommended);
|
setIsRecommended(res.recommended);
|
||||||
@@ -78,38 +99,89 @@ export default function PostActions({ postId, authorId, pinned, recommended }: P
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!canManage) return null;
|
||||||
|
|
||||||
|
const itemCls =
|
||||||
|
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="relative" ref={menuRef}>
|
||||||
{isAdmin && (
|
<button
|
||||||
<>
|
type="button"
|
||||||
<button
|
onClick={() => setOpen((v) => !v)}
|
||||||
onClick={handlePin}
|
disabled={acting || deleting}
|
||||||
disabled={acting}
|
aria-haspopup="menu"
|
||||||
className="btn btn-line btn-sm"
|
aria-expanded={open}
|
||||||
style={isPinned ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
|
className="btn btn-line btn-sm"
|
||||||
>
|
style={open ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
|
||||||
<Pin size={13} /> {isPinned ? "取消置顶" : "置顶"}
|
>
|
||||||
</button>
|
<SlidersHorizontal size={13} /> 管理
|
||||||
<button
|
<ChevronDown
|
||||||
onClick={handleRecommend}
|
size={13}
|
||||||
disabled={acting}
|
style={{ transition: "transform .15s", transform: open ? "rotate(180deg)" : "none" }}
|
||||||
className="btn btn-line btn-sm"
|
/>
|
||||||
style={
|
|
||||||
isRecommended
|
|
||||||
? { borderColor: "var(--gold)", color: "var(--gold)", background: "var(--gold-soft)" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Star size={13} /> {isRecommended ? "取消推荐" : "推荐"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link href={`/post/${postId}/edit`} className="btn btn-line btn-sm">
|
|
||||||
<Pencil size={13} /> 编辑
|
|
||||||
</Link>
|
|
||||||
<button onClick={handleDelete} disabled={deleting} className="btn btn-danger btn-sm">
|
|
||||||
<Trash2 size={13} /> {deleting ? "删除中..." : "删除"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 mt-2 w-44 overflow-hidden z-50 j13-toast-in"
|
||||||
|
style={{
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={handlePin}
|
||||||
|
className={itemCls}
|
||||||
|
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Pin size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
|
||||||
|
{isPinned && <Check size={14} className="shrink-0" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={handleRecommend}
|
||||||
|
className={itemCls}
|
||||||
|
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Star size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
|
||||||
|
{isRecommended && <Check size={14} className="shrink-0" />}
|
||||||
|
</button>
|
||||||
|
<div style={{ height: 1, background: "var(--line)" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/post/${postId}/edit`}
|
||||||
|
role="menuitem"
|
||||||
|
className={`${itemCls} items-center`}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Pencil size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">编辑帖子</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className={`${itemCls} items-center`}
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">{deleting ? "删除中..." : "删除帖子"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import type { PostListItem } from "@/lib/api";
|
import type { PostListItem } from "@/lib/api";
|
||||||
import LikeButton from "./LikeButton";
|
import LikeButton from "./LikeButton";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { IconComment, IconEye } from "./Icons";
|
import { IconComment, IconEye } from "./Icons";
|
||||||
|
|
||||||
function formatTime(dateStr: string) {
|
/** 发帖/回复时间统一为相对时间:刚刚 / N分钟前 / N小时前 / N天前 / N个月前 / N年前 */
|
||||||
const d = new Date(dateStr);
|
function formatRelative(dateStr: string) {
|
||||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
|
const t = new Date(dateStr).getTime();
|
||||||
d.getDate()
|
const diff = Date.now() - t;
|
||||||
).padStart(2, "0")}`;
|
const min = 60_000,
|
||||||
|
hour = 60 * min,
|
||||||
|
day = 24 * hour;
|
||||||
|
if (diff < min) return "刚刚";
|
||||||
|
if (diff < hour) return `${Math.floor(diff / min)}分钟前`;
|
||||||
|
if (diff < day) return `${Math.floor(diff / hour)}小时前`;
|
||||||
|
if (diff < 30 * day) return `${Math.floor(diff / day)}天前`;
|
||||||
|
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`;
|
||||||
|
return `${Math.floor(diff / (365 * day))}年前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 帖子列表行:头像 + 标题 + 作者元信息 + 统计 + 点赞
|
* 帖子列表行(参考社区信息流样式):
|
||||||
* 用于首页/板块页,以细线串联,悬浮浅色反馈
|
* 大头像 + 加粗标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+
|
||||||
|
* 右侧评论数等统计垂直居中。用于首页/板块页/搜索结果。
|
||||||
*/
|
*/
|
||||||
export default function PostRow({
|
export default function PostRow({
|
||||||
post,
|
post,
|
||||||
@@ -30,13 +40,16 @@ export default function PostRow({
|
|||||||
showViews?: boolean;
|
showViews?: boolean;
|
||||||
showLike?: boolean;
|
showLike?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const lastReply = post.last_reply;
|
||||||
|
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative flex items-center gap-3 sm:gap-4 px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up"
|
className="group relative flex items-center gap-3.5 sm:gap-4 px-4 sm:px-5 border-t transition-colors hover:bg-[var(--panel-2)] j13-fade-up"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "var(--line)",
|
borderColor: "var(--line)",
|
||||||
paddingTop: compact ? 13 : 16,
|
paddingTop: compact ? 15 : 18,
|
||||||
paddingBottom: compact ? 13 : 16,
|
paddingBottom: compact ? 15 : 18,
|
||||||
animationDelay: `${Math.min(index, 9) * 35}ms`,
|
animationDelay: `${Math.min(index, 9) * 35}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -46,57 +59,74 @@ export default function PostRow({
|
|||||||
aria-label={post.user.nickname}
|
aria-label={post.user.nickname}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<Avatar name={post.user.nickname || post.user.username} size={compact ? 38 : 42} />
|
<Avatar name={post.user.nickname || post.user.username} size={compact ? 44 : 46} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{(post.pinned > 0 || post.recommended) && (
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
|
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
|
||||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
|
||||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
<Link
|
||||||
</div>
|
href={`/post/${post.id}`}
|
||||||
)}
|
className="stretch-link min-w-0 flex-1"
|
||||||
<Link href={`/post/${post.id}`} className="stretch-link" aria-label={post.title}>
|
aria-label={post.title}
|
||||||
<h3
|
|
||||||
className="font-semibold text-[15px] sm:text-[16px] leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
>
|
||||||
{post.title}
|
<h3
|
||||||
</h3>
|
className="font-bold text-[16px] sm:text-[17px] leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||||
</Link>
|
style={{ color: "var(--ink)" }}
|
||||||
<div className="meta relative z-[2] flex items-center gap-2.5 mt-1.5 flex-wrap">
|
>
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="meta relative z-[2] flex items-center gap-x-2 gap-y-1 mt-1.5 flex-wrap text-[13px]">
|
||||||
|
{boardHref && (
|
||||||
|
<Link
|
||||||
|
href={`/board/${post.board.id}`}
|
||||||
|
className={`av-${post.board.id % 8} rounded-md px-1.5 py-px text-[11px] font-bold leading-[1.5] transition-opacity hover:opacity-85`}
|
||||||
|
>
|
||||||
|
{post.board.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${post.user.id}`}
|
href={`/u/${post.user.id}`}
|
||||||
className="hover:text-[var(--accent)] transition-colors"
|
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
>
|
>
|
||||||
{post.user.nickname}
|
{post.user.nickname}
|
||||||
</Link>
|
</Link>
|
||||||
{boardHref && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden>·</span>
|
|
||||||
<Link
|
|
||||||
href={`/board/${post.board.id}`}
|
|
||||||
className="hover:text-[var(--accent)] transition-colors"
|
|
||||||
>
|
|
||||||
{post.board.name}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span>{formatTime(post.created_at)}</span>
|
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
|
||||||
|
{replied && (
|
||||||
|
<span className="inline-flex items-center gap-1 min-w-0">
|
||||||
|
<ArrowLeft size={13} className="shrink-0" aria-hidden />
|
||||||
|
<Link
|
||||||
|
href={`/u/${lastReply!.user.id}`}
|
||||||
|
className="font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]"
|
||||||
|
>
|
||||||
|
{lastReply!.user.nickname || lastReply!.user.username}
|
||||||
|
</Link>
|
||||||
|
<span className="tabular-nums shrink-0">{formatRelative(lastReply!.created_at)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="above-link flex items-center gap-2.5 sm:gap-3.5 shrink-0">
|
<div className="above-link flex items-center gap-3 sm:gap-4 shrink-0">
|
||||||
<span
|
<Link
|
||||||
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} items-center gap-1.5 meta`}
|
href={`/post/${post.id}`}
|
||||||
|
tabIndex={-1}
|
||||||
aria-label={`${post.comment_count} 条评论`}
|
aria-label={`${post.comment_count} 条评论`}
|
||||||
|
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}
|
||||||
>
|
>
|
||||||
<IconComment size={15} />
|
<IconComment size={17} />
|
||||||
{post.comment_count}
|
{post.comment_count}
|
||||||
</span>
|
</Link>
|
||||||
{showViews && (
|
{showViews && (
|
||||||
<span className="hidden md:inline-flex items-center gap-1.5 meta" aria-label={`${post.view_count} 次浏览`}>
|
<span
|
||||||
|
className="hidden md:inline-flex items-center gap-1.5 meta tabular-nums"
|
||||||
|
aria-label={`${post.view_count} 次浏览`}
|
||||||
|
>
|
||||||
<IconEye size={15} />
|
<IconEye size={15} />
|
||||||
{post.view_count}
|
{post.view_count}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
93
frontend/components/PostToc.tsx
Normal file
93
frontend/components/PostToc.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface TocItem {
|
||||||
|
level: 2 | 3;
|
||||||
|
text: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章目录树:右栏 flex 限高列中的可收缩模块。
|
||||||
|
* - 标题常驻,目录列表在超高时于面板内部滚动(细滚动条,不顶出侧栏);
|
||||||
|
* - IntersectionObserver 高亮当前小节;
|
||||||
|
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
|
||||||
|
*/
|
||||||
|
export default function PostToc({ items }: { items: TocItem[] }) {
|
||||||
|
const [active, setActive] = useState<string>(items[0]?.id ?? "");
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
|
||||||
|
const skipFollow = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const heads = items
|
||||||
|
.map((i) => document.getElementById(i.id))
|
||||||
|
.filter((el): el is HTMLElement => Boolean(el));
|
||||||
|
if (heads.length === 0) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) if (e.isIntersecting) setActive(e.target.id);
|
||||||
|
},
|
||||||
|
// 顶部让出固定头部高度,底部提前切换到下一节
|
||||||
|
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 }
|
||||||
|
);
|
||||||
|
heads.forEach((h) => obs.observe(h));
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipFollow.current) {
|
||||||
|
skipFollow.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
|
||||||
|
<p
|
||||||
|
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
目录
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
className="j13-rail-scroll space-y-0.5 pr-0.5"
|
||||||
|
style={{ borderLeft: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{items.map((it) => {
|
||||||
|
const isActive = it.id === active;
|
||||||
|
return (
|
||||||
|
<li key={it.id}>
|
||||||
|
<a
|
||||||
|
data-toc-id={it.id}
|
||||||
|
href={`#${it.id}`}
|
||||||
|
onClick={jump(it.id)}
|
||||||
|
className="block py-1.5 pr-1 text-[12.5px] leading-snug transition-colors"
|
||||||
|
style={{
|
||||||
|
paddingLeft: it.level === 3 ? 20 : 12,
|
||||||
|
marginLeft: -1,
|
||||||
|
borderLeft: `2px solid ${isActive ? "var(--accent)" : "transparent"}`,
|
||||||
|
color: isActive ? "var(--accent)" : "var(--ink-2)",
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { PenSquare, Home, Bell, Settings, LogIn, UserPlus } from "lucide-react";
|
import { PenSquare, Home } from "lucide-react";
|
||||||
import type { BoardWithCount, User } from "@/lib/api";
|
import type { BoardWithCount, User } from "@/lib/api";
|
||||||
|
|
||||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||||
@@ -54,28 +54,6 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ borderTop: "1px solid var(--line)" }} className="pt-3 space-y-0.5">
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<Link href="/notifications" className="j13-raillink">
|
|
||||||
<Bell size={16} className="shrink-0" /> 通知中心
|
|
||||||
</Link>
|
|
||||||
<Link href="/settings" className="j13-raillink">
|
|
||||||
<Settings size={16} className="shrink-0" /> 账号设置
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link href="/login" className="j13-raillink">
|
|
||||||
<LogIn size={16} className="shrink-0" /> 登录
|
|
||||||
</Link>
|
|
||||||
<Link href="/register" className="j13-raillink">
|
|
||||||
<UserPlus size={16} className="shrink-0" /> 注册账号
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export interface PostListItem {
|
|||||||
comment_count: number;
|
comment_count: number;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
last_reply?: {
|
||||||
|
user: Pick<User, "id" | "username" | "nickname" | "avatar">;
|
||||||
|
created_at: string;
|
||||||
|
} | null;
|
||||||
board: Board;
|
board: Board;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user