From da7101621d0255ac247d1e98340641a28de93d90 Mon Sep 17 00:00:00 2001 From: mzaxd Date: Sun, 25 Jan 2026 16:51:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E4=BA=91=E7=BB=84=E4=BB=B6=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=92=8C=E5=B1=95=E5=BC=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 TagCloud 改造为客户端组件,添加实时搜索功能 - 新增 getTopTags 函数获取热门标签(按项目数量排序) - 支持展开/收起查看所有标签,提升大量标签场景下的用户体验 - 添加标签相关中英文翻译文本 - 新增 scripts/list-tags.js 辅助脚本用于标签统计分析 --- scripts/list-tags.js | 39 ++++++++ src/app/[locale]/projects/page.tsx | 14 ++- src/components/project/TagCloud.tsx | 141 ++++++++++++++++++++++------ src/hooks/useProjects.ts | 25 +++++ src/messages/en.json | 7 ++ src/messages/zh.json | 7 ++ 6 files changed, 199 insertions(+), 34 deletions(-) create mode 100644 scripts/list-tags.js diff --git a/scripts/list-tags.js b/scripts/list-tags.js new file mode 100644 index 0000000..f1f4fe2 --- /dev/null +++ b/scripts/list-tags.js @@ -0,0 +1,39 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +async function main() { + const tags = await prisma.tag.findMany({ + include: { + _count: { + select: { projects: true } + } + } + }); + + console.log('=== Tag Statistics ===\n'); + console.log('Total tags:', tags.length); + + const sortedTags = tags.sort((a, b) => b._count.projects - a._count.projects); + + console.log('\n=== Tags by Project Count ==='); + sortedTags.forEach((tag, idx) => { + console.log(`${idx + 1}. ${tag.name}: ${tag._count.projects} projects`); + }); + + const unusedTags = tags.filter(t => t._count.projects === 0); + console.log(`\n=== Unused Tags (${unusedTags.length}) ===`); + unusedTags.forEach(tag => console.log(`- ${tag.name}`)); + + const lowActivityTags = tags.filter(t => t._count.projects > 0 && t._count.projects <= 2); + console.log(`\n=== Low Activity Tags (1-2 projects) (${lowActivityTags.length}) ===`); + lowActivityTags.forEach(tag => console.log(`- ${tag.name}: ${tag._count.projects} projects`)); + + const highActivityTags = tags.filter(t => t._count.projects >= 5); + console.log(`\n=== High Activity Tags (5+ projects) (${highActivityTags.length}) ===`); + highActivityTags.forEach(tag => console.log(`- ${tag.name}: ${tag._count.projects} projects`)); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx index 57eff01..f8b77b0 100644 --- a/src/app/[locale]/projects/page.tsx +++ b/src/app/[locale]/projects/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react' import { getTranslations } from 'next-intl/server' -import { getProjects, getAllTags } from '@/hooks/useProjects' +import { getProjects, getAllTags, getTopTags } from '@/hooks/useProjects' import { ProjectList } from '@/components/project/ProjectList' import { TagCloud } from '@/components/project/TagCloud' import { SearchBar } from '@/components/search/SearchBar' @@ -23,9 +23,10 @@ export default async function ProjectsPage({ const tag = resolvedSearchParams.tag || '' const page = Number(resolvedSearchParams.page) || 1 - const [projectsData, tags] = await Promise.all([ + const [projectsData, topTags, allTags] = await Promise.all([ getProjects({ search, tag, page }), - getAllTags(), + getTopTags(10), // 获取前10个标签 + getAllTags(), // 获取所有标签(用于展开) ]) return ( @@ -44,7 +45,12 @@ export default async function ProjectsPage({

Browse by Tags

- + diff --git a/src/components/project/TagCloud.tsx b/src/components/project/TagCloud.tsx index ef47ed7..3530c9e 100644 --- a/src/components/project/TagCloud.tsx +++ b/src/components/project/TagCloud.tsx @@ -1,3 +1,6 @@ +'use client' + +import { useState, useMemo } from 'react' import Link from 'next/link' interface TagCloudProps { @@ -10,41 +13,119 @@ interface TagCloudProps { projects: number } }> + allTags?: Array<{ + id: string + name: string + nameEn?: string | null + slug: string + _count?: { + projects: number + } + }> locale: string activeTag?: string } -export function TagCloud({ tags, locale, activeTag }: TagCloudProps) { - return ( -
- {tags.map((tag) => { - const count = tag._count?.projects || 0 - const isActive = activeTag === tag.slug - const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name +export function TagCloud({ tags, allTags, locale, activeTag }: TagCloudProps) { + const [searchQuery, setSearchQuery] = useState('') + const [showAll, setShowAll] = useState(false) - return ( - - {displayName} - {count > 0 && ( - - {count} - - )} - - ) - })} + // 搜索过滤逻辑 + const filteredTags = useMemo(() => { + const sourceTags = allTags || tags + + if (!searchQuery.trim()) { + return showAll ? sourceTags : tags + } + + const query = searchQuery.toLowerCase() + return sourceTags.filter(tag => + tag.name.toLowerCase().includes(query) || + (tag.nameEn && tag.nameEn.toLowerCase().includes(query)) + ) + }, [searchQuery, showAll, tags, allTags]) + + const hasMoreTags = allTags && allTags.length > tags.length + const displayName = (tag: typeof tags[0]) => + locale === 'en' && tag.nameEn ? tag.nameEn : tag.name + + return ( +
+ {/* 搜索框 */} +
+ setSearchQuery(e.target.value)} + placeholder={locale === 'zh' ? '搜索标签...' : 'Search tags...'} + className="w-full px-4 py-2 pl-10 bg-white dark:bg-surface-dark border-2 border-gray-300 dark:border-gray-600 focus:border-primary text-sm font-display focus:outline-none transition-colors" + /> + + + +
+ + {/* 标签列表 */} +
+ {filteredTags.length > 0 ? ( + filteredTags.map((tag) => { + const count = tag._count?.projects || 0 + const isActive = activeTag === tag.slug + + return ( + + {displayName(tag)} + + {count} + + + ) + }) + ) : ( +
+ {locale === 'zh' ? '未找到匹配的标签' : 'No matching tags found'} +
+ )} +
+ + {/* 显示全部按钮 */} + {!searchQuery && hasMoreTags && !showAll && ( + + )} + + {/* 收起按钮 */} + {showAll && !searchQuery && ( + + )}
) } diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index b124099..b225452 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -156,3 +156,28 @@ export async function getTagsWithProjectCounts(): Promise return tags.filter(tag => tag._count.projects > 0) } + +export async function getTopTags(limit: number = 10): Promise { + const tags = await prisma.tag.findMany({ + include: { + _count: { + select: { projects: true } + } + }, + where: { + projects: { + some: {} // 只返回有项目的标签 + } + } + }) + + // 按项目数量降序、名称升序排序 + return tags + .filter(tag => tag._count.projects > 0) + .sort((a, b) => { + const countDiff = b._count.projects - a._count.projects + if (countDiff !== 0) return countDiff + return a.name.localeCompare(b.name, 'zh') + }) + .slice(0, limit) +} diff --git a/src/messages/en.json b/src/messages/en.json index 019a7ec..d7b8d6b 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -8,6 +8,13 @@ "backToHome": "Back to Home", "viewDetails": "VIEW DETAILS" }, + "tags": { + "searchPlaceholder": "Search tags...", + "showAll": "Show all tags ({count})", + "showLess": "↑ Show less", + "noResults": "No matching tags found", + "projectCount": "projects" + }, "home": { "title": "AI Project Navigator", "subtitle": "Discover and explore AI projects", diff --git a/src/messages/zh.json b/src/messages/zh.json index 2af85a5..2ad3166 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -8,6 +8,13 @@ "backToHome": "返回首页", "viewDetails": "查看详情" }, + "tags": { + "searchPlaceholder": "搜索标签...", + "showAll": "显示全部标签 ({count})", + "showLess": "↑ 收起", + "noResults": "未找到匹配的标签", + "projectCount": "个项目" + }, "home": { "title": "AI 项目导航", "subtitle": "发现和探索全网优质 AI 项目",