feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user