feat: 新增标签分类体系与固定类型筛选优化
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
→
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -69,7 +69,17 @@
|
||||
"onThisPage": "本页内容",
|
||||
"overview": "概述",
|
||||
"keyFeatures": "核心功能",
|
||||
"useCases": "使用场景"
|
||||
"useCases": "使用场景",
|
||||
"browseByFilters": "按分类筛选",
|
||||
"fixedProjectType": "固定项目分类",
|
||||
"allTypes": "全部类型",
|
||||
"filterByTagCategory": "按标签分类筛选",
|
||||
"searchTagsPlaceholder": "搜索标签...",
|
||||
"clearTagFilters": "清空标签筛选",
|
||||
"showMoreTags": "查看更多标签",
|
||||
"showLessTags": "收起标签",
|
||||
"noMatchingTags": "未找到匹配标签",
|
||||
"allProjects": "全部项目"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
|
||||
Reference in New Issue
Block a user