feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
82
frontend/components/site-doc/SiteDocAnnoNav.tsx
Normal file
82
frontend/components/site-doc/SiteDocAnnoNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/components/site-doc/SiteDocBreadcrumb.tsx
Normal file
32
frontend/components/site-doc/SiteDocBreadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/components/site-doc/SiteDocIdentity.tsx
Normal file
61
frontend/components/site-doc/SiteDocIdentity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/components/site-doc/SiteDocMasthead.tsx
Normal file
94
frontend/components/site-doc/SiteDocMasthead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/components/site-doc/SiteDocPageNav.tsx
Normal file
57
frontend/components/site-doc/SiteDocPageNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/components/site-doc/SiteDocShell.tsx
Normal file
42
frontend/components/site-doc/SiteDocShell.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
frontend/components/site-doc/index.ts
Normal file
8
frontend/components/site-doc/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user