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