重构社区论坛板块:10赛道二级导航+布局调整
- 更新种子脚本:10大赛道×51个子板块,热门排序 - 新增清理脚本:删除所有非10赛道旧板块 - 重做侧边栏:仿工具模块的二级平铺导航,带搜索过滤 - 新增排行组件:本月活跃+积分总排行移至右侧栏 - 调整页面布局:左导航(220px)+中内容+右指南/排行(280px) - TypeScript编译零错误通过
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const INDUSTRY_SLUGS = [
|
||||
"ecommerce", "ai-tech", "content", "finance", "cross-border",
|
||||
"real-estate", "food", "local-life", "health", "education",
|
||||
];
|
||||
|
||||
const INDUSTRY_CHILD_SLUGS = [
|
||||
"ec-platform", "ec-livestream", "ec-private", "ec-group", "ec-dtc", "ec-supply",
|
||||
"ai-tools-app", "ai-startup-forum", "ai-saas", "ai-hardware", "ai-llm",
|
||||
"ct-short-video", "ct-live", "ct-writing", "ct-mcn", "ct-podcast",
|
||||
"fi-stock", "fi-crypto", "fi-insurance", "fi-pe", "fi-forex",
|
||||
"cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment", "cb-brand",
|
||||
"re-residential", "re-commercial", "re-renovation", "re-property",
|
||||
"fd-restaurant", "fd-tea", "fd-prepared", "fd-supply", "fd-delivery",
|
||||
"ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping", "ll-repair", "ll-edu-local",
|
||||
"hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab", "hl-mental",
|
||||
"edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate", "edu-k12",
|
||||
];
|
||||
|
||||
const KEEP_SLUGS = new Set(["industry", ...INDUSTRY_SLUGS, ...INDUSTRY_CHILD_SLUGS]);
|
||||
|
||||
async function main() {
|
||||
console.log("Cleaning up old forum categories...");
|
||||
|
||||
const allCategories = await prisma.forumCategory.findMany({
|
||||
include: { _count: { select: { topics: true } } },
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const cat of allCategories) {
|
||||
if (KEEP_SLUGS.has(cat.slug)) {
|
||||
kept++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const topicsCount = cat._count.topics;
|
||||
console.log(` Deleting: ${cat.slug} "${cat.name}" (${topicsCount} topics)`);
|
||||
|
||||
if (topicsCount > 0) {
|
||||
await prisma.forumTopic.deleteMany({ where: { categoryId: cat.id } });
|
||||
}
|
||||
|
||||
await prisma.forumCategory.updateMany({
|
||||
where: { parentId: cat.id },
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
await prisma.forumCategory.delete({ where: { id: cat.id } });
|
||||
deleted++;
|
||||
}
|
||||
|
||||
console.log(`Done: ${deleted} deleted, ${kept} kept.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
+70
-59
@@ -16,102 +16,113 @@ const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
const INDUSTRY_CATEGORIES = [
|
||||
{
|
||||
name: "电商零售", slug: "ecommerce", icon: "🛒", sortOrder: 1,
|
||||
description: "电商平台运营、选品策略、流量玩法与变现",
|
||||
description: "平台运营、直播带货、私域与供应链",
|
||||
children: [
|
||||
{ name: "平台电商", slug: "ec-platform", icon: "🏪", description: "淘宝/天猫/京东/拼多多/唯品会" },
|
||||
{ name: "直播带货", slug: "ec-livestream", icon: "📱", description: "抖音/快手/视频号直播运营" },
|
||||
{ name: "平台电商", slug: "ec-platform", icon: "🏪", description: "淘宝/天猫/京东/拼多多运营" },
|
||||
{ name: "直播带货", slug: "ec-livestream", icon: "📱", description: "抖音/快手/视频号直播" },
|
||||
{ name: "私域电商", slug: "ec-private", icon: "🔗", description: "微信生态/社群/小程序电商" },
|
||||
{ name: "社区团购", slug: "ec-group", icon: "🏘️", description: "社区团购/买菜/本地配送" },
|
||||
{ name: "独立站/DTC", slug: "ec-dtc", icon: "🏷️", description: "Shopify/品牌官网自建站" },
|
||||
{ name: "选品与供应链", slug: "ec-supply", icon: "📦", description: "选品策略/货源/库存管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AI智能科技", slug: "ai-tech", icon: "🤖", sortOrder: 2,
|
||||
description: "AI技术应用、创业机会与产品出海",
|
||||
description: "AI工具、创业、SaaS与开源大模型",
|
||||
children: [
|
||||
{ name: "AI工具应用", slug: "ai-tools", icon: "🔧", description: "AI工具推荐、评测与变现" },
|
||||
{ name: "AI创业", slug: "ai-startup", icon: "💡", description: "AI创业方向与商业模式" },
|
||||
{ name: "AI工具与应用", slug: "ai-tools-app", icon: "🔧", description: "ChatGPT/Claude/Midjourney类工具" },
|
||||
{ name: "AI创业", slug: "ai-startup-forum", icon: "💡", description: "AI创业方向与商业模式" },
|
||||
{ name: "SaaS出海", slug: "ai-saas", icon: "🚀", description: "SaaS产品开发与全球化" },
|
||||
{ name: "智能硬件", slug: "ai-hardware", icon: "🔌", description: "AI硬件产品与IoT创业" },
|
||||
{ name: "智能硬件", slug: "ai-hardware", icon: "🔌", description: "AI硬件/IoT/可穿戴设备" },
|
||||
{ name: "大模型与开源", slug: "ai-llm", icon: "🧠", description: "LLM/开源模型/微调/RAG" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "房地产", slug: "real-estate", icon: "🏠", sortOrder: 3,
|
||||
description: "房产买卖、投资分析、装修与物业管理",
|
||||
name: "自媒体内容", slug: "content", icon: "🎬", sortOrder: 3,
|
||||
description: "短视频、直播、图文与MCN运营",
|
||||
children: [
|
||||
{ name: "住宅买卖", slug: "re-residential", icon: "🏡", description: "刚需/改善/学区房交易" },
|
||||
{ name: "商业地产", slug: "re-commercial", icon: "🏢", description: "商铺/写字楼/产业园投资" },
|
||||
{ name: "装修家居", slug: "re-renovation", icon: "🛋️", description: "装修设计/建材/智能家居" },
|
||||
{ name: "物业运营", slug: "re-property", icon: "🏗️", description: "物业管理/租赁运营/资产托管" },
|
||||
{ name: "短视频创作", slug: "ct-short-video", icon: "📱", description: "抖音/快手/B站/视频号创作" },
|
||||
{ name: "直播运营", slug: "ct-live", icon: "🎥", description: "带货直播/秀场/知识直播" },
|
||||
{ name: "图文内容", slug: "ct-writing", icon: "✍️", description: "公众号/小红书/知乎/头条" },
|
||||
{ name: "MCN机构", slug: "ct-mcn", icon: "🏢", description: "达人孵化/商务变现/MCN运营" },
|
||||
{ name: "播客与音频", slug: "ct-podcast", icon: "🎙️", description: "小宇宙/喜马拉雅/音频创作" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "金融投资", slug: "finance", icon: "📈", sortOrder: 4,
|
||||
description: "股票基金、加密货币、保险理财与股权投资",
|
||||
description: "股票基金、加密货币、PE/VC与理财",
|
||||
children: [
|
||||
{ name: "股票基金", slug: "fi-stock", icon: "📊", description: "A股/港股/美股/基金定投" },
|
||||
{ name: "加密货币", slug: "fi-crypto", icon: "₿", description: "比特币/以太坊/Web3" },
|
||||
{ name: "保险理财", slug: "fi-insurance", icon: "🛡️", description: "保险配置/理财规划" },
|
||||
{ name: "私募股权", slug: "fi-pe", icon: "💼", description: "PE/VC/天使投资" },
|
||||
{ name: "股票与基金", slug: "fi-stock", icon: "📊", description: "A股/港股/美股/基金定投" },
|
||||
{ name: "加密货币/Web3", slug: "fi-crypto", icon: "₿", description: "比特币/以太坊/DeFi/NFT" },
|
||||
{ name: "保险与理财", slug: "fi-insurance", icon: "🛡️", description: "保险配置/理财规划/资产配置" },
|
||||
{ name: "PE与VC", slug: "fi-pe", icon: "💼", description: "PE/VC/天使投资/项目评估" },
|
||||
{ name: "外汇与期货", slug: "fi-forex", icon: "💱", description: "外汇/黄金/大宗商品/期货" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "餐饮食品", slug: "food", icon: "🍜", sortOrder: 5,
|
||||
description: "餐饮创业、茶饮咖啡、预制菜与食品供应链",
|
||||
name: "外贸跨境", slug: "cross-border", icon: "🌏", sortOrder: 5,
|
||||
description: "跨境电商、外贸工厂、物流与品牌出海",
|
||||
children: [
|
||||
{ name: "跨境电商平台", slug: "cb-ecommerce", icon: "🛒", description: "Amazon/Temu/TikTok Shop/Shopee" },
|
||||
{ name: "外贸工厂/B2B", slug: "cb-factory", icon: "🏭", description: "代工转品牌/工贸一体/展会" },
|
||||
{ name: "海外仓与物流", slug: "cb-logistics", icon: "📦", description: "海外仓/FBA/跨境物流/清关" },
|
||||
{ name: "跨境支付合规", slug: "cb-payment", icon: "💳", description: "收付款/汇率/税务合规" },
|
||||
{ name: "品牌出海/DTC", slug: "cb-brand", icon: "🚩", description: "独立站/品牌全球化/本地化" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "房地产", slug: "real-estate", icon: "🏠", sortOrder: 6,
|
||||
description: "房产买卖、商业地产、装修与物业",
|
||||
children: [
|
||||
{ name: "住宅买卖", slug: "re-residential", icon: "🏡", description: "刚需/改善/学区房/公寓" },
|
||||
{ name: "商业地产", slug: "re-commercial", icon: "🏢", description: "商铺/写字楼/产业园/酒店" },
|
||||
{ name: "装修与家居", slug: "re-renovation", icon: "🛋️", description: "装修设计/建材/智能家居" },
|
||||
{ name: "物业与租赁", slug: "re-property", icon: "🏗️", description: "物业管理/租赁运营/资产托管" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "餐饮食品", slug: "food", icon: "🍜", sortOrder: 7,
|
||||
description: "餐饮创业、茶饮咖啡、预制菜与供应链",
|
||||
children: [
|
||||
{ name: "餐饮开店", slug: "fd-restaurant", icon: "🍽️", description: "餐厅选址/运营/连锁加盟" },
|
||||
{ name: "茶饮咖啡", slug: "fd-tea", icon: "🧋", description: "新茶饮/咖啡/饮品创业" },
|
||||
{ name: "预制菜", slug: "fd-prepared", icon: "📦", description: "预制菜/半成品/食品加工" },
|
||||
{ name: "食品供应链", slug: "fd-supply", icon: "🚚", description: "食材采购/冷链/中央厨房" },
|
||||
{ name: "茶饮与咖啡", slug: "fd-tea", icon: "🧋", description: "新茶饮/咖啡品牌/饮品创业" },
|
||||
{ name: "预制菜加工", slug: "fd-prepared", icon: "📦", description: "预制菜/半成品/中央厨房" },
|
||||
{ name: "食品供应链", slug: "fd-supply", icon: "🚚", description: "食材采购/冷链/仓储配送" },
|
||||
{ name: "外卖运营", slug: "fd-delivery", icon: "🛵", description: "美团/饿了么/外卖代运营" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "自媒体内容", slug: "content", icon: "🎬", sortOrder: 6,
|
||||
description: "短视频、直播、图文创作与MCN机构运营",
|
||||
name: "本地生活", slug: "local-life", icon: "🏪", sortOrder: 8,
|
||||
description: "美业、健身、宠物、家政等本地服务",
|
||||
children: [
|
||||
{ name: "短视频创作", slug: "ct-short-video", icon: "📱", description: "抖音/快手/B站内容创作" },
|
||||
{ name: "直播运营", slug: "ct-livestream", icon: "🎥", description: "带货直播/娱乐直播/知识直播" },
|
||||
{ name: "图文内容", slug: "ct-writing", icon: "✍️", description: "公众号/小红书/知乎/头条" },
|
||||
{ name: "MCN机构", slug: "ct-mcn", icon: "🏢", description: "MCN运营/达人孵化/商务变现" },
|
||||
{ name: "美容美发", slug: "ll-beauty", icon: "💇", description: "美发/美甲/美容/皮肤管理" },
|
||||
{ name: "健身运动", slug: "ll-fitness", icon: "🏋️", description: "健身房/瑜伽/私教/运动馆" },
|
||||
{ name: "宠物经济", slug: "ll-pet", icon: "🐾", description: "宠物店/食品/医疗/寄养" },
|
||||
{ name: "家政服务", slug: "ll-housekeeping", icon: "🏠", description: "保洁/月嫂/搬家/收纳" },
|
||||
{ name: "维修与数码", slug: "ll-repair", icon: "🔧", description: "手机维修/家电清洗/开锁" },
|
||||
{ name: "本地教培", slug: "ll-edu-local", icon: "🎓", description: "托管/艺术培训/体育培训" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "本地生活", slug: "local-life", icon: "🏪", sortOrder: 7,
|
||||
description: "美业、健身、宠物、家政等本地服务创业",
|
||||
children: [
|
||||
{ name: "美容美发", slug: "ll-beauty", icon: "💇", description: "美发/美甲/美容/医美" },
|
||||
{ name: "健身运动", slug: "ll-fitness", icon: "🏋️", description: "健身房/瑜伽/私教/运动康复" },
|
||||
{ name: "宠物经济", slug: "ll-pet", icon: "🐾", description: "宠物店/宠物食品/宠物医疗" },
|
||||
{ name: "家政服务", slug: "ll-housekeeping", icon: "🏠", description: "保洁/月嫂/搬家/维修" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "大健康", slug: "health", icon: "💊", sortOrder: 8,
|
||||
description: "医疗美容、健康管理、养老服务与运动康复",
|
||||
name: "大健康", slug: "health", icon: "💊", sortOrder: 9,
|
||||
description: "医疗美容、健康管理、养老与康复",
|
||||
children: [
|
||||
{ name: "医疗美容", slug: "hl-cosmetic", icon: "💉", description: "轻医美/微整形/皮肤管理" },
|
||||
{ name: "健康管理", slug: "hl-wellness", icon: "🫀", description: "体检/慢病管理/保健品" },
|
||||
{ name: "养老服务", slug: "hl-elderly", icon: "👴", description: "养老院/居家养老/适老化改造" },
|
||||
{ name: "运动康复", slug: "hl-rehab", icon: "🩹", description: "运动损伤/产后康复/中医理疗" },
|
||||
{ name: "健康管理", slug: "hl-wellness", icon: "🫀", description: "体检/慢病/保健品/营养" },
|
||||
{ name: "养老服务", slug: "hl-elderly", icon: "👴", description: "养老院/居家养老/适老化" },
|
||||
{ name: "运动康复", slug: "hl-rehab", icon: "🩹", description: "运动损伤/产后康复/理疗" },
|
||||
{ name: "心理健康", slug: "hl-mental", icon: "🧘", description: "心理咨询/情绪管理/冥想" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "教育培训", slug: "education", icon: "📚", sortOrder: 9,
|
||||
name: "教育培训", slug: "education", icon: "📚", sortOrder: 10,
|
||||
description: "技能培训、知识付费、留学与企业培训",
|
||||
children: [
|
||||
{ name: "技能培训", slug: "edu-skills", icon: "🛠️", description: "编程/设计/运营等职业技能" },
|
||||
{ name: "知识付费", slug: "edu-knowledge", icon: "💎", description: "课程制作/社群运营/个人IP" },
|
||||
{ name: "留学服务", slug: "edu-abroad", icon: "✈️", description: "留学申请/语言培训/海外游学" },
|
||||
{ name: "企业培训", slug: "edu-corporate", icon: "🏢", description: "企业内训/管理咨询/团队建设" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "外贸跨境", slug: "cross-border", icon: "🌏", sortOrder: 10,
|
||||
description: "跨境电商、外贸工厂、海外仓与跨境支付",
|
||||
children: [
|
||||
{ name: "跨境电商", slug: "cb-ecommerce", icon: "🛒", description: "Amazon/Temu/TikTok Shop/Shopee" },
|
||||
{ name: "外贸工厂", slug: "cb-factory", icon: "🏭", description: "代工转品牌/工贸一体/B2B" },
|
||||
{ name: "海外仓物流", slug: "cb-logistics", icon: "📦", description: "海外仓/FBA/跨境物流" },
|
||||
{ name: "跨境支付", slug: "cb-payment", icon: "💳", description: "收付款/汇率/资金合规" },
|
||||
{ name: "职业技能", slug: "edu-skills", icon: "🛠️", description: "编程/设计/运营/考证培训" },
|
||||
{ name: "知识付费", slug: "edu-knowledge", icon: "💎", description: "课程制作/社群/个人IP打造" },
|
||||
{ name: "留学与语言", slug: "edu-abroad", icon: "✈️", description: "留学申请/语言培训/游学" },
|
||||
{ name: "企业培训", slug: "edu-corporate", icon: "🏢", description: "企业内训/管理咨询/团建" },
|
||||
{ name: "K12与家庭", slug: "edu-k12", icon: "👨👩👧", description: "学科辅导/素质教育/家庭教育" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 || []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {});
|
||||
interface CommunitySidebarProps {
|
||||
categories: ForumCategory[];
|
||||
totalTopics: number;
|
||||
}
|
||||
|
||||
fetch("/api/user/rankings?type=points&limit=10")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setTopByPoints(data.users || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
export function CommunitySidebar({ categories, totalTopics }: CommunitySidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
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 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;
|
||||
})
|
||||
: categories;
|
||||
|
||||
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 totalSubCategories = categories.reduce((sum, c) => sum + c.children.length, 0);
|
||||
const totalAll = categories.length + totalSubCategories;
|
||||
|
||||
const isActive = (slug: string) => pathname === `/community/${slug}` || pathname.startsWith(`/community/${slug}`);
|
||||
|
||||
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">
|
||||
|
||||
{/* 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" />
|
||||
<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>
|
||||
<span className="text-[12px] font-semibold text-foreground/80">行业论坛</span>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{INDUSTRY_FORUMS.map((forum) => (
|
||||
{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>
|
||||
{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" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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 className="h-px bg-border/40 my-3" />
|
||||
|
||||
<div className="px-2.5 py-1">
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{categories.length} 个赛道 · {totalSubCategories} 个板块 · {totalTopics} 个话题
|
||||
</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+27
-13
@@ -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 },
|
||||
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" }],
|
||||
}),
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user