feat: 增强标签云组件支持搜索和展开功能

- 将 TagCloud 改造为客户端组件,添加实时搜索功能
- 新增 getTopTags 函数获取热门标签(按项目数量排序)
- 支持展开/收起查看所有标签,提升大量标签场景下的用户体验
- 添加标签相关中英文翻译文本
- 新增 scripts/list-tags.js 辅助脚本用于标签统计分析
This commit is contained in:
2026-01-25 16:51:32 +08:00
parent 3d62cacde8
commit da7101621d
6 changed files with 199 additions and 34 deletions
+39
View File
@@ -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());
+10 -4
View File
@@ -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({
<h3 className="font-display font-bold uppercase text-sm mb-4 border-b-2 border-black inline-block dark:border-primary pb-1">
Browse by Tags
</h3>
<TagCloud tags={tags} locale={locale} activeTag={tag} />
<TagCloud
tags={topTags} // 默认显示前10个
allTags={allTags} // 用于展开和搜索
locale={locale}
activeTag={tag}
/>
</div>
</div>
+111 -30
View File
@@ -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 (
<div className="flex flex-wrap gap-3">
{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 (
<Link
key={tag.id}
href={`/${locale}/projects?tag=${tag.slug}`}
className={`px-3 py-1 border-2 font-display text-xs font-bold uppercase transition-all shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center gap-2 group ${
isActive
? 'bg-black text-white border-black'
: 'bg-white dark:bg-surface-dark border-black dark:border-gray-500 hover:bg-black hover:text-white dark:hover:bg-primary dark:hover:text-black'
}`}
>
{displayName}
{count > 0 && (
<span className={`text-[10px] px-1.5 py-0.5 ${
isActive
? 'bg-gray-700 text-white'
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 group-hover:bg-white group-hover:text-black'
}`}>
{count}
</span>
)}
</Link>
)
})}
// 搜索过滤逻辑
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 (
<div className="space-y-4">
{/* 搜索框 */}
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* 标签列表 */}
<div className="flex flex-wrap gap-2">
{filteredTags.length > 0 ? (
filteredTags.map((tag) => {
const count = tag._count?.projects || 0
const isActive = activeTag === tag.slug
return (
<Link
key={tag.id}
href={`/${locale}/projects?tag=${tag.slug}`}
className={`px-3 py-1 border-2 font-display text-xs font-bold uppercase transition-all shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center gap-2 group ${
isActive
? 'bg-black text-white border-black'
: 'bg-white dark:bg-surface-dark border-black dark:border-gray-500 hover:bg-black hover:text-white dark:hover:bg-primary dark:hover:text-black'
}`}
>
{displayName(tag)}
<span className={`text-[10px] px-1.5 py-0.5 ${
isActive
? 'bg-gray-700 text-white'
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 group-hover:bg-white group-hover:text-black'
}`}>
{count}
</span>
</Link>
)
})
) : (
<div className="text-center py-4 text-gray-500 text-sm w-full">
{locale === 'zh' ? '未找到匹配的标签' : 'No matching tags found'}
</div>
)}
</div>
{/* 显示全部按钮 */}
{!searchQuery && hasMoreTags && !showAll && (
<button
onClick={() => setShowAll(true)}
className="w-full py-2 bg-gray-100 dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 font-display text-xs font-bold uppercase hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
{locale === 'zh'
? `显示全部标签 (+${allTags!.length - tags.length})`
: `Show all tags (+${allTags!.length - tags.length})`}
</button>
)}
{/* 收起按钮 */}
{showAll && !searchQuery && (
<button
onClick={() => setShowAll(false)}
className="text-xs font-display font-bold text-gray-500 hover:text-black"
>
{locale === 'zh' ? '↑ 收起' : '↑ Show less'}
</button>
)}
</div>
)
}
+25
View File
@@ -156,3 +156,28 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
return tags.filter(tag => tag._count.projects > 0)
}
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
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)
}
+7
View File
@@ -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",
+7
View File
@@ -8,6 +8,13 @@
"backToHome": "返回首页",
"viewDetails": "查看详情"
},
"tags": {
"searchPlaceholder": "搜索标签...",
"showAll": "显示全部标签 ({count})",
"showLess": "↑ 收起",
"noResults": "未找到匹配的标签",
"projectCount": "个项目"
},
"home": {
"title": "AI 项目导航",
"subtitle": "发现和探索全网优质 AI 项目",