feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,21 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ChevronRight, Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { fetchAnnouncementDetail } from "@/lib/api";
|
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { formatDate } from "@/lib/format";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
|
import PostToc from "@/components/PostToc";
|
||||||
|
import {
|
||||||
|
SiteDocShell,
|
||||||
|
SiteDocIdentity,
|
||||||
|
SiteDocPageNav,
|
||||||
|
SiteDocAnnoNav,
|
||||||
|
SiteDocMasthead,
|
||||||
|
SiteDocBreadcrumb,
|
||||||
|
} from "@/components/site-doc";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -32,14 +40,35 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
export default async function AnnouncementPage({ params }: PageProps) {
|
export default async function AnnouncementPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
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),
|
fetchAnnouncementDetail(id, cookie || undefined).catch(() => null),
|
||||||
getMeCached(cookie || undefined),
|
getMeCached(cookie || undefined),
|
||||||
|
fetchOverview(cookie || undefined).catch(() => null),
|
||||||
|
getPublicSettingsCached(),
|
||||||
]);
|
]);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
const a = data.announcement;
|
const a = data.announcement;
|
||||||
|
const annId = Number(id);
|
||||||
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${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 = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
@@ -50,69 +79,79 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
keywords: a.tag,
|
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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 面包屑 */}
|
<SiteDocShell
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
left={left}
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
right={right}
|
||||||
首页
|
mobileExtras={
|
||||||
</Link>
|
<>
|
||||||
<ChevronRight size={13} />
|
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
||||||
<Link href="/announcements" className="hover:text-[var(--accent)] transition-colors">
|
<SiteDocAnnoNav items={announcements} currentId={annId} />
|
||||||
站点公告
|
<SiteDocPageNav pages={sidebarPages} title="关于本站" />
|
||||||
</Link>
|
</>
|
||||||
<ChevronRight size={13} />
|
}
|
||||||
<span className="truncate" style={{ color: "var(--accent)" }}>
|
>
|
||||||
{a.title}
|
<SiteDocBreadcrumb
|
||||||
</span>
|
items={[
|
||||||
</nav>
|
{ href: "/", label: "首页" },
|
||||||
|
{ href: "/announcements", label: "站点公告" },
|
||||||
|
{ label: a.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<article className="panel p-6 sm:p-8">
|
<article className="panel p-6 sm:p-8">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<SiteDocMasthead
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
kicker="站点公告"
|
||||||
|
title={a.title}
|
||||||
|
createdAt={a.created_at}
|
||||||
|
updatedAt={a.updated_at}
|
||||||
|
readingMinutes={readingMinutes}
|
||||||
|
showTimeOfDay
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<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
|
<MarkdownBody
|
||||||
content={a.content}
|
content={a.content}
|
||||||
loggedIn={!!me.user}
|
loggedIn={!!me.user}
|
||||||
loginHref={loginHref}
|
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">
|
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
||||||
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
||||||
<span>由站点管理团队发布</span>
|
<span>由站点管理团队发布</span>
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="ml-auto font-semibold transition-colors hover:text-[var(--accent)]"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SiteDocShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Megaphone } from "lucide-react";
|
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 { formatDate } from "@/lib/format";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
|
import {
|
||||||
|
SiteDocShell,
|
||||||
|
SiteDocIdentity,
|
||||||
|
SiteDocPageNav,
|
||||||
|
SiteDocBreadcrumb,
|
||||||
|
} from "@/components/site-doc";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "站点公告",
|
title: "站点公告",
|
||||||
@@ -26,12 +33,48 @@ function plainSummary(md: string, max = 120): string {
|
|||||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
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);
|
const summary = plainSummary(a.content);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/announcement/${a.id}`}
|
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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
||||||
@@ -41,13 +84,13 @@ function AnnouncementCard({ a }: { a: Announcement }) {
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<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)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
>
|
>
|
||||||
{a.title}
|
{a.title}
|
||||||
</h2>
|
</h2>
|
||||||
{summary ? (
|
{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}
|
{summary}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -71,9 +114,34 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
error = e instanceof Error ? e.message : "加载公告失败";
|
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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<SiteDocShell
|
||||||
<header className="mb-6">
|
left={left}
|
||||||
|
right={right}
|
||||||
|
mobileExtras={<SiteDocPageNav pages={sidebarPages} title="关于本站" />}
|
||||||
|
>
|
||||||
|
<SiteDocBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ href: "/", label: "首页" },
|
||||||
|
{ label: "站点公告" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<header className="mb-5">
|
||||||
<h1
|
<h1
|
||||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||||
style={{ color: "var(--ink)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
@@ -81,7 +149,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
|
||||||
站点公告
|
站点公告
|
||||||
</h1>
|
</h1>
|
||||||
<p className="meta mt-2">站点通知与运营公告</p>
|
<p className="meta mt-2">
|
||||||
|
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -94,9 +164,10 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
<p className="meta mt-1.5">有新消息时会显示在这里</p>
|
<p className="meta mt-1.5">有新消息时会显示在这里</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="panel overflow-hidden">
|
||||||
{announcements.map((a) => (
|
{featured ? <FeaturedAnnouncement a={featured} /> : null}
|
||||||
<AnnouncementCard key={a.id} a={a} />
|
{rest.map((a, i) => (
|
||||||
|
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -104,6 +175,6 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
{!error ? (
|
{!error ? (
|
||||||
<Pagination total={total} page={page} size={size} basePath="/announcements" />
|
<Pagination total={total} page={page} size={size} basePath="/announcements" />
|
||||||
) : null}
|
) : 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 {
|
.admin-drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3849,81 +3868,178 @@ a.j13-mention-token:hover {
|
|||||||
.dark .j13-anno-dot.is-indigo { background: #a5b4fc; }
|
.dark .j13-anno-dot.is-indigo { background: #a5b4fc; }
|
||||||
.dark .j13-anno-dot.is-rose { background: #fb7185; }
|
.dark .j13-anno-dot.is-rose { background: #fb7185; }
|
||||||
|
|
||||||
/* ----- 正文时间线(更新树) ----- */
|
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
|
||||||
.j13-timeline {
|
.j13-timeline {
|
||||||
margin: 1.1rem 0;
|
margin: 1rem 0 1.25rem;
|
||||||
padding: 0.15rem 0;
|
padding: 0.1rem 0 0.1rem 0.1rem;
|
||||||
}
|
|
||||||
.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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.j13-timeline-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 0.95rem;
|
list-style: none !important;
|
||||||
|
}
|
||||||
|
.j13-timeline-item::marker {
|
||||||
|
content: none;
|
||||||
}
|
}
|
||||||
.j13-timeline-dot {
|
.j13-timeline-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -0.85rem;
|
left: -1.15rem;
|
||||||
top: 0.45rem;
|
top: 0.85rem;
|
||||||
width: 0.55rem;
|
width: 0.42rem;
|
||||||
height: 0.55rem;
|
height: 0.42rem;
|
||||||
|
margin-left: 0.28rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--gold);
|
||||||
border: 2px solid var(--panel);
|
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%);
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.j13-timeline-card {
|
.j13-timeline-card {
|
||||||
min-width: 0;
|
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 {
|
.j13-timeline-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.j13-timeline-title {
|
.j13-timeline-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14.5px;
|
flex: 1;
|
||||||
font-weight: 650;
|
min-width: 0;
|
||||||
line-height: 1.45;
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--ink);
|
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 {
|
.j13-timeline-src {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
|
opacity: 0.55;
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.j13-timeline-src:hover {
|
.j13-timeline-src:hover {
|
||||||
|
opacity: 1;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.j13-timeline-body {
|
.j13-timeline-body {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 13.5px;
|
padding-left: 0;
|
||||||
line-height: 1.65;
|
font-size: 12.5px;
|
||||||
color: var(--ink-2);
|
line-height: 1.55;
|
||||||
|
color: var(--ink-3);
|
||||||
}
|
}
|
||||||
.j13-timeline-body > :first-child { margin-top: 0; }
|
.j13-timeline-body > :first-child { margin-top: 0; }
|
||||||
.j13-timeline-body > :last-child { margin-bottom: 0; }
|
.j13-timeline-body > :last-child { margin-bottom: 0; }
|
||||||
|
.j13-timeline-body p {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 时间线编辑器 */
|
/* 时间线编辑器 */
|
||||||
.j13-tl-import {
|
.j13-tl-import {
|
||||||
@@ -4155,6 +4271,94 @@ a.j13-mention-token:hover {
|
|||||||
color: var(--ink);
|
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 {
|
.j13-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4450,6 +4654,26 @@ a.j13-mention-token:hover {
|
|||||||
Markdown 正文
|
Markdown 正文
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.prose-content { font-size: 16.5px; line-height: 1.85; color: var(--ink); }
|
.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 h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||||
.prose-content h2 {
|
.prose-content h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { fetchSitePageBySlug } from "@/lib/api";
|
import { fetchSitePageBySlug, fetchOverview } from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/format";
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
|
import PostToc from "@/components/PostToc";
|
||||||
|
import {
|
||||||
|
SiteDocShell,
|
||||||
|
SiteDocIdentity,
|
||||||
|
SiteDocPageNav,
|
||||||
|
SiteDocAnnoNav,
|
||||||
|
SiteDocMasthead,
|
||||||
|
SiteDocBreadcrumb,
|
||||||
|
} from "@/components/site-doc";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -29,56 +38,109 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
export default async function SitePagePublic({ params }: PageProps) {
|
export default async function SitePagePublic({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
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();
|
if (!data) notFound();
|
||||||
|
|
||||||
const p = data.page;
|
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 = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "WebPage",
|
||||||
headline: p.title,
|
name: p.title,
|
||||||
description: p.excerpt || undefined,
|
description: p.excerpt || undefined,
|
||||||
datePublished: p.created_at,
|
datePublished: p.created_at,
|
||||||
dateModified: p.updated_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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
<SiteDocShell
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
left={left}
|
||||||
首页
|
right={right}
|
||||||
</Link>
|
mobileExtras={
|
||||||
<ChevronRight size={13} />
|
<>
|
||||||
<span className="truncate" style={{ color: "var(--accent)" }}>
|
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
||||||
{p.title}
|
<SiteDocPageNav pages={sidebarPages} currentSlug={slug} title="文档目录" />
|
||||||
</span>
|
{toc.length === 0 && announcements.length > 0 ? (
|
||||||
</nav>
|
<SiteDocAnnoNav items={announcements} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SiteDocBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ href: "/", label: "首页" },
|
||||||
|
{ label: "关于本站" },
|
||||||
|
{ label: p.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<article className="panel p-6 sm:p-8">
|
<article className="panel p-6 sm:p-8">
|
||||||
<time className="meta text-[12px] tabular-nums" dateTime={p.updated_at || p.created_at}>
|
<SiteDocMasthead
|
||||||
{formatDate(p.updated_at || p.created_at)}
|
kicker="官方文件"
|
||||||
</time>
|
title={p.title}
|
||||||
<h1
|
excerpt={p.excerpt || undefined}
|
||||||
className="mt-2 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
|
createdAt={p.created_at}
|
||||||
style={{ color: "var(--ink)" }}
|
updatedAt={p.updated_at}
|
||||||
>
|
readingMinutes={readingMinutes}
|
||||||
{p.title}
|
showTimeOfDay
|
||||||
</h1>
|
/>
|
||||||
{p.excerpt ? (
|
|
||||||
<p className="mt-3 text-[14.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
|
||||||
{p.excerpt}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-5 pt-5" style={{ borderTop: "1px solid var(--line)" }}>
|
<MarkdownBody
|
||||||
<MarkdownBody content={p.content} />
|
content={p.content}
|
||||||
</div>
|
components={
|
||||||
|
headingToc.length > 0
|
||||||
|
? { h2: headingRenderer("h2"), h3: headingRenderer("h3") }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
||||||
|
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
||||||
|
<span>由站点管理团队维护</span>
|
||||||
</div>
|
</div>
|
||||||
|
</SiteDocShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
const isSearch = keyword.length > 0;
|
const isSearch = keyword.length > 0;
|
||||||
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
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);
|
const sort = normalizeSort(sp.sort, allowComments);
|
||||||
// 与 layout 共享同一份 /me 请求(React cache 去重)
|
// 与 layout 共享同一份 /me 请求(React cache 去重)
|
||||||
const mePromise = getMeCached(cookie || undefined);
|
const mePromise = getMeCached(cookie || undefined);
|
||||||
@@ -240,6 +241,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
allowComments={allowComments}
|
allowComments={allowComments}
|
||||||
canManageAnnouncements={canManageAnnouncements}
|
canManageAnnouncements={canManageAnnouncements}
|
||||||
sidebarPages={sidebarPages}
|
sidebarPages={sidebarPages}
|
||||||
|
siteDescription={publicSettings.site_description}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -273,6 +275,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
allowComments={allowComments}
|
allowComments={allowComments}
|
||||||
canManageAnnouncements={canManageAnnouncements}
|
canManageAnnouncements={canManageAnnouncements}
|
||||||
sidebarPages={sidebarPages}
|
sidebarPages={sidebarPages}
|
||||||
|
siteDescription={publicSettings.site_description}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
|||||||
import CommentSection from "@/components/CommentSection";
|
import CommentSection from "@/components/CommentSection";
|
||||||
import PostActions from "@/components/PostActions";
|
import PostActions from "@/components/PostActions";
|
||||||
import LikeButton from "@/components/LikeButton";
|
import LikeButton from "@/components/LikeButton";
|
||||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
import PostToc from "@/components/PostToc";
|
||||||
import MobilePostBar from "@/components/MobilePostBar";
|
import MobilePostBar from "@/components/MobilePostBar";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
@@ -41,6 +41,7 @@ import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
|
|||||||
import { canModerateBoard } from "@/lib/roles";
|
import { canModerateBoard } from "@/lib/roles";
|
||||||
import { IconComment, IconEye } from "@/components/Icons";
|
import { IconComment, IconEye } from "@/components/Icons";
|
||||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||||
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
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) {
|
function formatYMD(dateStr: string) {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
|
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 publicBody = publicMarkdownForSeo(post.content || "");
|
||||||
const toc = extractToc(publicBody);
|
const toc = extractToc(publicBody);
|
||||||
const readingMinutes = Math.max(
|
const headingToc = markdownHeadingToc(toc);
|
||||||
1,
|
const readingMinutes = estimateReadingMinutes(publicBody, post.title.length);
|
||||||
Math.ceil((publicBody.length > 0 ? publicBody.length : post.title.length) / 400)
|
|
||||||
);
|
|
||||||
const edited =
|
const edited =
|
||||||
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
|
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;
|
let headingIndex = 0;
|
||||||
const headingRenderer = (Tag: "h2" | "h3") =>
|
const headingRenderer = (Tag: "h2" | "h3") =>
|
||||||
function Heading({ children }: { children?: React.ReactNode }) {
|
function Heading({ children }: { children?: React.ReactNode }) {
|
||||||
const item = toc[headingIndex++];
|
const item = headingToc[headingIndex++];
|
||||||
return (
|
return (
|
||||||
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
|
<Tag id={item?.id} style={{ scrollMarginTop: 90 }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export default async function MarkdownBody({
|
|||||||
bodies.push(null);
|
bodies.push(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bodies.push(await renderMarkdownToReact(it.body, components));
|
// 条目正文不挂目录 id,避免与块外 headingRenderer 错位
|
||||||
|
bodies.push(await renderMarkdownToReact(it.body));
|
||||||
}
|
}
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<MdTimeline
|
<MdTimeline
|
||||||
@@ -74,7 +75,7 @@ export default async function MarkdownBody({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inner = seg.body.trim()
|
const inner = seg.body.trim()
|
||||||
? await renderMarkdownToReact(seg.body, components)
|
? await renderMarkdownToReact(seg.body)
|
||||||
: null;
|
: null;
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export default function MarkdownBodyClient({
|
|||||||
}: MarkdownBodyClientProps) {
|
}: MarkdownBodyClientProps) {
|
||||||
const segs = splitContentSegments(content || "");
|
const segs = splitContentSegments(content || "");
|
||||||
const mdComponents = mergeMarkdownComponents(components);
|
const mdComponents = mergeMarkdownComponents(components);
|
||||||
|
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
|
||||||
|
const nestedMd = mergeMarkdownComponents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@@ -49,7 +51,7 @@ export default function MarkdownBodyClient({
|
|||||||
items={seg.items}
|
items={seg.items}
|
||||||
renderBody={(it) =>
|
renderBody={(it) =>
|
||||||
it.body.trim() ? (
|
it.body.trim() ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||||
{it.body}
|
{it.body}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : null
|
) : null
|
||||||
@@ -74,7 +76,7 @@ export default function MarkdownBodyClient({
|
|||||||
return (
|
return (
|
||||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||||
{seg.body.trim() ? (
|
{seg.body.trim() ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||||
{seg.body}
|
{seg.body}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export default function PostToc({ items }: { items: TocItem[] }) {
|
|||||||
|
|
||||||
const jump = (id: string) => (e: React.MouseEvent) => {
|
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Info,
|
Info,
|
||||||
Pin,
|
Pin,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
PostListItem,
|
PostListItem,
|
||||||
@@ -27,6 +28,7 @@ import Avatar from "@/components/Avatar";
|
|||||||
import { PulseCheckin } from "./CheckinCard";
|
import { PulseCheckin } from "./CheckinCard";
|
||||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||||
|
import { pageGlyph } from "@/lib/pageGlyph";
|
||||||
|
|
||||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
/* ---------- 站点数据(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;
|
if (pages.length === 0) return null;
|
||||||
|
const lead = siteDescription?.trim() ?? "";
|
||||||
|
|
||||||
if (pages.length === 1) {
|
|
||||||
const p = pages[0];
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="关于本站">
|
<section className="j13-module j13-about" aria-label="关于本站">
|
||||||
<p className="j13-module-title">
|
<p className="j13-module-title">
|
||||||
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/p/${p.slug}`} className="block mt-2 rounded-lg px-0.5 py-1 transition-colors hover:bg-[var(--accent-soft)]">
|
{lead ? <p className="j13-about-lead">{lead}</p> : null}
|
||||||
<p className="text-[14px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
|
<ul className="j13-about-list">
|
||||||
{p.title}
|
{pages.map((p) => {
|
||||||
</p>
|
const Icon = pageGlyph(p.slug);
|
||||||
{p.excerpt ? (
|
|
||||||
<p className="meta text-[12.5px] mt-1.5 leading-relaxed line-clamp-3">{p.excerpt}</p>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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}>
|
<li key={p.id}>
|
||||||
<Link href={`/p/${p.slug}`} className="j13-raillink" data-tip={p.title}>
|
<Link href={`/p/${p.slug}`} className="j13-about-item">
|
||||||
<span className="flex-1 min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
<span className="j13-about-ico" aria-hidden="true">
|
||||||
{p.title}
|
<Icon size={15} strokeWidth={2} />
|
||||||
</span>
|
</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>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -421,6 +420,7 @@ type PanelsProps = {
|
|||||||
allowComments: boolean;
|
allowComments: boolean;
|
||||||
canManageAnnouncements?: boolean;
|
canManageAnnouncements?: boolean;
|
||||||
sidebarPages?: SidebarPageItem[];
|
sidebarPages?: SidebarPageItem[];
|
||||||
|
siteDescription?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CommunityPanelsBody({
|
function CommunityPanelsBody({
|
||||||
@@ -433,16 +433,17 @@ function CommunityPanelsBody({
|
|||||||
allowComments,
|
allowComments,
|
||||||
canManageAnnouncements,
|
canManageAnnouncements,
|
||||||
sidebarPages = [],
|
sidebarPages = [],
|
||||||
|
siteDescription,
|
||||||
}: PanelsProps) {
|
}: PanelsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||||
<AboutSite pages={sidebarPages} />
|
|
||||||
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
||||||
<HotRanking posts={hot} allowComments={allowComments} />
|
<HotRanking posts={hot} allowComments={allowComments} />
|
||||||
<NewUsers users={newUsers} />
|
<NewUsers users={newUsers} />
|
||||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||||
<FriendLinks />
|
<FriendLinks />
|
||||||
|
<AboutSite pages={sidebarPages} siteDescription={siteDescription} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
groupTimelineByDate,
|
groupTimelineByDate,
|
||||||
type TimelineItem,
|
type TimelineItem,
|
||||||
} from "@/lib/timelineBlocks";
|
} from "@/lib/timelineBlocks";
|
||||||
|
import { timelineDayId } from "@/lib/toc";
|
||||||
|
|
||||||
type MdTimelineProps = {
|
type MdTimelineProps = {
|
||||||
items: TimelineItem[];
|
items: TimelineItem[];
|
||||||
@@ -11,8 +12,24 @@ type MdTimelineProps = {
|
|||||||
renderBody?: (item: TimelineItem, index: number) => ReactNode;
|
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) {
|
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
@@ -24,19 +41,41 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="j13-timeline" role="list" aria-label="时间线">
|
<div className="j13-timeline" role="list" aria-label="时间线">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<div key={g.date} className="j13-timeline-day">
|
<section
|
||||||
<div className="j13-timeline-day-label">
|
key={g.date}
|
||||||
<time dateTime={g.date}>{g.date}</time>
|
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>
|
</div>
|
||||||
<ul className="j13-timeline-items">
|
<ul className="j13-timeline-items">
|
||||||
{g.items.map((it) => {
|
{g.items.map((it) => {
|
||||||
const idx = flat++;
|
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 (
|
return (
|
||||||
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
|
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
|
||||||
<span className="j13-timeline-dot" aria-hidden />
|
<span className="j13-timeline-dot" aria-hidden />
|
||||||
<div className="j13-timeline-card">
|
<div className="j13-timeline-card">
|
||||||
<div className="j13-timeline-head">
|
<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 ? (
|
{it.sourceUrl ? (
|
||||||
<a
|
<a
|
||||||
href={it.sourceUrl}
|
href={it.sourceUrl}
|
||||||
@@ -46,21 +85,17 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
|||||||
data-tip="查看提交"
|
data-tip="查看提交"
|
||||||
aria-label="查看提交"
|
aria-label="查看提交"
|
||||||
>
|
>
|
||||||
<ExternalLink size={13} />
|
<ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{renderBody ? (
|
{bodyNode ? <div className="j13-timeline-body">{bodyNode}</div> : null}
|
||||||
<div className="j13-timeline-body">{renderBody(it, idx)}</div>
|
|
||||||
) : it.body.trim() ? (
|
|
||||||
<div className="j13-timeline-body whitespace-pre-wrap">{it.body}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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";
|
||||||
20
frontend/lib/pageGlyph.ts
Normal file
20
frontend/lib/pageGlyph.ts
Normal 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
102
frontend/lib/toc.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user