首次提交:姜十三论坛

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