Files
jiang13-bbs/frontend/app/announcements/page.tsx
freefire d784b0ea7a feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 05:27:33 +08:00

196 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
export const metadata: Metadata = {
title: "站点公告",
description: "站点公告与通知列表",
};
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
/** 粗略去掉 Markdown,取摘要 */
function plainSummary(md: string, max = 120): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
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(--panel-2)] focus-visible:bg-[var(--panel-2)]"
>
<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);
return (
<Link
href={`/announcement/${a.id}`}
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
<div className="flex items-center gap-2 flex-wrap">
{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="mt-2 text-[16px] sm:text-[17px] font-semibold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-1.5 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
</Link>
);
}
export default async function AnnouncementsListPage({ searchParams }: PageProps) {
const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const size = 20;
let announcements: Announcement[] = [];
let total = 0;
let error = "";
try {
const res = await fetchAnnouncementsPage(page, size);
announcements = res.announcements ?? [];
total = res.total ?? 0;
} catch (e) {
error = e instanceof Error ? e.message : "加载公告失败";
}
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="关于本站" canManage={canManagePages} />
);
return (
<SiteDocShell
left={left}
right={right}
mobileExtras={
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
}
>
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<SiteDocBreadcrumb
className="mb-3"
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
</p>
</header>
{error ? (
<p className="alert-error m-5">{error}</p>
) : announcements.length === 0 ? (
<div className="p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无公告
</p>
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<>
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
))}
</>
)}
</div>
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />
) : null}
</SiteDocShell>
);
}