全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
Award,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Heart,
|
||||
Bookmark,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import UserActivityCard from "@/components/forum/UserActivityCard";
|
||||
import UserBadges from "@/components/forum/UserBadges";
|
||||
import MemberBadge from "@/components/common/MemberBadge";
|
||||
import LevelProgress from "@/components/common/LevelProgress";
|
||||
|
||||
type Tab = "topics" | "posts" | "comments" | "bookmarks";
|
||||
|
||||
interface ProfileData {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
points: number;
|
||||
level: string;
|
||||
role: string;
|
||||
isBot: boolean;
|
||||
createdAt: string;
|
||||
levelInfo: {
|
||||
level: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
progress: number;
|
||||
nextLevel: { label: string; minPoints: number; remaining: number } | null;
|
||||
};
|
||||
stats: {
|
||||
topicCount: number;
|
||||
postCount: number;
|
||||
commentCount: number;
|
||||
favoriteCount: number;
|
||||
bookmarkCount: number;
|
||||
followedCategoryCount: number;
|
||||
bestAnswerCount: number;
|
||||
likesReceived: number;
|
||||
checkInStreak: number;
|
||||
lastCheckInDate: string | null;
|
||||
};
|
||||
achievements: Array<{ key: string; name: string; icon: string; tier: string; unlockedAt: string | null }>;
|
||||
}
|
||||
|
||||
function getTimeAgo(date: string): string {
|
||||
const now = Date.now();
|
||||
const t = new Date(date).getTime();
|
||||
const seconds = Math.floor((now - t) / 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 new Date(date).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
const statCards = (stats: ProfileData["stats"]) => [
|
||||
{ label: "积分", value: stats ? 0 : 0, icon: Award, color: "from-amber-400 to-amber-600" },
|
||||
];
|
||||
|
||||
export default function UserPublicProfile({ userId, initialData }: { userId: number; initialData?: any }) {
|
||||
const [profile, setProfile] = useState<ProfileData | null>(initialData || null);
|
||||
const [tab, setTab] = useState<Tab>("topics");
|
||||
const [tabData, setTabData] = useState<any[]>([]);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) return;
|
||||
let mounted = true;
|
||||
fetch(`/api/user/${userId}/profile`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (mounted && !d.error) setProfile(d); })
|
||||
.catch(() => {});
|
||||
return () => { mounted = false; };
|
||||
}, [userId, initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
let mounted = true;
|
||||
setTabLoading(true);
|
||||
setTabData([]);
|
||||
const urls: Record<Tab, string> = {
|
||||
topics: `/api/user/${userId}/topics`,
|
||||
posts: `/api/user/${userId}/posts`,
|
||||
comments: `/api/user/${userId}/comments`,
|
||||
bookmarks: `/api/user/${userId}/bookmarks`,
|
||||
};
|
||||
fetch(urls[tab])
|
||||
.then(r => r.json())
|
||||
.then(d => { if (mounted) setTabData(d.items || d.topics || d.posts || d.comments || d.bookmarks || []); })
|
||||
.catch(() => {})
|
||||
.finally(() => { if (mounted) setTabLoading(false); });
|
||||
return () => { mounted = false; };
|
||||
}, [tab, userId, profile]);
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = profile.name || profile.email.split("@")[0];
|
||||
const initial = (profile.name || profile.email).charAt(0).toUpperCase();
|
||||
const isOwner = false;
|
||||
|
||||
const copyShareLink = () => {
|
||||
const url = `${window.location.origin}/user/${userId}`;
|
||||
navigator.clipboard?.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const tabs: Array<{ key: Tab; label: string; count: number; icon: any }> = [
|
||||
{ key: "topics", label: "话题", count: profile.stats.topicCount, icon: TrendingUp },
|
||||
{ key: "posts", label: "回复", count: profile.stats.postCount, icon: MessageSquare },
|
||||
{ key: "comments", label: "评价", count: profile.stats.commentCount, icon: MessageSquare },
|
||||
{ key: "bookmarks", label: "收藏", count: profile.stats.bookmarkCount, icon: Bookmark },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#f8fafc] to-white">
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="mb-4">
|
||||
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
← 返回社区
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-5">
|
||||
<div className="h-36 bg-gradient-to-br from-violet-500 via-purple-500 to-indigo-500 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.15),transparent_60%)]" />
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={copyShareLink}
|
||||
className="px-3 py-1.5 bg-white/20 backdrop-blur text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3" /> 已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3 h-3" /> 分享
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-end justify-between -mt-12 mb-4 flex-wrap gap-4">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="relative">
|
||||
{profile.avatarUrl ? (
|
||||
<img
|
||||
src={profile.avatarUrl}
|
||||
alt=""
|
||||
className="h-24 w-24 rounded-2xl border-4 border-white object-cover shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-2xl bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
{profile.isBot && (
|
||||
<span className="absolute -bottom-1 -right-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-violet-600 text-white">
|
||||
Bot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold">{displayName}</h1>
|
||||
<MemberBadge level={profile.level as any} points={profile.points} size="md" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
{profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.bio && (
|
||||
<p className="text-sm text-muted-foreground/80 mb-4 bg-muted/30 rounded-lg p-3">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground/60 mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
加入于 {new Date(profile.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
{profile.stats.lastCheckInDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
🔥 连续签到 {profile.stats.checkInStreak} 天
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-3 mb-5">
|
||||
{[
|
||||
{ label: "话题", value: profile.stats.topicCount, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
|
||||
{ label: "回复", value: profile.stats.postCount, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
|
||||
{ label: "评价", value: profile.stats.commentCount, icon: MessageSquare, color: "text-amber-600 bg-amber-50" },
|
||||
{ label: "获赞", value: profile.stats.likesReceived, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
|
||||
{ label: "最佳", value: profile.stats.bestAnswerCount, icon: CheckCircle2, color: "text-emerald-600 bg-emerald-50" },
|
||||
{ label: "收藏", value: profile.stats.bookmarkCount, icon: Bookmark, color: "text-indigo-600 bg-indigo-50" },
|
||||
{ label: "关注板块", value: profile.stats.followedCategoryCount, icon: Heart, color: "text-rose-600 bg-rose-50" },
|
||||
{ label: "成就", value: profile.achievements.length, icon: Award, color: "text-yellow-600 bg-yellow-50" },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="text-center p-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<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-base font-bold">{stat.value}</p>
|
||||
<p className="text-[10px] text-muted-foreground/70">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<LevelProgress level={profile.level as any} points={profile.points} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
|
||||
<div className="lg:col-span-2">
|
||||
<UserActivityCard userId={userId} />
|
||||
</div>
|
||||
<div>
|
||||
<UserBadges userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
||||
<div className="flex border-b border-border/40">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
|
||||
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
<span className="text-xs text-muted-foreground/50">({t.count})</span>
|
||||
</span>
|
||||
{tab === t.key && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-5 min-h-[200px]">
|
||||
{tabLoading ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground/50">加载中...</div>
|
||||
) : tabData.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground/50">暂无内容</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tabData.slice(0, 15).map((item: any) => (
|
||||
<ActivityItem key={item.id} item={item} type={tab} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityItem({ item, type }: { item: any; type: Tab }) {
|
||||
if (type === "topics") {
|
||||
return (
|
||||
<Link href={`/community/topic/${item.id}`} className="block">
|
||||
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
|
||||
{item.category?.name || "话题"}
|
||||
</span>
|
||||
{item.isPinned && <span className="text-[10px] text-amber-600">📌 置顶</span>}
|
||||
{item.isFeatured && <span className="text-[10px] text-rose-600">🌟 精华</span>}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>👁 {item.viewCount || 0}</span>
|
||||
<span>💬 {item.replyCount || 0}</span>
|
||||
<span>👍 {item.likeCount || 0}</span>
|
||||
<span>·</span>
|
||||
<span>{getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (type === "posts") {
|
||||
return (
|
||||
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
|
||||
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
|
||||
{item.isAnswer && (
|
||||
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
|
||||
✓ 最佳答案
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>回复于:{item.topic?.title || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>{getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (type === "comments") {
|
||||
const target = item.tool || item.skill;
|
||||
return (
|
||||
<Link href={target ? `/${item.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
|
||||
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
|
||||
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>评价于:{target?.name || "—"}</span>
|
||||
{item.rating && <span>· ⭐ {item.rating}/5</span>}
|
||||
<span>·</span>
|
||||
<span>{getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// bookmarks
|
||||
return (
|
||||
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
|
||||
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
|
||||
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+88
-152
@@ -1,12 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
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";
|
||||
import UserPublicProfile from "./UserPublicProfile";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -14,167 +12,105 @@ interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "用户资料 - 追光AI",
|
||||
};
|
||||
|
||||
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 } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) notFound();
|
||||
|
||||
const [bestAnswerCount, totalLikesReceived, checkInStreak, followCount, achievements] = await Promise.all([
|
||||
prisma.forumPost.count({ where: { userId, isAnswer: true } }),
|
||||
prisma.forumPost.aggregate({ where: { userId }, _sum: { likeCount: true } }),
|
||||
prisma.forumCheckIn.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { checkInDate: "desc" },
|
||||
select: { streak: true, checkInDate: true },
|
||||
}),
|
||||
prisma.forumCategoryFollow.count({ where: { userId } }),
|
||||
prisma.userAchievement.findMany({
|
||||
where: { userId },
|
||||
include: { achievement: { select: { key: true, name: true, icon: true, tier: true } } },
|
||||
orderBy: { unlockedAt: "desc" },
|
||||
take: 6,
|
||||
}),
|
||||
]);
|
||||
|
||||
const counts = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
forumTopics: { where: { isHidden: false } },
|
||||
forumPosts: { where: { isHidden: false } },
|
||||
comments: { where: { status: "ACTIVE" } },
|
||||
favorites: true,
|
||||
topicBookmarks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) notFound();
|
||||
const fullUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
points: true,
|
||||
level: true,
|
||||
role: true,
|
||||
isBot: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const isOwner = session?.user?.id === userId.toString();
|
||||
if (!fullUser) notFound();
|
||||
|
||||
const levelInfo = getLevelInfo(user.points);
|
||||
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
|
||||
const levelInfo = getLevelInfo(fullUser.points);
|
||||
|
||||
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>
|
||||
const initialData = {
|
||||
id: fullUser.id,
|
||||
name: fullUser.name,
|
||||
email: fullUser.email,
|
||||
bio: fullUser.bio,
|
||||
avatarUrl: fullUser.avatarUrl,
|
||||
points: fullUser.points,
|
||||
level: fullUser.level,
|
||||
role: fullUser.role,
|
||||
isBot: fullUser.isBot,
|
||||
createdAt: fullUser.createdAt,
|
||||
levelInfo,
|
||||
stats: {
|
||||
topicCount: counts?._count.forumTopics || 0,
|
||||
postCount: counts?._count.forumPosts || 0,
|
||||
commentCount: counts?._count.comments || 0,
|
||||
favoriteCount: counts?._count.favorites || 0,
|
||||
bookmarkCount: counts?._count.topicBookmarks || 0,
|
||||
followedCategoryCount: followCount,
|
||||
bestAnswerCount,
|
||||
likesReceived: totalLikesReceived._sum.likeCount || 0,
|
||||
checkInStreak: checkInStreak?.streak || 0,
|
||||
lastCheckInDate: checkInStreak?.checkInDate?.toISOString() || null,
|
||||
},
|
||||
achievements: achievements.map(a => ({
|
||||
key: a.achievement.key,
|
||||
name: a.achievement.name,
|
||||
icon: a.achievement.icon,
|
||||
tier: a.achievement.tier,
|
||||
progress: a.progress,
|
||||
unlockedAt: a.unlockedAt?.toISOString() || null,
|
||||
})),
|
||||
};
|
||||
|
||||
<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>
|
||||
);
|
||||
return <UserPublicProfile userId={userId} initialData={initialData} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -14,6 +15,10 @@ interface Collection {
|
||||
_count: { items: number };
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "我的收藏夹 - 追光AI",
|
||||
};
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
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";
|
||||
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
|
||||
|
||||
interface FavoriteItem {
|
||||
id: number;
|
||||
@@ -33,20 +35,6 @@ interface FavoriteItem {
|
||||
} | 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",
|
||||
@@ -63,6 +51,10 @@ const sourceTypeColor: Record<string, string> = {
|
||||
|
||||
type FilterType = "all" | "tool" | "skill";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "我的收藏 - 追光AI",
|
||||
};
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
@@ -274,8 +266,8 @@ export default function FavoritesPage() {
|
||||
</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 className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${PRICING_COLOR[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
|
||||
{PRICING_LABEL[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"}`}>
|
||||
|
||||
+334
-98
@@ -1,18 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
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, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
|
||||
import {
|
||||
Heart,
|
||||
LogOut,
|
||||
Edit3,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
MessageCircle,
|
||||
TrendingUp,
|
||||
Award,
|
||||
Star,
|
||||
Bookmark,
|
||||
CheckCircle2,
|
||||
ThumbsUp,
|
||||
Bell,
|
||||
Loader2,
|
||||
Activity,
|
||||
Trophy,
|
||||
} from "lucide-react";
|
||||
import MemberBadge from "@/components/common/MemberBadge";
|
||||
import LevelProgress from "@/components/common/LevelProgress";
|
||||
import ActivityHeatmap from "@/components/forum/ActivityHeatmap";
|
||||
import { UserLevel } from "@prisma/client";
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
type Tab = "overview" | "topics" | "posts" | "bookmarks" | "achievements";
|
||||
|
||||
function getTimeAgo(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -22,9 +44,14 @@ function getTimeAgo(date: Date): string {
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days < 30) return `${days}天前`;
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
return d.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "个人中心 - 追光AI",
|
||||
description: "管理你的追光AI个人资料、收藏与成就。",
|
||||
};
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
@@ -43,6 +70,12 @@ export default function UserProfilePage() {
|
||||
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
|
||||
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
|
||||
const [userStats, setUserStats] = useState<any>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [tabData, setTabData] = useState<any[]>([]);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [activity, setActivity] = useState<any>(null);
|
||||
const [achievements, setAchievements] = useState<any[]>([]);
|
||||
const [unreadNotifs, setUnreadNotifs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
@@ -84,12 +117,65 @@ export default function UserProfilePage() {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/user/points?type=history&pageSize=5")
|
||||
fetch("/api/user/points?type=history&pageSize=8")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setPointsHistory(data.history || []))
|
||||
.catch(() => {});
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userId = parseInt(session.user.id);
|
||||
fetch(`/api/user/${userId}/activity`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.heatmap) setActivity(d); })
|
||||
.catch(() => {});
|
||||
|
||||
fetch(`/api/forum/achievements`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
|
||||
setAchievements(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch(`/api/notifications?pageSize=1`)
|
||||
.then(r => r.json())
|
||||
.then(d => setUnreadNotifs(d.unreadCount || 0))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [status, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user?.id) return;
|
||||
if (tab === "overview") {
|
||||
setTabData([]);
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
setTabLoading(true);
|
||||
setTabData([]);
|
||||
const userId = session.user.id;
|
||||
const urls: Record<Exclude<Tab, "overview">, string> = {
|
||||
topics: `/api/user/${userId}/topics`,
|
||||
posts: `/api/user/${userId}/posts`,
|
||||
bookmarks: `/api/user/${userId}/bookmarks`,
|
||||
achievements: `/api/forum/achievements`,
|
||||
};
|
||||
fetch(urls[tab as Exclude<Tab, "overview">])
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!mounted) return;
|
||||
if (tab === "achievements") {
|
||||
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
|
||||
setAchievements(list);
|
||||
} else {
|
||||
setTabData(d.items || d.topics || d.posts || d.bookmarks || []);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (mounted) setTabLoading(false); });
|
||||
return () => { mounted = false; };
|
||||
}, [tab, session]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -153,14 +239,22 @@ export default function UserProfilePage() {
|
||||
|
||||
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" },
|
||||
{ icon: Bookmark, label: "话题收藏", desc: "论坛收藏的话题", href: "/user?tab=bookmarks", color: "text-amber-600 bg-amber-50 border-amber-200/60" },
|
||||
{ icon: Bell, label: "消息通知", desc: `${unreadNotifs} 条未读`, href: "/notifications", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
|
||||
];
|
||||
|
||||
const initial = currentName
|
||||
? currentName.charAt(0).toUpperCase()
|
||||
: session?.user?.email?.charAt(0).toUpperCase() || "?";
|
||||
|
||||
const tabs: Array<{ key: Tab; label: string }> = [
|
||||
{ key: "overview", label: "概览" },
|
||||
{ key: "topics", label: "话题" },
|
||||
{ key: "posts", label: "回复" },
|
||||
{ key: "bookmarks", label: "收藏" },
|
||||
{ key: "achievements", label: "成就" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
@@ -168,8 +262,8 @@ export default function UserProfilePage() {
|
||||
<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="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 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>
|
||||
@@ -218,11 +312,16 @@ export default function UserProfilePage() {
|
||||
<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">
|
||||
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start flex-wrap">
|
||||
<MemberBadge level={userLevel} points={userPoints} size="md" />
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{favoriteTotal} 个收藏
|
||||
</span>
|
||||
{userStats?.checkInStreak !== undefined && userStats.checkInStreak > 0 && (
|
||||
<span className="text-xs text-orange-600 flex items-center gap-0.5">
|
||||
🔥 连续{userStats.checkInStreak}天
|
||||
</span>
|
||||
)}
|
||||
{createdAt && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/40">·</span>
|
||||
@@ -275,118 +374,255 @@ export default function UserProfilePage() {
|
||||
</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">
|
||||
<div className="grid gap-3 grid-cols-4 sm:grid-cols-8 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.postCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-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" },
|
||||
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-cyan-600 bg-cyan-50" },
|
||||
{ label: "最佳", value: userStats.bestAnswerCount || 0, icon: CheckCircle2, color: "text-green-600 bg-green-50" },
|
||||
{ label: "获赞", value: userStats.likesReceived || 0, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
|
||||
{ label: "成就", value: achievements.length, icon: Trophy, color: "text-yellow-600 bg-yellow-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 key={stat.label} className="rounded-xl border border-border/40 bg-white p-2 sm:p-3 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<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>
|
||||
<p className="text-base font-bold">{stat.value}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
<div className="flex gap-2 mb-4 border-b border-border/40 overflow-x-auto">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap relative ${
|
||||
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{t.label}
|
||||
{tab === t.key && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</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;
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 mb-6">
|
||||
<LevelProgress level={userLevel} points={userPoints} />
|
||||
|
||||
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>
|
||||
)}
|
||||
<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 max-h-[160px] overflow-y-auto pr-1">
|
||||
{pointsHistory.map((ph: any) => (
|
||||
<div key={ph.id} className="flex items-center justify-between text-xs">
|
||||
<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>
|
||||
<p className="font-medium text-foreground/80 truncate">{ph.reason}</p>
|
||||
{ph.detail && <p className="text-muted-foreground/50 truncate">{ph.detail}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
<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>
|
||||
|
||||
{activity && (
|
||||
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold">活跃足迹</h3>
|
||||
<span className="text-xs text-muted-foreground/60 ml-2">最近 {activity.summary.totalDays} 天 · 活跃 {activity.summary.activeDays} 天</span>
|
||||
</div>
|
||||
<ActivityHeatmap data={activity.heatmap} />
|
||||
</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={() => {
|
||||
if (s.href.startsWith("/user?tab=")) setTab("bookmarks");
|
||||
else 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>
|
||||
);
|
||||
})}
|
||||
</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.slice(0, 12).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(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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(tab === "topics" || tab === "posts" || tab === "bookmarks") && (
|
||||
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
|
||||
</div>
|
||||
) : tabData.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-muted-foreground/50">
|
||||
暂无{tab === "topics" ? "话题" : tab === "posts" ? "回复" : "收藏"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tabData.slice(0, 15).map((item: any) => {
|
||||
if (tab === "topics") {
|
||||
return (
|
||||
<div key={item.id} onClick={() => router.push(`/community/topic/${item.id}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
|
||||
{item.category?.name || "话题"}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>👁 {item.viewCount || 0}</span>
|
||||
<span>💬 {item.replyCount || 0}</span>
|
||||
<span>👍 {item.likeCount || 0}</span>
|
||||
<span>·</span>
|
||||
<span>{getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (tab === "posts") {
|
||||
return (
|
||||
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
|
||||
{item.isAnswer && (
|
||||
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">✓ 最佳答案</span>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>回复于:{item.topic?.title || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>{getTimeAgo(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// bookmarks
|
||||
return (
|
||||
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
|
||||
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
|
||||
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
|
||||
</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)]">
|
||||
{tab === "achievements" && (
|
||||
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
<CardContent className="p-5">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
|
||||
</div>
|
||||
) : achievements.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-muted-foreground/50">
|
||||
<Trophy className="w-10 h-10 text-muted-foreground/30 mx-auto mb-2" />
|
||||
尚未解锁任何成就
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{achievements.map((a: any) => {
|
||||
const tierColors: Record<string, string> = {
|
||||
bronze: "from-amber-100 to-amber-50 text-amber-700",
|
||||
silver: "from-slate-100 to-slate-50 text-slate-700",
|
||||
gold: "from-yellow-100 to-yellow-50 text-yellow-700",
|
||||
diamond: "from-violet-100 to-violet-50 text-violet-700",
|
||||
};
|
||||
const cls = tierColors[a.tier] || tierColors.bronze;
|
||||
return (
|
||||
<div key={a.key} className={`rounded-xl border border-border/40 bg-gradient-to-br ${cls} p-3 text-center`}>
|
||||
<div className="text-3xl mb-1">{a.icon}</div>
|
||||
<p className="text-xs font-semibold truncate">{a.name}</p>
|
||||
{a.unlockedAt && (
|
||||
<p className="text-[10px] opacity-60 mt-0.5">{getTimeAgo(a.unlockedAt)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mt-6 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: "/" })}
|
||||
|
||||
Reference in New Issue
Block a user