diff --git a/backend/handler/site_page.go b/backend/handler/site_page.go
index 9bb1aa6..5e0bbfd 100644
--- a/backend/handler/site_page.go
+++ b/backend/handler/site_page.go
@@ -10,6 +10,16 @@ import (
"gorm.io/gorm"
)
+// SitePagesList 已发布单页目录(全部页)
+func (h *Handlers) SitePagesList(c *gin.Context) {
+ list, err := h.SitePage.ListPublished()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取页面失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"pages": nonNilSlice(list)})
+}
+
// SitePageDetail 公开单页详情(按 slug)
func (h *Handlers) SitePageDetail(c *gin.Context) {
slug := c.Param("slug")
diff --git a/backend/router/router.go b/backend/router/router.go
index b0a49d5..b67c8a4 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -131,6 +131,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
+ pubAPI.GET("/pages", h.SitePagesList)
pubAPI.GET("/pages/:slug", h.SitePageDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
diff --git a/backend/service/site_page.go b/backend/service/site_page.go
index 26a83ba..acf6362 100644
--- a/backend/service/site_page.go
+++ b/backend/service/site_page.go
@@ -4,6 +4,7 @@ import (
"errors"
"regexp"
"strings"
+ "time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
@@ -47,6 +48,16 @@ type SidebarPageItem struct {
Excerpt string `json:"excerpt"`
}
+// PublishedPageItem 公开单页目录(全部页,含未进侧栏的已发布页)
+type PublishedPageItem struct {
+ ID uint `json:"id"`
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Excerpt string `json:"excerpt"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
func (in *SitePageInput) normalize(existing *model.SitePage) error {
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
if in.Slug == "" {
@@ -133,6 +144,27 @@ func (s *SitePageService) ListSidebarPublished() ([]SidebarPageItem, error) {
return out, nil
}
+// ListPublished 已发布单页目录(全部页;含未勾选侧栏的)
+func (s *SitePageService) ListPublished() ([]PublishedPageItem, error) {
+ var pages []model.SitePage
+ err := s.db.Where("published = ?", true).
+ Order("sort_order ASC, id ASC").
+ Select("id", "slug", "title", "excerpt", "created_at", "updated_at").
+ Limit(100).
+ Find(&pages).Error
+ if err != nil {
+ return nil, err
+ }
+ out := make([]PublishedPageItem, 0, len(pages))
+ for _, p := range pages {
+ out = append(out, PublishedPageItem{
+ ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
+ CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
+ })
+ }
+ return out, nil
+}
+
// GetPublishedBySlug 公开详情(仅已发布)
func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx
new file mode 100644
index 0000000..7d722a5
--- /dev/null
+++ b/frontend/app/about/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {p.title}
+
+
+
+ {p.excerpt ? (
+
+ {p.excerpt}
+
+ ) : null}
+
+
+
+ );
+}
+
+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 = ;
+ const right = ;
+
+ return (
+
+ }
+ mobileExtras={
+ announcements.length > 0 ? : null
+ }
+ >
+
+
+ {error ? (
+ {error}
+ ) : pages.length === 0 ? (
+
+
+ 暂无介绍
+
+
有新文档时会显示在这里
+
+ ) : (
+
+ {pages.map((p, i) => (
+
0} />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx
index 8e151ca..b838e3b 100644
--- a/frontend/app/announcement/[id]/page.tsx
+++ b/frontend/app/announcement/[id]/page.tsx
@@ -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 ? : null}
-
+
>
);
@@ -116,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<>
{toc.length > 0 ? : null}
-
+
>
}
>
diff --git a/frontend/app/announcements/page.tsx b/frontend/app/announcements/page.tsx
index a823a2f..0c3d174 100644
--- a/frontend/app/announcements/page.tsx
+++ b/frontend/app/announcements/page.tsx
@@ -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 = ;
- const right = ;
+ const right = (
+
+ );
return (
}
- mobileExtras={}
+ mobileExtras={
+
+ }
>
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 = (
<>
-
+
>
);
@@ -101,7 +111,7 @@ export default async function SitePagePublic({ params }: PageProps) {
@@ -109,7 +119,12 @@ export default async function SitePagePublic({ params }: PageProps) {
mobileExtras={
<>
{toc.length > 0 ? : null}
-
+
{toc.length === 0 && announcements.length > 0 ? (
) : null}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index fcf480b..b03df75 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -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}
/>
diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts
index 5b04da5..5080d6a 100644
--- a/frontend/app/sitemap.ts
+++ b/frontend/app/sitemap.ts
@@ -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 {
const baseUrl = "http://localhost:3000";
@@ -11,6 +15,12 @@ export default async function sitemap(): Promise {
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 {
/* 忽略 */
}
- // 公开单页列表 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,
});
diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx
index aab004a..1c22ce1 100644
--- a/frontend/components/home/CommunityPanels.tsx
+++ b/frontend/components/home/CommunityPanels.tsx
@@ -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 (
-
- 关于本站
-
+
{lead ? {lead}
: null}
-
- {pages.map((p) => {
- const Icon = pageGlyph(p.slug);
- return (
- -
-
-
-
-
-
- {p.title}
- {p.excerpt ? {p.excerpt} : null}
-
-
-
-
- );
- })}
-
+ {pages.length === 0 ? (
+ 暂无介绍
+ ) : (
+
+ {pages.map((p) => {
+ const Icon = pageGlyph(p.slug);
+ return (
+ -
+
+
+
+
+
+ {p.title}
+ {p.excerpt ? {p.excerpt} : null}
+
+
+
+
+ );
+ })}
+
+ )}
);
}
@@ -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({
-
+
>
);
}
diff --git a/frontend/components/site-doc/AboutSiteHeader.tsx b/frontend/components/site-doc/AboutSiteHeader.tsx
new file mode 100644
index 0000000..39f25eb
--- /dev/null
+++ b/frontend/components/site-doc/AboutSiteHeader.tsx
@@ -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 (
+
+
+ {title}
+
+
+
+ 全部
+
+ {canManage ? (
+
+ 管理
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/components/site-doc/SiteDocIdentity.tsx b/frontend/components/site-doc/SiteDocIdentity.tsx
index 7ef9b58..e903f35 100644
--- a/frontend/components/site-doc/SiteDocIdentity.tsx
+++ b/frontend/components/site-doc/SiteDocIdentity.tsx
@@ -15,7 +15,7 @@ const META: Record<
},
about: {
title: "关于本站",
- href: "/",
+ href: "/about",
Icon: Info,
fallbackLead: "站点说明与政策文档",
},
diff --git a/frontend/components/site-doc/SiteDocPageNav.tsx b/frontend/components/site-doc/SiteDocPageNav.tsx
index 2c83e68..9d2ef00 100644
--- a/frontend/components/site-doc/SiteDocPageNav.tsx
+++ b/frontend/components/site-doc/SiteDocPageNav.tsx
@@ -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 (
-
- {title}
-
-
- {pages.map((p) => {
- const Icon = pageGlyph(p.slug);
- const current = currentSlug === p.slug;
- return (
- -
-
-
-
-
-
- {p.title}
- {p.excerpt ? {p.excerpt} : null}
-
-
-
-
- );
- })}
-
+
+ {pages.length === 0 ? (
+ 暂无介绍
+ ) : (
+
+ {pages.map((p) => {
+ const Icon = pageGlyph(p.slug);
+ const current = currentSlug === p.slug;
+ return (
+ -
+
+
+
+
+
+ {p.title}
+ {p.excerpt ? {p.excerpt} : null}
+
+
+
+
+ );
+ })}
+
+ )}
);
}
diff --git a/frontend/components/site-doc/index.ts b/frontend/components/site-doc/index.ts
index ecec572..507e478 100644
--- a/frontend/components/site-doc/index.ts
+++ b/frontend/components/site-doc/index.ts
@@ -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";
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 9c6284e..c31a13a 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -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> {