feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText, MessageCircle } from "lucide-react";
|
||||
import { fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
|
||||
|
||||
interface UserProfileClientProps {
|
||||
userId: string;
|
||||
initialPosts: UserProfile;
|
||||
initialPage: number;
|
||||
}
|
||||
|
||||
type Tab = "posts" | "comments";
|
||||
|
||||
export default function UserProfileClient({ userId, initialPosts, initialPage }: UserProfileClientProps) {
|
||||
const [tab, setTab] = useState<Tab>("posts");
|
||||
const [commentData, setCommentData] = useState<UserCommentsResponse | null>(null);
|
||||
const [commentLoading, setCommentLoading] = useState(false);
|
||||
const [commentPage, setCommentPage] = useState(1);
|
||||
|
||||
const loadComments = async (page: number) => {
|
||||
setCommentLoading(true);
|
||||
try {
|
||||
const data = await fetchUserComments(userId, page);
|
||||
setCommentData({ ...data, comments: data.comments || [] });
|
||||
setCommentPage(page);
|
||||
} catch {
|
||||
setCommentData({ comments: [], total: 0, page, size: 20 });
|
||||
}
|
||||
setCommentLoading(false);
|
||||
};
|
||||
|
||||
const handleTabChange = (t: Tab) => {
|
||||
setTab(t);
|
||||
if (t === "comments" && commentData === null) {
|
||||
loadComments(1);
|
||||
}
|
||||
};
|
||||
|
||||
const tabBtn = (t: Tab, label: string, count: number, icon: React.ReactNode) => {
|
||||
const active = tab === t;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleTabChange(t)}
|
||||
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-full text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: active ? "var(--panel)" : "transparent",
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
boxShadow: active ? "var(--shadow-sm)" : "none",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<span
|
||||
className="text-[11px] font-bold px-1.5 py-px rounded-full"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--tint)",
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="inline-flex w-full sm:w-auto items-center gap-1 mb-6 p-1 rounded-full"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
{tabBtn("posts", "发帖", initialPosts.stats.post_count, <FileText size={15} />)}
|
||||
{tabBtn("comments", "评论", initialPosts.stats.comment_count, <MessageCircle size={15} />)}
|
||||
</div>
|
||||
|
||||
{tab === "posts" ? (
|
||||
<div>
|
||||
{initialPosts.posts.length > 0 ? (
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{initialPosts.posts.map((post, i) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/post/${post.id}`}
|
||||
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="chip">{post.board.name}</span>
|
||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||
</div>
|
||||
<h2
|
||||
className="font-semibold mb-2 leading-snug group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 meta text-[12px]">
|
||||
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconComment size={12} /> {post.comment_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconEye size={12} /> {post.view_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconHeart size={12} /> {post.like_count}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel text-center py-16 px-6">
|
||||
<FileText size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无发帖</p>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
total={initialPosts.posts_total}
|
||||
page={initialPage}
|
||||
size={20}
|
||||
basePath={`/u/${userId}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{commentLoading ? (
|
||||
<div className="panel text-center py-16">
|
||||
<p className="meta">正在加载评论…</p>
|
||||
</div>
|
||||
) : commentData && commentData.comments.length > 0 ? (
|
||||
<>
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{commentData.comments.map((c, i) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/post/${c.post_id}`}
|
||||
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="meta text-xs mb-2">
|
||||
评论于《<span style={{ color: "var(--accent)" }}>{c.post_title}</span>》
|
||||
</div>
|
||||
<p
|
||||
className="text-[15px] leading-relaxed line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{c.content}
|
||||
</p>
|
||||
<div className="meta text-xs mt-2">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
total={commentData.total}
|
||||
page={commentPage}
|
||||
size={20}
|
||||
basePath={`/u/${userId}`}
|
||||
query={{ tab: "comments" }}
|
||||
onChange={loadComments}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="panel text-center py-16 px-6">
|
||||
<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无评论</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/app/u/[id]/UserProfileContent.tsx
Normal file
165
frontend/app/u/[id]/UserProfileContent.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, MessageCircle, AlertCircle } from "lucide-react";
|
||||
import Pagination from "@/components/Pagination";
|
||||
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
|
||||
import type { UserProfile, UserCommentsResponse } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 用户主页内容区(纯服务端渲染):
|
||||
* tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。
|
||||
*/
|
||||
export default function UserProfileContent({
|
||||
userId,
|
||||
profile,
|
||||
tab,
|
||||
commentData,
|
||||
commentError,
|
||||
page,
|
||||
}: {
|
||||
userId: string;
|
||||
profile: UserProfile;
|
||||
tab: "posts" | "comments";
|
||||
commentData: UserCommentsResponse | null;
|
||||
commentError: boolean;
|
||||
page: number;
|
||||
}) {
|
||||
const tabLink = (t: "posts" | "comments", label: string, count: number, icon: React.ReactNode) => {
|
||||
const active = tab === t;
|
||||
const href = t === "posts" ? `/u/${userId}` : `/u/${userId}?tab=comments`;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-full text-sm font-semibold transition-all flex-1 sm:flex-none ${
|
||||
active ? "" : "hover:text-[var(--ink)]"
|
||||
}`}
|
||||
style={{
|
||||
background: active ? "var(--panel)" : "transparent",
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
boxShadow: active ? "var(--shadow-sm)" : "none",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<span
|
||||
className="text-[11px] font-bold px-1.5 py-px rounded-full tabular-nums"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--tint)",
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="inline-flex w-full sm:w-auto items-center gap-1 mb-6 p-1 rounded-full"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
{tabLink("posts", "发帖", profile.stats.post_count, <FileText size={15} />)}
|
||||
{tabLink("comments", "评论", profile.stats.comment_count, <MessageCircle size={15} />)}
|
||||
</div>
|
||||
|
||||
{tab === "posts" ? (
|
||||
<div>
|
||||
{profile.posts.length > 0 ? (
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{profile.posts.map((post, i) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/post/${post.id}`}
|
||||
className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="j13-board-tag">{post.board.name}</span>
|
||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||
</div>
|
||||
<h2
|
||||
className="font-semibold mb-2 leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 meta text-[12px]">
|
||||
<span className="tabular-nums">{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconComment size={12} /> {post.comment_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconEye size={12} /> {post.view_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconHeart size={12} /> {post.like_count}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel text-center py-16 px-6">
|
||||
<FileText size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无发帖</p>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
total={profile.posts_total}
|
||||
page={page}
|
||||
size={20}
|
||||
basePath={`/u/${userId}`}
|
||||
/>
|
||||
</div>
|
||||
) : commentError ? (
|
||||
<div className="panel text-center py-16 px-6" role="alert">
|
||||
<AlertCircle size={26} className="mx-auto mb-3" style={{ color: "var(--danger)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>评论加载失败</p>
|
||||
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
|
||||
<Link href={`/u/${userId}?tab=comments`} className="btn btn-line btn-sm">重新加载</Link>
|
||||
</div>
|
||||
) : commentData && commentData.comments.length > 0 ? (
|
||||
<div>
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{commentData.comments.map((c, i) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/post/${c.post_id}`}
|
||||
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="meta text-xs mb-2">
|
||||
评论于《<span style={{ color: "var(--accent)" }}>{c.post_title}</span>》
|
||||
</div>
|
||||
<p
|
||||
className="text-[15px] leading-relaxed line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{c.content}
|
||||
</p>
|
||||
<div className="meta text-xs mt-2 tabular-nums">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
total={commentData.total}
|
||||
page={commentData.page}
|
||||
size={20}
|
||||
basePath={`/u/${userId}`}
|
||||
query={{ tab: "comments" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel text-center py-16 px-6">
|
||||
<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>暂无评论</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Metadata } from "next";
|
||||
import { ChevronRight, FileText, MessageCircle } from "lucide-react";
|
||||
import { fetchUserProfile, type UserProfile } from "@/lib/api";
|
||||
import UserProfileClient from "./UserProfileClient";
|
||||
import { ChevronRight, FileText, MessageCircle, Flame, AlertCircle } from "lucide-react";
|
||||
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
||||
import UserProfileContent from "./UserProfileContent";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
interface UserPageProps {
|
||||
@@ -25,8 +25,9 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
|
||||
|
||||
export default async function UserPage({ params, searchParams }: UserPageProps) {
|
||||
const { id } = await params;
|
||||
const { page: pageStr } = await searchParams;
|
||||
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
||||
const tab: "posts" | "comments" = sp.tab === "comments" ? "comments" : "posts";
|
||||
|
||||
let data: UserProfile;
|
||||
try {
|
||||
@@ -45,11 +46,23 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
);
|
||||
}
|
||||
|
||||
// 评论 tab:SSR 直出对应分页,URL(?tab=comments&page=N)与内容严格一致
|
||||
let commentData: UserCommentsResponse | null = null;
|
||||
let commentError = false;
|
||||
if (tab === "comments") {
|
||||
try {
|
||||
commentData = await fetchUserComments(id, page);
|
||||
} catch {
|
||||
commentError = true;
|
||||
}
|
||||
}
|
||||
|
||||
const u = data.user;
|
||||
const stats = [
|
||||
{ label: "发帖", value: data.stats.post_count, icon: <FileText size={15} /> },
|
||||
{ label: "评论", value: data.stats.comment_count, icon: <MessageCircle size={15} /> },
|
||||
];
|
||||
const showPoints = data.stats.points > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
@@ -70,8 +83,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
<Avatar
|
||||
name={u.nickname || u.username}
|
||||
src={u.avatar || undefined}
|
||||
size={80}
|
||||
className="sm:!w-[88px] sm:!h-[88px]"
|
||||
size={88}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
@@ -90,7 +102,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
<p className="meta mt-1.5 text-[13px]">
|
||||
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
||||
</p>
|
||||
<div className="flex gap-2.5 mt-4">
|
||||
<div className="flex gap-2.5 mt-4 flex-wrap">
|
||||
{stats.map((s) => (
|
||||
<span
|
||||
key={s.label}
|
||||
@@ -98,16 +110,34 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
||||
>
|
||||
<span style={{ color: "var(--accent)" }}>{s.icon}</span>
|
||||
{s.value} {s.label}
|
||||
<span className="tabular-nums">{s.value}</span> {s.label}
|
||||
</span>
|
||||
))}
|
||||
{showPoints && (
|
||||
<span
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full text-[13px] font-semibold"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
title={`连续签到 ${data.stats.streak} 天`}
|
||||
>
|
||||
<Flame size={15} />
|
||||
<span className="tabular-nums">{data.stats.points}</span> 积分
|
||||
{data.stats.streak > 0 && <span className="font-normal opacity-80">· 连签 {data.stats.streak} 天</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 发帖 / 评论 切换 */}
|
||||
<UserProfileClient userId={id} initialPosts={data} initialPage={page} />
|
||||
{/* 发帖 / 评论 切换(URL 驱动,SSR 直出) */}
|
||||
<UserProfileContent
|
||||
userId={id}
|
||||
profile={data}
|
||||
tab={tab}
|
||||
commentData={commentData}
|
||||
commentError={commentError}
|
||||
page={page}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user