chore: 去掉未上线前的旧兼容垫片,板块流收口到首页

鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:04:47 +08:00
parent 0e61a14ea3
commit 833bcd33a1
33 changed files with 715 additions and 548 deletions

View File

@@ -270,7 +270,7 @@ export default function ModerationAdmin({
<div key={p.id} className="panel p-4 sm:p-5">
<div className="flex items-center gap-2 flex-wrap mb-2.5">
<Link
href={`/board/${p.board.id}`}
href={`/?board=${p.board.id}`}
className="chip"
style={{ fontSize: 11.5 }}
>
@@ -354,7 +354,7 @@ export default function ModerationAdmin({
{c.user.nickname}
</span>
<Link
href={`/board/${c.board.id}`}
href={`/?board=${c.board.id}`}
className="chip"
style={{ fontSize: 11 }}
>

View File

@@ -1,156 +0,0 @@
import Link from "next/link";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react";
import { fetchPosts, fetchBoards } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import PinnedPosts from "@/components/PinnedPosts";
import FeedNewTip from "@/components/home/FeedNewTip";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const { boards } = await fetchBoards();
const board = boards.find((b) => b.id === Number(id));
return {
title: board ? `${board.name} · 姜十三论坛` : "板块 · 姜十三论坛",
description: board?.description || "板块帖子列表",
};
}
export default async function BoardPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const cookie = authCookieHeader(await cookies());
const [postsData, boardsData, me] = await Promise.all([
fetchPosts(page, 20, Number(id), "latest", "", false, cookie),
fetchBoards(),
getMeCached(cookie || undefined),
]);
const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id));
const board = boardIndex >= 0 ? boardsData.boards[boardIndex] : null;
const user = me.user;
// 第一页:置顶帖独立成区,列表只保留非置顶
const pinned = page === 1 ? postsData.posts.filter((p) => p.pinned > 0) : [];
const rows = page === 1 ? postsData.posts.filter((p) => p.pinned <= 0) : postsData.posts;
return (
<div>
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>{board?.name || "未知板块"}</span>
</nav>
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
<span
aria-hidden
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
style={{ background: "var(--accent-soft)" }}
/>
<div className="relative flex items-end justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<span
className={`av-${Math.max(0, boardIndex) % 8} w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center text-2xl font-extrabold shrink-0`}
>
{board?.name.slice(0, 1) || "?"}
</span>
<div>
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{board?.name || "未知板块"}
</h1>
{board?.description && (
<p className="mt-1 text-sm" style={{ color: "var(--ink-2)" }}>
{board.description}
</p>
)}
<p className="meta mt-1.5">{postsData.total} 篇讨论</p>
</div>
</div>
<Link href={`/compose?board=${id}`} className="btn btn-primary">
<PenSquare size={15} /> 在此板块发帖
</Link>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_290px] gap-6">
<div>
<PinnedPosts posts={pinned} showBoard={false} />
{rows.length > 0 ? (
<div className="panel overflow-hidden">
<div className="px-4 sm:px-5 py-3.5 flex items-center justify-between" style={{ borderBottom: "1px solid var(--line)" }}>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>全部讨论</span>
<span className="meta text-xs">第 {page} 页</span>
</div>
{user && (
<div className="pt-2.5">
<FeedNewTip
sort="latest"
boardId={Number(id)}
page={page}
userId={user.id}
/>
</div>
)}
{rows.map((post, i) => (
<PostRow key={post.id} post={post} index={i} />
))}
</div>
) : (
pinned.length === 0 && (
<div className="panel 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)" }}
>
<MessagesSquare size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>该板块还没有帖子</p>
<p className="meta mt-1.5">来发第一篇,抢占沙发</p>
<Link href={`/compose?board=${id}`} className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
</div>
)
)}
<Pagination total={postsData.total} page={page} size={20} basePath={`/board/${id}`} />
</div>
<aside>
<div className="panel p-5">
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>其他板块</p>
<div className="space-y-1">
{boardsData.boards
.map((b, i) => ({ b, i }))
.filter(({ b }) => b.id !== Number(id))
.map(({ b, i }) => (
<Link
key={b.id}
href={`/board/${b.id}`}
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}>
{b.name.slice(0, 1)}
</span>
<span className="text-sm font-medium flex-1 truncate group-hover:text-[var(--accent)] transition-colors" style={{ color: "var(--ink)" }}>
{b.name}
</span>
</Link>
))}
</div>
</div>
</aside>
</div>
</div>
);
}

View File

@@ -1,108 +0,0 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight, MessagesSquare, LayoutGrid } from "lucide-react";
import { fetchBoards } from "@/lib/api";
export const metadata: Metadata = {
title: "板块目录 · 姜十三论坛",
description: "浏览论坛全部板块,找到感兴趣的话题社区",
};
// 板块汇总列表页:首页左栏「板块目录」标题的落地页,
// 展示全部板块(介绍 + 讨论量),点击进入板块独立页
export default async function BoardsPage() {
const { boards } = await fetchBoards();
const totalPosts = boards.reduce((sum, b) => sum + (b.post_count || 0), 0);
return (
<div>
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>板块目录</span>
</nav>
{/* 页头 */}
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
<span
aria-hidden
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
style={{ background: "var(--accent-soft)" }}
/>
<div className="relative flex items-center gap-4">
<span
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<LayoutGrid size={28} />
</span>
<div>
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
板块目录
</h1>
<p className="meta mt-1.5">
共 {boards.length} 个板块 · {totalPosts} 篇讨论
</p>
</div>
</div>
</div>
{/* 板块卡片网格 */}
{boards.length === 0 ? (
<div className="panel 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)" }}
>
<MessagesSquare size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有任何板块</p>
<p className="meta mt-1.5">板块由管理员创建,敬请期待</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map((b, i) => (
<Link
key={b.id}
href={`/board/${b.id}`}
className="j13-module group flex items-start gap-3.5 transition-[border-color,background] duration-150 hover:border-[var(--nav-active-line)] hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico !w-11 !h-11 !text-lg`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span className="meta shrink-0 text-[11.5px] tabular-nums">{b.post_count} 篇</span>
</span>
{b.description?.trim() && (
<span
className="block mt-1 text-[12.5px] leading-relaxed line-clamp-2"
style={{ color: "var(--ink-3)" }}
>
{b.description.trim()}
</span>
)}
<span
className="mt-2.5 inline-flex items-center gap-0.5 text-[12px] font-bold transition-colors group-hover:text-[var(--accent)]"
style={{ color: "var(--ink-2)" }}
>
进入板块
<ChevronRight
size={12}
className="transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -11,7 +11,7 @@ import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
@@ -59,11 +59,9 @@ export default async function RootLayout({
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
// SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
// SSR 直出登录态(access 过期时 middleware 已先行轮转并注入请求头,见 middleware.ts),
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const { accent } = await getPublicSettingsCached();
@@ -72,8 +70,7 @@ export default async function RootLayout({
let user: User | null = null;
let unread = 0;
let chatUnread = 0;
const hasAuthCookie =
!!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value;
const hasAuthCookie = !!cookieStore.get(TOKEN_COOKIE)?.value;
if (hasAuthCookie) {
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
const me = await getMeCached(authCookieHeader(cookieStore) || undefined);

View File

@@ -23,8 +23,8 @@ export default function NotFound() {
<Link href="/" className="btn btn-primary">
<Home size={15} /> 返回首页
</Link>
<Link href="/board/1" className="btn btn-line">
逛逛板块
<Link href="/" className="btn btn-line">
逛逛讨论
</Link>
</div>
</div>

View File

@@ -5,7 +5,9 @@ import { PenSquare, SearchX, MessagesSquare } from "lucide-react";
import {
fetchPosts,
fetchOverview,
fetchBoardSidebar,
type OverviewResponse,
type BoardSidebarResponse,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
@@ -15,6 +17,11 @@ import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
import {
BoardPanels,
BoardPanelsMobile,
BoardMissingPanel,
} from "@/components/home/BoardPanels";
export const metadata: Metadata = {
title: "姜十三论坛 · 技术讨论社区",
@@ -27,8 +34,6 @@ interface HomePageProps {
function normalizeSort(v?: string): FeedSort {
if (v === "new" || v === "recommended") return v;
// 兼容旧的 ?sort=hot 链接:落到最接近的推荐帖
if (v === "hot") return "recommended";
return "latest";
}
@@ -81,22 +86,30 @@ export default async function HomePage({ searchParams }: HomePageProps) {
page,
20,
isSearch || boardId <= 0 ? undefined : boardId,
sort === "recommended" ? "hot" : sort,
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 [me, postsData, overview] = await Promise.all([
const [me, postsData, overview, boardSidebar] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
]);
const user = me.user;
const boards = overview?.boards ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || null;
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
const boardPanelsMissing = showBoardPanels && !boardSidebar;
// 搜索态:单栏占满,无 Tabs/侧栏
if (isSearch) {
@@ -193,31 +206,61 @@ export default async function HomePage({ searchParams }: HomePageProps) {
/>
</div>
{/* 右栏:社区面板 */}
{overview && (
<CommunityPanels
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
/>
{/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
{showBoardPanels ? (
boardSidebar ? (
<BoardPanels
board={boardSidebar.board}
moderators={boardSidebar.moderators}
stats={boardSidebar.stats}
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
/>
) : boardPanelsMissing ? (
<BoardMissingPanel />
) : null
) : (
overview && (
<CommunityPanels
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
/>
)
)}
</div>
{/* 窄屏:面板下沉到帖子流之后 */}
{overview && (
<div className="mt-6">
<CommunityPanelsMobile
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
/>
</div>
{showBoardPanels ? (
boardSidebar ? (
<div className="mt-6">
<BoardPanelsMobile
board={boardSidebar.board}
moderators={boardSidebar.moderators}
stats={boardSidebar.stats}
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
/>
</div>
) : null
) : (
overview && (
<div className="mt-6">
<CommunityPanelsMobile
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
/>
</div>
)
)}
</div>
);

View File

@@ -121,7 +121,7 @@ function BoardCard({ board }: { board: Board }) {
<p className="j13-module-title mb-3" id="board-card-title">
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
</p>
<Link href={`/board/${board.id}`} className="flex items-center gap-3 group">
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
<span
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
>
@@ -140,10 +140,10 @@ function BoardCard({ board }: { board: Board }) {
</p>
)}
<Link
href={`/board/${board.id}`}
href={`/?board=${board.id}`}
className="btn btn-line btn-sm w-full justify-center mt-3.5"
>
进入板块 <ArrowRight size={13} />
查看该板讨论 <ArrowRight size={13} />
</Link>
</section>
);
@@ -266,7 +266,7 @@ function RelatedPosts({
))}
</ol>
<Link
href={`/board/${boardId}`}
href={`/?board=${boardId}`}
className="j13-raillink justify-center mt-1.5 text-[12.5px]"
style={{ color: "var(--accent)" }}
>
@@ -334,7 +334,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
fetchComments(id, commentPage, cookie),
getMeCached(cookie || undefined),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
const comments = commentsData.comments;
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
@@ -424,7 +424,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<Link href={`/board/${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
<Link href={`/?board=${post.board.id}`} className="hover:text-[var(--accent)] transition-colors">
{post.board.name}
</Link>
</nav>
@@ -432,7 +432,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<div className="panel p-6 sm:p-8">
<header className="mb-7">
<div className="flex items-center gap-2 mb-4 flex-wrap">
<Link href={`/board/${post.board.id}`} className="chip">
<Link href={`/?board=${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>

View File

@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
import { fetchPosts, fetchBoards } from "@/lib/api";
import { fetchPosts } from "@/lib/api";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "http://localhost:3000";
@@ -8,16 +8,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
];
try {
const { boards } = await fetchBoards();
for (const board of boards) {
entries.push({
url: `${baseUrl}/board/${board.id}`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
});
}
const { posts } = await fetchPosts(1, 100);
for (const post of posts) {
entries.push({