- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
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 { 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 hasLeftRail = scopeLists.left.length > 0;
|
||
const hasRightRail = showBoardPanels
|
||
? boardSidebar
|
||
? scopeLists.right.length > 0
|
||
: boardPanelsMissing
|
||
: !!(overview && scopeLists.right.length > 0);
|
||
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,
|
||
urlStyle: publicSettings.url_style,
|
||
});
|
||
|
||
// 搜索态:单栏占满,无 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 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
|
||
{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 className="j13-fluid-home">
|
||
<JsonLd data={listingLd} />
|
||
<WorkspaceNavMobile boards={boards} />
|
||
|
||
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "lg:mt-0"}`}>
|
||
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
|
||
{scopeLists.left.length > 0 && (
|
||
<aside className="j13-rail-left 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 ${hasLeftRail ? "j13-home-sep-l" : ""} ${
|
||
hasRightRail ? "j13-home-sep-r" : ""
|
||
}`}
|
||
>
|
||
<h1 className="sr-only">{pageName}</h1>
|
||
|
||
<div className="max-sm:-mx-4">
|
||
<div className="j13-feed-head flex items-stretch justify-between gap-2 px-3 sm:px-4">
|
||
<FeedTabs current={sort} boardId={boardId} />
|
||
<span className="meta self-center 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="j13-flat-rail mt-3 lg:hidden">
|
||
<SidebarRenderer
|
||
blocks={mobileBlocks}
|
||
widgets={sidebar.widgets}
|
||
props={sidebarProps}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|