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
+180
View File
@@ -0,0 +1,180 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { getLevelInfo } from "@/lib/level-config";
import { MessageSquare, TrendingUp, Star, Award, Calendar, Share2, Edit3, Check } from "lucide-react";
export const dynamic = "force-dynamic";
interface Props {
params: { id: string };
}
export default async function UserProfilePage({ params }: Props) {
const userId = parseInt(params.id);
if (isNaN(userId)) notFound();
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
forumTopics: {
take: 10,
orderBy: { createdAt: "desc" },
include: { category: { select: { name: true, slug: true, icon: true } } },
},
forumPosts: {
take: 10,
orderBy: { createdAt: "desc" },
include: { topic: { select: { id: true, title: true } } },
},
comments: {
take: 10,
orderBy: { createdAt: "desc" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
},
},
},
});
if (!user) notFound();
const session = await getServerSession(authOptions);
const isOwner = session?.user?.id === userId.toString();
const levelInfo = getLevelInfo(user.points);
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="h-32 bg-gradient-to-r from-violet-500 via-purple-500 to-indigo-500 relative">
<div className="absolute -bottom-12 left-6">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="absolute top-4 right-4 flex items-center gap-2">
{isOwner ? (
<Link href="/user" className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1">
<Edit3 className="w-3 h-3" /> 编辑资料
</Link>
) : (
<a href={shareUrl} className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
onClick={(e) => {
e.preventDefault();
navigator.clipboard?.writeText(shareUrl).then(() => alert("已复制分享链接!"));
}}>
<Share2 className="w-3 h-3" /> 分享
</a>
)}
</div>
</div>
<div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-xl font-bold">{user.name || user.email.split("@")[0]}</h1>
<p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p>
{user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" />
注册于 {user.createdAt.toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-6">
{[
{ label: "积分", value: user.points, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "话题", value: user.forumTopics.length, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: user.forumPosts.length, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评论", value: user.comments.length, icon: Star, color: "text-amber-600 bg-amber-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-3 rounded-lg bg-muted/30">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
<div className="mt-6">
<LevelProgress level={user.level} points={user.points} />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">发布的话题</h2>
{user.forumTopics.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无话题</p>
) : (
<div className="space-y-3">
{user.forumTopics.map((topic) => (
<Link key={topic.id} href={`/community/topic/${topic.id}`} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">参与的评论</h2>
{user.comments.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无评论</p>
) : (
<div className="space-y-3">
{user.comments.map((comment) => {
const target = comment.tool || comment.skill;
return (
<Link key={comment.id} href={target ? `/${comment.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-sm text-muted-foreground/80 truncate">{comment.content}</p>
{target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt.toLocaleDateString("zh-CN")}</span>
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+546
View File
@@ -0,0 +1,546 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface CollectionItem {
id: number;
toolId: number | null;
skillId: number | null;
addedAt: string;
tool?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
pricingModel: string;
category: { id: number; name: string; slug: string } | null;
} | null;
skill?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceType: string;
category: { id: number; name: string; slug: string } | null;
} | null;
}
interface Collection {
id: number;
name: string;
description: string | null;
_count: { items: number };
}
interface SearchResult {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
type: "tool" | "skill";
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
const sourceTypeLabel: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
github: "bg-slate-50 text-slate-700",
gitlab: "bg-orange-50 text-orange-700",
huggingface: "bg-yellow-50 text-yellow-700",
other: "bg-secondary text-muted-foreground",
};
export default function CollectionDetailPage() {
const { data: session, status } = useSession();
const router = useRouter();
const params = useParams();
const collectionId = params.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [items, setItems] = useState<CollectionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [addingItems, setAddingItems] = useState<Set<number>>(new Set());
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [collRes, itemsRes] = await Promise.all([
fetch(`/api/user/collections/${collectionId}`),
fetch(`/api/user/collections/${collectionId}/items`),
]);
if (!collRes.ok) {
const e = await collRes.json();
throw new Error(e.error || "加载失败");
}
const collData = await collRes.json();
const itemsData = await itemsRes.json();
setCollection(collData);
setItems(itemsData.items || []);
} catch (err: any) {
setError(err.message || "加载收藏夹失败");
} finally {
setLoading(false);
}
}, [collectionId]);
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchData();
}, [status, fetchData]);
const handleRemoveItem = async (itemId: number) => {
try {
const res = await fetch(`/api/user/collections/${collectionId}/items/${itemId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("删除失败");
setItems((prev) => prev.filter((i) => i.id !== itemId));
if (collection) {
setCollection({ ...collection, _count: { items: collection._count.items - 1 } });
}
} catch {
alert("删除失败,请重试");
}
};
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (query.trim().length < 1) {
setSearchResults([]);
return;
}
setSearching(true);
try {
const [toolsRes, skillsRes] = await Promise.all([
fetch(`/api/tools?search=${encodeURIComponent(query)}&pageSize=8`),
fetch(`/api/skills?search=${encodeURIComponent(query)}&pageSize=8`),
]);
const toolsData = await toolsRes.json();
const skillsData = await skillsRes.json();
const existingToolIds = new Set(items.filter((i) => i.toolId).map((i) => i.toolId!));
const existingSkillIds = new Set(items.filter((i) => i.skillId).map((i) => i.skillId!));
const results: SearchResult[] = [
...(toolsData.tools || [])
.filter((t: any) => !existingToolIds.has(t.id))
.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug, description: t.description, logoUrl: t.logoUrl, type: "tool" as const })),
...(skillsData.skills || [])
.filter((s: any) => !existingSkillIds.has(s.id))
.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug, description: s.description, logoUrl: s.logoUrl, type: "skill" as const })),
];
setSearchResults(results);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
};
const handleAddItem = async (result: SearchResult) => {
setAddingItems((prev) => new Set(prev).add(result.id));
try {
const body: any = {};
if (result.type === "tool") body.toolId = result.id;
else body.skillId = result.id;
const res = await fetch(`/api/user/collections/${collectionId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const e = await res.json();
throw new Error(e.error || "添加失败");
}
setSearchResults((prev) => prev.filter((r) => r.id !== result.id));
fetchData();
} catch (err: any) {
alert(err.message || "添加失败");
} finally {
setAddingItems((prev) => {
const next = new Set(prev);
next.delete(result.id);
return next;
});
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">
登录后即可查看收藏夹详情
</p>
<button
onClick={() => router.push("/login")}
className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
去登录
</button>
</Card>
</div>
);
}
const itemCount = collection?._count.items ?? 0;
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchData} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<>
<div className="mb-8 animate-fade-in">
<div className="h-4 w-48 animate-pulse rounded bg-muted/40" />
<div className="mt-3 h-8 w-64 animate-pulse rounded bg-muted/40" />
<div className="mt-2 h-4 w-32 animate-pulse rounded bg-muted/30" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
</>
) : !collection ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹不存在</p>
<button onClick={() => router.push("/user/collections")} className="mt-4 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
返回收藏夹列表
</button>
</div>
) : (
<>
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">
首页
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<button onClick={() => router.push("/user/collections")} className="transition-colors hover:text-foreground">
我的收藏夹
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span className="text-foreground truncate max-w-[200px]">{collection.name}</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
<span className="gradient-text">{collection.name}</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{itemCount}
</span>
</div>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
{collection.description && (
<p className="mt-2 text-muted-foreground">{collection.description}</p>
)}
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" onClick={() => setShowAddModal(false)} />
<div className="relative w-full max-w-lg bg-white/95 backdrop-blur-md rounded-2xl border border-border/60 shadow-[0_20px_60px_rgba(0,0,0,0.15)] overflow-hidden animate-slide-up">
<div className="p-5 border-b border-border/40">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">添加到收藏夹</h3>
<button
onClick={() => setShowAddModal(false)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-muted/50 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative">
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
placeholder="搜索 AI 工具或技能..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 bg-muted/30 rounded-xl border border-border/40 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto p-3">
{searching ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : searchResults.length === 0 && searchQuery.trim() ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mb-3 text-muted-foreground/30">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<p className="text-sm">未找到匹配的内容</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-2">
{searchResults.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="flex items-center gap-3 p-3 rounded-xl border border-border/40 bg-white hover:border-primary/20 hover:shadow-sm transition-all"
>
{result.logoUrl ? (
<img
src={result.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 shrink-0"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary shrink-0">
{result.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{result.name}</p>
<p className="text-xs text-muted-foreground/60 truncate">{result.description || "暂无简介"}</p>
</div>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
result.type === "tool"
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{result.type === "tool" ? "工具" : "技能"}
</span>
<button
onClick={() => handleAddItem(result)}
disabled={addingItems.has(result.id)}
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{addingItems.has(result.id) ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : "添加"}
</button>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹为空</p>
<p className="mt-1 text-sm text-muted-foreground/50">
添加 AI 工具或技能到这个收藏夹
</p>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => {
const isTool = !!item.tool;
const data = item.tool || item.skill;
if (!data) return null;
const href = isTool
? `/tools/${data.slug}`
: `/skills/${data.slug}`;
return (
<Card
key={item.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(href)}
>
<button
className="absolute top-2 right-2 z-10 p-1.5 rounded-lg text-muted-foreground/40 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={(e) => {
e.stopPropagation();
handleRemoveItem(item.id);
}}
title="从收藏夹移除"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{data.logoUrl ? (
<img
src={data.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{data.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{data.name}
</p>
<div className="mt-1 flex items-center gap-2">
{isTool && item.tool ? (
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[item.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[item.tool.pricingModel] || item.tool.pricingModel}
</span>
) : !isTool && item.skill ? (
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[item.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[item.skill.sourceType] || item.skill.sourceType}
</span>
) : null}
{data.category && (
<span className="text-[10px] text-muted-foreground/60">
{data.category.name}
</span>
)}
</div>
</div>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{data.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center justify-between">
<span className={`rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
isTool
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{isTool ? "工具" : "技能"}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FolderOpen, Plus, ChevronRight, Loader2, X } from "lucide-react";
interface Collection {
id: number;
name: string;
description: string | null;
createdAt: string;
_count: { items: number };
}
export default function CollectionsPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchCollections();
}, [status]);
const fetchCollections = () => {
setLoading(true);
setError("");
fetch("/api/user/collections")
.then((r) => r.json())
.then((data) => setCollections(data.collections || []))
.catch(() => setError("加载收藏夹失败"))
.finally(() => setLoading(false));
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
setCreateError("");
try {
const res = await fetch("/api/user/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "创建失败");
}
setShowCreate(false);
setNewName("");
setNewDesc("");
fetchCollections();
} catch (err: any) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<FolderOpen className="w-7 h-7 text-primary" />
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">登录后即可管理收藏夹</p>
<button onClick={() => router.push("/login")} className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
去登录
</button>
</Card>
</div>
);
}
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
</div>
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">我的收藏夹</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
我的<span className="gradient-text">收藏夹</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{collections.length}
</span>
</div>
<button
onClick={() => { setShowCreate(true); setCreateError(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
</div>
{showCreate && (
<Card className="mb-6 border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
<CardContent className="p-5">
<form onSubmit={handleCreate} className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">新建收藏夹</h3>
<button type="button" onClick={() => setShowCreate(false)} className="p-1 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<input
type="text"
placeholder="收藏夹名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
maxLength={50}
required
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
<input
type="text"
placeholder="描述(选填)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
/>
{createError && <p className="text-sm text-red-500">{createError}</p>}
<button
type="submit"
disabled={creating || !newName.trim()}
className="w-full py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : "创建"}
</button>
</form>
</CardContent>
</Card>
)}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchCollections} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white">
<CardContent className="p-5">
<div className="h-5 w-32 animate-pulse rounded bg-muted/40 mb-2" />
<div className="h-3 w-48 animate-pulse rounded bg-muted/30" />
</CardContent>
</Card>
))}
</div>
) : collections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<FolderOpen className="w-8 h-8 text-muted-foreground/40" />
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无收藏夹</p>
<p className="mt-1 text-sm text-muted-foreground/50">创建收藏夹来整理你的AI工具和技能</p>
<button onClick={() => setShowCreate(true)} className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{collections.map((c, i) => (
<Card
key={c.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/user/collections/${c.id}`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate group-hover:text-primary transition-colors">{c.name}</h3>
{c.description && (
<p className="mt-1 text-sm text-muted-foreground truncate">{c.description}</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground/60">
<span>{c._count.items} 项</span>
<span>{new Date(c.createdAt).toLocaleDateString("zh-CN")} 创建</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-all mt-0.5" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
+146 -8
View File
@@ -5,7 +5,10 @@ import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock } from "lucide-react";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { UserLevel } from "@prisma/client";
function getTimeAgo(date: Date): string {
const now = new Date();
@@ -29,8 +32,17 @@ export default function UserProfilePage() {
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState("");
const [currentName, setCurrentName] = useState("");
const [editingBio, setEditingBio] = useState(false);
const [bioValue, setBioValue] = useState("");
const [currentBio, setCurrentBio] = useState("");
const [saving, setSaving] = useState(false);
const [browsingHistory, setBrowsingHistory] = useState<any[]>([]);
const [createdAt, setCreatedAt] = useState<string | null>(null);
const [historyTotal, setHistoryTotal] = useState(0);
const [userPoints, setUserPoints] = useState(0);
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
const [userStats, setUserStats] = useState<any>(null);
useEffect(() => {
if (status === "loading") return;
@@ -41,6 +53,15 @@ export default function UserProfilePage() {
setNameValue(session.user.name);
}
fetch("/api/user/profile")
.then((r) => r.json())
.then((data) => {
data.createdAt && setCreatedAt(data.createdAt);
data.bio && setCurrentBio(data.bio);
data.bio && setBioValue(data.bio);
})
.catch(() => {});
fetch("/api/user/favorites?type=all")
.then((r) => r.json())
.then((data) => setFavoriteTotal(data.total || 0))
@@ -48,7 +69,24 @@ export default function UserProfilePage() {
fetch("/api/user/history")
.then((r) => r.json())
.then((data) => setBrowsingHistory(data.history || []))
.then((data) => {
setBrowsingHistory(data.history || []);
setHistoryTotal(data.history?.length || 0);
})
.catch(() => {});
fetch("/api/user/points?type=stats")
.then((r) => r.json())
.then((data) => {
setUserPoints(data.points || 0);
setUserLevel(data.level?.level || UserLevel.LV01);
setUserStats(data);
})
.catch(() => {});
fetch("/api/user/points?type=history&pageSize=5")
.then((r) => r.json())
.then((data) => setPointsHistory(data.history || []))
.catch(() => {});
}, [status, session]);
@@ -91,18 +129,28 @@ export default function UserProfilePage() {
const res = await fetch("/api/user/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nameValue }),
body: JSON.stringify({ name: nameValue, bio: bioValue }),
});
if (!res.ok) throw new Error();
setCurrentName(nameValue || "");
setCurrentBio(bioValue || "");
setEditingName(false);
setEditingBio(false);
} catch {
setNameValue(currentName);
setBioValue(currentBio);
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditingName(false);
setEditingBio(false);
setNameValue(currentName);
setBioValue(currentBio);
};
const shortcuts = [
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
@@ -136,7 +184,7 @@ export default function UserProfilePage() {
</div>
<div className="flex-1 text-center sm:text-left min-w-0">
{editingName ? (
<div className="flex items-center gap-2 justify-center sm:justify-start">
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
<input
type="text"
value={nameValue}
@@ -149,7 +197,7 @@ export default function UserProfilePage() {
<button onClick={handleSaveName} disabled={saving} className="p-1.5 rounded-lg text-emerald-600 hover:bg-emerald-50 transition-colors">
<Check className="w-4 h-4" />
</button>
<button onClick={() => { setEditingName(false); setNameValue(currentName); }} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<button onClick={handleCancelEdit} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
@@ -171,17 +219,107 @@ export default function UserProfilePage() {
{session?.user?.email}
</p>
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{((session?.user as any)?.role === "admin") ? "管理员" : "用户"}
</span>
<MemberBadge level={userLevel} points={userPoints} size="md" />
<span className="text-xs text-muted-foreground/60">
{favoriteTotal} 个收藏
</span>
{createdAt && (
<>
<span className="text-xs text-muted-foreground/40">·</span>
<span className="text-xs text-muted-foreground/60">
注册于 {new Date(createdAt).toLocaleDateString("zh-CN")}
</span>
</>
)}
</div>
<div className="mt-4 pt-4 border-t border-border/40">
{editingBio ? (
<div className="flex flex-col gap-2">
<textarea
value={bioValue}
onChange={(e) => setBioValue(e.target.value)}
maxLength={500}
rows={3}
className="px-3 py-2 bg-white rounded-lg border border-border/70 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors w-full text-foreground resize-none"
placeholder="介绍一下自己吧..."
autoFocus
/>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{bioValue.length}/500</span>
<div className="flex gap-2">
<button onClick={handleSaveName} disabled={saving} className="px-3 py-1 rounded-lg text-sm bg-primary text-white hover:bg-primary/90 transition-colors">
保存
</button>
<button onClick={handleCancelEdit} className="px-3 py-1 rounded-lg text-sm text-muted-foreground hover:bg-accent transition-colors">
取消
</button>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2 justify-center sm:justify-start">
<p className="text-sm text-muted-foreground flex-1">
{currentBio || "这个人很懒,还没有写简介~"}
</p>
<button
onClick={() => setEditingBio(true)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
title="编辑简介"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
</div>
</Card>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">积分明细</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div>
<p className="font-medium text-foreground/80">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate max-w-[150px]">{ph.detail}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{userStats && (
<div className="grid gap-4 grid-cols-4 mb-6">
{[
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "话题", value: userStats.topicCount || 0, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "评价", value: userStats.reviewCount || 0, icon: Star, color: "text-amber-600 bg-amber-50" },
{ label: "积分", value: userStats.points || 0, icon: Award, color: "text-emerald-600 bg-emerald-50" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-4 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-4 h-4" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card