Files
jiang13-bbs/frontend/app/announcements/page.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

191 lines
6.8 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,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { canonicalPage } from "@/lib/siteUrl";
import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const sp = await searchParams;
const page = canonicalPage(sp.page);
return {
title: "站点公告",
description: "站点公告与通知列表",
...(await requestCanonical("/announcements", page ? { page } : undefined)),
};
}
/** 粗略去掉 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, urlStyle }: { a: Announcement; urlStyle: UrlStyle }) {
const summary = plainSummary(a.content, 180);
return (
<Link
href={announcementPath(urlStyle, 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, urlStyle }: { a: Announcement; showDivider: boolean; urlStyle: UrlStyle }) {
const summary = plainSummary(a.content);
return (
<Link
href={announcementPath(urlStyle, 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 urlStyle = settings.url_style;
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}>
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<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} urlStyle={urlStyle} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} urlStyle={urlStyle} />
))}
</>
)}
</div>
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />
) : null}
</SiteDocShell>
);
}