首次提交:姜十三论坛
后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
83
frontend/app/board/[id]/page.tsx
Normal file
83
frontend/app/board/[id]/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { Metadata } from "next";
|
||||
import { fetchPosts, fetchBoards } from "@/lib/api";
|
||||
import Pagination from "@/components/Pagination";
|
||||
|
||||
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 [postsData, boardsData] = await Promise.all([
|
||||
fetchPosts(page, 20, Number(id)),
|
||||
fetchBoards(),
|
||||
]);
|
||||
const board = boardsData.boards.find((b) => b.id === Number(id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<Link href="/" className="hover:underline">首页</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span>{board?.name || "板块"}</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2">{board?.name}</h1>
|
||||
<p className="mb-6" style={{ color: "var(--text-secondary)" }}>{board?.description}</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{postsData.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-1">
|
||||
{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">{post.title}</h2>
|
||||
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
|
||||
{post.user.nickname}
|
||||
</Link>
|
||||
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
<span>💬 {post.comment_count}</span>
|
||||
<span>👁 {post.view_count}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{postsData.posts.length === 0 && (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
|
||||
该板块暂无帖子
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
total={postsData.total}
|
||||
page={page}
|
||||
size={20}
|
||||
basePath={`/board/${id}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
frontend/app/compose/page.tsx
Normal file
122
frontend/app/compose/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCreatePost, apiMe, fetchBoards, type Board } from "@/lib/api";
|
||||
|
||||
export default function ComposePage() {
|
||||
const router = useRouter();
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState<number>(0);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// 通过 /api/me 检查登录态(HttpOnly cookie 自动携带)
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (!res.user) {
|
||||
router.push("/login?redirect=/compose");
|
||||
return;
|
||||
}
|
||||
fetchBoards().then((data) => {
|
||||
setBoards(data.boards);
|
||||
if (data.boards.length > 0) setBoardId(data.boards[0].id);
|
||||
});
|
||||
})
|
||||
.catch(() => router.push("/login?redirect=/compose"));
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiCreatePost({
|
||||
board_id: boardId,
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
post_type: "normal",
|
||||
});
|
||||
if (res.post) {
|
||||
router.push(`/post/${res.post.id}`);
|
||||
} else {
|
||||
setError(res.error || "发帖失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">发布新帖</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>板块</label>
|
||||
<select
|
||||
value={boardId}
|
||||
onChange={(e) => setBoardId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
>
|
||||
{boards.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={256}
|
||||
placeholder="帖子标题"
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标签(逗号分隔,可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="例如:技术,Go"
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>内容(支持 Markdown)</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={12}
|
||||
placeholder="帖子内容,支持 Markdown 格式..."
|
||||
className="w-full px-3 py-2 rounded-lg border resize-y font-mono"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim() || !content.trim()}
|
||||
className="px-6 py-2 rounded-lg text-white disabled:opacity-50"
|
||||
style={{ background: "var(--primary)" }}
|
||||
>
|
||||
{loading ? "发布中..." : "发布帖子"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/app/globals.css
Normal file
40
frontend/app/globals.css
Normal file
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--border: #334155;
|
||||
--primary: #60a5fa;
|
||||
--primary-hover: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Markdown 内容样式 */
|
||||
.prose-content h1 { font-size: 1.875rem; font-weight: 700; margin: 1rem 0; }
|
||||
.prose-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
|
||||
.prose-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
|
||||
.prose-content p { margin: 0.75rem 0; line-height: 1.7; }
|
||||
.prose-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||
.prose-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||
.prose-content code { background: var(--bg-secondary); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
.prose-content pre { background: var(--bg-secondary); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
|
||||
.prose-content pre code { background: none; padding: 0; }
|
||||
.prose-content blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--text-secondary); margin: 1rem 0; }
|
||||
.prose-content a { color: var(--primary); text-decoration: underline; }
|
||||
.prose-content img { max-width: 100%; border-radius: 0.5rem; }
|
||||
36
frontend/app/layout.tsx
Normal file
36
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "姜十三论坛",
|
||||
description: "姜十三论坛 - 技术分享与讨论社区",
|
||||
openGraph: {
|
||||
title: "姜十三论坛",
|
||||
description: "姜十三论坛 - 技术分享与讨论社区",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const theme = cookieStore.get("j13-theme")?.value || "light";
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" className={theme === "dark" ? "dark" : ""}>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 max-w-5xl mx-auto w-full px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
77
frontend/app/login/page.tsx
Normal file
77
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiLogin } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiLogin(username, password);
|
||||
// 登录成功后后端通过 Set-Cookie 写入 HttpOnly JWT,前端无需存储 token
|
||||
if (res.user) {
|
||||
router.push(redirect);
|
||||
} else {
|
||||
setError(res.error || "登录失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">登录</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-lg text-white disabled:opacity-50"
|
||||
style={{ background: "var(--primary)" }}
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center mt-4 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
还没有账号?<Link href="/register" className="hover:underline" style={{ color: "var(--primary)" }}>立即注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
frontend/app/notifications/page.tsx
Normal file
131
frontend/app/notifications/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Heart, MessageCircle, CheckCheck } from "lucide-react";
|
||||
import {
|
||||
apiFetchNotifications,
|
||||
apiMarkRead,
|
||||
apiMarkAllRead,
|
||||
type NotificationItem,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetchNotifications(1);
|
||||
setNotifications(res.notifications || []);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleClick = async (n: NotificationItem) => {
|
||||
if (!n.is_read) {
|
||||
apiMarkRead(n.id).catch(() => {});
|
||||
setNotifications((list) =>
|
||||
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
||||
);
|
||||
}
|
||||
router.push(`/post/${n.post_id}`);
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
await apiMarkAllRead();
|
||||
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hour = Math.floor(min / 60);
|
||||
if (hour < 24) return `${hour} 小时前`;
|
||||
return `${Math.floor(hour / 24)} 天前`;
|
||||
};
|
||||
|
||||
const hasUnread = notifications.some((n) => !n.is_read);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text)" }}>通知中心</h1>
|
||||
{hasUnread && (
|
||||
<button
|
||||
onClick={handleMarkAll}
|
||||
className="flex items-center gap-1 text-sm hover:opacity-80"
|
||||
style={{ color: "var(--primary)" }}
|
||||
>
|
||||
<CheckCheck size={16} /> 全部标记已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>加载中...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>暂无通知</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClick(n)}
|
||||
className="w-full text-left p-4 rounded-lg border hover:shadow-sm transition-shadow"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
background: n.is_read ? "var(--bg)" : "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span style={{ color: n.type === "like" ? "#ef4444" : "var(--primary)" }}>
|
||||
{n.type === "like" ? <Heart size={18} /> : <MessageCircle size={18} />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm" style={{ color: "var(--text)" }}>
|
||||
<Link
|
||||
href={`/u/${n.actor.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{n.actor.nickname}
|
||||
</Link>{" "}
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
{n.type === "comment" ? "评论了你的帖子" : "点赞了你的帖子"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm mt-1 font-medium truncate" style={{ color: "var(--text)" }}>
|
||||
《{n.post.title}》
|
||||
</p>
|
||||
{n.content && (
|
||||
<p className="text-sm mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{n.content}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs mt-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{formatTime(n.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!n.is_read && (
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0 mt-1.5" style={{ background: "var(--primary)" }} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
frontend/app/page.tsx
Normal file
105
frontend/app/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Link from "next/link";
|
||||
import { Metadata } from "next";
|
||||
import { fetchPosts, fetchBoards } from "@/lib/api";
|
||||
import Pagination from "@/components/Pagination";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "姜十三论坛 - 技术分享与讨论社区",
|
||||
description: "姜十三论坛,分享技术、讨论问题、交流心得的社区",
|
||||
openGraph: {
|
||||
title: "姜十三论坛 - 技术分享与讨论社区",
|
||||
description: "姜十三论坛,分享技术、讨论问题、交流心得的社区",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
interface HomePageProps {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}
|
||||
|
||||
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
const { q, page: pageStr } = await searchParams;
|
||||
const keyword = q?.trim() || "";
|
||||
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
|
||||
const [postsData, boardsData] = await Promise.all([
|
||||
fetchPosts(page, 20, undefined, "latest", keyword),
|
||||
fetchBoards(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* 主内容:帖子列表 */}
|
||||
<div className="md:col-span-3">
|
||||
<h1 className="text-2xl font-bold mb-4">
|
||||
{keyword ? `搜索结果:"${keyword}"` : "最新帖子"}
|
||||
</h1>
|
||||
<div className="space-y-3">
|
||||
{postsData.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)" }}>
|
||||
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
|
||||
{post.user.nickname}
|
||||
</Link>
|
||||
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
<span>💬 {post.comment_count}</span>
|
||||
<span>👁 {post.view_count}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{postsData.posts.length === 0 && (
|
||||
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
|
||||
{keyword ? "没有找到相关帖子" : "暂无帖子,来发第一篇吧!"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
total={postsData.total}
|
||||
page={page}
|
||||
size={20}
|
||||
basePath="/"
|
||||
query={keyword ? { q: keyword } : {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏:板块列表 */}
|
||||
<aside className="md:col-span-1">
|
||||
<h2 className="text-lg font-bold mb-3">板块</h2>
|
||||
<div className="space-y-2">
|
||||
{boardsData.boards.map((board) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
href={`/board/${board.id}`}
|
||||
className="block p-3 rounded-lg border hover:shadow-sm transition-shadow"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg-secondary)" }}
|
||||
>
|
||||
<div className="font-medium" style={{ color: "var(--text)" }}>{board.name}</div>
|
||||
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>{board.description}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/app/post/[id]/edit/page.tsx
Normal file
137
frontend/app/post/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { fetchPostDetail, apiMe, apiUpdatePost, type Post, type User } from "@/lib/api";
|
||||
|
||||
interface EditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function EditPostPage({ params }: EditPageProps) {
|
||||
const router = useRouter();
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// params 是 Promise,需要解包
|
||||
const [id, setId] = useState("");
|
||||
useEffect(() => {
|
||||
params.then((p) => setId(p.id));
|
||||
}, [params]);
|
||||
|
||||
// 加载帖子 + 校验权限
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
Promise.all([fetchPostDetail(id), apiMe()])
|
||||
.then(([postData, meData]) => {
|
||||
setPost(postData.post);
|
||||
setTitle(postData.post.title);
|
||||
setContent(postData.post.content);
|
||||
setTags(postData.post.tags || "");
|
||||
const u = meData.user;
|
||||
setUser(u || null);
|
||||
// 非作者且非管理员 → 跳转
|
||||
if (!u || (u.id !== postData.post.user.id && u.role !== "admin")) {
|
||||
router.push(`/post/${id}`);
|
||||
}
|
||||
})
|
||||
.catch(() => router.push(`/post/${id}`))
|
||||
.finally(() => setChecking(false));
|
||||
}, [id, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!post) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await apiUpdatePost(String(post.id), { title, content, tags });
|
||||
if (res.post) {
|
||||
router.push(`/post/${post.id}`);
|
||||
} else {
|
||||
setError(res.error || "更新失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto mt-12 text-center" style={{ color: "var(--text-secondary)" }}>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!post) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">编辑帖子</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={256}
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标签(逗号分隔,可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="例如:技术,Go"
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>内容(支持 Markdown)</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 rounded-lg border resize-y font-mono"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim() || !content.trim()}
|
||||
className="px-6 py-2 rounded-lg text-white disabled:opacity-50"
|
||||
style={{ background: "var(--primary)" }}
|
||||
>
|
||||
{loading ? "保存中..." : "保存修改"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/post/${post.id}`)}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
style={{ background: "var(--bg-secondary)", color: "var(--text)" }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/app/post/[id]/page.tsx
Normal file
98
frontend/app/post/[id]/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { fetchPostDetail, fetchComments } from "@/lib/api";
|
||||
import CommentSection from "@/components/CommentSection";
|
||||
import PostActions from "@/components/PostActions";
|
||||
import LikeButton from "@/components/LikeButton";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { post } = await fetchPostDetail(id);
|
||||
return {
|
||||
title: `${post.title} - 姜十三论坛`,
|
||||
description: post.content.slice(0, 160),
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.content.slice(0, 160),
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: "帖子不存在 - 姜十三论坛" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PostDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const { post } = await fetchPostDetail(id);
|
||||
const { comments } = await fetchComments(id);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
headline: post.title,
|
||||
articleBody: post.content,
|
||||
datePublished: post.created_at,
|
||||
dateModified: post.updated_at,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: post.user.nickname,
|
||||
},
|
||||
commentCount: post.comment_count,
|
||||
interactionStatistic: {
|
||||
"@type": "InteractionCounter",
|
||||
interactionType: { "@type": "ReadAction" },
|
||||
userInteractionCount: post.view_count,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<Link href="/" className="hover:underline">首页</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link href={`/board/${post.board.id}`} className="hover:underline">{post.board.name}</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{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>
|
||||
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mb-6 pb-4 border-b" style={{ borderColor: "var(--border)" }}>
|
||||
<div className="flex items-center gap-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
|
||||
{post.user.nickname}
|
||||
</Link>
|
||||
<span>{new Date(post.created_at).toLocaleString("zh-CN")}</span>
|
||||
<span>👁 {post.view_count}</span>
|
||||
<span>💬 {post.comment_count}</span>
|
||||
<LikeButton postId={id} liked={post.liked} likeCount={post.like_count} />
|
||||
</div>
|
||||
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
|
||||
</div>
|
||||
|
||||
<div className="prose-content mb-8" style={{ color: "var(--text)" }}>
|
||||
<ReactMarkdown>{post.content}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<CommentSection postId={id} comments={comments} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
88
frontend/app/register/page.tsx
Normal file
88
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiRegister } from "@/lib/api";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiRegister(username, email, password);
|
||||
if (res.id) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
setError(res.error || "注册失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">注册</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>邮箱(可选)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-lg text-white disabled:opacity-50"
|
||||
style={{ background: "var(--primary)" }}
|
||||
>
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-center mt-4 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
已有账号?<Link href="/login" className="hover:underline" style={{ color: "var(--primary)" }}>去登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/app/robots.ts
Normal file
11
frontend/app/robots.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: "http://localhost:3000/sitemap.xml",
|
||||
};
|
||||
}
|
||||
139
frontend/app/settings/page.tsx
Normal file
139
frontend/app/settings/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiChangePassword, apiMe, type User } from "@/lib/api";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => {
|
||||
if (!res.user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setUser(res.user);
|
||||
})
|
||||
.catch(() => router.push("/login"));
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("新密码至少 6 位");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await apiChangePassword(oldPassword, newPassword);
|
||||
if (res.message) {
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
} else {
|
||||
setError(res.error || "修改失败");
|
||||
}
|
||||
} catch (e) {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <p style={{ color: "var(--text-secondary)" }}>加载中...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<Link href="/" className="hover:underline">首页</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span>账号设置</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">账号设置</h1>
|
||||
|
||||
<div className="p-4 rounded-lg mb-6" style={{ background: "var(--bg-secondary)" }}>
|
||||
<div className="text-sm" style={{ color: "var(--text-secondary)" }}>当前账号</div>
|
||||
<div className="font-medium mt-1" style={{ color: "var(--text)" }}>{user.nickname}({user.username})</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-4">修改密码</h2>
|
||||
|
||||
{success && (
|
||||
<div className="p-3 rounded-lg mb-4 text-sm" style={{ background: "var(--success-bg, #dcfce7)", color: "#166534" }}>
|
||||
密码修改成功,正在跳转登录页...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg mb-4 text-sm" style={{ background: "var(--error-bg, #fee2e2)", color: "#991b1b" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
className="w-full p-3 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full p-3 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full p-3 rounded-lg border"
|
||||
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || success}
|
||||
className="w-full py-3 rounded-lg text-white font-medium disabled:opacity-50"
|
||||
style={{ background: "var(--primary)" }}
|
||||
>
|
||||
{submitting ? "提交中..." : "修改密码"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/app/sitemap.ts
Normal file
35
frontend/app/sitemap.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { fetchPosts, fetchBoards } from "@/lib/api";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = "http://localhost:3000";
|
||||
const entries: MetadataRoute.Sitemap = [
|
||||
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
|
||||
];
|
||||
|
||||
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({
|
||||
url: `${baseUrl}/post/${post.id}`,
|
||||
lastModified: new Date(post.created_at),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.6,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 API 错误 */
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
148
frontend/app/u/[id]/UserProfileClient.tsx
Normal file
148
frontend/app/u/[id]/UserProfileClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
frontend/app/u/[id]/page.tsx
Normal file
93
frontend/app/u/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user