feat: 公文三栏单页布局,并修复时间线目录跳转与样式

公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 21:15:15 +08:00
parent a83276060f
commit 0b60c4299b
20 changed files with 1140 additions and 222 deletions

View File

@@ -47,7 +47,8 @@ export default async function MarkdownBody({
bodies.push(null);
continue;
}
bodies.push(await renderMarkdownToReact(it.body, components));
// 条目正文不挂目录 id,避免与块外 headingRenderer 错位
bodies.push(await renderMarkdownToReact(it.body));
}
nodes.push(
<MdTimeline
@@ -74,7 +75,7 @@ export default async function MarkdownBody({
continue;
}
const inner = seg.body.trim()
? await renderMarkdownToReact(seg.body, components)
? await renderMarkdownToReact(seg.body)
: null;
nodes.push(
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>

View File

@@ -30,6 +30,8 @@ export default function MarkdownBodyClient({
}: MarkdownBodyClientProps) {
const segs = splitContentSegments(content || "");
const mdComponents = mergeMarkdownComponents(components);
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
const nestedMd = mergeMarkdownComponents();
return (
<div className={className}>
@@ -49,7 +51,7 @@ export default function MarkdownBodyClient({
items={seg.items}
renderBody={(it) =>
it.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{it.body}
</ReactMarkdown>
) : null
@@ -74,7 +76,7 @@ export default function MarkdownBodyClient({
return (
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
{seg.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{seg.body}
</ReactMarkdown>
) : null}

View File

@@ -50,7 +50,10 @@ export default function PostToc({ items }: { items: TocItem[] }) {
const jump = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
setActive(id);
};
return (

View File

@@ -13,6 +13,7 @@ import {
TrendingUp,
Info,
Pin,
ChevronRight,
} from "lucide-react";
import type {
PostListItem,
@@ -27,6 +28,7 @@ import Avatar from "@/components/Avatar";
import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -120,45 +122,42 @@ function PulseCard({
);
}
/* ---------- 关于本站(侧栏单页) ---------- */
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
function AboutSite({ pages }: { pages: SidebarPageItem[] }) {
function AboutSite({
pages,
siteDescription,
}: {
pages: SidebarPageItem[];
siteDescription?: string;
}) {
if (pages.length === 0) return null;
if (pages.length === 1) {
const p = pages[0];
return (
<section className="j13-module" aria-label="关于本站">
<p className="j13-module-title">
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
</p>
<Link href={`/p/${p.slug}`} className="block mt-2 rounded-lg px-0.5 py-1 transition-colors hover:bg-[var(--accent-soft)]">
<p className="text-[14px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
{p.title}
</p>
{p.excerpt ? (
<p className="meta text-[12.5px] mt-1.5 leading-relaxed line-clamp-3">{p.excerpt}</p>
) : null}
</Link>
</section>
);
}
const lead = siteDescription?.trim() ?? "";
return (
<section className="j13-module" aria-label="关于本站">
<p className="j13-module-title mb-1.5">
<section className="j13-module j13-about" aria-label="关于本站">
<p className="j13-module-title">
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
</p>
<ul className="space-y-0.5">
{pages.map((p) => (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-raillink" data-tip={p.title}>
<span className="flex-1 min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{p.title}
</span>
</Link>
</li>
))}
{lead ? <p className="j13-about-lead">{lead}</p> : null}
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item">
<span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} />
</span>
<span className="j13-about-body">
<span className="j13-about-name">{p.title}</span>
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
</span>
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
</Link>
</li>
);
})}
</ul>
</section>
);
@@ -421,6 +420,7 @@ type PanelsProps = {
allowComments: boolean;
canManageAnnouncements?: boolean;
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
};
function CommunityPanelsBody({
@@ -433,16 +433,17 @@ function CommunityPanelsBody({
allowComments,
canManageAnnouncements,
sidebarPages = [],
siteDescription,
}: PanelsProps) {
return (
<>
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
<AboutSite pages={sidebarPages} />
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
<HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks />
<AboutSite pages={sidebarPages} siteDescription={siteDescription} />
</>
);
}

View File

@@ -4,6 +4,7 @@ import {
groupTimelineByDate,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { timelineDayId } from "@/lib/toc";
type MdTimelineProps = {
items: TimelineItem[];
@@ -11,8 +12,24 @@ type MdTimelineProps = {
renderBody?: (item: TimelineItem, index: number) => ReactNode;
};
const PREFIX_RE =
/^(feat|fix|polish|chore|docs|refactor|style|perf|test)(?:\(([^)]*)\))?\s*:?\s*/i;
function splitTitlePrefix(title: string): { kind?: string; scope?: string; rest: string } {
const m = PREFIX_RE.exec(title);
if (!m) return { rest: title };
const rest = title.slice(m[0].length).trim() || title;
return {
kind: m[1].toLowerCase(),
scope: m[2]?.trim() || undefined,
rest,
};
}
/**
* 按日分组的垂直时间线(更新树)。颜色走 CSS 令牌。
* 按日分组的垂直时间线(更新树)。
* 日分组 id 与 extractToc 的 tl-YYYY-MM-DD 对齐,供右侧目录跳转。
* 条目用普通段落而非 h3,避免被 .prose-content 标题样式放大。
*/
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
if (!items.length) {
@@ -24,19 +41,41 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
return (
<div className="j13-timeline" role="list" aria-label="时间线">
{groups.map((g) => (
<div key={g.date} className="j13-timeline-day">
<div className="j13-timeline-day-label">
<time dateTime={g.date}>{g.date}</time>
<section
key={g.date}
id={timelineDayId(g.date)}
className="j13-timeline-day"
style={{ scrollMarginTop: 90 }}
aria-labelledby={`${timelineDayId(g.date)}-label`}
>
<div className="j13-timeline-day-label" id={`${timelineDayId(g.date)}-label`}>
<time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}>
{g.date}
</time>
<span className="j13-timeline-day-count">{g.items.length}</span>
</div>
<ul className="j13-timeline-items">
{g.items.map((it) => {
const idx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
? renderBody(it, idx)
: it.body.trim()
? <span className="whitespace-pre-wrap">{it.body}</span>
: null;
return (
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
<span className="j13-timeline-dot" aria-hidden />
<div className="j13-timeline-card">
<div className="j13-timeline-head">
<h3 className="j13-timeline-title">{it.title}</h3>
{kind ? (
<span className={`j13-timeline-chip is-${kind}`}>
{kind}
{scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null}
</span>
) : null}
<p className="j13-timeline-summary">{rest}</p>
{it.sourceUrl ? (
<a
href={it.sourceUrl}
@@ -46,21 +85,17 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
data-tip="查看提交"
aria-label="查看提交"
>
<ExternalLink size={13} />
<ExternalLink size={12} />
</a>
) : null}
</div>
{renderBody ? (
<div className="j13-timeline-body">{renderBody(it, idx)}</div>
) : it.body.trim() ? (
<div className="j13-timeline-body whitespace-pre-wrap">{it.body}</div>
) : null}
{bodyNode ? <div className="j13-timeline-body">{bodyNode}</div> : null}
</div>
</li>
);
})}
</ul>
</div>
</section>
))}
</div>
);

View File

@@ -0,0 +1,82 @@
import Link from "next/link";
import { Megaphone, Pin } from "lucide-react";
import type { AnnouncementSummary } from "@/lib/api";
import { formatShortDate } from "@/lib/format";
/** 近期公告目录(高亮当前 id) */
export default function SiteDocAnnoNav({
items,
currentId,
showAllLink = true,
}: {
items: AnnouncementSummary[];
currentId?: number;
showAllLink?: boolean;
}) {
return (
<section className="j13-module flex min-h-0 flex-col" aria-label="近期公告">
<div className="flex items-center justify-between gap-2 shrink-0">
<p className="j13-module-title !mb-0">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 近期公告
</p>
{showAllLink ? (
<Link
href="/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
全部
</Link>
) : null}
</div>
{items.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无公告</p>
) : (
<ul className="j13-anno-list j13-rail-scroll">
{items.map((a) => {
const current = currentId === a.id;
return (
<li key={a.id}>
<Link
href={`/announcement/${a.id}`}
className="j13-anno-item"
data-tip={a.title}
aria-current={current ? "page" : undefined}
style={
current
? {
background: "var(--accent-soft)",
borderColor: "color-mix(in srgb, var(--accent) 28%, var(--line))",
}
: undefined
}
>
<span className="j13-anno-item-body">
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
{a.pinned ? (
<Pin
size={11}
className="shrink-0"
style={{ color: "var(--accent)" }}
aria-label="置顶"
/>
) : null}
<span className="truncate">{a.title}</span>
</span>
<span className="j13-anno-item-meta">
{a.tag ? (
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
) : null}
<span className="meta text-[11px] tabular-nums">
{formatShortDate(a.created_at)}
</span>
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export type Crumb = { href?: string; label: string };
/** 公文页面包屑 */
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
return (
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]" aria-label="面包屑">
{items.map((item, i) => {
const last = i === items.length - 1;
return (
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1.5 min-w-0">
{i > 0 ? <ChevronRight size={13} className="shrink-0" /> : null}
{item.href && !last ? (
<Link href={item.href} className="hover:text-[var(--accent)] transition-colors shrink-0">
{item.label}
</Link>
) : (
<span
className={last ? "truncate" : "shrink-0"}
style={last ? { color: "var(--accent)" } : undefined}
>
{item.label}
</span>
)}
</span>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,61 @@
import Link from "next/link";
import { Info, Megaphone, type LucideIcon } from "lucide-react";
export type SiteDocIdentityKind = "announcements" | "about";
const META: Record<
SiteDocIdentityKind,
{ title: string; href: string; Icon: LucideIcon; fallbackLead: string }
> = {
announcements: {
title: "站点公告",
href: "/announcements",
Icon: Megaphone,
fallbackLead: "官方通知与运营公告",
},
about: {
title: "关于本站",
href: "/",
Icon: Info,
fallbackLead: "站点说明与政策文档",
},
};
/** 左栏身份卡:标明当前公文区 */
export default function SiteDocIdentity({
kind,
lead,
}: {
kind: SiteDocIdentityKind;
lead?: string;
}) {
const m = META[kind];
const Icon = m.Icon;
const text = lead?.trim() || m.fallbackLead;
return (
<section className="j13-module shrink-0" aria-labelledby={`site-doc-id-${kind}`}>
<p className="j13-module-title mb-3" id={`site-doc-id-${kind}`}>
<Icon size={15} style={{ color: "var(--accent)" }} /> {m.title}
</p>
<Link href={m.href} className="flex items-center gap-3 group">
<span
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Icon size={20} strokeWidth={2} />
</span>
<span
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{m.title}
</span>
</Link>
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{text}
</p>
</section>
);
}

View File

@@ -0,0 +1,94 @@
import type { ReactNode } from "react";
import { Clock } from "lucide-react";
import { formatDate } from "@/lib/format";
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",
});
}
/** 公文头:kicker / 标签 / 大标题 / 导语 / 元信息 */
export default function SiteDocMasthead({
kicker,
title,
excerpt,
createdAt,
updatedAt,
readingMinutes,
badges,
showTimeOfDay = false,
}: {
kicker: string;
title: string;
excerpt?: string;
createdAt: string;
updatedAt?: string;
readingMinutes?: number;
badges?: ReactNode;
/** 详情页用完整日期时间;列表头图可用短日期 */
showTimeOfDay?: boolean;
}) {
const edited =
updatedAt &&
new Date(updatedAt).getTime() - new Date(createdAt).getTime() > 60_000;
const publishedLabel = showTimeOfDay ? formatDateTime(createdAt) : formatDate(createdAt);
const updatedLabel =
edited && updatedAt
? showTimeOfDay
? formatDateTime(updatedAt)
: formatDate(updatedAt)
: null;
return (
<header className="mb-6 sm:mb-7">
<div className="flex items-center gap-2 flex-wrap mb-3">
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold tracking-wide"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{kicker}
</span>
{badges}
</div>
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight"
style={{ color: "var(--ink)" }}
>
{title}
</h1>
{excerpt ? (
<p
className="mt-3.5 text-[15px] sm:text-[15.5px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{excerpt}
</p>
) : null}
<div
className="mt-5 pt-4 flex flex-wrap items-center gap-x-4 gap-y-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<p className="meta text-[12.5px]">
发布于 {publishedLabel}
{updatedLabel ? <> · 更新于 {updatedLabel}</> : null}
</p>
{readingMinutes != null ? (
<span
className="meta inline-flex items-center gap-1.5 text-[12.5px] sm:ml-auto"
aria-label={`阅读约需 ${readingMinutes} 分钟`}
>
<Clock size={13} /> 约 {readingMinutes} 分钟读完
</span>
) : null}
</div>
</header>
);
}

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
import { ChevronRight, Info } from "lucide-react";
import type { SidebarPageItem } from "@/lib/api";
import { pageGlyph } from "@/lib/pageGlyph";
/** 侧栏单页兄弟目录(高亮当前 slug) */
export default function SiteDocPageNav({
pages,
currentSlug,
title = "文档目录",
}: {
pages: SidebarPageItem[];
currentSlug?: string;
title?: string;
}) {
if (pages.length === 0) return null;
return (
<section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}>
<p className="j13-module-title mb-1 shrink-0">
<Info size={15} style={{ color: "var(--accent)" }} /> {title}
</p>
<ul className="j13-about-list j13-rail-scroll">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
const current = currentSlug === p.slug;
return (
<li key={p.id}>
<Link
href={`/p/${p.slug}`}
className="j13-about-item"
aria-current={current ? "page" : undefined}
style={
current
? {
background: "var(--accent-soft)",
borderColor: "var(--nav-active-line)",
}
: undefined
}
>
<span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} />
</span>
<span className="j13-about-body">
<span className="j13-about-name">{p.title}</span>
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
</span>
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
</Link>
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
/**
* 公文阅读三栏壳:宽度与帖子详情对齐。
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;窄屏仅中栏,侧栏下沉。
*/
export default function SiteDocShell({
left,
right,
children,
mobileExtras,
}: {
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
}) {
return (
<>
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
<aside
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
aria-label="站点导航"
>
{left}
</aside>
<div className="min-w-0">{children}</div>
<aside
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
aria-label="阅读辅助"
>
{right}
</aside>
</div>
{mobileExtras ? <div className="lg:hidden mt-6 space-y-4">{mobileExtras}</div> : null}
</>
);
}

View File

@@ -0,0 +1,8 @@
export { default as SiteDocShell } from "./SiteDocShell";
export { default as SiteDocIdentity } from "./SiteDocIdentity";
export { default as SiteDocPageNav } from "./SiteDocPageNav";
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
export { default as SiteDocMasthead } from "./SiteDocMasthead";
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
export type { Crumb } from "./SiteDocBreadcrumb";
export type { SiteDocIdentityKind } from "./SiteDocIdentity";