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 { Suspense } from 'react'
|
||||||
import { getTranslations } from 'next-intl/server'
|
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 { ProjectList } from '@/components/project/ProjectList'
|
||||||
import { TagCloud } from '@/components/project/TagCloud'
|
import { TagCloud } from '@/components/project/TagCloud'
|
||||||
import { SearchBar } from '@/components/search/SearchBar'
|
import { SearchBar } from '@/components/search/SearchBar'
|
||||||
@@ -23,9 +23,10 @@ export default async function ProjectsPage({
|
|||||||
const tag = resolvedSearchParams.tag || ''
|
const tag = resolvedSearchParams.tag || ''
|
||||||
const page = Number(resolvedSearchParams.page) || 1
|
const page = Number(resolvedSearchParams.page) || 1
|
||||||
|
|
||||||
const [projectsData, tags] = await Promise.all([
|
const [projectsData, topTags, allTags] = await Promise.all([
|
||||||
getProjects({ search, tag, page }),
|
getProjects({ search, tag, page }),
|
||||||
getAllTags(),
|
getTopTags(10), // 获取前10个标签
|
||||||
|
getAllTags(), // 获取所有标签(用于展开)
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
Browse by Tags
|
||||||
</h3>
|
</h3>
|
||||||
<TagCloud tags={tags} locale={locale} activeTag={tag} />
|
<TagCloud
|
||||||
|
tags={topTags} // 默认显示前10个
|
||||||
|
allTags={allTags} // 用于展开和搜索
|
||||||
|
locale={locale}
|
||||||
|
activeTag={tag}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface TagCloudProps {
|
interface TagCloudProps {
|
||||||
@@ -10,41 +13,119 @@ interface TagCloudProps {
|
|||||||
projects: number
|
projects: number
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
|
allTags?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
nameEn?: string | null
|
||||||
|
slug: string
|
||||||
|
_count?: {
|
||||||
|
projects: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
locale: string
|
locale: string
|
||||||
activeTag?: string
|
activeTag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagCloud({ tags, locale, activeTag }: TagCloudProps) {
|
export function TagCloud({ tags, allTags, locale, activeTag }: TagCloudProps) {
|
||||||
return (
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
<div className="flex flex-wrap gap-3">
|
const [showAll, setShowAll] = useState(false)
|
||||||
{tags.map((tag) => {
|
|
||||||
const count = tag._count?.projects || 0
|
|
||||||
const isActive = activeTag === tag.slug
|
|
||||||
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
|
||||||
|
|
||||||
return (
|
// 搜索过滤逻辑
|
||||||
<Link
|
const filteredTags = useMemo(() => {
|
||||||
key={tag.id}
|
const sourceTags = allTags || tags
|
||||||
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 ${
|
if (!searchQuery.trim()) {
|
||||||
isActive
|
return showAll ? sourceTags : tags
|
||||||
? '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'
|
|
||||||
}`}
|
const query = searchQuery.toLowerCase()
|
||||||
>
|
return sourceTags.filter(tag =>
|
||||||
{displayName}
|
tag.name.toLowerCase().includes(query) ||
|
||||||
{count > 0 && (
|
(tag.nameEn && tag.nameEn.toLowerCase().includes(query))
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 ${
|
)
|
||||||
isActive
|
}, [searchQuery, showAll, tags, allTags])
|
||||||
? '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'
|
const hasMoreTags = allTags && allTags.length > tags.length
|
||||||
}`}>
|
const displayName = (tag: typeof tags[0]) =>
|
||||||
{count}
|
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||||
</span>
|
|
||||||
)}
|
return (
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,3 +156,28 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
|
|||||||
|
|
||||||
return tags.filter(tag => tag._count.projects > 0)
|
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",
|
"backToHome": "Back to Home",
|
||||||
"viewDetails": "VIEW DETAILS"
|
"viewDetails": "VIEW DETAILS"
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"searchPlaceholder": "Search tags...",
|
||||||
|
"showAll": "Show all tags ({count})",
|
||||||
|
"showLess": "↑ Show less",
|
||||||
|
"noResults": "No matching tags found",
|
||||||
|
"projectCount": "projects"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "AI Project Navigator",
|
"title": "AI Project Navigator",
|
||||||
"subtitle": "Discover and explore AI projects",
|
"subtitle": "Discover and explore AI projects",
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"backToHome": "返回首页",
|
"backToHome": "返回首页",
|
||||||
"viewDetails": "查看详情"
|
"viewDetails": "查看详情"
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"searchPlaceholder": "搜索标签...",
|
||||||
|
"showAll": "显示全部标签 ({count})",
|
||||||
|
"showLess": "↑ 收起",
|
||||||
|
"noResults": "未找到匹配的标签",
|
||||||
|
"projectCount": "个项目"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "AI 项目导航",
|
"title": "AI 项目导航",
|
||||||
"subtitle": "发现和探索全网优质 AI 项目",
|
"subtitle": "发现和探索全网优质 AI 项目",
|
||||||
|
|||||||
Reference in New Issue
Block a user