2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user