diff --git a/package.json b/package.json index 56f05fc..b297305 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09e6590..dbe35e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/scripts/backfill-tag-taxonomy.ts b/scripts/backfill-tag-taxonomy.ts new file mode 100644 index 0000000..7a29d75 --- /dev/null +++ b/scripts/backfill-tag-taxonomy.ts @@ -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) { + 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() + }) diff --git a/src/app/[locale]/projects/ProjectsPageClient.tsx b/src/app/[locale]/projects/ProjectsPageClient.tsx index e3ef48e..4a66b9a 100644 --- a/src/app/[locale]/projects/ProjectsPageClient.tsx +++ b/src/app/[locale]/projects/ProjectsPageClient.tsx @@ -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(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 } diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx index 3c0cb71..1f63a96 100644 --- a/src/app/[locale]/projects/page.tsx +++ b/src/app/[locale]/projects/page.tsx @@ -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({ }> - {/* Tag Cloud - rendered inside client component for non-AI mode */} + {/* Filters - rendered inside client component for non-AI mode */}

- Browse by Tags + {tProject('browseByFilters')}

-
@@ -73,7 +136,11 @@ export default async function ProjectsPage({ {/* Projects Section */}

- {tag ? `Tag: ${tag}` : 'All Projects'} + {activeProjectType + ? locale === 'en' + ? activeProjectTypeMeta?.nameEn || activeProjectType + : activeProjectTypeMeta?.name || activeProjectType + : tProject('allProjects')}

{projectsData.pagination.total} projects @@ -88,7 +155,12 @@ export default async function ProjectsPage({
{page > 1 && ( ← @@ -100,7 +172,12 @@ export default async function ProjectsPage({ return ( → diff --git a/src/app/api/discovery/lib/discovery-service.ts b/src/app/api/discovery/lib/discovery-service.ts index 82b9d9a..1fa13f7 100644 --- a/src/app/api/discovery/lib/discovery-service.ts +++ b/src/app/api/discovery/lib/discovery-service.ts @@ -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 { 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 { 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 { + 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 +): Promise { + 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: { diff --git a/src/app/api/discovery/tasks/[id]/complete/route.ts b/src/app/api/discovery/tasks/[id]/complete/route.ts index 07dfded..5c61f37 100644 --- a/src/app/api/discovery/tasks/[id]/complete/route.ts +++ b/src/app/api/discovery/tasks/[id]/complete/route.ts @@ -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) diff --git a/src/app/api/webhook/projects/route.ts b/src/app/api/webhook/projects/route.ts index c51bd1c..76cb145 100644 --- a/src/app/api/webhook/projects/route.ts +++ b/src/app/api/webhook/projects/route.ts @@ -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) diff --git a/src/components/project/ProjectDetail.tsx b/src/components/project/ProjectDetail.tsx index b7a6105..5c0f033 100644 --- a/src/components/project/ProjectDetail.tsx +++ b/src/components/project/ProjectDetail.tsx @@ -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 ( diff --git a/src/components/project/TagFilterPanel.tsx b/src/components/project/TagFilterPanel.tsx new file mode 100644 index 0000000..9669fa1 --- /dev/null +++ b/src/components/project/TagFilterPanel.tsx @@ -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>({}) + const [showAllTagsByGroup, setShowAllTagsByGroup] = useState>({}) + + 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 ( +
+
+
+ + {translations.allTypes} + + {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 ( + + {displayName} + + {typeOption.projectCount} + + + ) + })} +
+
+ +
+ + + {shouldShowTagPanelBody && ( +
+
+ 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" + /> + +
+ + {selectedTags.length > 0 && ( +
+
+ {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 ( + + {displayName} × + + ) + })} +
+ + {translations.clearTagFilters} + +
+ )} + +
+ {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 ( +
+ + + {isGroupExpanded && ( +
+
+ {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 ( + + {displayName} + + {tag._count.projects} + + + ) + })} +
+ + {!isSearching && group.tags.length > DEFAULT_VISIBLE_TAGS && ( + + )} +
+ )} +
+ ) + })} + + {filteredGroups.length === 0 && ( +
+ {translations.noMatchingTags} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index ea0b150..6dd2ba7 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -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 { + 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(operationName: string, task: () => Promise): Promise { + 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 + 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 { - 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 { - 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 { - 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 { - 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 { + 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 { + 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, 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 + 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) => ({ diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 46aca39..6949811 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -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, } } diff --git a/src/lib/tag-taxonomy.ts b/src/lib/tag-taxonomy.ts new file mode 100644 index 0000000..cf7b607 --- /dev/null +++ b/src/lib/tag-taxonomy.ts @@ -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( + FIXED_PROJECT_TYPE_TAGS.map((item) => item.slug) +) + +export const TAG_CATEGORY_META: Record = { + 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 { + 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) +} diff --git a/src/messages/en.json b/src/messages/en.json index ce064bc..3855cbb 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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", diff --git a/src/messages/zh.json b/src/messages/zh.json index 7d7fd8f..457f8b3 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -69,7 +69,17 @@ "onThisPage": "本页内容", "overview": "概述", "keyFeatures": "核心功能", - "useCases": "使用场景" + "useCases": "使用场景", + "browseByFilters": "按分类筛选", + "fixedProjectType": "固定项目分类", + "allTypes": "全部类型", + "filterByTagCategory": "按标签分类筛选", + "searchTagsPlaceholder": "搜索标签...", + "clearTagFilters": "清空标签筛选", + "showMoreTags": "查看更多标签", + "showLessTags": "收起标签", + "noMatchingTags": "未找到匹配标签", + "allProjects": "全部项目" }, "navigation": { "home": "首页",