Files
jiang13-bbs/frontend/app/page.tsx
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

344 lines
13 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 { PenSquare, SearchX, MessagesSquare } from "lucide-react";
import {
fetchPosts,
fetchOverview,
fetchBoardSidebar,
fetchSidebar,
type OverviewResponse,
type BoardSidebarResponse,
type SidebarPublicPayload,
} from "@/lib/api";
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { siteDocumentTitle } from "@/lib/brand";
import { isAdminOrAbove } from "@/lib/roles";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import { BoardMissingPanel } from "@/components/home/BoardPanels";
import { requestCanonical } from "@/lib/canonicalMeta";
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
import { listingJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface HomePageProps {
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
}
export async function generateMetadata({ searchParams }: HomePageProps): Promise<Metadata> {
const settings = await getPublicSettingsCached();
const sp = await searchParams;
const allowComments = settings.allow_comments !== false;
return {
title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
description: settings.site_description,
...(await requestCanonical("/", homeCanonicalQuery(sp, allowComments))),
};
}
// 空结果面板(无帖 / 无搜索结果)
function EmptyState({ keyword }: { keyword: string }) {
return (
<div className="text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{keyword ? <SearchX size={26} /> : <MessagesSquare size={26} />}
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>
{keyword ? "没有找到相关帖子" : "还没有帖子"}
</p>
<p className="meta mt-1.5">
{keyword ? `换个关键词试试,或者发起“${keyword}”相关的话题` : "成为第一个发起讨论的人吧"}
</p>
{keyword ? (
<div className="flex items-center justify-center gap-3 mt-6">
<Link href="/" className="btn btn-line">
清除搜索
</Link>
<Link href="/compose" className="btn btn-primary">
<PenSquare size={15} /> 发起这个话题
</Link>
</div>
) : (
<Link href="/compose" className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
)}
</div>
);
}
export default async function HomePage({ searchParams }: HomePageProps) {
const sp = await searchParams;
const keyword = sp.q?.trim() || "";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const boardId = Math.max(0, parseInt(sp.board || "0", 10) || 0);
const isSearch = keyword.length > 0;
const cookie = authCookieHeader(await cookies());
const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false;
const sort = normalizeFeedSort(sp.sort, allowComments);
// 与 layout 共享同一份 /me 请求(React cache 去重)
const mePromise = getMeCached(cookie || undefined);
const postsPromise = fetchPosts(
page,
20,
isSearch || boardId <= 0 ? undefined : boardId,
sort,
keyword,
sort === "recommended",
cookie
);
// 聚合面板失败不阻塞帖子流
const overviewPromise: Promise<OverviewResponse | null> = fetchOverview(cookie).catch(() => null);
const boardSidebarPromise: Promise<BoardSidebarResponse | null> =
boardId > 0 && !isSearch
? fetchBoardSidebar(boardId, cookie).catch(() => null)
: Promise.resolve(null);
// 侧栏布局配置失败不阻塞页面,回落默认布局
const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch
? Promise.resolve(null)
: fetchSidebar(cookie).catch(() => null);
const [me, postsData, overview, boardSidebar, sidebarData] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
sidebarPromise,
]);
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
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;
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
const boardPanelsMissing = showBoardPanels && !boardSidebar;
// 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
const sidebarProps: SidebarBlockProps = {
allowComments,
user,
boards,
checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
...(overview
? {
stats: overview.stats,
announcements: overview.announcements,
canManageAnnouncements,
ads: overview.ads ?? [],
adsPanelTitle: overview.ads_panel_title || "自助推广",
adsEnabled: overview.ads_enabled !== false,
sponsors: overview.sponsors ?? [],
sponsorsPanelTitle: overview.sponsors_panel_title || "赞助商",
sponsorsEnabled: overview.sponsors_enabled !== false,
hot: overview.hot,
newUsers: overview.new_users,
activeUsers: overview.active_users,
}
: {}),
canManagePages,
sidebarPages,
siteDescription: publicSettings.site_description,
...(boardSidebar
? {
board: boardSidebar.board,
moderators: boardSidebar.moderators,
boardStats: boardSidebar.stats,
boardHot: boardSidebar.hot,
boardActiveUsers: boardSidebar.active_users,
}
: {}),
};
// 窄屏堆叠:右栏列表 + 左栏列表(板块目录已在顶部有横向板块条,跳过)
const mobileBlocks = [
...scopeLists.right,
...scopeLists.left.filter((r) => r.key !== "board_nav"),
];
const pageName = isSearch
? `搜索“${keyword}”`
: activeBoard
? activeBoard.name
: sort === "new"
? "最新发布"
: sort === "recommended"
? "推荐帖"
: "最新讨论";
const listingLd = listingJsonLd({
siteName: publicSettings.site_name || "姜十三论坛",
siteDescription: publicSettings.site_description,
pageName,
path: "/",
query: homeCanonicalQuery(sp, allowComments),
items: postsData.posts.map((p) => ({ id: p.id, name: p.title })),
page,
pageSize: 20,
});
// 搜索态:单栏占满,无 Tabs/侧栏
if (isSearch) {
return (
<div className="max-w-3xl mx-auto">
<JsonLd data={listingLd} />
<div className="mb-5">
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
搜索“<span style={{ color: "var(--accent)" }}>{keyword}</span>”
</h1>
<p className="meta mt-2">共 {postsData.total} 篇结果 · 按相关度与时间排序</p>
</div>
<div className="panel overflow-hidden">
{postsData.posts.length > 0 ? (
postsData.posts.map((post, i) => (
<PostRow key={post.id} post={post} showBoard index={i} showViews={false} showLike={false} />
))
) : (
<EmptyState keyword={keyword} />
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath="/"
query={{
q: keyword,
...(sort !== defaultFeedSort(allowComments) ? { sort } : {}),
}}
/>
</div>
);
}
// 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器
const pageQuery: Record<string, string> = {};
if (boardId > 0) pageQuery.board = String(boardId);
if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
return (
<div>
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} user={user} />
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
{scopeLists.left.length > 0 && (
<aside className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
</aside>
)}
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
<div className="flex-1 min-w-0">
<h1 className="sr-only">{pageName}</h1>
<div className="panel overflow-hidden">
{activeBoard ? (
<div className="px-3 sm:px-4 pt-3">
<SiteDocBreadcrumb
className="is-ruled !mb-0"
items={[
{ href: "/", label: "首页" },
{ label: activeBoard.name },
]}
/>
</div>
) : null}
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
<FeedTabs current={sort} boardId={boardId} />
<span className="meta shrink-0 text-[12px] tabular-nums">
共 {postsData.total} 篇
</span>
</div>
{user && (
<FeedNewTip
sort={sort}
boardId={boardId}
page={page}
userId={user.id}
/>
)}
{postsData.posts.length > 0 ? (
postsData.posts.map((post, i) => (
<PostRow
key={post.id}
post={post}
showBoard={!activeBoard}
compact
index={i}
showViews={false}
showLike={false}
/>
))
) : (
<EmptyState keyword="" />
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath="/"
query={Object.keys(pageQuery).length > 0 ? pageQuery : undefined}
/>
</div>
{/* 右栏:选中板块时切板级面板,否则全站社区面板;均按配置渲染 */}
{showBoardPanels ? (
boardSidebar ? (
scopeLists.right.length > 0 ? (
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</aside>
) : null
) : boardPanelsMissing ? (
<BoardMissingPanel />
) : null
) : overview && scopeLists.right.length > 0 ? (
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</aside>
) : null}
</div>
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
{mobileBlocks.length > 0 && (
<div className="mt-6 lg:hidden">
<SidebarRenderer
blocks={mobileBlocks}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</div>
)}
</div>
);
}