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

@@ -1,13 +1,21 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { ChevronRight, Megaphone } from "lucide-react";
import { fetchAnnouncementDetail } from "@/lib/api";
import { Megaphone } from "lucide-react";
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { formatDate } from "@/lib/format";
import { getMeCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
import {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocAnnoNav,
SiteDocMasthead,
SiteDocBreadcrumb,
} from "@/components/site-doc";
interface PageProps {
params: Promise<{ id: string }>;
@@ -32,14 +40,35 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function AnnouncementPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const [data, me] = await Promise.all([
const [data, me, overview, settings] = await Promise.all([
fetchAnnouncementDetail(id, cookie || undefined).catch(() => null),
getMeCached(cookie || undefined),
fetchOverview(cookie || undefined).catch(() => null),
getPublicSettingsCached(),
]);
if (!data) notFound();
const a = data.announcement;
const annId = Number(id);
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const toc = extractToc(a.content);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(a.content, a.title.length);
let headingIndex = 0;
const headingRenderer = (Tag: "h2" | "h3") =>
function Heading({ children }: { children?: React.ReactNode }) {
const item = headingToc[headingIndex++];
return (
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
{children}
</Tag>
);
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
@@ -50,69 +79,79 @@ export default async function AnnouncementPage({ params }: PageProps) {
keywords: a.tag,
};
const left = (
<>
<SiteDocIdentity kind="announcements" lead={siteLead} />
<SiteDocAnnoNav items={announcements} currentId={annId} />
</>
);
const right = (
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocPageNav pages={sidebarPages} title="关于本站" />
</>
);
return (
<div className="max-w-3xl mx-auto">
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<Link href="/announcements" className="hover:text-[var(--accent)] transition-colors">
站点公告
</Link>
<ChevronRight size={13} />
<span className="truncate" style={{ color: "var(--accent)" }}>
{a.title}
</span>
</nav>
<SiteDocShell
left={left}
right={right}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocAnnoNav items={announcements} currentId={annId} />
<SiteDocPageNav pages={sidebarPages} title="关于本站" />
</>
}
>
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ href: "/announcements", label: "站点公告" },
{ label: a.title },
]}
/>
<article className="panel p-6 sm:p-8">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<SiteDocMasthead
kicker="站点公告"
title={a.title}
createdAt={a.created_at}
updatedAt={a.updated_at}
readingMinutes={readingMinutes}
showTimeOfDay
badges={
<>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
</div>
<time className="meta text-[12px] tabular-nums" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
</div>
</>
}
/>
<h1
className="mt-3.5 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h1>
<div
className="mt-5 pt-5"
style={{ borderTop: "1px solid var(--line)" }}
>
<MarkdownBody
content={a.content}
loggedIn={!!me.user}
loginHref={loginHref}
components={
headingToc.length > 0
? { h2: headingRenderer("h2"), h3: headingRenderer("h3") }
: undefined
}
/>
</div>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
<Megaphone size={14} style={{ color: "var(--accent)" }} />
<span>由站点管理团队发布</span>
<Link
href="/"
className="ml-auto font-semibold transition-colors hover:text-[var(--accent)]"
style={{ color: "var(--accent)" }}
>
返回首页
</Link>
</div>
</div>
</SiteDocShell>
</>
);
}

View File

@@ -1,9 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { fetchAnnouncementsPage, type Announcement } from "@/lib/api";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
export const metadata: Metadata = {
title: "站点公告",
@@ -26,12 +33,48 @@ function plainSummary(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function AnnouncementCard({ a }: { a: Announcement }) {
function FeaturedAnnouncement({ a }: { a: Announcement }) {
const summary = plainSummary(a.content, 180);
return (
<Link
href={`/announcement/${a.id}`}
className="block px-5 sm:px-6 pt-5 sm:pt-6 pb-5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<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)" }}
>
置顶公告
</span>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
</div>
<h2
className="text-[22px] sm:text-[26px] font-extrabold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-3 text-[14.5px] leading-relaxed line-clamp-3" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
</Link>
);
}
function AnnouncementRow({ a, showDivider }: { a: Announcement; showDivider: boolean }) {
const summary = plainSummary(a.content);
return (
<Link
href={`/announcement/${a.id}`}
className="panel block p-5 sm:p-6 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
@@ -41,13 +84,13 @@ function AnnouncementCard({ a }: { a: Announcement }) {
</time>
</div>
<h2
className="mt-2.5 text-[17px] sm:text-[18px] font-semibold leading-snug tracking-tight"
className="mt-2 text-[16px] sm:text-[17px] font-semibold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-2 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
<p className="mt-1.5 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
@@ -71,9 +114,34 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
error = e instanceof Error ? e.message : "加载公告失败";
}
const [overview, settings] = await Promise.all([
fetchOverview().catch(() => null),
getPublicSettingsCached(),
]);
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
const rest = featured ? announcements.filter((a) => a.id !== featured.id) : announcements;
const left = <SiteDocIdentity kind="announcements" lead={siteLead} />;
const right = <SiteDocPageNav pages={sidebarPages} title="关于本站" />;
return (
<div className="max-w-3xl mx-auto">
<header className="mb-6">
<SiteDocShell
left={left}
right={right}
mobileExtras={<SiteDocPageNav pages={sidebarPages} title="关于本站" />}
>
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
<header className="mb-5">
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
@@ -81,7 +149,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">站点通知与运营公告</p>
<p className="meta mt-2">
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
</p>
</header>
{error ? (
@@ -94,9 +164,10 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<div className="flex flex-col gap-3">
{announcements.map((a) => (
<AnnouncementCard key={a.id} a={a} />
<div className="panel overflow-hidden">
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
))}
</div>
)}
@@ -104,6 +175,6 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />
) : null}
</div>
</SiteDocShell>
);
}

View File

@@ -2538,6 +2538,25 @@ body:has([data-admin-viewport]) {
}
}
/* 后台公告/单页编辑:取消画布内边距,顶栏贴紧 admin-topbar 钉住,避免缝里透出下方内容 */
.admin-canvas:has(.j13-compose-page) {
padding: 0;
}
.admin-canvas .j13-compose-page,
.admin-canvas .j13-compose-stage {
min-height: 100%;
}
.admin-canvas .j13-compose-stage {
border: 0;
border-radius: 0;
box-shadow: none;
}
.admin-canvas .j13-compose-toolbar {
top: 0;
border-radius: 0;
background: var(--panel);
}
/* 移动端导航抽屉 */
.admin-drawer {
position: fixed;
@@ -3849,81 +3868,178 @@ a.j13-mention-token:hover {
.dark .j13-anno-dot.is-indigo { background: #a5b4fc; }
.dark .j13-anno-dot.is-rose { background: #fb7185; }
/* ----- 正文时间线(更新树) ----- */
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
.j13-timeline {
margin: 1.1rem 0;
padding: 0.15rem 0;
}
.j13-timeline-day {
margin-bottom: 1.15rem;
}
.j13-timeline-day:last-child {
margin-bottom: 0;
}
.j13-timeline-day-label {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-3);
margin-bottom: 0.55rem;
font-variant-numeric: tabular-nums;
}
.j13-timeline-items {
list-style: none;
margin: 0;
padding: 0 0 0 0.85rem;
border-left: 2px solid var(--line);
margin: 1rem 0 1.25rem;
padding: 0.1rem 0 0.1rem 0.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1.15rem;
}
.j13-timeline-day {
position: relative;
padding-left: 1.15rem;
}
.j13-timeline-day::before {
content: "";
position: absolute;
left: 0.28rem;
top: 1.7rem;
bottom: 0.2rem;
width: 1.5px;
border-radius: 1px;
background: color-mix(in srgb, var(--gold) 32%, var(--line));
}
.j13-timeline-day:last-child::before {
bottom: 0.85rem;
}
.j13-timeline-day-label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0 0 0.65rem -0.1rem;
padding: 0.32rem 0.75rem 0.32rem 0.6rem;
border-radius: 999px;
background: var(--gold-soft);
color: var(--gold);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.015em;
font-variant-numeric: tabular-nums;
line-height: 1.35;
position: relative;
z-index: 1;
}
.j13-timeline-day-label time {
color: var(--gold);
}
.j13-timeline-day-count {
font-size: 12px;
font-weight: 500;
color: var(--ink-3);
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.j13-timeline-day-count::after {
content: " 项";
}
.j13-timeline-items {
list-style: none !important;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.j13-timeline-item {
position: relative;
padding-left: 0.95rem;
list-style: none !important;
}
.j13-timeline-item::marker {
content: none;
}
.j13-timeline-dot {
position: absolute;
left: -0.85rem;
top: 0.45rem;
width: 0.55rem;
height: 0.55rem;
left: -1.15rem;
top: 0.85rem;
width: 0.42rem;
height: 0.42rem;
margin-left: 0.28rem;
border-radius: 999px;
background: var(--accent);
background: var(--gold);
border: 2px solid var(--panel);
box-shadow: 0 0 0 1px var(--line);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gold) 40%, var(--line));
transform: translateX(-50%);
z-index: 1;
}
.j13-timeline-card {
min-width: 0;
padding: 0.55rem 0.15rem 0.55rem 0.2rem;
border-radius: 0;
background: transparent;
border: none;
border-bottom: 1px solid var(--line);
}
.j13-timeline-item:last-child .j13-timeline-card {
border-bottom: none;
padding-bottom: 0.15rem;
}
.j13-timeline-head {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.j13-timeline-title {
.j13-timeline-summary {
margin: 0;
font-size: 14.5px;
font-weight: 650;
line-height: 1.45;
flex: 1;
min-width: 0;
font-size: 13.5px;
font-weight: 500;
line-height: 1.5;
color: var(--ink);
word-break: break-word;
}
.j13-timeline-chip {
display: inline-flex;
align-items: center;
gap: 0.05em;
flex-shrink: 0;
margin-top: 0.12rem;
padding: 0.05rem 0.35rem;
border-radius: 4px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.4;
text-transform: lowercase;
background: var(--tint);
color: var(--ink-3);
}
.j13-timeline-chip-scope {
font-weight: 500;
opacity: 0.8;
}
.j13-timeline-chip.is-feat,
.j13-timeline-chip.is-polish {
background: var(--accent-soft);
color: var(--accent);
}
.j13-timeline-chip.is-fix {
background: var(--danger-soft);
color: var(--danger);
}
.j13-timeline-chip.is-docs,
.j13-timeline-chip.is-chore,
.j13-timeline-chip.is-refactor,
.j13-timeline-chip.is-style,
.j13-timeline-chip.is-perf,
.j13-timeline-chip.is-test {
background: var(--tint);
color: var(--ink-3);
}
.j13-timeline-src {
flex-shrink: 0;
margin-top: 0.15rem;
opacity: 0.55;
color: var(--ink-3);
transition: color 0.15s ease, opacity 0.15s ease;
}
.j13-timeline-src:hover {
opacity: 1;
color: var(--accent);
}
.j13-timeline-body {
margin-top: 0.35rem;
font-size: 13.5px;
line-height: 1.65;
color: var(--ink-2);
margin-top: 0.25rem;
padding-left: 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--ink-3);
}
.j13-timeline-body > :first-child { margin-top: 0; }
.j13-timeline-body > :last-child { margin-bottom: 0; }
.j13-timeline-body p {
margin: 0.2rem 0;
}
/* 时间线编辑器 */
.j13-tl-import {
@@ -4155,6 +4271,94 @@ a.j13-mention-token:hover {
color: var(--ink);
}
/* 关于本站:右栏底部署录式目录,与内容模块区分开 */
.j13-about-lead {
margin-top: 8px;
font-size: 12.5px;
line-height: 1.55;
color: var(--ink-2);
}
.j13-about-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.j13-about-item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 9px 10px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
transition: background 0.16s ease, border-color 0.16s ease;
}
.j13-about-item:hover,
.j13-about-item:focus-visible {
background: var(--accent-soft);
border-color: var(--nav-active-line);
}
.j13-about-ico {
width: 30px;
height: 30px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--panel);
color: var(--accent);
border: 1px solid var(--line);
}
.j13-about-item:hover .j13-about-ico,
.j13-about-item:focus-visible .j13-about-ico {
border-color: var(--nav-active-line);
}
.j13-about-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.j13-about-name {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
color: var(--ink);
}
.j13-about-excerpt {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
color: var(--ink-3);
}
.j13-about-chev {
flex-shrink: 0;
color: var(--ink-3);
transition: color 0.16s ease, transform 0.16s ease;
}
.j13-about-item:hover .j13-about-chev,
.j13-about-item:focus-visible .j13-about-chev {
color: var(--accent);
transform: translateX(2px);
}
@media (prefers-reduced-motion: reduce) {
.j13-about-item,
.j13-about-chev {
transition: none;
}
.j13-about-item:hover .j13-about-chev,
.j13-about-item:focus-visible .j13-about-chev {
transform: none;
}
}
/* 统计数字格:白底细线,避免灰块 */
.j13-stat {
display: flex;
@@ -4450,6 +4654,26 @@ a.j13-mention-token:hover {
Markdown 正文
============================================================ */
.prose-content { font-size: 16.5px; line-height: 1.85; color: var(--ink); }
/* 时间线嵌在 prose 内:压住 ul/p 默认,避免列表圆点与段落撑大 */
.prose-content .j13-timeline-items,
.prose-content .j13-timeline-item {
list-style: none;
padding-left: 0;
margin: 0;
}
.prose-content .j13-timeline-summary {
margin: 0;
font-size: 13.5px;
font-weight: 500;
line-height: 1.5;
color: var(--ink);
}
.prose-content .j13-timeline-body,
.prose-content .j13-timeline-body p {
font-size: 12.5px;
line-height: 1.55;
color: var(--ink-3);
}
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
.prose-content h2 {
font-size: 1.4rem;

View File

@@ -1,10 +1,19 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { fetchSitePageBySlug } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { Megaphone } from "lucide-react";
import { fetchSitePageBySlug, fetchOverview } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
import {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocAnnoNav,
SiteDocMasthead,
SiteDocBreadcrumb,
} from "@/components/site-doc";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -29,56 +38,109 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function SitePagePublic({ params }: PageProps) {
const { slug } = await params;
const data = await fetchSitePageBySlug(slug).catch(() => null);
const [data, overview, settings] = await Promise.all([
fetchSitePageBySlug(slug).catch(() => null),
fetchOverview().catch(() => null),
getPublicSettingsCached(),
]);
if (!data) notFound();
const p = data.page;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const toc = extractToc(p.content);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(p.content, p.title.length);
let headingIndex = 0;
const headingRenderer = (Tag: "h2" | "h3") =>
function Heading({ children }: { children?: React.ReactNode }) {
const item = headingToc[headingIndex++];
return (
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
{children}
</Tag>
);
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: p.title,
"@type": "WebPage",
name: p.title,
description: p.excerpt || undefined,
datePublished: p.created_at,
dateModified: p.updated_at,
};
const left = (
<>
<SiteDocIdentity kind="about" lead={siteLead} />
<SiteDocPageNav pages={sidebarPages} currentSlug={slug} />
</>
);
const right =
toc.length > 0 ? (
<PostToc items={toc} />
) : (
<SiteDocAnnoNav items={announcements} />
);
return (
<div className="max-w-3xl mx-auto">
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<span className="truncate" style={{ color: "var(--accent)" }}>
{p.title}
</span>
</nav>
<SiteDocShell
left={left}
right={right}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocPageNav pages={sidebarPages} currentSlug={slug} title="文档目录" />
{toc.length === 0 && announcements.length > 0 ? (
<SiteDocAnnoNav items={announcements} />
) : null}
</>
}
>
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
{ label: p.title },
]}
/>
<article className="panel p-6 sm:p-8">
<time className="meta text-[12px] tabular-nums" dateTime={p.updated_at || p.created_at}>
{formatDate(p.updated_at || p.created_at)}
</time>
<h1
className="mt-2 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{p.title}
</h1>
{p.excerpt ? (
<p className="mt-3 text-[14.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
{p.excerpt}
</p>
) : null}
<SiteDocMasthead
kicker="官方文件"
title={p.title}
excerpt={p.excerpt || undefined}
createdAt={p.created_at}
updatedAt={p.updated_at}
readingMinutes={readingMinutes}
showTimeOfDay
/>
<div className="mt-5 pt-5" style={{ borderTop: "1px solid var(--line)" }}>
<MarkdownBody content={p.content} />
</div>
<MarkdownBody
content={p.content}
components={
headingToc.length > 0
? { h2: headingRenderer("h2"), h3: headingRenderer("h3") }
: undefined
}
/>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
<Megaphone size={14} style={{ color: "var(--accent)" }} />
<span>由站点管理团队维护</span>
</div>
</SiteDocShell>
</>
);
}

View File

@@ -83,7 +83,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const isSearch = keyword.length > 0;
const cookie = authCookieHeader(await cookies());
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false;
const sort = normalizeSort(sp.sort, allowComments);
// 与 layout 共享同一份 /me 请求(React cache 去重)
const mePromise = getMeCached(cookie || undefined);
@@ -240,6 +241,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>
)
)}
@@ -273,6 +275,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>
</div>
)

View File

@@ -29,7 +29,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
import PostToc, { type TocItem } from "@/components/PostToc";
import PostToc from "@/components/PostToc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
@@ -41,6 +41,7 @@ import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
interface PageProps {
params: Promise<{ id: string }>;
@@ -78,26 +79,6 @@ 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 formatYMD(dateStr: string) {
const d = new Date(dateStr);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
@@ -484,10 +465,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const publicBody = publicMarkdownForSeo(post.content || "");
const toc = extractToc(publicBody);
const readingMinutes = Math.max(
1,
Math.ceil((publicBody.length > 0 ? publicBody.length : post.title.length) / 400)
);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(publicBody, post.title.length);
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
@@ -559,11 +538,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
}),
};
// 目录项与 Markdown 标题按渲染顺序一一对应(extractToc 顺序 == 文档顺序)
// 仅正文 Markdown 标题与 headingRenderer 按序对应(时间线日锚点另有 tl-* id)
let headingIndex = 0;
const headingRenderer = (Tag: "h2" | "h3") =>
function Heading({ children }: { children?: React.ReactNode }) {
const item = toc[headingIndex++];
const item = headingToc[headingIndex++];
return (
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
{children}

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;
const lead = siteDescription?.trim() ?? "";
if (pages.length === 1) {
const p = pages[0];
return (
<section className="j13-module" aria-label="关于本站">
<section className="j13-module j13-about" 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>
);
}
{lead ? <p className="j13-about-lead">{lead}</p> : null}
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<section className="j13-module" aria-label="关于本站">
<p className="j13-module-title mb-1.5">
<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}
<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";

20
frontend/lib/pageGlyph.ts Normal file
View File

@@ -0,0 +1,20 @@
import {
BookOpen,
CircleHelp,
FileText,
Mail,
Scale,
Shield,
type LucideIcon,
} from "lucide-react";
/** 按 slug 语义选图标(关于本站目录 / 公文左栏共用) */
export function pageGlyph(slug: string): LucideIcon {
const s = slug.toLowerCase();
if (/privacy|隐私/.test(s)) return Shield;
if (/term|tos|协议|条款/.test(s)) return Scale;
if (/rule|守则|规范|公约/.test(s)) return BookOpen;
if (/contact|联系/.test(s)) return Mail;
if (/faq|help|帮助|问答/.test(s)) return CircleHelp;
return FileText;
}

102
frontend/lib/toc.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { TocItem } from "@/components/PostToc";
const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/;
const HIDE_OPEN_RE = /^\[hide(?:\s+.+)?\]$/i;
/** 时间线日分组锚点 id(与 MdTimeline 一致) */
export function timelineDayId(date: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return `tl-${date}`;
return `tl-${date.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown"}`;
}
/**
* 从正文提取目录:
* - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id)
* - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点)
* - 代码围栏 / [hide] 块内标题不计入
*/
export function extractToc(content: string): TocItem[] {
const items: TocItem[] = [];
let inCode = false;
let inTimeline = false;
let inHide = false;
let headingCount = 0;
/** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */
let timelineDates: string[] = [];
const timelineSeen = new Set<string>();
const flushTimelineDates = () => {
if (timelineDates.length === 0) return;
const sorted = [...timelineDates].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
for (const date of sorted) {
items.push({ level: 2, text: date, id: timelineDayId(date) });
}
timelineDates = [];
timelineSeen.clear();
};
for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
const line = raw.trim();
if (line.startsWith("```")) {
inCode = !inCode;
continue;
}
if (inCode) continue;
const lower = line.toLowerCase();
if (!inHide && !inTimeline && lower === "[timeline]") {
inTimeline = true;
continue;
}
if (inTimeline && lower === "[/timeline]") {
inTimeline = false;
flushTimelineDates();
continue;
}
if (inTimeline) {
const tm = TIMELINE_DAY_RE.exec(line);
if (tm && !timelineSeen.has(tm[1])) {
timelineSeen.add(tm[1]);
timelineDates.push(tm[1]);
}
continue;
}
if (!inHide && HIDE_OPEN_RE.test(line)) {
inHide = true;
continue;
}
if (inHide && lower === "[/hide]") {
inHide = false;
continue;
}
if (inHide) continue;
const m = HEADING_RE.exec(line);
if (m) {
items.push({
level: m[1].length as 2 | 3,
text: m[2],
id: `h-${headingCount++}`,
});
}
}
// 未闭合的 timeline 也尽量发出已收集日期
flushTimelineDates();
return items;
}
/** 仅正文 Markdown 标题(排除时间线日锚点),供 headingRenderer 按序挂 id */
export function markdownHeadingToc(toc: TocItem[]): TocItem[] {
return toc.filter((t) => /^h-\d+$/.test(t.id));
}
/** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */
export function estimateReadingMinutes(text: string, fallbackLen = 0): number {
const len = text.trim().length > 0 ? text.trim().length : fallbackLen;
return Math.max(1, Math.ceil(len / 400));
}