2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间

This commit is contained in:
ZhuiGuangAI Dev
2026-05-24 23:05:38 +08:00
parent 6c78c32157
commit 9bf3e7574a
118 changed files with 11842 additions and 3262 deletions
+396
View File
@@ -0,0 +1,396 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface HomePageClientProps {
flatCategories: FlatCategory[];
parentCategories: ParentCategory[];
totalTools: number;
totalSkills: number;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
}: HomePageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
key={tool.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)]"
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<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-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/categories/${cat}`);
}}
>
查看全部 {displayTools.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}