feat: 新增标签分类体系与固定类型筛选优化
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest",
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ model Tag {
|
|||||||
name String @unique
|
name String @unique
|
||||||
nameEn String?
|
nameEn String?
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
category TagCategory @default(FREE_TAG)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
projects ProjectTag[]
|
projects ProjectTag[]
|
||||||
|
|
||||||
|
@@index([category], map: "idx_tag_category")
|
||||||
@@index([slug], map: "idx_tag_slug")
|
@@index([slug], map: "idx_tag_slug")
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
}
|
}
|
||||||
@@ -114,3 +116,13 @@ enum TaskStatus {
|
|||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TagCategory {
|
||||||
|
FIXED_PROJECT_TYPE
|
||||||
|
TECH_STACK
|
||||||
|
AI_PARADIGM
|
||||||
|
PROTOCOL_INTERFACE
|
||||||
|
PRODUCT_FORM
|
||||||
|
DOMAIN_SCENARIO
|
||||||
|
RESOURCE_TYPE
|
||||||
|
FREE_TAG
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import {
|
||||||
|
FIXED_PROJECT_TYPE_TAGS,
|
||||||
|
inferProjectTypeSlug,
|
||||||
|
inferTagCategory,
|
||||||
|
} from '../src/lib/tag-taxonomy'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function ensureFixedProjectTypeTags() {
|
||||||
|
const result = await Promise.all(
|
||||||
|
FIXED_PROJECT_TYPE_TAGS.map((item) =>
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug: item.slug },
|
||||||
|
update: {
|
||||||
|
name: item.name,
|
||||||
|
nameEn: item.nameEn,
|
||||||
|
category: 'FIXED_PROJECT_TYPE',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: item.name,
|
||||||
|
nameEn: item.nameEn,
|
||||||
|
slug: item.slug,
|
||||||
|
category: 'FIXED_PROJECT_TYPE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Map(result.map((tag) => [tag.slug, tag.id]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillTagCategories() {
|
||||||
|
const tags = await prisma.tag.findMany()
|
||||||
|
let updated = 0
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const inferredCategory = inferTagCategory({
|
||||||
|
slug: tag.slug,
|
||||||
|
name: tag.name,
|
||||||
|
nameEn: tag.nameEn,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tag.category !== inferredCategory) {
|
||||||
|
await prisma.tag.update({
|
||||||
|
where: { id: tag.id },
|
||||||
|
data: { category: inferredCategory },
|
||||||
|
})
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillProjectFixedTypes(fixedTypeIdMap: Map<string, string>) {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
include: {
|
||||||
|
tags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createData: Array<{ projectId: string; tagId: string }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const fixedTypeSlug = inferProjectTypeSlug({
|
||||||
|
name: project.name,
|
||||||
|
nameEn: project.nameEn,
|
||||||
|
description: project.description,
|
||||||
|
descriptionEn: project.descriptionEn,
|
||||||
|
tags: project.tags.map((tag) => ({
|
||||||
|
slug: tag.tag.slug,
|
||||||
|
name: tag.tag.name,
|
||||||
|
nameEn: tag.tag.nameEn,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const fixedTypeTagId = fixedTypeIdMap.get(fixedTypeSlug)
|
||||||
|
if (!fixedTypeTagId) {
|
||||||
|
throw new Error(`Missing fixed project type tag: ${fixedTypeSlug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
createData.push({
|
||||||
|
projectId: project.id,
|
||||||
|
tagId: fixedTypeTagId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await prisma.projectTag.deleteMany({
|
||||||
|
where: {
|
||||||
|
tag: {
|
||||||
|
category: 'FIXED_PROJECT_TYPE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const inserted = await prisma.projectTag.createMany({
|
||||||
|
data: createData,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectCount: projects.length,
|
||||||
|
inserted: inserted.count,
|
||||||
|
removed: removed.count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[taxonomy] start')
|
||||||
|
|
||||||
|
const fixedTypeIdMap = await ensureFixedProjectTypeTags()
|
||||||
|
const updatedTagCategoryCount = await backfillTagCategories()
|
||||||
|
const projectResult = await backfillProjectFixedTypes(fixedTypeIdMap)
|
||||||
|
|
||||||
|
console.log('[taxonomy] done', {
|
||||||
|
updatedTagCategoryCount,
|
||||||
|
projectCount: projectResult.projectCount,
|
||||||
|
insertedProjectTypeLinks: projectResult.inserted,
|
||||||
|
removedOldProjectTypeLinks: projectResult.removed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[taxonomy] failed', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { AISearchBar } from '@/components/search/AISearchBar'
|
import { AISearchBar } from '@/components/search/AISearchBar'
|
||||||
import { AISearchResults } from '@/components/search/AISearchResults'
|
import { AISearchResults } from '@/components/search/AISearchResults'
|
||||||
@@ -14,6 +14,8 @@ interface AISearchResult {
|
|||||||
|
|
||||||
interface ProjectsPageClientProps {
|
interface ProjectsPageClientProps {
|
||||||
locale: string
|
locale: string
|
||||||
|
selectedTags: string[]
|
||||||
|
projectType?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
searchPlaceholder: string
|
searchPlaceholder: string
|
||||||
searchLabel: string
|
searchLabel: string
|
||||||
@@ -32,6 +34,8 @@ interface ProjectsPageClientProps {
|
|||||||
|
|
||||||
export function ProjectsPageClient({
|
export function ProjectsPageClient({
|
||||||
locale,
|
locale,
|
||||||
|
selectedTags,
|
||||||
|
projectType,
|
||||||
children,
|
children,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
searchLabel,
|
searchLabel,
|
||||||
@@ -49,18 +53,7 @@ export function ProjectsPageClient({
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [initialAIHandled, setInitialAIHandled] = useState(false)
|
const [initialAIHandled, setInitialAIHandled] = useState(false)
|
||||||
|
|
||||||
// 检查 URL 参数,如果有 ai=1 且有搜索词,则自动触发 AI 搜索
|
const performAISearch = useCallback(async (query: string) => {
|
||||||
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) => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setIsAIResult(true)
|
setIsAIResult(true)
|
||||||
@@ -72,6 +65,7 @@ export function ProjectsPageClient({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
search: query,
|
search: query,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
|
tags: selectedTags,
|
||||||
limit: 20
|
limit: 20
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -85,7 +79,6 @@ export function ProjectsPageClient({
|
|||||||
// 转换 API 返回格式为组件期望格式
|
// 转换 API 返回格式为组件期望格式
|
||||||
// API 返回: { projects: [{ ...project, similarity }], pagination }
|
// API 返回: { projects: [{ ...project, similarity }], pagination }
|
||||||
// 组件期望: [{ project, similarity }]
|
// 组件期望: [{ project, similarity }]
|
||||||
const { similarity, ...projectData } = data.projects || []
|
|
||||||
const results = (data.projects || []).map((p: any) => ({
|
const results = (data.projects || []).map((p: any) => ({
|
||||||
project: {
|
project: {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -112,13 +105,26 @@ export function ProjectsPageClient({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleAISearch = async (query: string, isAI: boolean) => {
|
||||||
if (!isAI) {
|
if (!isAI) {
|
||||||
// 传统搜索:刷新页面到 URL 参数
|
// 传统搜索:刷新页面到 URL 参数
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (query) params.set('search', query)
|
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()}`
|
window.location.href = `/${locale}/projects?${params.toString()}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { getTranslations } from 'next-intl/server'
|
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 { ProjectList } from '@/components/project/ProjectList'
|
||||||
import { TagCloud } from '@/components/project/TagCloud'
|
import { TagFilterPanel } from '@/components/project/TagFilterPanel'
|
||||||
import { ProjectsPageClient } from './ProjectsPageClient'
|
import { ProjectsPageClient } from './ProjectsPageClient'
|
||||||
|
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
|
||||||
|
|
||||||
interface ProjectsPageProps {
|
interface ProjectsPageProps {
|
||||||
params: Promise<{ locale: string }>
|
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({
|
export default async function ProjectsPage({
|
||||||
@@ -21,14 +54,30 @@ export default async function ProjectsPage({
|
|||||||
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
|
const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams])
|
||||||
const { locale } = resolvedParams
|
const { locale } = resolvedParams
|
||||||
const search = resolvedSearchParams.search || ''
|
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 page = Number(resolvedSearchParams.page) || 1
|
||||||
|
|
||||||
const [projectsData, topTags, allTags] = await Promise.all([
|
const [projectsData, projectTypeOptions, tagCategoryGroups] = await Promise.all([
|
||||||
getProjects({ search, tag, page }),
|
getProjects({
|
||||||
getTopTags(10), // 获取前10个标签
|
search,
|
||||||
getAllTags(), // 获取所有标签(用于展开)
|
tags: selectedTags,
|
||||||
|
projectType: activeProjectType,
|
||||||
|
page,
|
||||||
|
}),
|
||||||
|
getFixedProjectTypeFilters(),
|
||||||
|
getTagCategoryGroups(),
|
||||||
])
|
])
|
||||||
|
const activeProjectTypeMeta = projectTypeOptions.find((type) => type.slug === activeProjectType)
|
||||||
|
|
||||||
// 准备翻译文本传递给客户端组件
|
// 准备翻译文本传递给客户端组件
|
||||||
const translations = {
|
const translations = {
|
||||||
@@ -45,6 +94,8 @@ export default async function ProjectsPage({
|
|||||||
<Suspense fallback={<div className="h-20"></div>}>
|
<Suspense fallback={<div className="h-20"></div>}>
|
||||||
<ProjectsPageClient
|
<ProjectsPageClient
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
projectType={activeProjectType}
|
||||||
searchPlaceholder={t('searchPlaceholder')}
|
searchPlaceholder={t('searchPlaceholder')}
|
||||||
searchLabel={tCommon('search')}
|
searchLabel={tCommon('search')}
|
||||||
aiPlaceholder={t('aiSearchPlaceholder')}
|
aiPlaceholder={t('aiSearchPlaceholder')}
|
||||||
@@ -54,16 +105,28 @@ export default async function ProjectsPage({
|
|||||||
searching={tCommon('searching')}
|
searching={tCommon('searching')}
|
||||||
translations={translations}
|
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">
|
<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">
|
<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>
|
</h3>
|
||||||
<TagCloud
|
<TagFilterPanel
|
||||||
tags={topTags} // 默认显示前10个
|
|
||||||
allTags={allTags} // 用于展开和搜索
|
|
||||||
locale={locale}
|
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>
|
</div>
|
||||||
</ProjectsPageClient>
|
</ProjectsPageClient>
|
||||||
@@ -73,7 +136,11 @@ export default async function ProjectsPage({
|
|||||||
{/* Projects Section */}
|
{/* Projects Section */}
|
||||||
<div className="flex justify-between items-end mb-8 border-b border-black dark:border-white/20 pb-4">
|
<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">
|
<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>
|
</h2>
|
||||||
<span className="font-display text-xs font-bold uppercase text-gray-500">
|
<span className="font-display text-xs font-bold uppercase text-gray-500">
|
||||||
{projectsData.pagination.total} projects
|
{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">
|
<div className="flex border-2 border-black dark:border-white/20 bg-white dark:bg-surface-dark shadow-neo dark:shadow-none">
|
||||||
{page > 1 && (
|
{page > 1 && (
|
||||||
<a
|
<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"
|
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 (
|
return (
|
||||||
<a
|
<a
|
||||||
key={pageNum}
|
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 ${
|
className={`px-4 py-2 font-display font-bold text-sm border-r border-black dark:border-white/20 ${
|
||||||
pageNum === page
|
pageNum === page
|
||||||
? 'bg-primary text-black'
|
? 'bg-primary text-black'
|
||||||
@@ -113,7 +190,12 @@ export default async function ProjectsPage({
|
|||||||
})}
|
})}
|
||||||
{page < projectsData.pagination.totalPages && (
|
{page < projectsData.pagination.totalPages && (
|
||||||
<a
|
<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"
|
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 type { ProjectInput } from '@/lib/validations'
|
||||||
import { generateSlug } from '@/lib/slug'
|
import { generateSlug } from '@/lib/slug'
|
||||||
import type { Prisma } from '@prisma/client'
|
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([
|
const PLURAL_NORMALIZATION_MAP = new Map([
|
||||||
['agents', 'agent'],
|
['agents', 'agent'],
|
||||||
@@ -141,16 +147,23 @@ function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
|
|||||||
|
|
||||||
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
||||||
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn)
|
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({
|
const fallbackTag = await prisma.tag.upsert({
|
||||||
where: { slug: fallbackSlug },
|
where: { slug: fallbackSlug },
|
||||||
update: {
|
update: {
|
||||||
name: TAG_FALLBACK.name,
|
name: TAG_FALLBACK.name,
|
||||||
nameEn: TAG_FALLBACK.nameEn,
|
nameEn: TAG_FALLBACK.nameEn,
|
||||||
|
category: fallbackCategory,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: TAG_FALLBACK.name,
|
name: TAG_FALLBACK.name,
|
||||||
nameEn: TAG_FALLBACK.nameEn,
|
nameEn: TAG_FALLBACK.nameEn,
|
||||||
slug: fallbackSlug,
|
slug: fallbackSlug,
|
||||||
|
category: fallbackCategory,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -162,6 +175,57 @@ async function getFallbackTag(): Promise<TagWithProjectCount> {
|
|||||||
return fallbackTag
|
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 (matchedTag) {
|
||||||
if (incomingTag.nameEn && !matchedTag.nameEn) {
|
const inferredCategory = inferTagCategory({
|
||||||
await prisma.tag.update({
|
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 },
|
where: { id: matchedTag.id },
|
||||||
data: { nameEn: incomingTag.nameEn },
|
data: {
|
||||||
|
...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}),
|
||||||
|
...(shouldUpdateCategory ? { category: inferredCategory } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { projects: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
matchedTag = {
|
matchedTag = updatedTag
|
||||||
...matchedTag,
|
|
||||||
nameEn: incomingTag.nameEn,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedTags.push(matchedTag)
|
resolvedTags.push(matchedTag)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inferredCategory = inferTagCategory({
|
||||||
|
slug: incomingTag.slug,
|
||||||
|
name: incomingTag.name,
|
||||||
|
nameEn: incomingTag.nameEn,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdTag = await prisma.tag.create({
|
const createdTag = await prisma.tag.create({
|
||||||
data: {
|
data: {
|
||||||
name: incomingTag.name,
|
name: incomingTag.name,
|
||||||
nameEn: incomingTag.nameEn,
|
nameEn: incomingTag.nameEn,
|
||||||
slug: incomingTag.slug,
|
slug: incomingTag.slug,
|
||||||
|
category: inferredCategory,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
ProjectInputSchema,
|
ProjectInputSchema,
|
||||||
type ProjectInput,
|
type ProjectInput,
|
||||||
} from '@/lib/validations'
|
} from '@/lib/validations'
|
||||||
import { findExistingProject, upsertTags } from '../../../lib/discovery-service'
|
import {
|
||||||
|
findExistingProject,
|
||||||
|
resolveFixedProjectTypeTag,
|
||||||
|
upsertTags,
|
||||||
|
} from '../../../lib/discovery-service'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/discovery/tasks/:id/complete
|
* POST /api/discovery/tasks/:id/complete
|
||||||
@@ -58,8 +62,16 @@ export async function POST(
|
|||||||
// 查找已存在的项目(复用webhook的多级去重逻辑)
|
// 查找已存在的项目(复用webhook的多级去重逻辑)
|
||||||
const existingProject = await findExistingProject(projectData)
|
const existingProject = await findExistingProject(projectData)
|
||||||
|
|
||||||
// Upsert tags(复用服务层函数)
|
// Upsert tags(复用服务层函数)+ 固定项目分类标签
|
||||||
const tagConnections = await upsertTags(projectData.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
|
// 生成slug
|
||||||
const slug = generateSlug(projectData.name, projectData.nameEn)
|
const slug = generateSlug(projectData.name, projectData.nameEn)
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
type WebhookPayload,
|
type WebhookPayload,
|
||||||
type ProjectInput,
|
type ProjectInput,
|
||||||
} from '@/lib/validations'
|
} 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'
|
import { generateSlug } from '@/lib/slug'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -91,7 +95,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const existingProject = await findExistingProject(validProject)
|
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
|
// Generate slug for project
|
||||||
const slug = generateSlug(validProject.name, validProject.nameEn)
|
const slug = generateSlug(validProject.name, validProject.nameEn)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MarkdownContent } from './MarkdownContent'
|
|||||||
import { ShareButtons } from './ShareButtons'
|
import { ShareButtons } from './ShareButtons'
|
||||||
import { GitHubBadges } from './GitHubBadges'
|
import { GitHubBadges } from './GitHubBadges'
|
||||||
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
||||||
|
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
|
||||||
|
|
||||||
interface ProjectDetailProps {
|
interface ProjectDetailProps {
|
||||||
project: {
|
project: {
|
||||||
@@ -59,9 +60,10 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
// Generate GitHub badge URLs
|
// Generate GitHub badge URLs
|
||||||
const badges = getGitHubBadgesFromLinks(project.links as any)
|
const badges = getGitHubBadgesFromLinks(project.links as any)
|
||||||
|
|
||||||
// Get category from first tag
|
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
|
||||||
const category = project.tags[0]?.name || 'AI Agent'
|
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
|
||||||
const categoryEn = project.tags[0]?.nameEn || 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
|
const displayCategory = locale === 'en' ? categoryEn : category
|
||||||
|
|
||||||
return (
|
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 { 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<{
|
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?: {
|
export async function getProjects(options?: {
|
||||||
search?: string
|
search?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
|
tags?: string[]
|
||||||
|
projectType?: string
|
||||||
status?: 'ACTIVE' | 'ARCHIVED'
|
status?: 'ACTIVE' | 'ARCHIVED'
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
@@ -39,6 +110,8 @@ export async function getProjects(options?: {
|
|||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
tag,
|
tag,
|
||||||
|
tags = [],
|
||||||
|
projectType,
|
||||||
status = 'ACTIVE',
|
status = 'ACTIVE',
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
@@ -58,35 +131,74 @@ export async function getProjects(options?: {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tag) {
|
const andFilters: Prisma.ProjectWhereInput[] = []
|
||||||
where.tags = {
|
const normalizedTagSlugs = Array.from(
|
||||||
some: {
|
new Set(
|
||||||
tag: {
|
[tag, ...tags]
|
||||||
slug: tag,
|
.map((value) => (value || '').trim())
|
||||||
},
|
.filter((value) => value.length > 0)
|
||||||
},
|
)
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const [projects, total] = await Promise.all([
|
for (const tagSlug of normalizedTagSlugs) {
|
||||||
prisma.project.findMany({
|
andFilters.push({
|
||||||
where,
|
tags: {
|
||||||
include: {
|
some: {
|
||||||
tags: {
|
tag: {
|
||||||
include: {
|
slug: tagSlug,
|
||||||
tag: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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
|
// Transform tags to flatten the structure
|
||||||
const transformedProjects = projects.map((project) => ({
|
const transformedProjects = projects.map((project) => ({
|
||||||
@@ -106,17 +218,19 @@ export async function getProjects(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await withDbRetry('getProjectBySlug', () =>
|
||||||
where: { slug },
|
prisma.project.findUnique({
|
||||||
include: {
|
where: { slug },
|
||||||
tags: {
|
include: {
|
||||||
include: {
|
tags: {
|
||||||
tag: true,
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
links: true,
|
||||||
},
|
},
|
||||||
links: true,
|
})
|
||||||
},
|
)
|
||||||
})
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return null
|
return null
|
||||||
@@ -130,46 +244,65 @@ export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTag
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
||||||
return prisma.tag.findMany({
|
return withDbRetry('getAllTags', () =>
|
||||||
include: {
|
prisma.tag.findMany({
|
||||||
_count: {
|
where: {
|
||||||
select: { projects: true },
|
category: {
|
||||||
|
not: 'FIXED_PROJECT_TYPE',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
include: {
|
||||||
orderBy: {
|
_count: {
|
||||||
name: 'asc',
|
select: { projects: true },
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
||||||
const tags = await prisma.tag.findMany({
|
const tags = await withDbRetry('getTagsWithProjectCounts', () =>
|
||||||
include: {
|
prisma.tag.findMany({
|
||||||
_count: {
|
where: {
|
||||||
select: { projects: true },
|
category: {
|
||||||
|
not: 'FIXED_PROJECT_TYPE',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
include: {
|
||||||
orderBy: {
|
_count: {
|
||||||
name: 'asc',
|
select: { projects: true },
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return tags.filter(tag => tag._count.projects > 0)
|
return tags.filter(tag => tag._count.projects > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
||||||
const tags = await prisma.tag.findMany({
|
const tags = await withDbRetry('getTopTags', () =>
|
||||||
include: {
|
prisma.tag.findMany({
|
||||||
_count: {
|
include: {
|
||||||
select: { projects: true }
|
_count: {
|
||||||
}
|
select: { projects: true },
|
||||||
},
|
},
|
||||||
where: {
|
},
|
||||||
projects: {
|
where: {
|
||||||
some: {} // 只返回有项目的标签
|
category: {
|
||||||
}
|
not: 'FIXED_PROJECT_TYPE',
|
||||||
}
|
},
|
||||||
})
|
projects: {
|
||||||
|
some: {}, // 只返回有项目的标签
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// 按项目数量降序、名称升序排序
|
// 按项目数量降序、名称升序排序
|
||||||
return tags
|
return tags
|
||||||
@@ -182,6 +315,112 @@ export async function getTopTags(limit: number = 10): Promise<TagWithProjectCoun
|
|||||||
.slice(0, limit)
|
.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 搜索结果类型
|
// 定义 AI 搜索结果类型
|
||||||
export type AISearchResultItem = ProjectWithFlatTags & {
|
export type AISearchResultItem = ProjectWithFlatTags & {
|
||||||
similarity: number
|
similarity: number
|
||||||
@@ -203,21 +442,23 @@ export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTa
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await withDbRetry('getProjectsByIds', () =>
|
||||||
where: {
|
prisma.project.findMany({
|
||||||
id: {
|
where: {
|
||||||
in: ids,
|
id: {
|
||||||
},
|
in: ids,
|
||||||
},
|
|
||||||
include: {
|
|
||||||
tags: {
|
|
||||||
include: {
|
|
||||||
tag: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
links: true,
|
include: {
|
||||||
},
|
tags: {
|
||||||
})
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
links: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
return projects.map((project) => ({
|
return projects.map((project) => ({
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { useSearchParams } from 'next/navigation'
|
|||||||
export function useSearch() {
|
export function useSearch() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const tags = (searchParams.get('tags') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter((tag) => tag.length > 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
search: searchParams.get('search') || '',
|
search: searchParams.get('search') || '',
|
||||||
tag: searchParams.get('tag') || '',
|
tag: searchParams.get('tag') || '',
|
||||||
|
tags,
|
||||||
|
projectType: searchParams.get('projectType') || '',
|
||||||
page: Number(searchParams.get('page')) || 1,
|
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",
|
"onThisPage": "On this page",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"keyFeatures": "Key Features",
|
"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": {
|
"navigation": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
|||||||
+11
-1
@@ -69,7 +69,17 @@
|
|||||||
"onThisPage": "本页内容",
|
"onThisPage": "本页内容",
|
||||||
"overview": "概述",
|
"overview": "概述",
|
||||||
"keyFeatures": "核心功能",
|
"keyFeatures": "核心功能",
|
||||||
"useCases": "使用场景"
|
"useCases": "使用场景",
|
||||||
|
"browseByFilters": "按分类筛选",
|
||||||
|
"fixedProjectType": "固定项目分类",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"filterByTagCategory": "按标签分类筛选",
|
||||||
|
"searchTagsPlaceholder": "搜索标签...",
|
||||||
|
"clearTagFilters": "清空标签筛选",
|
||||||
|
"showMoreTags": "查看更多标签",
|
||||||
|
"showLessTags": "收起标签",
|
||||||
|
"noMatchingTags": "未找到匹配标签",
|
||||||
|
"allProjects": "全部项目"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
|
|||||||
Reference in New Issue
Block a user