feat: 新增标签分类体系与固定类型筛选优化

This commit is contained in:
2026-02-21 14:14:45 +08:00
parent 56eada659b
commit fb33b76ff5
15 changed files with 1601 additions and 128 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start",
"lint": "next lint",
"test": "vitest",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"taxonomy:backfill": "ts-node --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\"}' scripts/backfill-tag-taxonomy.ts"
},
"dependencies": {
"@prisma/client": "^6.1.0",
+12
View File
@@ -88,9 +88,11 @@ model Tag {
name String @unique
nameEn String?
slug String @unique
category TagCategory @default(FREE_TAG)
createdAt DateTime @default(now())
projects ProjectTag[]
@@index([category], map: "idx_tag_category")
@@index([slug], map: "idx_tag_slug")
@@map("tags")
}
@@ -114,3 +116,13 @@ enum TaskStatus {
FAILED
}
enum TagCategory {
FIXED_PROJECT_TYPE
TECH_STACK
AI_PARADIGM
PROTOCOL_INTERFACE
PRODUCT_FORM
DOMAIN_SCENARIO
RESOURCE_TYPE
FREE_TAG
}
+136
View File
@@ -0,0 +1,136 @@
import { PrismaClient } from '@prisma/client'
import {
FIXED_PROJECT_TYPE_TAGS,
inferProjectTypeSlug,
inferTagCategory,
} from '../src/lib/tag-taxonomy'
const prisma = new PrismaClient()
async function ensureFixedProjectTypeTags() {
const result = await Promise.all(
FIXED_PROJECT_TYPE_TAGS.map((item) =>
prisma.tag.upsert({
where: { slug: item.slug },
update: {
name: item.name,
nameEn: item.nameEn,
category: 'FIXED_PROJECT_TYPE',
},
create: {
name: item.name,
nameEn: item.nameEn,
slug: item.slug,
category: 'FIXED_PROJECT_TYPE',
},
})
)
)
return new Map(result.map((tag) => [tag.slug, tag.id]))
}
async function backfillTagCategories() {
const tags = await prisma.tag.findMany()
let updated = 0
for (const tag of tags) {
const inferredCategory = inferTagCategory({
slug: tag.slug,
name: tag.name,
nameEn: tag.nameEn,
})
if (tag.category !== inferredCategory) {
await prisma.tag.update({
where: { id: tag.id },
data: { category: inferredCategory },
})
updated += 1
}
}
return updated
}
async function backfillProjectFixedTypes(fixedTypeIdMap: Map<string, string>) {
const projects = await prisma.project.findMany({
where: { status: 'ACTIVE' },
include: {
tags: {
include: {
tag: true,
},
},
},
})
const createData: Array<{ projectId: string; tagId: string }> = []
for (const project of projects) {
const fixedTypeSlug = inferProjectTypeSlug({
name: project.name,
nameEn: project.nameEn,
description: project.description,
descriptionEn: project.descriptionEn,
tags: project.tags.map((tag) => ({
slug: tag.tag.slug,
name: tag.tag.name,
nameEn: tag.tag.nameEn,
})),
})
const fixedTypeTagId = fixedTypeIdMap.get(fixedTypeSlug)
if (!fixedTypeTagId) {
throw new Error(`Missing fixed project type tag: ${fixedTypeSlug}`)
}
createData.push({
projectId: project.id,
tagId: fixedTypeTagId,
})
}
const removed = await prisma.projectTag.deleteMany({
where: {
tag: {
category: 'FIXED_PROJECT_TYPE',
},
},
})
const inserted = await prisma.projectTag.createMany({
data: createData,
skipDuplicates: true,
})
return {
projectCount: projects.length,
inserted: inserted.count,
removed: removed.count,
}
}
async function main() {
console.log('[taxonomy] start')
const fixedTypeIdMap = await ensureFixedProjectTypeTags()
const updatedTagCategoryCount = await backfillTagCategories()
const projectResult = await backfillProjectFixedTypes(fixedTypeIdMap)
console.log('[taxonomy] done', {
updatedTagCategoryCount,
projectCount: projectResult.projectCount,
insertedProjectTypeLinks: projectResult.inserted,
removedOldProjectTypeLinks: projectResult.removed,
})
}
main()
.catch((error) => {
console.error('[taxonomy] failed', error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { AISearchBar } from '@/components/search/AISearchBar'
import { AISearchResults } from '@/components/search/AISearchResults'
@@ -14,6 +14,8 @@ interface AISearchResult {
interface ProjectsPageClientProps {
locale: string
selectedTags: string[]
projectType?: string
children: React.ReactNode
searchPlaceholder: string
searchLabel: string
@@ -32,6 +34,8 @@ interface ProjectsPageClientProps {
export function ProjectsPageClient({
locale,
selectedTags,
projectType,
children,
searchPlaceholder,
searchLabel,
@@ -49,18 +53,7 @@ export function ProjectsPageClient({
const [error, setError] = useState<string | null>(null)
const [initialAIHandled, setInitialAIHandled] = useState(false)
// 检查 URL 参数,如果有 ai=1 且有搜索词,则自动触发 AI 搜索
useEffect(() => {
const aiParam = searchParams.get('ai')
const searchParam = searchParams.get('search')
if (aiParam === '1' && searchParam && !initialAIHandled) {
setInitialAIHandled(true)
performAISearch(searchParam)
}
}, [searchParams, initialAIHandled])
const performAISearch = async (query: string) => {
const performAISearch = useCallback(async (query: string) => {
setLoading(true)
setError(null)
setIsAIResult(true)
@@ -72,6 +65,7 @@ export function ProjectsPageClient({
body: JSON.stringify({
search: query,
locale: locale,
tags: selectedTags,
limit: 20
})
})
@@ -85,7 +79,6 @@ export function ProjectsPageClient({
// 转换 API 返回格式为组件期望格式
// API 返回: { projects: [{ ...project, similarity }], pagination }
// 组件期望: [{ project, similarity }]
const { similarity, ...projectData } = data.projects || []
const results = (data.projects || []).map((p: any) => ({
project: {
id: p.id,
@@ -112,13 +105,26 @@ export function ProjectsPageClient({
} finally {
setLoading(false)
}
}
}, [locale, selectedTags])
// 检查 URL 参数,如果有 ai=1 且有搜索词,则自动触发 AI 搜索
useEffect(() => {
const aiParam = searchParams.get('ai')
const searchParam = searchParams.get('search')
if (aiParam === '1' && searchParam && !initialAIHandled) {
setInitialAIHandled(true)
performAISearch(searchParam)
}
}, [searchParams, initialAIHandled, performAISearch])
const handleAISearch = async (query: string, isAI: boolean) => {
if (!isAI) {
// 传统搜索:刷新页面到 URL 参数
const params = new URLSearchParams()
if (query) params.set('search', query)
if (projectType) params.set('projectType', projectType)
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','))
window.location.href = `/${locale}/projects?${params.toString()}`
return
}
+100 -18
View File
@@ -1,13 +1,46 @@
import { Suspense } from 'react'
import { getTranslations } from 'next-intl/server'
import { getProjects, getAllTags, getTopTags } from '@/hooks/useProjects'
import {
getFixedProjectTypeFilters,
getProjects,
getTagCategoryGroups,
} from '@/hooks/useProjects'
import { ProjectList } from '@/components/project/ProjectList'
import { TagCloud } from '@/components/project/TagCloud'
import { TagFilterPanel } from '@/components/project/TagFilterPanel'
import { ProjectsPageClient } from './ProjectsPageClient'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectsPageProps {
params: Promise<{ locale: string }>
searchParams: Promise<{ search?: string; tag?: string; page?: string }>
searchParams: Promise<{
search?: string
tag?: string
tags?: string
projectType?: string
page?: string
}>
}
function buildProjectsQueryString(options: {
search: string
projectType: string
selectedTags: string[]
page: number
}): string {
const query = new URLSearchParams()
if (options.search) {
query.set('search', options.search)
}
if (options.projectType) {
query.set('projectType', options.projectType)
}
if (options.selectedTags.length > 0) {
query.set('tags', options.selectedTags.join(','))
}
query.set('page', String(options.page))
return query.toString()
}
export default async function ProjectsPage({
@@ -21,14 +54,30 @@ export default async function ProjectsPage({
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
const { locale } = resolvedParams
const search = resolvedSearchParams.search || ''
const tag = resolvedSearchParams.tag || ''
const selectedTags = Array.from(
new Set(
[resolvedSearchParams.tag || '', ...(resolvedSearchParams.tags || '').split(',')]
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
)
const projectTypeParam = resolvedSearchParams.projectType || ''
const activeProjectType = isFixedProjectTypeSlug(projectTypeParam)
? projectTypeParam
: ''
const page = Number(resolvedSearchParams.page) || 1
const [projectsData, topTags, allTags] = await Promise.all([
getProjects({ search, tag, page }),
getTopTags(10), // 获取前10个标签
getAllTags(), // 获取所有标签(用于展开)
const [projectsData, projectTypeOptions, tagCategoryGroups] = await Promise.all([
getProjects({
search,
tags: selectedTags,
projectType: activeProjectType,
page,
}),
getFixedProjectTypeFilters(),
getTagCategoryGroups(),
])
const activeProjectTypeMeta = projectTypeOptions.find((type) => type.slug === activeProjectType)
// 准备翻译文本传递给客户端组件
const translations = {
@@ -45,6 +94,8 @@ export default async function ProjectsPage({
<Suspense fallback={<div className="h-20"></div>}>
<ProjectsPageClient
locale={locale}
selectedTags={selectedTags}
projectType={activeProjectType}
searchPlaceholder={t('searchPlaceholder')}
searchLabel={tCommon('search')}
aiPlaceholder={t('aiSearchPlaceholder')}
@@ -54,16 +105,28 @@ export default async function ProjectsPage({
searching={tCommon('searching')}
translations={translations}
>
{/* Tag Cloud - rendered inside client component for non-AI mode */}
{/* Filters - rendered inside client component for non-AI mode */}
<div className="border-t-2 border-gray-100 dark:border-gray-800 pt-6 mt-8">
<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
{tProject('browseByFilters')}
</h3>
<TagCloud
tags={topTags} // 默认显示前10个
allTags={allTags} // 用于展开和搜索
<TagFilterPanel
locale={locale}
activeTag={tag}
search={search}
activeProjectType={activeProjectType}
selectedTags={selectedTags}
projectTypeOptions={projectTypeOptions}
tagCategoryGroups={tagCategoryGroups}
translations={{
fixedProjectType: tProject('fixedProjectType'),
allTypes: tProject('allTypes'),
filterByTagCategory: tProject('filterByTagCategory'),
searchTagsPlaceholder: tProject('searchTagsPlaceholder'),
clearTagFilters: tProject('clearTagFilters'),
showMoreTags: tProject('showMoreTags'),
showLessTags: tProject('showLessTags'),
noMatchingTags: tProject('noMatchingTags'),
}}
/>
</div>
</ProjectsPageClient>
@@ -73,7 +136,11 @@ export default async function ProjectsPage({
{/* Projects Section */}
<div className="flex justify-between items-end mb-8 border-b border-black dark:border-white/20 pb-4">
<h2 className="font-display font-bold text-3xl uppercase tracking-tight">
{tag ? `Tag: ${tag}` : 'All Projects'}
{activeProjectType
? locale === 'en'
? activeProjectTypeMeta?.nameEn || activeProjectType
: activeProjectTypeMeta?.name || activeProjectType
: tProject('allProjects')}
</h2>
<span className="font-display text-xs font-bold uppercase text-gray-500">
{projectsData.pagination.total} projects
@@ -88,7 +155,12 @@ export default async function ProjectsPage({
<div className="flex border-2 border-black dark:border-white/20 bg-white dark:bg-surface-dark shadow-neo dark:shadow-none">
{page > 1 && (
<a
href={`?search=${search}&tag=${tag}&page=${page - 1}`}
href={`?${buildProjectsQueryString({
search,
projectType: activeProjectType,
selectedTags,
page: page - 1,
})}`}
className="px-4 py-2 border-r border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
>
@@ -100,7 +172,12 @@ export default async function ProjectsPage({
return (
<a
key={pageNum}
href={`?search=${search}&tag=${tag}&page=${pageNum}`}
href={`?${buildProjectsQueryString({
search,
projectType: activeProjectType,
selectedTags,
page: pageNum,
})}`}
className={`px-4 py-2 font-display font-bold text-sm border-r border-black dark:border-white/20 ${
pageNum === page
? 'bg-primary text-black'
@@ -113,7 +190,12 @@ export default async function ProjectsPage({
})}
{page < projectsData.pagination.totalPages && (
<a
href={`?search=${search}&tag=${tag}&page=${page + 1}`}
href={`?${buildProjectsQueryString({
search,
projectType: activeProjectType,
selectedTags,
page: page + 1,
})}`}
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
>
+92 -7
View File
@@ -2,6 +2,12 @@ import { prisma } from '@/lib/prisma'
import type { ProjectInput } from '@/lib/validations'
import { generateSlug } from '@/lib/slug'
import type { Prisma } from '@prisma/client'
import {
FIXED_PROJECT_TYPE_TAGS,
inferProjectTypeSlug,
inferTagCategory,
type FixedProjectTypeSlug,
} from '@/lib/tag-taxonomy'
const PLURAL_NORMALIZATION_MAP = new Map([
['agents', 'agent'],
@@ -141,16 +147,23 @@ function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
async function getFallbackTag(): Promise<TagWithProjectCount> {
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn)
const fallbackCategory = inferTagCategory({
slug: fallbackSlug,
name: TAG_FALLBACK.name,
nameEn: TAG_FALLBACK.nameEn,
})
const fallbackTag = await prisma.tag.upsert({
where: { slug: fallbackSlug },
update: {
name: TAG_FALLBACK.name,
nameEn: TAG_FALLBACK.nameEn,
category: fallbackCategory,
},
create: {
name: TAG_FALLBACK.name,
nameEn: TAG_FALLBACK.nameEn,
slug: fallbackSlug,
category: fallbackCategory,
},
include: {
_count: {
@@ -162,6 +175,57 @@ async function getFallbackTag(): Promise<TagWithProjectCount> {
return fallbackTag
}
const FIXED_PROJECT_TYPE_MAP = new Map(
FIXED_PROJECT_TYPE_TAGS.map((tag) => [tag.slug, tag] as const)
)
export async function ensureFixedProjectTypeTag(
slug: FixedProjectTypeSlug
): Promise<TagWithProjectCount> {
const fixedTag = FIXED_PROJECT_TYPE_MAP.get(slug)
if (!fixedTag) {
throw new Error(`Unsupported fixed project type slug: ${slug}`)
}
return prisma.tag.upsert({
where: { slug: fixedTag.slug },
update: {
name: fixedTag.name,
nameEn: fixedTag.nameEn,
category: 'FIXED_PROJECT_TYPE',
},
create: {
name: fixedTag.name,
nameEn: fixedTag.nameEn,
slug: fixedTag.slug,
category: 'FIXED_PROJECT_TYPE',
},
include: {
_count: {
select: { projects: true },
},
},
})
}
export async function resolveFixedProjectTypeTag(
projectData: Pick<ProjectInput, 'name' | 'nameEn' | 'description' | 'descriptionEn' | 'tags'>
): Promise<TagWithProjectCount> {
const projectTypeSlug = inferProjectTypeSlug({
name: projectData.name,
nameEn: projectData.nameEn,
description: projectData.description,
descriptionEn: projectData.descriptionEn,
tags: projectData.tags.map((tag) => ({
name: tag.name,
nameEn: tag.nameEn,
slug: generateSlug(tag.name, tag.nameEn || null),
})),
})
return ensureFixedProjectTypeTag(projectTypeSlug)
}
/**
* 多级去重策略:查找已存在的项目
*
@@ -334,27 +398,48 @@ export async function upsertTags(tags: ProjectInput['tags']) {
}
if (matchedTag) {
if (incomingTag.nameEn && !matchedTag.nameEn) {
await prisma.tag.update({
const inferredCategory = inferTagCategory({
slug: incomingTag.slug,
name: incomingTag.name,
nameEn: incomingTag.nameEn,
})
const shouldUpdateNameEn = incomingTag.nameEn && !matchedTag.nameEn
const shouldUpdateCategory =
matchedTag.category === 'FREE_TAG' && inferredCategory !== 'FREE_TAG'
if (shouldUpdateNameEn || shouldUpdateCategory) {
const updatedTag = await prisma.tag.update({
where: { id: matchedTag.id },
data: { nameEn: incomingTag.nameEn },
data: {
...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}),
...(shouldUpdateCategory ? { category: inferredCategory } : {}),
},
include: {
_count: {
select: { projects: true },
},
},
})
matchedTag = {
...matchedTag,
nameEn: incomingTag.nameEn,
}
matchedTag = updatedTag
}
resolvedTags.push(matchedTag)
continue
}
const inferredCategory = inferTagCategory({
slug: incomingTag.slug,
name: incomingTag.name,
nameEn: incomingTag.nameEn,
})
try {
const createdTag = await prisma.tag.create({
data: {
name: incomingTag.name,
nameEn: incomingTag.nameEn,
slug: incomingTag.slug,
category: inferredCategory,
},
include: {
_count: {
@@ -6,7 +6,11 @@ import {
ProjectInputSchema,
type ProjectInput,
} from '@/lib/validations'
import { findExistingProject, upsertTags } from '../../../lib/discovery-service'
import {
findExistingProject,
resolveFixedProjectTypeTag,
upsertTags,
} from '../../../lib/discovery-service'
/**
* POST /api/discovery/tasks/:id/complete
@@ -58,8 +62,16 @@ export async function POST(
// 查找已存在的项目(复用webhook的多级去重逻辑)
const existingProject = await findExistingProject(projectData)
// Upsert tags(复用服务层函数)
const tagConnections = await upsertTags(projectData.tags)
// Upsert tags(复用服务层函数)+ 固定项目分类标签
const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
upsertTags(projectData.tags),
resolveFixedProjectTypeTag(projectData),
])
const tagConnections = Array.from(
new Map(
[...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])
).values()
)
// 生成slug
const slug = generateSlug(projectData.name, projectData.nameEn)
+14 -2
View File
@@ -8,7 +8,11 @@ import {
type WebhookPayload,
type ProjectInput,
} from '@/lib/validations'
import { findExistingProject, upsertTags } from '../../discovery/lib/discovery-service'
import {
findExistingProject,
resolveFixedProjectTypeTag,
upsertTags,
} from '../../discovery/lib/discovery-service'
import { generateSlug } from '@/lib/slug'
export async function POST(request: NextRequest) {
@@ -91,7 +95,15 @@ export async function POST(request: NextRequest) {
const existingProject = await findExistingProject(validProject)
// 标签规范化与自动复用(防止同义标签持续膨胀)
const tagConnections = await upsertTags(validProject.tags)
const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
upsertTags(validProject.tags),
resolveFixedProjectTypeTag(validProject),
])
const tagConnections = Array.from(
new Map(
[...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])
).values()
)
// Generate slug for project
const slug = generateSlug(validProject.name, validProject.nameEn)
+5 -3
View File
@@ -4,6 +4,7 @@ import { MarkdownContent } from './MarkdownContent'
import { ShareButtons } from './ShareButtons'
import { GitHubBadges } from './GitHubBadges'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectDetailProps {
project: {
@@ -59,9 +60,10 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
// Generate GitHub badge URLs
const badges = getGitHubBadgesFromLinks(project.links as any)
// Get category from first tag
const category = project.tags[0]?.name || 'AI Agent'
const categoryEn = project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
const categoryEn =
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
const displayCategory = locale === 'en' ? categoryEn : category
return (
+397
View File
@@ -0,0 +1,397 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import type { FilterTagCategoryGroup, FixedProjectTypeFilter } from '@/hooks/useProjects'
interface TagFilterPanelProps {
locale: string
search: string
activeProjectType?: string
selectedTags: string[]
projectTypeOptions: FixedProjectTypeFilter[]
tagCategoryGroups: FilterTagCategoryGroup[]
translations: {
fixedProjectType: string
allTypes: string
filterByTagCategory: string
searchTagsPlaceholder: string
clearTagFilters: string
showMoreTags: string
showLessTags: string
noMatchingTags: string
}
}
function buildProjectsUrl(params: {
locale: string
search: string
projectType?: string
selectedTags: string[]
}): string {
const query = new URLSearchParams()
if (params.search) {
query.set('search', params.search)
}
if (params.projectType) {
query.set('projectType', params.projectType)
}
if (params.selectedTags.length > 0) {
query.set('tags', params.selectedTags.join(','))
}
const queryString = query.toString()
return queryString
? `/${params.locale}/projects?${queryString}`
: `/${params.locale}/projects`
}
export function TagFilterPanel({
locale,
search,
activeProjectType,
selectedTags,
projectTypeOptions,
tagCategoryGroups,
translations,
}: TagFilterPanelProps) {
const DEFAULT_VISIBLE_TAGS = 12
const [tagQuery, setTagQuery] = useState('')
const [isTagPanelExpanded, setIsTagPanelExpanded] = useState(false)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
const [showAllTagsByGroup, setShowAllTagsByGroup] = useState<Record<string, boolean>>({})
const selectedTagSet = useMemo(() => new Set(selectedTags), [selectedTags])
const tagBySlug = useMemo(
() =>
new Map(
tagCategoryGroups.flatMap((group) =>
group.tags.map((tag) => [tag.slug, tag] as const)
)
),
[tagCategoryGroups]
)
const normalizedQuery = tagQuery.trim().toLowerCase()
const totalTagCount = useMemo(
() => tagCategoryGroups.reduce((total, group) => total + group.tags.length, 0),
[tagCategoryGroups]
)
const filteredGroups = useMemo(() => {
if (!normalizedQuery) {
return tagCategoryGroups
}
return tagCategoryGroups
.map((group) => ({
...group,
tags: group.tags.filter((tag) => {
const name = tag.name.toLowerCase()
const nameEn = (tag.nameEn || '').toLowerCase()
return name.includes(normalizedQuery) || nameEn.includes(normalizedQuery)
}),
}))
.filter((group) => group.tags.length > 0)
}, [tagCategoryGroups, normalizedQuery])
const filteredTagCount = useMemo(
() => filteredGroups.reduce((total, group) => total + group.tags.length, 0),
[filteredGroups]
)
const allTypesHref = buildProjectsUrl({
locale,
search,
selectedTags,
})
const allTagsClearedHref = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedTags: [],
})
const toggleTag = (slug: string) => {
if (selectedTagSet.has(slug)) {
return selectedTags.filter((item) => item !== slug)
}
return [...selectedTags, slug]
}
const hasSelectedTags = selectedTags.length > 0
const isSearching = normalizedQuery.length > 0
const shouldShowTagPanelBody = isTagPanelExpanded || hasSelectedTags || isSearching
const toggleGroupExpanded = (category: string) => {
setExpandedGroups((prev) => ({
...prev,
[category]: !prev[category],
}))
}
const toggleGroupShowAll = (category: string) => {
setShowAllTagsByGroup((prev) => ({
...prev,
[category]: !prev[category],
}))
}
return (
<div className="space-y-6">
<section>
<div className="flex flex-wrap gap-2">
<Link
href={allTypesHref}
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] ${
!activeProjectType
? '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'
}`}
>
{translations.allTypes}
</Link>
{projectTypeOptions.map((typeOption) => {
const isActive = activeProjectType === typeOption.slug
const href = buildProjectsUrl({
locale,
search,
projectType: isActive ? undefined : typeOption.slug,
selectedTags,
})
const displayName =
locale === 'en' && typeOption.nameEn ? typeOption.nameEn : typeOption.name
return (
<Link
key={typeOption.slug}
href={href}
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 ${
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}
<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'
}`}
>
{typeOption.projectCount}
</span>
</Link>
)
})}
</div>
</section>
<section>
<button
type="button"
onClick={() => setIsTagPanelExpanded((value) => !value)}
className="w-full flex items-center justify-between gap-3 border-2 border-black dark:border-gray-500 px-4 py-2 bg-white dark:bg-surface-dark text-left shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
aria-expanded={shouldShowTagPanelBody}
>
<span className="font-display font-bold uppercase text-xs text-gray-700 dark:text-gray-200">
{translations.filterByTagCategory}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-display font-bold uppercase text-gray-500">
{isSearching ? filteredTagCount : totalTagCount}
</span>
<svg
className={`w-4 h-4 text-gray-600 dark:text-gray-300 transition-transform ${
shouldShowTagPanelBody ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{shouldShowTagPanelBody && (
<div className="mt-4">
<div className="relative mb-4">
<input
type="text"
value={tagQuery}
onChange={(event) => setTagQuery(event.target.value)}
placeholder={translations.searchTagsPlaceholder}
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"
aria-hidden="true"
>
<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>
{selectedTags.length > 0 && (
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{selectedTags.map((tagSlug) => {
const matchedTag = tagBySlug.get(tagSlug)
const displayName =
matchedTag && locale === 'en' && matchedTag.nameEn
? matchedTag.nameEn
: matchedTag?.name || tagSlug
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedTags: selectedTags.filter((item) => item !== tagSlug),
})
return (
<Link
key={tagSlug}
href={href}
className="px-2 py-1 bg-black text-white border border-black text-[10px] font-display uppercase"
>
{displayName} ×
</Link>
)
})}
</div>
<Link
href={allTagsClearedHref}
className="text-[10px] font-display font-bold uppercase text-gray-500 hover:text-black dark:hover:text-white"
>
{translations.clearTagFilters}
</Link>
</div>
)}
<div className="space-y-3">
{filteredGroups.map((group) => {
const groupName = locale === 'en' && group.nameEn ? group.nameEn : group.name
const groupHasSelectedTag = group.tags.some((tag) => selectedTagSet.has(tag.slug))
const isGroupExpanded =
isSearching || groupHasSelectedTag || expandedGroups[group.category] === true
const isGroupShowAll = isSearching || showAllTagsByGroup[group.category] === true
const visibleTags = isGroupShowAll
? group.tags
: group.tags.slice(0, DEFAULT_VISIBLE_TAGS)
return (
<div key={group.category} className="border-2 border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => toggleGroupExpanded(group.category)}
className="w-full flex items-center justify-between gap-3 px-3 py-2 bg-gray-50 dark:bg-gray-900/40 border-b-2 border-gray-200 dark:border-gray-700"
aria-expanded={isGroupExpanded}
>
<span className="font-display font-bold uppercase text-[11px] text-gray-600 dark:text-gray-300">
{groupName}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-display font-bold uppercase text-gray-500">
{group.tags.length}
</span>
<svg
className={`w-3.5 h-3.5 text-gray-500 transition-transform ${
isGroupExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
{isGroupExpanded && (
<div className="p-3">
<div className="flex flex-wrap gap-2">
{visibleTags.map((tag) => {
const isActive = selectedTagSet.has(tag.slug)
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedTags: toggleTag(tag.slug),
})
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return (
<Link
key={tag.id}
href={href}
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 ${
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}
<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'
}`}
>
{tag._count.projects}
</span>
</Link>
)
})}
</div>
{!isSearching && group.tags.length > DEFAULT_VISIBLE_TAGS && (
<button
type="button"
onClick={() => toggleGroupShowAll(group.category)}
className="mt-3 text-[10px] font-display font-bold uppercase text-gray-500 hover:text-black dark:hover:text-white"
>
{isGroupShowAll
? translations.showLessTags
: `${translations.showMoreTags} (+${
group.tags.length - DEFAULT_VISIBLE_TAGS
})`}
</button>
)}
</div>
)}
</div>
)
})}
{filteredGroups.length === 0 && (
<div className="text-center py-4 text-gray-500 text-sm">
{translations.noMatchingTags}
</div>
)}
</div>
</div>
)}
</section>
</div>
)
}
+318 -77
View File
@@ -1,5 +1,60 @@
import { prisma } from '@/lib/prisma'
import type { Prisma } from '@prisma/client'
import { Prisma, type TagCategory } from '@prisma/client'
import {
FIXED_PROJECT_TYPE_TAGS,
TAG_CATEGORY_META,
getTagCategoryOrder,
isFixedProjectTypeSlug,
type FixedProjectTypeSlug,
} from '@/lib/tag-taxonomy'
const DB_RETRY_DELAYS_MS = [300, 900] as const
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
function isTransientDbError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientInitializationError) {
return true
}
if (error instanceof Prisma.PrismaClientRustPanicError) {
return true
}
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return (
message.includes("can't reach database server") ||
message.includes('p1001') ||
message.includes('connection terminated') ||
message.includes('timeout') ||
message.includes('econnreset')
)
}
async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) {
try {
return await task()
} catch (error) {
lastError = error
if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) {
break
}
await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0)
}
}
throw new Error(
`[db] ${operationName} failed: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
)
}
// 定义带有标签和链接的项目类型
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
@@ -21,9 +76,25 @@ export type TagWithProjectCount = Prisma.TagGetPayload<{
}
}>
export type FilterTagCategoryGroup = {
category: Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>
name: string
nameEn: string
tags: TagWithProjectCount[]
}
export type FixedProjectTypeFilter = {
slug: FixedProjectTypeSlug
name: string
nameEn: string
projectCount: number
}
export async function getProjects(options?: {
search?: string
tag?: string
tags?: string[]
projectType?: string
status?: 'ACTIVE' | 'ARCHIVED'
page?: number
limit?: number
@@ -39,6 +110,8 @@ export async function getProjects(options?: {
const {
search,
tag,
tags = [],
projectType,
status = 'ACTIVE',
page = 1,
limit = 20,
@@ -58,35 +131,74 @@ export async function getProjects(options?: {
]
}
if (tag) {
where.tags = {
some: {
tag: {
slug: tag,
},
},
}
}
const andFilters: Prisma.ProjectWhereInput[] = []
const normalizedTagSlugs = Array.from(
new Set(
[tag, ...tags]
.map((value) => (value || '').trim())
.filter((value) => value.length > 0)
)
)
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
include: {
tags: {
include: {
tag: true,
for (const tagSlug of normalizedTagSlugs) {
andFilters.push({
tags: {
some: {
tag: {
slug: tagSlug,
},
},
links: true,
},
orderBy: {
createdAt: 'desc',
})
}
if (projectType && isFixedProjectTypeSlug(projectType)) {
andFilters.push({
tags: {
some: {
tag: {
slug: projectType,
},
},
},
skip: (page - 1) * limit,
take: limit,
}),
prisma.project.count({ where }),
])
})
}
if (andFilters.length > 0) {
where.AND = andFilters
}
let projects: ProjectWithTagsAndLinks[] = []
let total = 0
try {
;[projects, total] = await withDbRetry('getProjects', () =>
Promise.all([
prisma.project.findMany({
where,
include: {
tags: {
include: {
tag: true,
},
},
links: true,
},
orderBy: {
createdAt: 'desc',
},
skip: (page - 1) * limit,
take: limit,
}),
prisma.project.count({ where }),
])
)
} catch (error) {
console.error(
'[db] getProjects degraded to empty result:',
error instanceof Error ? error.message : String(error)
)
}
// Transform tags to flatten the structure
const transformedProjects = projects.map((project) => ({
@@ -106,17 +218,19 @@ export async function getProjects(options?: {
}
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
const project = await prisma.project.findUnique({
where: { slug },
include: {
tags: {
include: {
tag: true,
const project = await withDbRetry('getProjectBySlug', () =>
prisma.project.findUnique({
where: { slug },
include: {
tags: {
include: {
tag: true,
},
},
links: true,
},
links: true,
},
})
})
)
if (!project) {
return null
@@ -130,46 +244,65 @@ export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTag
}
export async function getAllTags(): Promise<TagWithProjectCount[]> {
return prisma.tag.findMany({
include: {
_count: {
select: { projects: true },
return withDbRetry('getAllTags', () =>
prisma.tag.findMany({
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
},
},
orderBy: {
name: 'asc',
},
})
include: {
_count: {
select: { projects: true },
},
},
orderBy: {
name: 'asc',
},
})
)
}
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
const tags = await prisma.tag.findMany({
include: {
_count: {
select: { projects: true },
const tags = await withDbRetry('getTagsWithProjectCounts', () =>
prisma.tag.findMany({
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
},
},
orderBy: {
name: 'asc',
},
})
include: {
_count: {
select: { projects: true },
},
},
orderBy: {
name: 'asc',
},
})
)
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: {} // 只返回有项目的标签
}
}
})
const tags = await withDbRetry('getTopTags', () =>
prisma.tag.findMany({
include: {
_count: {
select: { projects: true },
},
},
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
projects: {
some: {}, // 只返回有项目的标签
},
},
})
)
// 按项目数量降序、名称升序排序
return tags
@@ -182,6 +315,112 @@ export async function getTopTags(limit: number = 10): Promise<TagWithProjectCoun
.slice(0, limit)
}
export async function getFixedProjectTypeFilters(
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
): Promise<FixedProjectTypeFilter[]> {
let counts: number[] = []
try {
counts = await withDbRetry('getFixedProjectTypeFilters', () =>
Promise.all(
FIXED_PROJECT_TYPE_TAGS.map((type) =>
prisma.project.count({
where: {
status,
tags: {
some: {
tag: {
slug: type.slug,
},
},
},
},
})
)
)
)
} catch (error) {
console.error(
'[db] getFixedProjectTypeFilters degraded to zero counts:',
error instanceof Error ? error.message : String(error)
)
}
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
slug: type.slug,
name: type.name,
nameEn: type.nameEn,
projectCount: counts[index] ?? 0,
}))
}
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
let tags: TagWithProjectCount[] = []
try {
tags = await withDbRetry('getTagCategoryGroups', () =>
prisma.tag.findMany({
where: {
category: {
not: 'FIXED_PROJECT_TYPE',
},
projects: {
some: {},
},
},
include: {
_count: {
select: { projects: true },
},
},
orderBy: {
name: 'asc',
},
})
)
} catch (error) {
console.error(
'[db] getTagCategoryGroups degraded to empty groups:',
error instanceof Error ? error.message : String(error)
)
}
const groups = new Map<Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>, TagWithProjectCount[]>()
for (const category of getTagCategoryOrder()) {
if (category === 'FIXED_PROJECT_TYPE') {
continue
}
groups.set(category, [])
}
for (const tag of tags) {
if (tag.category === 'FIXED_PROJECT_TYPE') {
continue
}
const current = groups.get(tag.category)
if (!current) {
groups.set(tag.category, [tag])
continue
}
current.push(tag)
}
return Array.from(groups.entries())
.map(([category, categoryTags]) => ({
category,
name: TAG_CATEGORY_META[category].name,
nameEn: TAG_CATEGORY_META[category].nameEn,
tags: categoryTags.sort((a, b) => {
const countDiff = b._count.projects - a._count.projects
if (countDiff !== 0) {
return countDiff
}
return a.name.localeCompare(b.name, 'zh')
}),
}))
.filter((group) => group.tags.length > 0)
}
// 定义 AI 搜索结果类型
export type AISearchResultItem = ProjectWithFlatTags & {
similarity: number
@@ -203,21 +442,23 @@ export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTa
return []
}
const projects = await prisma.project.findMany({
where: {
id: {
in: ids,
},
},
include: {
tags: {
include: {
tag: true,
const projects = await withDbRetry('getProjectsByIds', () =>
prisma.project.findMany({
where: {
id: {
in: ids,
},
},
links: true,
},
})
include: {
tags: {
include: {
tag: true,
},
},
links: true,
},
})
)
// Transform tags to flatten the structure
return projects.map((project) => ({
+7
View File
@@ -3,9 +3,16 @@ import { useSearchParams } from 'next/navigation'
export function useSearch() {
const searchParams = useSearchParams()
const tags = (searchParams.get('tags') || '')
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
return {
search: searchParams.get('search') || '',
tag: searchParams.get('tag') || '',
tags,
projectType: searchParams.get('projectType') || '',
page: Number(searchParams.get('page')) || 1,
}
}
+460
View File
@@ -0,0 +1,460 @@
import type { TagCategory } from '@prisma/client'
export const FIXED_PROJECT_TYPE_TAGS = [
{
slug: 'agent-tooling',
name: '智能体与应用工具',
nameEn: 'Agent & Tooling',
},
{
slug: 'inference-model',
name: '模型与推理框架',
nameEn: 'Model & Inference Framework',
},
{
slug: 'docs-tutorial',
name: '文档教程与资源',
nameEn: 'Docs, Tutorials & Resources',
},
{
slug: 'other-mixed',
name: '其他',
nameEn: 'Other',
},
] as const
export type FixedProjectTypeSlug = (typeof FIXED_PROJECT_TYPE_TAGS)[number]['slug']
export const FIXED_PROJECT_TYPE_SLUGS = new Set<FixedProjectTypeSlug>(
FIXED_PROJECT_TYPE_TAGS.map((item) => item.slug)
)
export const TAG_CATEGORY_META: Record<TagCategory, { name: string; nameEn: string }> = {
FIXED_PROJECT_TYPE: {
name: '固定项目分类',
nameEn: 'Fixed Project Type',
},
TECH_STACK: {
name: '技术栈',
nameEn: 'Tech Stack',
},
AI_PARADIGM: {
name: 'AI 技术范式',
nameEn: 'AI Paradigm',
},
PROTOCOL_INTERFACE: {
name: '协议与接口',
nameEn: 'Protocol & Interface',
},
PRODUCT_FORM: {
name: '产品形态',
nameEn: 'Product Form',
},
DOMAIN_SCENARIO: {
name: '应用场景',
nameEn: 'Domain Scenario',
},
RESOURCE_TYPE: {
name: '资源类型',
nameEn: 'Resource Type',
},
FREE_TAG: {
name: '自由标签',
nameEn: 'Free Tag',
},
}
const TAG_CATEGORY_ORDER: TagCategory[] = [
'FIXED_PROJECT_TYPE',
'TECH_STACK',
'AI_PARADIGM',
'PROTOCOL_INTERFACE',
'PRODUCT_FORM',
'DOMAIN_SCENARIO',
'RESOURCE_TYPE',
'FREE_TAG',
]
export function getTagCategoryOrder(): TagCategory[] {
return [...TAG_CATEGORY_ORDER]
}
function normalize(value: string): string {
return value
.toLowerCase()
.replace(/[\-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function includesAny(text: string, keywords: string[]): boolean {
return keywords.some((keyword) => text.includes(keyword))
}
function createNormalizedSlugSet(slugs: string[]): Set<string> {
return new Set(slugs.map((slug) => normalize(slug)))
}
const TECH_STACK_SLUGS = new Set([
'python',
'typescript',
'javascript',
'java',
'go',
'rust',
'nodejs',
'react',
'nextjs',
'nestjs',
'fastapi',
'spring-boot',
'flask',
'docker',
'postgresql',
'mysql',
'redis',
'electron',
'bun',
'langchain',
'langgraph',
])
const AI_PARADIGM_SLUGS = new Set([
'llm',
'大语言模型',
'rag',
'transformers',
'pytorch',
'machine-learning',
'deep-learning',
'reinforcement-learning',
'multimodal',
'multimodal-ai',
'multi-agent-system',
'multi-agent',
'autonomous-agents',
'vllm',
'llamaindex',
'agent-framework',
'ai-agent-framework',
'ai-agents',
])
const PROTOCOL_INTERFACE_SLUGS = new Set([
'model-context-protocol',
'mcp-protocol',
'openai-api',
'sdk',
'api-gateway',
])
const PRODUCT_FORM_SLUGS = new Set([
'cli',
'web-application',
'桌面应用',
'vs-code-extension',
'workflow-automation',
'automation',
])
const DOMAIN_SCENARIO_SLUGS = new Set([
'data-analytics',
'数据分析',
'knowledge-management',
'知识管理',
'ai-security',
'natural-language-processing',
'nlp',
'computer-vision',
])
const RESOURCE_TYPE_SLUGS = new Set([
'knowledge-base',
'markdown',
'ai-research',
'guide',
'tutorial',
'paper',
])
const TECH_STACK_KEYWORDS = [
'python',
'typescript',
'javascript',
'java',
'rust',
'node',
'react',
'next',
'fastapi',
'spring',
'docker',
'postgres',
'mysql',
'c#',
'c++',
]
const AI_PARADIGM_KEYWORDS = [
'llm',
'rag',
'模型',
'model',
'推理',
'inference',
'transformer',
'强化学习',
'machine learning',
'deep learning',
'多模态',
'multi agent',
'智能体',
'agent',
]
const PROTOCOL_INTERFACE_KEYWORDS = [
'mcp',
'protocol',
'api',
'sdk',
'接口',
'协议',
]
const PRODUCT_FORM_KEYWORDS = [
'cli',
'desktop',
'web app',
'extension',
'workflow',
'automation',
'命令行',
'桌面',
'插件',
'自动化',
]
const DOMAIN_SCENARIO_KEYWORDS = [
'security',
'analysis',
'knowledge',
'nlp',
'vision',
'speech',
'robot',
'finance',
'安全',
'分析',
'知识',
'语音',
'视觉',
]
const RESOURCE_TYPE_KEYWORDS = [
'guide',
'tutorial',
'paper',
'list',
'awesome',
'docs',
'documentation',
'resource',
'指南',
'教程',
'论文',
'合集',
'文档',
'资源',
'课程',
]
const PROJECT_DOC_KEYWORDS = [
...RESOURCE_TYPE_KEYWORDS,
'survey',
'入门',
'学习',
]
const PROJECT_MODEL_KEYWORDS = [
'模型',
'model',
'推理',
'inference',
'训练',
'training',
'微调',
'finetune',
'llm',
'transformer',
]
const PROJECT_MODEL_TAG_SLUGS = new Set([
'llm',
'大语言模型',
'transformers',
'pytorch',
'machine-learning',
'deep-learning',
'reinforcement-learning',
'vllm',
])
const PROJECT_DOC_TAG_SLUGS = new Set([
'knowledge-base',
'markdown',
'ai-research',
])
const PROJECT_TOOL_TAG_SLUGS = new Set([
'ai-development-tool',
'agent-framework',
'ai-agent-framework',
'ai-agents',
'mcp',
'model-context-protocol',
'mcp-protocol',
'cli',
'workflow-automation',
'automation',
'openai-api',
'sdk',
'web-application',
'桌面应用',
'vs-code-extension',
])
const PROJECT_TOOL_KEYWORDS = [
'agent',
'智能体',
'assistant',
'助手',
'tool',
'工具',
'framework',
'框架',
'platform',
'平台',
'sdk',
'server',
'cli',
'mcp',
'workflow',
'automation',
'应用',
'app',
]
const PROJECT_MODEL_TAG_KEYS = createNormalizedSlugSet(Array.from(PROJECT_MODEL_TAG_SLUGS))
const PROJECT_DOC_TAG_KEYS = createNormalizedSlugSet(Array.from(PROJECT_DOC_TAG_SLUGS))
const PROJECT_TOOL_TAG_KEYS = createNormalizedSlugSet(Array.from(PROJECT_TOOL_TAG_SLUGS))
export function isFixedProjectTypeSlug(value: string): value is FixedProjectTypeSlug {
return FIXED_PROJECT_TYPE_SLUGS.has(value as FixedProjectTypeSlug)
}
export function inferTagCategory(input: {
slug: string
name: string
nameEn?: string | null
}): TagCategory {
const rawSlug = input.slug.trim().toLowerCase()
if (isFixedProjectTypeSlug(rawSlug)) {
return 'FIXED_PROJECT_TYPE'
}
const normalizedSlug = normalize(rawSlug)
if (TECH_STACK_SLUGS.has(input.slug)) {
return 'TECH_STACK'
}
if (AI_PARADIGM_SLUGS.has(input.slug)) {
return 'AI_PARADIGM'
}
if (PROTOCOL_INTERFACE_SLUGS.has(input.slug)) {
return 'PROTOCOL_INTERFACE'
}
if (PRODUCT_FORM_SLUGS.has(input.slug)) {
return 'PRODUCT_FORM'
}
if (DOMAIN_SCENARIO_SLUGS.has(input.slug)) {
return 'DOMAIN_SCENARIO'
}
if (RESOURCE_TYPE_SLUGS.has(input.slug)) {
return 'RESOURCE_TYPE'
}
const normalizedName = normalize(input.name)
const normalizedNameEn = normalize(input.nameEn || '')
const mergedText = `${normalizedSlug} ${normalizedName} ${normalizedNameEn}`
if (includesAny(mergedText, RESOURCE_TYPE_KEYWORDS)) {
return 'RESOURCE_TYPE'
}
if (includesAny(mergedText, TECH_STACK_KEYWORDS)) {
return 'TECH_STACK'
}
if (includesAny(mergedText, AI_PARADIGM_KEYWORDS)) {
return 'AI_PARADIGM'
}
if (includesAny(mergedText, PROTOCOL_INTERFACE_KEYWORDS)) {
return 'PROTOCOL_INTERFACE'
}
if (includesAny(mergedText, PRODUCT_FORM_KEYWORDS)) {
return 'PRODUCT_FORM'
}
if (includesAny(mergedText, DOMAIN_SCENARIO_KEYWORDS)) {
return 'DOMAIN_SCENARIO'
}
return 'FREE_TAG'
}
export function inferProjectTypeSlug(input: {
name: string
nameEn?: string | null
description: string
descriptionEn?: string | null
tags?: Array<{ slug?: string; name?: string; nameEn?: string | null }>
}): FixedProjectTypeSlug {
const text = normalize(
[
input.name,
input.nameEn || '',
input.description,
input.descriptionEn || '',
].join(' ')
)
const normalizedTagSlugs = (input.tags || [])
.map((tag) => (tag.slug ? normalize(tag.slug) : ''))
.filter((slug) => slug.length > 0)
const docSignal =
includesAny(text, PROJECT_DOC_KEYWORDS) ||
normalizedTagSlugs.some((slug) => PROJECT_DOC_TAG_KEYS.has(slug))
const modelSignal =
includesAny(text, PROJECT_MODEL_KEYWORDS) ||
normalizedTagSlugs.some((slug) => PROJECT_MODEL_TAG_KEYS.has(slug))
const toolSignal =
includesAny(text, PROJECT_TOOL_KEYWORDS) ||
normalizedTagSlugs.some((slug) => PROJECT_TOOL_TAG_KEYS.has(slug))
const activeSignalCount = [docSignal, modelSignal, toolSignal].filter(Boolean).length
if (activeSignalCount === 0 || activeSignalCount === 3) {
return 'other-mixed'
}
if (docSignal) {
return 'docs-tutorial'
}
if (modelSignal) {
return 'inference-model'
}
if (toolSignal) {
return 'agent-tooling'
}
return 'other-mixed'
}
export function getProjectTypeBySlug(slug: FixedProjectTypeSlug) {
return FIXED_PROJECT_TYPE_TAGS.find((item) => item.slug === slug)
}
+11 -1
View File
@@ -69,7 +69,17 @@
"onThisPage": "On this page",
"overview": "Overview",
"keyFeatures": "Key Features",
"useCases": "Use Cases"
"useCases": "Use Cases",
"browseByFilters": "Browse by Filters",
"fixedProjectType": "Project Type",
"allTypes": "All Types",
"filterByTagCategory": "Filter by Tag Category",
"searchTagsPlaceholder": "Search tags...",
"clearTagFilters": "Clear Tag Filters",
"showMoreTags": "Show more tags",
"showLessTags": "Show fewer tags",
"noMatchingTags": "No matching tags",
"allProjects": "All Projects"
},
"navigation": {
"home": "Home",
+11 -1
View File
@@ -69,7 +69,17 @@
"onThisPage": "本页内容",
"overview": "概述",
"keyFeatures": "核心功能",
"useCases": "使用场景"
"useCases": "使用场景",
"browseByFilters": "按分类筛选",
"fixedProjectType": "固定项目分类",
"allTypes": "全部类型",
"filterByTagCategory": "按标签分类筛选",
"searchTagsPlaceholder": "搜索标签...",
"clearTagFilters": "清空标签筛选",
"showMoreTags": "查看更多标签",
"showLessTags": "收起标签",
"noMatchingTags": "未找到匹配标签",
"allProjects": "全部项目"
},
"navigation": {
"home": "首页",