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">
{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>
<article className="panel p-6 sm:p-8">
<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>
</>
}
/>
<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>
</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>
<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

@@ -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}
<article className="panel p-6 sm:p-8">
<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} />
<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>
</article>
</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}