首次提交:姜十三论坛

后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
2026-09-12 01:11:05 +08:00
commit 0c2b0d2d6a
62 changed files with 8289 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
import Pagination from "@/components/Pagination";
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) => (
<button
onClick={() => handleTabChange(t)}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={
tab === t
? { background: "var(--primary)", color: "white" }
: { background: "var(--bg-secondary)", color: "var(--text-secondary)" }
}
>
{label}
</button>
);
return (
<div>
<div className="flex gap-2 mb-5">
{tabBtn("posts", `发帖 (${initialPosts.stats.post_count})`)}
{tabBtn("comments", `评论 (${initialPosts.stats.comment_count})`)}
</div>
{tab === "posts" ? (
<div className="space-y-3">
{initialPosts.posts.map((post) => (
<Link
key={post.id}
href={`/post/${post.id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded"
style={{ background: "var(--bg-secondary)", color: "var(--primary)" }}
>
{post.board.name}
</span>
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<h2 className="font-semibold mb-1 hover:underline" style={{ color: "var(--text)" }}>
{post.title}
</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span>
<span>❤️ {post.like_count}</span>
</div>
</Link>
))}
{initialPosts.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
暂无发帖
</p>
)}
<Pagination
total={initialPosts.posts_total}
page={initialPage}
size={20}
basePath={`/u/${userId}`}
/>
</div>
) : (
<div className="space-y-3">
{commentLoading ? (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>加载中...</p>
) : commentData && commentData.comments.length > 0 ? (
<>
{commentData.comments.map((c) => (
<Link
key={c.id}
href={`/post/${c.post_id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="text-sm mb-2" style={{ color: "var(--text-secondary)" }}>
评论于 <span className="hover:underline" style={{ color: "var(--primary)" }}>{c.post_title}</span>
</div>
<p className="line-clamp-2" style={{ color: "var(--text)" }}>{c.content}</p>
<div className="text-xs mt-2" style={{ color: "var(--text-secondary)" }}>
{new Date(c.created_at).toLocaleString("zh-CN")}
</div>
</Link>
))}
<Pagination
total={commentData.total}
page={commentPage}
size={20}
basePath={`/u/${userId}`}
query={{ tab: "comments" }}
onChange={loadComments}
/>
</>
) : (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
暂无评论
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import Link from "next/link";
import { Metadata } from "next";
import { fetchUserProfile, type UserProfile } from "@/lib/api";
import Pagination from "@/components/Pagination";
import UserProfileClient from "./UserProfileClient";
interface UserPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> {
const { id } = await params;
return {
title: `用户资料 - 姜十三论坛`,
description: `查看用户 ${id} 的资料、发帖与评论`,
};
}
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);
let data: UserProfile;
try {
data = await fetchUserProfile(id, page);
} catch {
return (
<div className="text-center py-16" style={{ color: "var(--text-secondary)" }}>
<p className="text-lg">用户不存在</p>
<Link href="/" className="mt-4 inline-block hover:underline" style={{ color: "var(--primary)" }}>
返回首页
</Link>
</div>
);
}
const u = data.user;
const roleLabel = u.role === "admin" ? "管理员" : "用户";
return (
<div className="max-w-4xl mx-auto">
{/* 用户信息卡片 */}
<div
className="rounded-xl border p-6 mb-6 flex items-start gap-5"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div
className="w-20 h-20 rounded-full flex items-center justify-center text-2xl font-bold shrink-0"
style={{ background: "var(--bg-secondary)", color: "var(--primary)" }}
>
{u.avatar ? (
<img src={u.avatar} alt={u.nickname} className="w-full h-full rounded-full object-cover" />
) : (
(u.nickname || u.username).charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold" style={{ color: "var(--text)" }}>
{u.nickname || u.username}
</h1>
{u.role === "admin" && (
<span
className="text-xs px-2 py-0.5 rounded font-medium"
style={{ background: "var(--primary)", color: "white" }}
>
{roleLabel}
</span>
)}
</div>
<p className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
@{u.username} · 加入于 {new Date(u.created_at).toLocaleDateString("zh-CN")}
</p>
<div className="flex gap-6 mt-3 text-sm">
<span style={{ color: "var(--text)" }}>
<strong>{data.stats.post_count}</strong>{" "}
<span style={{ color: "var(--text-secondary)" }}>帖子</span>
</span>
<span style={{ color: "var(--text)" }}>
<strong>{data.stats.comment_count}</strong>{" "}
<span style={{ color: "var(--text-secondary)" }}>评论</span>
</span>
</div>
</div>
</div>
{/* 发帖 / 评论 切换 */}
<UserProfileClient userId={id} initialPosts={data} initialPage={page} />
</div>
);
}