feat: 增强标签云组件支持搜索和展开功能
- 将 TagCloud 改造为客户端组件,添加实时搜索功能 - 新增 getTopTags 函数获取热门标签(按项目数量排序) - 支持展开/收起查看所有标签,提升大量标签场景下的用户体验 - 添加标签相关中英文翻译文本 - 新增 scripts/list-tags.js 辅助脚本用于标签统计分析
This commit is contained in:
@@ -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());
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"backToHome": "返回首页",
|
||||
"viewDetails": "查看详情"
|
||||
},
|
||||
"tags": {
|
||||
"searchPlaceholder": "搜索标签...",
|
||||
"showAll": "显示全部标签 ({count})",
|
||||
"showLess": "↑ 收起",
|
||||
"noResults": "未找到匹配的标签",
|
||||
"projectCount": "个项目"
|
||||
},
|
||||
"home": {
|
||||
"title": "AI 项目导航",
|
||||
"subtitle": "发现和探索全网优质 AI 项目",
|
||||
|
||||
Reference in New Issue
Block a user