首次提交:姜十三论坛

后端 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,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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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",
};
}

View 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
View 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;
}

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>
);
}