2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间

This commit is contained in:
ZhuiGuangAI Dev
2026-05-24 23:05:38 +08:00
parent 6c78c32157
commit 9bf3e7574a
118 changed files with 11842 additions and 3262 deletions
+57
View File
@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { MessageSquare, TrendingUp } from "lucide-react";
interface ForumBoardData {
id: number;
name: string;
slug: string;
description?: string;
icon?: string;
topicCount: number;
}
interface ForumBoardProps {
board: ForumBoardData;
}
const ICON_MAP: Record<string, string> = {
"general": "💬",
"ai-tools": "🤖",
"skills": "⚡",
"tech": "💻",
"tutorials": "📚",
"jobs": "💼",
"feedback": "📢",
"random": "🎲",
};
export default function ForumBoard({ board }: ForumBoardProps) {
const icon = board.icon || ICON_MAP[board.slug] || "📌";
return (
<Link href={`/community/${board.slug}`}>
<div className="flex items-center gap-4 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)] group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/5 text-2xl">
{icon}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold group-hover:text-primary transition-colors">{board.name}</h3>
{board.description && (
<p className="mt-0.5 text-xs text-muted-foreground/60 truncate">{board.description}</p>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground/40 flex-shrink-0">
<div className="flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5" />
<span>{board.topicCount}</span>
</div>
</div>
<svg className="w-4 h-4 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
interface ForumEditorProps {
onSubmit: (content: string) => void;
placeholder?: string;
maxLength?: number;
submitText?: string;
disabled?: boolean;
}
export default function ForumEditor({
onSubmit,
placeholder = "写下你的想法...",
maxLength = 10000,
submitText = "发布",
disabled = false,
}: ForumEditorProps) {
const { data: session, status } = useSession();
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
const trimmed = content.trim();
if (!trimmed) {
setError("内容不能为空");
return;
}
if (trimmed.length > maxLength) {
setError(`内容不能超过${maxLength}个字符`);
return;
}
setSubmitting(true);
setError("");
try {
await onSubmit(trimmed);
setContent("");
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
};
return (
<>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
if (error) setError("");
}}
placeholder={placeholder}
rows={4}
maxLength={maxLength}
disabled={disabled}
className="w-full resize-none rounded-t-xl border-0 bg-transparent px-4 py-3 text-sm focus:outline-none focus:ring-0 placeholder:text-muted-foreground/40 disabled:opacity-50"
/>
<div className="flex items-center justify-between border-t border-border/40 px-4 py-2">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground/50">
{content.length}/{maxLength}
</span>
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
</div>
<button
onClick={handleSubmit}
disabled={submitting || !content.trim() || disabled}
className="rounded-lg bg-primary text-white text-sm px-4 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm shadow-primary/20"
>
{submitting ? "发布中..." : submitText}
</button>
</div>
</div>
<LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} />
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { MessageSquare, TrendingUp, Clock, Pin } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import { UserLevel } from "@prisma/client";
interface TopicUser {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
}
interface TopicCategory {
name: string;
slug: string;
icon?: string;
}
interface Topic {
id: number;
title: string;
slug: string;
isPinned: boolean;
isLocked: boolean;
viewCount: number;
replyCount: number;
lastReplyAt: string | null;
createdAt: string;
category: TopicCategory;
user: TopicUser;
}
interface ForumTopicCardProps {
topic: Topic;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
}
export default function ForumTopicCard({ topic }: ForumTopicCardProps) {
const initial = topic.user.name.charAt(0).toUpperCase();
return (
<Link href={`/community/topic/${topic.id}`}>
<div className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)] group">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-9 w-9 rounded-full border border-border/40 object-cover flex-shrink-0" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary flex-shrink-0">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{topic.isPinned && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">
<Pin className="w-2.5 h-2.5" />
置顶
</span>
)}
{topic.isLocked && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded-full">
已锁定
</span>
)}
<h3 className="text-sm font-medium group-hover:text-primary transition-colors truncate">
{topic.title}
</h3>
</div>
<div className="mt-1.5 flex items-center gap-3 flex-wrap text-xs text-muted-foreground/50">
<span className="inline-flex items-center gap-1">
<span className={`px-1.5 py-0.5 rounded bg-muted/30 text-[10px]`}>{topic.category.name}</span>
</span>
<span>{topic.user.name}</span>
<MemberBadge level={topic.user.level} points={topic.user.points} size="sm" showPoints={false} />
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{getTimeAgo(new Date(topic.createdAt))}
</span>
<span className="flex items-center gap-1 ml-auto">
<MessageSquare className="w-3 h-3" />
{topic.replyCount}
</span>
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{topic.viewCount}
</span>
</div>
{topic.lastReplyAt && (
<p className="mt-1 text-[10px] text-muted-foreground/30">
最后回复: {getTimeAgo(new Date(topic.lastReplyAt))}
</p>
)}
</div>
</div>
</Link>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useSession } from "next-auth/react";
import { Bell } from "lucide-react";
import { LoginModal } from "@/components/common/LoginModal";
interface Notif {
id: number;
type: string;
title: string;
content?: string;
link?: string;
isRead: boolean;
createdAt: string;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
}
export default function NotificationBell() {
const { data: session, status } = useSession();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<Notif[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showLoginModal, setShowLoginModal] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const fetchNotifications = async () => {
try {
const res = await fetch("/api/notifications?pageSize=5");
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications || []);
setUnreadCount(data.unreadCount || 0);
}
} catch (e) {
console.error("Failed to fetch notifications:", e);
}
};
useEffect(() => {
if (status === "authenticated") {
fetchNotifications();
}
}, [status]);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const handleClick = () => {
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
setOpen(!open);
if (!open && unreadCount > 0) {
fetch("/api/notifications", { method: "PUT" }).then(() => {
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
});
}
};
const handleNotifClick = (notif: Notif) => {
setOpen(false);
if (notif.link) {
window.location.href = notif.link;
}
};
return (
<>
<div className="relative" ref={ref}>
<button
onClick={handleClick}
className="relative p-2 rounded-full transition-colors hover:bg-muted/50"
>
<Bell className="w-5 h-5 text-muted-foreground" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 h-4 w-4 rounded-full bg-red-500 text-[10px] font-bold text-white flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 mt-2 w-80 rounded-xl border border-border/50 bg-white shadow-xl z-50 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<h3 className="text-sm font-semibold">通知</h3>
</div>
{notifications.length === 0 ? (
<div className="py-8 text-center">
<p className="text-xs text-muted-foreground/50">暂无通知</p>
</div>
) : (
<div className="max-h-80 overflow-y-auto">
{notifications.map((notif) => (
<button
key={notif.id}
onClick={() => handleNotifClick(notif)}
className={`w-full text-left px-4 py-3 border-b border-border/30 transition-colors hover:bg-muted/20 ${
!notif.isRead ? "bg-primary/[0.02]" : ""
}`}
>
<p className={`text-xs ${!notif.isRead ? "font-medium" : ""}`}>{notif.title}</p>
{notif.content && (
<p className="mt-0.5 text-[10px] text-muted-foreground/50 truncate">{notif.content}</p>
)}
<p className="mt-1 text-[10px] text-muted-foreground/30">{getTimeAgo(new Date(notif.createdAt))}</p>
</button>
))}
</div>
)}
<div className="px-4 py-2 border-t border-border/40">
<button
onClick={() => {
setOpen(false);
window.location.href = "/user/notifications";
}}
className="w-full text-center text-xs text-primary hover:text-primary/80"
>
查看全部通知
</button>
</div>
</div>
)}
</div>
<LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} />
</>
);
}