refactor(frontend): 完善SSR鉴权与点赞组件样式优化

1. 修正类型文件导入路径到.dev目录
2. 为API请求添加cookie传递支持,实现SSR用户登录态识别
3. 重构LikeButton组件,新增compact变体适配列表页展示
4. 调整首页和板块页的帖子列表布局,添加点赞按钮
5. 拆分帖子列表点击区域,优化交互体验
This commit is contained in:
2026-09-12 01:35:37 +08:00
parent 0c2b0d2d6a
commit a8f6ce4e4f
6 changed files with 76 additions and 40 deletions

View File

@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { Metadata } from "next"; import { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchPosts, fetchBoards } from "@/lib/api"; import { fetchPosts, fetchBoards } from "@/lib/api";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import LikeButton from "@/components/LikeButton";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -22,8 +24,9 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
const { page: pageStr } = await searchParams; const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const cookie = (await cookies()).toString();
const [postsData, boardsData] = await Promise.all([ const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, Number(id)), fetchPosts(page, 20, Number(id), "latest", "", cookie),
fetchBoards(), fetchBoards(),
]); ]);
const board = boardsData.boards.find((b) => b.id === Number(id)); const board = boardsData.boards.find((b) => b.id === Number(id));
@@ -41,10 +44,9 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
<div className="space-y-3"> <div className="space-y-3">
{postsData.posts.map((post) => ( {postsData.posts.map((post) => (
<Link <div
key={post.id} key={post.id}
href={`/post/${post.id}`} className="p-4 rounded-lg border hover:shadow-md transition-shadow"
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }} style={{ borderColor: "var(--border)", background: "var(--bg)" }}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -55,8 +57,11 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span> <span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)} )}
</div> </div>
<Link href={`/post/${post.id}`}>
<h2 className="font-semibold mb-1 hover:underline">{post.title}</h2> <h2 className="font-semibold mb-1 hover:underline">{post.title}</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}> </Link>
<div className="flex items-center justify-between gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<div className="flex items-center gap-4">
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}> <Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname} {post.user.nickname}
</Link> </Link>
@@ -64,7 +69,9 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
<span>💬 {post.comment_count}</span> <span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span> <span>👁 {post.view_count}</span>
</div> </div>
</Link> <LikeButton postId={String(post.id)} liked={post.liked} likeCount={post.like_count} variant="compact" />
</div>
</div>
))} ))}
{postsData.posts.length === 0 && ( {postsData.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}> <p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>

View File

@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { Metadata } from "next"; import { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchPosts, fetchBoards } from "@/lib/api"; import { fetchPosts, fetchBoards } from "@/lib/api";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import LikeButton from "@/components/LikeButton";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "姜十三论坛 - 技术分享与讨论社区", title: "姜十三论坛 - 技术分享与讨论社区",
@@ -21,8 +23,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const { q, page: pageStr } = await searchParams; const { q, page: pageStr } = await searchParams;
const keyword = q?.trim() || ""; const keyword = q?.trim() || "";
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const cookie = (await cookies()).toString();
const [postsData, boardsData] = await Promise.all([ const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, undefined, "latest", keyword), fetchPosts(page, 20, undefined, "latest", keyword, cookie),
fetchBoards(), fetchBoards(),
]); ]);
@@ -35,10 +38,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
</h1> </h1>
<div className="space-y-3"> <div className="space-y-3">
{postsData.posts.map((post) => ( {postsData.posts.map((post) => (
<Link <div
key={post.id} key={post.id}
href={`/post/${post.id}`} className="p-4 rounded-lg border hover:shadow-md transition-shadow"
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }} style={{ borderColor: "var(--border)", background: "var(--bg)" }}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -55,10 +57,13 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span> <span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)} )}
</div> </div>
<Link href={`/post/${post.id}`}>
<h2 className="font-semibold mb-1 hover:underline" style={{ color: "var(--text)" }}> <h2 className="font-semibold mb-1 hover:underline" style={{ color: "var(--text)" }}>
{post.title} {post.title}
</h2> </h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}> </Link>
<div className="flex items-center justify-between gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<div className="flex items-center gap-4">
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}> <Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname} {post.user.nickname}
</Link> </Link>
@@ -66,7 +71,9 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<span>💬 {post.comment_count}</span> <span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span> <span>👁 {post.view_count}</span>
</div> </div>
</Link> <LikeButton postId={String(post.id)} liked={post.liked} likeCount={post.like_count} variant="compact" />
</div>
</div>
))} ))}
{postsData.posts.length === 0 && ( {postsData.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}> <p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>

View File

@@ -1,5 +1,6 @@
import { Metadata } from "next"; import { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { fetchPostDetail, fetchComments } from "@/lib/api"; import { fetchPostDetail, fetchComments } from "@/lib/api";
import CommentSection from "@/components/CommentSection"; import CommentSection from "@/components/CommentSection";
@@ -30,7 +31,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function PostDetailPage({ params }: PageProps) { export default async function PostDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
const { post } = await fetchPostDetail(id); const cookie = (await cookies()).toString();
const { post } = await fetchPostDetail(id, cookie);
const { comments } = await fetchComments(id); const { comments } = await fetchComments(id);
const jsonLd = { const jsonLd = {

View File

@@ -8,9 +8,10 @@ interface LikeButtonProps {
postId: string; postId: string;
liked: boolean; liked: boolean;
likeCount: number; likeCount: number;
variant?: "default" | "compact";
} }
export default function LikeButton({ postId, liked: initialLiked, likeCount: initialCount }: LikeButtonProps) { export default function LikeButton({ postId, liked: initialLiked, likeCount: initialCount, variant = "default" }: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked); const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount); const [count, setCount] = useState(initialCount);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -33,17 +34,21 @@ export default function LikeButton({ postId, liked: initialLiked, likeCount: ini
} }
}; };
const sizing = variant === "compact" ? "px-2 py-1 gap-1" : "px-3 py-1.5 gap-1.5";
const iconSize = variant === "compact" ? 14 : 16;
return ( return (
<button <button
onClick={handleClick} onClick={handleClick}
disabled={loading} disabled={loading}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all disabled:opacity-50 ${ title={liked ? "取消点赞" : "点赞"}
className={`flex items-center ${sizing} rounded-full text-sm font-medium transition-all disabled:opacity-50 ${
liked liked
? "text-red-500 bg-red-50 dark:bg-red-900/20" ? "text-red-500 bg-red-50 dark:bg-red-900/20"
: "text-gray-500 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-gray-800" : "text-gray-500 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-gray-800"
}`} }`}
> >
<Heart size={16} fill={liked ? "currentColor" : "none"} /> <Heart size={iconSize} fill={liked ? "currentColor" : "none"} />
<span>{count}</span> <span>{count}</span>
</button> </button>
); );

View File

@@ -189,17 +189,26 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
// ===== SSR 端 fetch(公开接口,直接请求后端) ===== // ===== SSR 端 fetch(公开接口,直接请求后端) =====
// SSR 页面通过 (await cookies()).toString() 传入,后端据此识别登录用户并填充 liked 状态
function cookieHeaders(cookieHeader?: string): HeadersInit | undefined {
return cookieHeader ? { Cookie: cookieHeader } : undefined;
}
export async function fetchPosts( export async function fetchPosts(
page = 1, page = 1,
size = 20, size = 20,
boardId?: number, boardId?: number,
sort = "latest", sort = "latest",
keyword = "" keyword = "",
cookieHeader?: string
): Promise<PostsResponse> { ): Promise<PostsResponse> {
const params = new URLSearchParams({ page: String(page), size: String(size), sort }); const params = new URLSearchParams({ page: String(page), size: String(size), sort });
if (boardId) params.set("board_id", String(boardId)); if (boardId) params.set("board_id", String(boardId));
if (keyword) params.set("keyword", keyword); if (keyword) params.set("keyword", keyword);
const res = await fetch(`${API_BASE}/api/posts?${params}`, { cache: "no-store" }); const res = await fetch(`${API_BASE}/api/posts?${params}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
if (!res.ok) throw new Error("获取帖子失败"); if (!res.ok) throw new Error("获取帖子失败");
return res.json(); return res.json();
} }
@@ -210,8 +219,11 @@ export async function fetchBoards(): Promise<{ boards: Board[] }> {
return res.json(); return res.json();
} }
export async function fetchPostDetail(id: string): Promise<{ post: Post }> { export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, { cache: "no-store" }); const res = await fetch(`${API_BASE}/api/posts/${id}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
if (!res.ok) throw new Error("获取帖子失败"); if (!res.ok) throw new Error("获取帖子失败");
return res.json(); return res.json();
} }
@@ -222,9 +234,12 @@ export async function fetchComments(postId: string): Promise<{ comments: Comment
return res.json(); return res.json();
} }
export async function fetchUserProfile(id: string, page = 1): Promise<UserProfile> { export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" }); const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, { cache: "no-store" }); const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, {
cache: "no-store",
headers: cookieHeaders(cookieHeader),
});
if (res.status === 404) throw new Error("用户不存在"); if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败"); if (!res.ok) throw new Error("获取用户资料失败");
return res.json(); return res.json();

View File

@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
import "./.next/types/root-params.d.ts"; import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.