重构社区论坛板块:10赛道二级导航+布局调整

- 更新种子脚本:10大赛道×51个子板块,热门排序
- 新增清理脚本:删除所有非10赛道旧板块
- 重做侧边栏:仿工具模块的二级平铺导航,带搜索过滤
- 新增排行组件:本月活跃+积分总排行移至右侧栏
- 调整页面布局:左导航(220px)+中内容+右指南/排行(280px)
- TypeScript编译零错误通过
This commit is contained in:
ZhuiGuangAI Dev
2026-05-30 19:55:31 +08:00
parent 10d1acb305
commit 7109af6dcd
5 changed files with 387 additions and 200 deletions
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getLevelInfo } from "@/lib/level-config";
interface UserRanking {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
level: string;
points: number;
bio?: string | null;
}
export function CommunityRankings() {
const [monthlyActive, setMonthlyActive] = useState<UserRanking[]>([]);
const [topByPoints, setTopByPoints] = useState<UserRanking[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user/rankings?type=active&limit=10")
.then((r) => r.json())
.then((data) => {
setMonthlyActive(data.users || []);
})
.catch(() => {});
fetch("/api/user/rankings?type=points&limit=10")
.then((r) => r.json())
.then((data) => {
setTopByPoints(data.users || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
const renderSkeleton = () =>
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-4 w-4 rounded-full bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
));
const renderUserRow = (user: UserRanking, i: number) => {
const initial = user.name?.charAt(0).toUpperCase() || user.email.charAt(0).toUpperCase() || "?";
const levelInfo = getLevelInfo(user.points);
return (
<Link
key={user.id}
href={`/user/${user.id}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-sky-100 text-sky-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-4 w-4 rounded-full object-cover shrink-0" />
) : (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-[8px] font-bold text-sky-600 shrink-0">
{initial}
</div>
)}
<span className="flex-1 truncate text-left text-[12px]">{user.name || user.email?.split("@")[0]}</span>
{levelInfo && (
<span className="text-[9px] text-foreground/30 shrink-0">{levelInfo.label}</span>
)}
</Link>
);
};
return (
<>
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-sky-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">本月活跃用户</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : monthlyActive.map(renderUserRow)}
</div>
</div>
<div className="h-px bg-border/40 my-4" />
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">积分总排行</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : topByPoints.map(renderUserRow)}
</div>
</div>
</>
);
}
+108 -123
View File
@@ -1,147 +1,132 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import Link from "next/link";
import { getLevelInfo } from "@/lib/level-config";
import { usePathname } from "next/navigation";
const INDUSTRY_FORUMS = [
{ name: "电商零售", slug: "ecommerce", icon: "🛒" },
{ name: "AI智能科技", slug: "ai-tech", icon: "🤖" },
{ name: "房地产", slug: "real-estate", icon: "🏠" },
{ name: "金融投资", slug: "finance", icon: "📈" },
{ name: "餐饮食品", slug: "food", icon: "🍜" },
{ name: "自媒体内容", slug: "content", icon: "🎬" },
{ name: "本地生活", slug: "local-life", icon: "🏪" },
{ name: "大健康", slug: "health", icon: "💊" },
{ name: "教育培训", slug: "education", icon: "📚" },
{ name: "外贸跨境", slug: "cross-border", icon: "🌏" },
];
interface UserRanking {
interface ForumCategoryChild {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
level: string;
points: number;
bio?: string | null;
name: string;
slug: string;
icon: string | null;
description: string | null;
topicCount: number;
}
export function CommunitySidebar() {
const [monthlyActive, setMonthlyActive] = useState<UserRanking[]>([]);
const [topByPoints, setTopByPoints] = useState<UserRanking[]>([]);
const [loading, setLoading] = useState(true);
interface ForumCategory {
id: number;
name: string;
slug: string;
icon: string | null;
description: string | null;
topicCount: number;
children: ForumCategoryChild[];
}
useEffect(() => {
fetch("/api/user/rankings?type=active&limit=10")
.then((r) => r.json())
.then((data) => {
setMonthlyActive(data.users || []);
interface CommunitySidebarProps {
categories: ForumCategory[];
totalTopics: number;
}
export function CommunitySidebar({ categories, totalTopics }: CommunitySidebarProps) {
const pathname = usePathname();
const [search, setSearch] = useState("");
const filteredCategories = search.trim()
? categories.filter((cat) => {
const catMatch = cat.name.includes(search) || cat.slug.includes(search);
const childMatch = cat.children.some(
(c) => c.name.includes(search) || c.slug.includes(search)
);
return catMatch || childMatch;
})
.catch(() => {})
.finally(() => {});
: categories;
fetch("/api/user/rankings?type=points&limit=10")
.then((r) => r.json())
.then((data) => {
setTopByPoints(data.users || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
const totalSubCategories = categories.reduce((sum, c) => sum + c.children.length, 0);
const totalAll = categories.length + totalSubCategories;
const renderSkeleton = () => (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-4 w-4 rounded-full bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
);
const isActive = (slug: string) => pathname === `/community/${slug}` || pathname.startsWith(`/community/${slug}`);
const renderUserRow = (user: UserRanking, i: number) => {
const initial = user.name?.charAt(0).toUpperCase() || user.email.charAt(0).toUpperCase() || "?";
const levelInfo = getLevelInfo(user.points);
return (
<Link
key={user.id}
href={`/user/${user.id}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-sky-100 text-sky-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-4 w-4 rounded-full object-cover shrink-0" />
) : (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-[8px] font-bold text-sky-600 shrink-0">
{initial}
</div>
)}
<span className="flex-1 truncate text-left text-[12px]">{user.name || user.email?.split("@")[0]}</span>
{levelInfo && (
<span className="text-[9px] text-foreground/30 shrink-0">{levelInfo.label}</span>
)}
</Link>
);
};
const parentItemClass = (slug: string) =>
`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] font-semibold transition-all duration-200 ${
isActive(slug)
? "bg-sky-50 text-sky-700 shadow-sm"
: "text-foreground/80 hover:bg-white hover:text-foreground hover:shadow-sm"
}`;
const childItemClass = (slug: string) =>
`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-[12px] transition-all duration-200 pl-7 ${
isActive(slug)
? "bg-sky-50/60 text-sky-600"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`;
return (
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
<nav className="space-y-1">
<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)}
placeholder="搜索行业板块..."
className="h-9 w-full rounded-lg border border-border/60 bg-white pl-9 pr-3 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-sky-400/50 focus:outline-none focus:ring-2 focus:ring-sky-200/50 transition-all"
/>
</div>
{/* Industry Forums */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-indigo-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0H5m0 0H3m6-8h1m4 0h1m-6 4h1m4 0h1" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">行业论坛</span>
</div>
<div className="space-y-0.5">
{INDUSTRY_FORUMS.map((forum) => (
<div className="h-px bg-border/40 my-2" />
<div className="mb-1 flex items-center gap-2 px-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-sky-500">
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0H5m0 0H3m6-8h1m4 0h1m-6 4h1m4 0h1" />
</svg>
<span className="text-[11px] font-semibold uppercase tracking-wider text-sky-500/70">行业论坛</span>
<span className="ml-auto text-[10px] text-muted-foreground/40">{totalAll}</span>
</div>
<div className="space-y-0.5">
{filteredCategories.map((cat) => (
<div key={cat.id}>
<Link
key={forum.slug}
href={`/community/${forum.slug}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[11px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-sky-600 hover:shadow-sm"
href={`/community/${cat.slug}`}
className={parentItemClass(cat.slug)}
>
<span className="text-sm shrink-0">{forum.icon}</span>
<span className="flex-1 truncate text-left">{forum.name}</span>
<span className="text-sm shrink-0">{cat.icon || "📌"}</span>
<span className="truncate">{cat.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive(cat.slug) ? "text-sky-500/60" : "text-muted-foreground/40"
}`}>{cat.topicCount}</span>
</Link>
))}
</div>
{cat.children.length > 0 && (
<div className="mt-0.5 space-y-0.5">
{cat.children.map((child) => (
<Link
key={child.id}
href={`/community/${child.slug}`}
className={childItemClass(child.slug)}
>
<span className="text-xs shrink-0">{child.icon || "📌"}</span>
<span className="truncate">{child.name}</span>
<span className={`ml-auto shrink-0 text-[9px] tabular-nums ${
isActive(child.slug) ? "text-sky-500/50" : "text-muted-foreground/30"
}`}>{child.topicCount}</span>
</Link>
))}
</div>
)}
</div>
))}
</div>
<div className="h-px bg-border/40" />
<div className="h-px bg-border/40 my-3" />
{/* Monthly Active Users */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-sky-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">本月活跃用户</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : monthlyActive.map(renderUserRow)}
</div>
<div className="px-2.5 py-1">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个赛道 · {totalSubCategories} 个板块 · {totalTopics} 个话题
</p>
</div>
<div className="h-px bg-border/40" />
{/* Total User Points Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">积分总排行</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : topByPoints.map(renderUserRow)}
</div>
</div>
</div>
</nav>
);
}
+32 -18
View File
@@ -2,20 +2,26 @@ import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { HeroBanner } from "@/components/common/HeroBanner";
import { CommunitySidebar } from "./CommunitySidebar";
import { CommunityRankings } from "./CommunityRankings";
export const dynamic = "force-dynamic";
export default async function CommunityPage() {
const [categories, topicsCount, userCount] = await Promise.all([
prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
const industryParent = await prisma.forumCategory.findUnique({
where: { slug: "industry" },
});
const categories = await prisma.forumCategory.findMany({
where: { parentId: industryParent?.id ?? undefined },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}),
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
const [topicsCount, userCount] = await Promise.all([
prisma.forumTopic.count(),
prisma.user.count(),
]);
@@ -31,13 +37,17 @@ export default async function CommunityPage() {
take: 5,
});
const totalSubCategories = categories.reduce((sum, c) => sum + c.children.length, 0);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr_280px] gap-6">
{/* Left Sidebar: Rankings */}
{/* Left Sidebar: Two-level navigation */}
<aside className="hidden lg:block">
<CommunitySidebar />
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
<CommunitySidebar categories={categories} totalTopics={topicsCount} />
</div>
</aside>
{/* Middle Content */}
@@ -47,18 +57,18 @@ export default async function CommunityPage() {
highlight="会"
colorScheme="forest"
stats={[
{ label: "板块", value: categories.reduce((sum, c) => sum + c.children.length + 1, 0), unit: "个" },
{ label: "板块", value: categories.length + totalSubCategories, unit: "个" },
{ label: "话题", value: topicsCount, unit: "帖" },
{ label: "会员", value: userCount, unit: "人" },
]}
slogan="AI爱好者交流社区,分享你的发现和思考"
subSlogan="AI工具 · 开源技能 · 技术深度 · 教程分享 · 社区互动"
slogan="AI驱动的行业智囊团,连接商业智慧"
subSlogan="电商零售 · AI科技 · 金融投资 · 跨境出海 · 实体经营"
/>
{/* Forum Categories */}
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="px-5 py-4 border-b border-border/30">
<h2 className="text-sm font-semibold text-foreground">板块</h2>
<h2 className="text-sm font-semibold text-foreground">全部板块</h2>
</div>
<div className="p-5 space-y-5">
{categories.map((cat) => (
@@ -142,9 +152,9 @@ export default async function CommunityPage() {
</div>
</main>
{/* Right Sidebar */}
{/* Right Sidebar: Guide + Rankings */}
<aside className="hidden lg:block">
<div className="sticky top-[68px] space-y-5">
<div className="sticky top-[68px] space-y-5 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
@@ -173,6 +183,10 @@ export default async function CommunityPage() {
</div>
</div>
</div>
<div className="sidebar-bg rounded-xl p-3">
<CommunityRankings />
</div>
</div>
</aside>