feat: 关于本站对齐站点公告,增加全部与管理入口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 01:47:39 +08:00
parent 184489bca2
commit 2ccfb5f330
15 changed files with 384 additions and 80 deletions

156
frontend/app/about/page.tsx Normal file
View File

@@ -0,0 +1,156 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Info } from "lucide-react";
import {
fetchPublishedSitePages,
fetchOverview,
type SitePageSummary,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import {
SiteDocShell,
SiteDocIdentity,
SiteDocAnnoNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
export const metadata: Metadata = {
title: "关于本站",
description: "站点说明、政策与帮助文档",
};
function PageRow({
p,
showDivider,
}: {
p: SitePageSummary;
showDivider: boolean;
}) {
const Icon = pageGlyph(p.slug);
return (
<Link
href={`/p/${p.slug}`}
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-start gap-3 min-w-0">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Icon size={16} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 flex-wrap">
<h2
className="text-[16px] sm:text-[17px] font-semibold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{p.title}
</h2>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={p.updated_at}>
{formatDate(p.updated_at || p.created_at)}
</time>
</span>
{p.excerpt ? (
<p className="mt-1.5 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{p.excerpt}
</p>
) : null}
</span>
</div>
</Link>
);
}
export default async function AboutIndexPage() {
const cookie = authCookieHeader(await cookies());
let pages: SitePageSummary[] = [];
let error = "";
try {
const res = await fetchPublishedSitePages();
pages = res.pages ?? [];
} catch (e) {
error = e instanceof Error ? e.message : "加载页面失败";
}
const [overview, settings, me] = await Promise.all([
fetchOverview().catch(() => null),
getPublicSettingsCached(),
getMeCached(cookie || undefined),
]);
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
const right = <SiteDocAnnoNav items={announcements} />;
return (
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
]}
/>
}
mobileExtras={
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
}
>
<header className="mb-5 flex items-end justify-between gap-3">
<div>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Info size={26} style={{ color: "var(--accent)" }} aria-hidden />
关于本站
</h1>
<p className="meta mt-2">
{error
? "加载失败"
: pages.length > 0
? `共 ${pages.length} 篇站点文档`
: "站点说明与政策文档"}
</p>
</div>
{canManagePages ? (
<Link
href="/admin/pages"
className="meta text-[13px] shrink-0 mb-0.5 transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</header>
{error ? (
<p className="alert-error">{error}</p>
) : pages.length === 0 ? (
<div className="panel p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无介绍
</p>
<p className="meta mt-1.5">有新文档时会显示在这里</p>
</div>
) : (
<div className="panel overflow-hidden">
{pages.map((p, i) => (
<PageRow key={p.id} p={p} showDivider={i > 0} />
))}
</div>
)}
</SiteDocShell>
);
}

View File

@@ -5,6 +5,7 @@ import { Megaphone } from "lucide-react";
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -53,6 +54,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined;
const toc = extractToc(a.content);
const headingToc = markdownHeadingToc(toc);
@@ -89,7 +91,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const right = (
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocPageNav pages={sidebarPages} title="关于本站" />
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
</>
);
@@ -116,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocAnnoNav items={announcements} currentId={annId} />
<SiteDocPageNav pages={sidebarPages} title="关于本站" />
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
</>
}
>

View File

@@ -1,8 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
@@ -114,19 +117,24 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
error = e instanceof Error ? e.message : "加载公告失败";
}
const [overview, settings] = await Promise.all([
const cookie = authCookieHeader(await cookies());
const [overview, settings, me] = await Promise.all([
fetchOverview().catch(() => null),
getPublicSettingsCached(),
getMeCached(cookie || undefined),
]);
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
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="关于本站" />;
const right = (
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
);
return (
<SiteDocShell
@@ -140,7 +148,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
]}
/>
}
mobileExtras={<SiteDocPageNav pages={sidebarPages} title="关于本站" />}
mobileExtras={
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
}
>
<header className="mb-5">
<h1

View File

@@ -1,8 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { Megaphone } from "lucide-react";
import { fetchSitePageBySlug, fetchOverview } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -38,16 +41,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function SitePagePublic({ params }: PageProps) {
const { slug } = await params;
const [data, overview, settings] = await Promise.all([
const cookie = authCookieHeader(await cookies());
const [data, overview, settings, me] = await Promise.all([
fetchSitePageBySlug(slug).catch(() => null),
fetchOverview().catch(() => null),
getPublicSettingsCached(),
getMeCached(cookie || undefined),
]);
if (!data) notFound();
const p = data.page;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined;
const toc = extractToc(p.content);
const headingToc = markdownHeadingToc(toc);
@@ -76,7 +82,11 @@ export default async function SitePagePublic({ params }: PageProps) {
const left = (
<>
<SiteDocIdentity kind="about" lead={siteLead} />
<SiteDocPageNav pages={sidebarPages} currentSlug={slug} />
<SiteDocPageNav
pages={sidebarPages}
currentSlug={slug}
canManage={canManagePages}
/>
</>
);
@@ -101,7 +111,7 @@ export default async function SitePagePublic({ params }: PageProps) {
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
{ href: "/about", label: "关于本站" },
{ label: p.title },
]}
/>
@@ -109,7 +119,12 @@ export default async function SitePagePublic({ params }: PageProps) {
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
<SiteDocPageNav pages={sidebarPages} currentSlug={slug} title="文档目录" />
<SiteDocPageNav
pages={sidebarPages}
currentSlug={slug}
title="文档目录"
canManage={canManagePages}
/>
{toc.length === 0 && announcements.length > 0 ? (
<SiteDocAnnoNav items={announcements} />
) : null}

View File

@@ -114,6 +114,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
const canManagePages = canManageAnnouncements;
const sidebarPages = overview?.sidebar_pages ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;
@@ -251,6 +252,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
canManagePages={canManagePages}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>
@@ -285,6 +287,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
canManagePages={canManagePages}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>

View File

@@ -1,5 +1,9 @@
import type { MetadataRoute } from "next";
import { fetchPosts, fetchAnnouncementsPage, fetchOverview } from "@/lib/api";
import {
fetchPosts,
fetchAnnouncementsPage,
fetchPublishedSitePages,
} from "@/lib/api";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "http://localhost:3000";
@@ -11,6 +15,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily",
priority: 0.7,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
];
try {
@@ -41,13 +51,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 */
}
// 公开单页列表 API 尚未提供:用 overview 侧栏页近似收录已发布且展示中的页面
try {
const overview = await fetchOverview();
for (const p of overview.sidebar_pages ?? []) {
const { pages } = await fetchPublishedSitePages();
for (const p of pages) {
entries.push({
url: `${baseUrl}/p/${p.slug}`,
lastModified: new Date(),
lastModified: new Date(p.updated_at || p.created_at),
changeFrequency: "monthly",
priority: 0.4,
});

View File

@@ -11,7 +11,6 @@ import {
PenLine,
Activity,
TrendingUp,
Info,
Pin,
ChevronRight,
} from "lucide-react";
@@ -29,6 +28,7 @@ import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -127,38 +127,42 @@ function PulseCard({
function AboutSite({
pages,
siteDescription,
canManagePages,
}: {
pages: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
}) {
if (pages.length === 0) return null;
if (pages.length === 0 && !canManagePages) return null;
const lead = siteDescription?.trim() ?? "";
return (
<section className="j13-module j13-about" aria-label="关于本站">
<p className="j13-module-title">
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
</p>
<AboutSiteHeader canManage={canManagePages} />
{lead ? <p className="j13-about-lead">{lead}</p> : null}
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item">
<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>
{pages.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无介绍</p>
) : (
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item">
<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>
);
}
@@ -419,6 +423,7 @@ type PanelsProps = {
checkin?: CheckinStatus | null;
allowComments: boolean;
canManageAnnouncements?: boolean;
canManagePages?: boolean;
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
};
@@ -432,6 +437,7 @@ function CommunityPanelsBody({
checkin,
allowComments,
canManageAnnouncements,
canManagePages,
sidebarPages = [],
siteDescription,
}: PanelsProps) {
@@ -443,7 +449,11 @@ function CommunityPanelsBody({
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks />
<AboutSite pages={sidebarPages} siteDescription={siteDescription} />
<AboutSite
pages={sidebarPages}
siteDescription={siteDescription}
canManagePages={canManagePages}
/>
</>
);
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
import { Info } from "lucide-react";
const linkClass =
"meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]";
/** 关于本站模块标题栏:与站点公告一致,全员「全部」、管理员另见「管理」 */
export default function AboutSiteHeader({
title = "关于本站",
canManage,
}: {
title?: string;
canManage?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 shrink-0">
<p className="j13-module-title !mb-0">
<Info size={15} style={{ color: "var(--accent)" }} /> {title}
</p>
<span className="inline-flex items-center gap-2 shrink-0">
<Link href="/about" className={linkClass}>
全部
</Link>
{canManage ? (
<Link href="/admin/pages" className={linkClass}>
管理
</Link>
) : null}
</span>
</div>
);
}

View File

@@ -15,7 +15,7 @@ const META: Record<
},
about: {
title: "关于本站",
href: "/",
href: "/about",
Icon: Info,
fallbackLead: "站点说明与政策文档",
},

View File

@@ -1,57 +1,62 @@
import Link from "next/link";
import { ChevronRight, Info } from "lucide-react";
import { ChevronRight } from "lucide-react";
import type { SidebarPageItem } from "@/lib/api";
import { pageGlyph } from "@/lib/pageGlyph";
import AboutSiteHeader from "./AboutSiteHeader";
/** 侧栏单页兄弟目录(高亮当前 slug) */
/** 侧栏单页兄弟目录(高亮当前 slug);标题栏与首页「关于本站」一致 */
export default function SiteDocPageNav({
pages,
currentSlug,
title = "文档目录",
canManage,
}: {
pages: SidebarPageItem[];
currentSlug?: string;
title?: string;
canManage?: boolean;
}) {
if (pages.length === 0) return null;
if (pages.length === 0 && !canManage) 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>
<AboutSiteHeader title={title} canManage={canManage} />
{pages.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无介绍</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

@@ -1,5 +1,6 @@
export { default as SiteDocShell } from "./SiteDocShell";
export { default as SiteDocIdentity } from "./SiteDocIdentity";
export { default as AboutSiteHeader } from "./AboutSiteHeader";
export { default as SiteDocPageNav } from "./SiteDocPageNav";
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
export { default as SiteDocMasthead } from "./SiteDocMasthead";

View File

@@ -124,6 +124,16 @@ export interface SidebarPageItem {
excerpt: string;
}
/** 公开单页目录(/about 全部页) */
export interface SitePageSummary {
id: number;
slug: string;
title: string;
excerpt: string;
created_at: string;
updated_at: string;
}
export interface SitePage {
id: number;
slug: string;
@@ -1680,6 +1690,14 @@ export async function apiTimelineFromGit(input: {
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
}
/** SSR / 公开:已发布单页目录 */
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取页面失败");
return { pages: data.pages ?? [] };
}
export async function fetchSitePageBySlug(
slug: string
): Promise<{ page: SitePage } | null> {