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

@@ -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";