v0.11.1: 权限体系重构 + AI管线优化 + 内容整理
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
"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 { FavoriteButton } from "@/components/common/FavoriteButton";
|
||||
|
||||
interface FavoriteItem {
|
||||
id: number;
|
||||
toolId: number | null;
|
||||
skillId: number | null;
|
||||
createdAt: string;
|
||||
tool?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
websiteUrl: string;
|
||||
pricingModel: string;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
} | null;
|
||||
skill?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceType: string;
|
||||
category: { id: number; name: string; slug: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
type FilterType = "all" | "tool" | "skill";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [favorites, setFavorites] = useState<FavoriteItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status === "unauthenticated") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
fetch(`/api/user/favorites?type=${filter}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setFavorites(data.favorites || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setFavorites([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [status, filter]);
|
||||
|
||||
const handleUnfavorite = (favId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.id !== favId));
|
||||
setTotal((prev) => prev - 1);
|
||||
};
|
||||
|
||||
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="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</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 filterButtons: { key: FilterType; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "tool", label: "工具" },
|
||||
{ key: "skill", label: "技能" },
|
||||
];
|
||||
|
||||
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">
|
||||
<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 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">
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
管理你收藏的AI工具和技能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
{filterButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
onClick={() => setFilter(btn.key)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
filter === btn.key
|
||||
? "bg-primary text-white shadow-sm shadow-primary/25"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground border border-border/60"
|
||||
}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : favorites.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="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-5 text-base font-medium text-muted-foreground">
|
||||
{filter === "all" ? "暂无收藏" : `暂无收藏的${filter === "tool" ? "工具" : "技能"}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground/50">
|
||||
去发现并收藏你喜欢的AI工具和技能吧
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(filter === "skill" ? "/skills" : "/tools")}
|
||||
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"
|
||||
>
|
||||
浏览{filter === "skill" ? "技能" : "工具"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{favorites.map((fav, i) => {
|
||||
const isTool = !!fav.tool;
|
||||
const item = fav.tool || fav.skill;
|
||||
if (!item) return null;
|
||||
|
||||
const href = isTool
|
||||
? `/tools/${(fav.tool!).slug}`
|
||||
: `/skills/${(fav.skill!).slug}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={fav.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)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnfavorite(fav.id);
|
||||
}}
|
||||
>
|
||||
<FavoriteButton
|
||||
type={isTool ? "tool" : "skill"}
|
||||
id={item.id}
|
||||
initialFavorited={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
{item.logoUrl ? (
|
||||
<img
|
||||
src={item.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">
|
||||
{item.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">
|
||||
{item.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{isTool && fav.tool ? (
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{pricingLabel[fav.tool.pricingModel] || fav.tool.pricingModel}
|
||||
</span>
|
||||
) : !isTool && fav.skill ? (
|
||||
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[fav.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
|
||||
{sourceTypeLabel[fav.skill.sourceType] || fav.skill.sourceType}
|
||||
</span>
|
||||
) : null}
|
||||
{item.category && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{item.category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
|
||||
{item.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,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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";
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return "刚刚";
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days < 30) return `${days}天前`;
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [favoriteTotal, setFavoriteTotal] = useState(0);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState("");
|
||||
const [currentName, setCurrentName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [browsingHistory, setBrowsingHistory] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status === "unauthenticated") return;
|
||||
|
||||
if (session?.user?.name) {
|
||||
setCurrentName(session.user.name);
|
||||
setNameValue(session.user.name);
|
||||
}
|
||||
|
||||
fetch("/api/user/favorites?type=all")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setFavoriteTotal(data.total || 0))
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/user/history")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setBrowsingHistory(data.history || []))
|
||||
.catch(() => {});
|
||||
}, [status, session]);
|
||||
|
||||
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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</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 handleSaveName = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/user/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nameValue }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setCurrentName(nameValue || "");
|
||||
setEditingName(false);
|
||||
} catch {
|
||||
setNameValue(currentName);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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" },
|
||||
{ icon: Zap, label: "浏览技能", desc: "探索AI技能", href: "/skills", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
|
||||
];
|
||||
|
||||
const initial = currentName
|
||||
? currentName.charAt(0).toUpperCase()
|
||||
: session?.user?.email?.charAt(0).toUpperCase() || "?";
|
||||
|
||||
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-3xl 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>
|
||||
|
||||
<Card className="rounded-2xl border border-border/60 bg-white p-6 sm:p-8 mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_4px_12px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 text-3xl font-bold text-primary border border-primary/20 shrink-0">
|
||||
{initial}
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveName()}
|
||||
maxLength={100}
|
||||
className="px-3 py-1.5 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-48 text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<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">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 justify-center sm:justify-start">
|
||||
<h1 className="text-2xl font-bold truncate">
|
||||
{currentName || "未设置昵称"}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setEditingName(true)}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="修改昵称"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
||||
{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>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{favoriteTotal} 个收藏
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3 mb-6">
|
||||
{shortcuts.map((s) => (
|
||||
<Card
|
||||
key={s.label}
|
||||
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)]"
|
||||
onClick={() => router.push(s.href)}
|
||||
>
|
||||
<CardContent className="p-5 text-center">
|
||||
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
|
||||
<s.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="font-medium text-sm">{s.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{browsingHistory.length > 0 && (
|
||||
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">最近浏览</h2>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{browsingHistory.map((item: any) => {
|
||||
const isTool = !!item.tool;
|
||||
const entity = item.tool || item.skill;
|
||||
if (!entity) return null;
|
||||
|
||||
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
|
||||
const timeAgo = getTimeAgo(new Date(item.viewedAt));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
|
||||
onClick={() => router.push(href)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{entity.logoUrl ? (
|
||||
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
|
||||
) : (
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
|
||||
{entity.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
|
||||
{isTool ? "工具" : "技能"}
|
||||
</span>
|
||||
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user