From 2ccfb5f330ae762cac261effb4c5e53bffca1b2a Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 18 Sep 2026 01:47:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=B3=E4=BA=8E=E6=9C=AC=E7=AB=99?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=AB=99=E7=82=B9=E5=85=AC=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=A8=E9=83=A8=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/handler/site_page.go | 10 ++ backend/router/router.go | 1 + backend/service/site_page.go | 32 ++++ frontend/app/about/page.tsx | 156 ++++++++++++++++++ frontend/app/announcement/[id]/page.tsx | 6 +- frontend/app/announcements/page.tsx | 18 +- frontend/app/p/[slug]/page.tsx | 25 ++- frontend/app/page.tsx | 3 + frontend/app/sitemap.ts | 19 ++- frontend/components/home/CommunityPanels.tsx | 60 ++++--- .../components/site-doc/AboutSiteHeader.tsx | 32 ++++ .../components/site-doc/SiteDocIdentity.tsx | 2 +- .../components/site-doc/SiteDocPageNav.tsx | 81 ++++----- frontend/components/site-doc/index.ts | 1 + frontend/lib/api.ts | 18 ++ 15 files changed, 384 insertions(+), 80 deletions(-) create mode 100644 frontend/app/about/page.tsx create mode 100644 frontend/components/site-doc/AboutSiteHeader.tsx 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 + ? "加载失败" + : pages.length > 0 + ? `共 ${pages.length} 篇站点文档` + : "站点说明与政策文档"} +

+
+ {canManagePages ? ( + + 管理 + + ) : 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> {