feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -16,7 +16,6 @@ import {
fetchUserProfile,
fetchSidebar,
postDeleteTypeLabel,
type UserProfile,
type PostListItem,
type CommentsResponse,
type SidebarPublicPayload,
@@ -39,6 +38,7 @@ import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments";
import PostEditedMark from "@/components/PostEditedMark";
import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
@@ -122,7 +122,7 @@ function RelatedPosts({
const titleId = `${uid}-title`;
return (
<section
className="j13-module shrink-0 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
className="j13-module shrink-0"
aria-labelledby={titleId}
>
<p className="j13-module-title mb-1.5" id={titleId}>
@@ -163,87 +163,9 @@ function RelatedPosts({
);
}
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
function AuthorCardStacked({
profile,
allowComments,
boardNames,
boardColors,
urlStyle,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
urlStyle: UrlStyle;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
return (
<div className="panel p-4 sm:p-6 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<p
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
>
关于作者
</p>
<div className="flex items-start gap-4 flex-wrap">
<Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={userPath(urlStyle, u.id)}
className="font-semibold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</Link>
<LevelBadge level={u.level} />
{(u.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{u.role !== "user" && (
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNames} boardColors={boardColors} />
)}
</div>
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<span>发帖 {post_count}</span>
{allowComments ? (
<>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
</>
) : null}
<span aria-hidden>·</span>
<span>积分 {points}</span>
{streak > 0 && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-0.5 font-medium" style={{ color: "var(--gold)" }}>
<Flame size={11} /> 连签 {streak} 天
</span>
</>
)}
</p>
{signature ? (
<p className="mt-2 text-[13px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{signature}
</p>
) : null}
</div>
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm shrink-0 self-center">
访问主页
</Link>
</div>
</div>
);
}
/* ============================================================
右栏模块(lg 起展示;移动端收进右抽屉)
============================================================ */
export default async function PostDetailPage({ params, searchParams }: PageProps) {
const { id } = await params;
@@ -374,6 +296,29 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
: "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
// 移动端抽屉内容(lg 以下):左抽屉 = 左栏侧栏块,右抽屉 = 右栏侧栏块(含文章目录)+ 同板块热议;
// 桌面常驻 aside 与抽屉共用渲染函数,uid 区分同屏两份 DOM 的 aria-labelledby
const leftRailNode = hasLeftRail ? (
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
) : null;
const hasTocBlock = sidebarLists.right.some((r) => r.key === "post_toc");
const renderRightRail = (uid: string) => (
<>
{hasRightRail ? (
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
) : null}
{related.length > 0 ? (
<RelatedPosts
posts={related}
boardId={post.board_id}
uid={uid}
allowComments={allowComments}
urlStyle={urlStyle}
/>
) : null}
</>
);
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const postType = normalizePostType(post.post_type);
@@ -487,6 +432,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return (
<>
<JsonLd data={jsonLd} />
{/* 移动端:吸顶工具栏 + 左右抽屉(导航 / 目录),lg 起隐藏;两侧皆无内容时组件自身不渲染 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{post.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {readingMinutes} 分钟读完</p>
</>
}
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
right={
hasRightRail || related.length > 0
? {
label: hasTocBlock ? "目录" : "热议",
content: renderRightRail("related-drawer"),
}
: undefined
}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div
@@ -500,7 +465,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="帖子导航"
>
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
{leftRailNode}
</aside>
)}
@@ -697,18 +662,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div>
</div>
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-4">
<AuthorCardStacked
profile={profile}
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
urlStyle={urlStyle}
/>
</div>
)}
{/* 窄屏作者卡已移除:作者信息随左栏配置收进移动端左抽屉 */}
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
{isPublished ? (
@@ -756,17 +710,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</p>
</section>
) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-4">
<RelatedPosts
posts={related}
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
urlStyle={urlStyle}
/>
</div>
</article>
</div>
@@ -776,8 +719,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
aria-label="阅读辅助"
>
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} urlStyle={urlStyle} />
{renderRightRail("related")}
</aside>
)}
</div>