feat: 升级项目列表筛选结构并优化首屏体验

This commit is contained in:
2026-02-22 11:18:55 +08:00
parent fb33b76ff5
commit 2e2edac6af
12 changed files with 1623 additions and 242 deletions
+105 -120
View File
@@ -1,21 +1,19 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { AISearchBar } from '@/components/search/AISearchBar'
import { AISearchResults } from '@/components/search/AISearchResults'
import type { ProjectWithFlatTags } from '@/hooks/useProjects'
interface AISearchResult {
project: ProjectWithFlatTags
similarity: number
matchReason?: string
}
import { SlidersHorizontal } from 'lucide-react'
interface ProjectsPageClientProps {
locale: string
isAI: boolean
selectedTags: string[]
selectedDomains: string[]
selectedProductForms: string[]
projectType?: string
sort: 'latest' | 'stars_desc' | 'stars_asc'
limit: 10 | 20 | 50
children: React.ReactNode
searchPlaceholder: string
searchLabel: string
@@ -24,18 +22,19 @@ interface ProjectsPageClientProps {
toggleToAI: string
toggleToTraditional: string
searching: string
translations: {
viewDetails: string
submitProjectTitle: string
submitProjectDescription: string
submitNow: string
}
showFilterPanel: string
hideFilterPanel: string
}
export function ProjectsPageClient({
locale,
isAI,
selectedTags,
selectedDomains,
selectedProductForms,
projectType,
sort,
limit,
children,
searchPlaceholder,
searchLabel,
@@ -44,124 +43,110 @@ export function ProjectsPageClient({
toggleToAI,
toggleToTraditional,
searching,
translations,
showFilterPanel,
hideFilterPanel,
}: ProjectsPageClientProps) {
const searchParams = useSearchParams()
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
const [isAIResult, setIsAIResult] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialAIHandled, setInitialAIHandled] = useState(false)
const router = useRouter()
const activeFilterCount = useMemo(
() =>
(projectType ? 1 : 0) +
selectedDomains.length +
selectedProductForms.length +
selectedTags.length,
[projectType, selectedDomains.length, selectedProductForms.length, selectedTags.length]
)
const [isFilterPanelExpanded, setIsFilterPanelExpanded] = useState(activeFilterCount > 0)
const filterPanelId = 'projects-filter-panel'
const toggleFilterLabel = isFilterPanelExpanded ? hideFilterPanel : showFilterPanel
const performAISearch = useCallback(async (query: string) => {
setLoading(true)
setError(null)
setIsAIResult(true)
try {
const response = await fetch('/api/search/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search: query,
locale: locale,
tags: selectedTags,
limit: 20
})
})
if (!response.ok) {
throw new Error('搜索失败,请稍后重试')
}
const data = await response.json()
// 转换 API 返回格式为组件期望格式
// API 返回: { projects: [{ ...project, similarity }], pagination }
// 组件期望: [{ project, similarity }]
const results = (data.projects || []).map((p: any) => ({
project: {
id: p.id,
name: p.name,
nameEn: p.nameEn,
slug: p.slug,
description: p.description,
descriptionEn: p.descriptionEn,
content: p.content,
contentEn: p.contentEn,
status: p.status,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
tags: p.tags || [],
links: p.links || [],
},
similarity: p.similarity,
}))
setAiResults(results)
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误')
setAiResults([])
} 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)
if (activeFilterCount > 0) {
setIsFilterPanelExpanded(true)
}
}, [searchParams, initialAIHandled, performAISearch])
}, [activeFilterCount])
const handleAISearch = async (query: string, isAI: boolean) => {
if (!isAI) {
// 传统搜索:刷新页面到 URL 参数
const handleAISearch = useCallback(
(query: string, useAI: boolean) => {
const params = new URLSearchParams()
if (query) params.set('search', query)
const normalizedQuery = query.trim()
if (normalizedQuery) params.set('search', normalizedQuery)
if (projectType) params.set('projectType', projectType)
if (selectedDomains.length > 0) params.set('domains', selectedDomains.join(','))
if (selectedProductForms.length > 0) {
params.set('productForms', selectedProductForms.join(','))
}
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','))
window.location.href = `/${locale}/projects?${params.toString()}`
return
}
if (sort !== 'latest') params.set('sort', sort)
if (limit !== 20) params.set('limit', String(limit))
if (useAI) params.set('ai', '1')
router.push(`/${locale}/projects?${params.toString()}`)
},
[limit, locale, projectType, router, selectedDomains, selectedProductForms, selectedTags, sort]
)
// AI 搜索
await performAISearch(query)
const searchBar = (
<AISearchBar
locale={locale}
searchPlaceholder={searchPlaceholder}
searchLabel={searchLabel}
aiPlaceholder={aiPlaceholder}
aiLabel={aiLabel}
toggleToAI={toggleToAI}
toggleToTraditional={toggleToTraditional}
searching={searching}
onSearch={handleAISearch}
initialAiMode={isAI}
className="w-full max-w-none mx-0"
/>
)
if (isAI) {
return searchBar
}
return (
<>
{/* AI 搜索栏 */}
<AISearchBar
locale={locale}
searchPlaceholder={searchPlaceholder}
searchLabel={searchLabel}
aiPlaceholder={aiPlaceholder}
aiLabel={aiLabel}
toggleToAI={toggleToAI}
toggleToTraditional={toggleToTraditional}
searching={searching}
onSearch={handleAISearch}
loading={loading}
/>
{/* Tag Cloud / AI 搜索结果 */}
{isAIResult ? (
<>
{error && (
<div className="mb-8 px-4 py-3 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 rounded-r">
<p className="text-red-700 dark:text-red-400 font-display text-sm">{error}</p>
<div className="flex flex-col lg:flex-row gap-3 lg:items-stretch max-w-5xl mx-auto">
<div className="min-w-0 flex-1">{searchBar}</div>
<button
type="button"
onClick={() => setIsFilterPanelExpanded((value) => !value)}
className="w-full lg:w-auto lg:min-w-[52px] xl:min-w-[156px] border-2 border-black dark:border-gray-500 bg-white dark:bg-surface-dark px-3 py-3 text-left shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
aria-expanded={isFilterPanelExpanded}
aria-controls={filterPanelId}
aria-label={toggleFilterLabel}
title={toggleFilterLabel}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-gray-700 dark:text-gray-200" aria-hidden="true" />
<span className="font-display font-bold uppercase text-xs lg:hidden xl:inline">
{toggleFilterLabel}
</span>
</div>
)}
<AISearchResults results={aiResults} locale={locale} translations={translations} />
</>
) : (
children
)}
<div className="flex items-center gap-2">
{activeFilterCount > 0 ? (
<span className="text-[10px] font-display font-bold uppercase px-2 py-0.5 bg-black text-white">
{activeFilterCount}
</span>
) : null}
<svg
className={`w-4 h-4 text-gray-600 dark:text-gray-300 transition-transform ${
isFilterPanelExpanded ? '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>
</div>
</button>
</div>
<div id={filterPanelId}>{isFilterPanelExpanded ? children : null}</div>
</>
)
}
@@ -0,0 +1,780 @@
'use client'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ProjectSortOption, ProjectWithFlatTags } from '@/hooks/useProjects'
import { ProjectCard } from '@/components/project/ProjectCard'
import { AISearchResults } from '@/components/search/AISearchResults'
const DEFAULT_PAGE_SIZE = 20
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
type ProjectListItem = ProjectWithFlatTags
type AISearchResult = {
project: ProjectListItem
similarity: number
}
type AISearchPagination = {
total: number
page: number
limit: number
totalPages: number
hasMore: boolean
}
type ProjectsPagination = {
total: number
page: number
limit: number
totalPages: number
}
type RawAIProject = ProjectWithFlatTags & {
similarity?: number
}
type PageToken = number | 'ellipsis'
interface ProjectsResultsClientProps {
locale: string
search: string
isAI: boolean
selectedTags: string[]
selectedDomains: string[]
selectedProductForms: string[]
projectType: string
sort: ProjectSortOption
page: number
activeProjectTypeLabel: string
projects: ProjectListItem[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
translations: {
allProjects: string
sortLatest: string
sortStarsDesc: string
sortStarsAsc: string
itemsPerPage: string
paginationFirst: string
paginationPrev: string
paginationNext: string
paginationLast: string
viewDetails: string
noProjects: string
noResults: string
searching: string
}
}
function normalizePageLimit(limit: number | null | undefined): (typeof PAGE_SIZE_OPTIONS)[number] {
if (PAGE_SIZE_OPTIONS.includes(limit as (typeof PAGE_SIZE_OPTIONS)[number])) {
return limit as (typeof PAGE_SIZE_OPTIONS)[number]
}
return DEFAULT_PAGE_SIZE
}
function buildProjectsQueryString(options: {
search: string
projectType: string
selectedDomains: string[]
selectedProductForms: string[]
selectedTags: string[]
sort: ProjectSortOption
page: number
limit: number
isAI: boolean
}): string {
const query = new URLSearchParams()
if (options.search) query.set('search', options.search)
if (options.projectType) query.set('projectType', options.projectType)
if (options.selectedDomains.length > 0) {
query.set('domains', options.selectedDomains.join(','))
}
if (options.selectedProductForms.length > 0) {
query.set('productForms', options.selectedProductForms.join(','))
}
if (options.selectedTags.length > 0) query.set('tags', options.selectedTags.join(','))
if (options.sort !== 'latest') query.set('sort', options.sort)
if (options.limit !== DEFAULT_PAGE_SIZE) query.set('limit', String(options.limit))
if (options.isAI) query.set('ai', '1')
query.set('page', String(options.page))
return query.toString()
}
function buildPageTokens(currentPage: number, totalPages: number): PageToken[] {
if (totalPages <= 0) return []
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1)
}
let start = Math.max(2, currentPage - 1)
let end = Math.min(totalPages - 1, currentPage + 1)
if (currentPage <= 3) {
start = 2
end = 4
} else if (currentPage >= totalPages - 2) {
start = totalPages - 3
end = totalPages - 1
}
const tokens: PageToken[] = [1]
if (start > 2) {
tokens.push('ellipsis')
}
for (let pageNum = start; pageNum <= end; pageNum += 1) {
tokens.push(pageNum)
}
if (end < totalPages - 1) {
tokens.push('ellipsis')
}
tokens.push(totalPages)
return tokens
}
export function ProjectsResultsClient({
locale,
search,
isAI,
selectedTags,
selectedDomains,
selectedProductForms,
projectType,
sort,
page,
activeProjectTypeLabel,
projects,
pagination,
translations,
}: ProjectsResultsClientProps) {
const pathname = usePathname()
const selectedTagsKey = selectedTags.join(',')
const selectedDomainsKey = selectedDomains.join(',')
const selectedProductFormsKey = selectedProductForms.join(',')
const initialPage = Math.max(1, page || 1)
const initialLimit = normalizePageLimit(pagination.limit)
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
const [loadingAI, setLoadingAI] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const [aiCurrentPage, setAiCurrentPage] = useState(initialPage)
const [aiSort, setAiSort] = useState<ProjectSortOption>(sort)
const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit)
const [aiPagination, setAiPagination] = useState<AISearchPagination>({
total: 0,
page: initialPage,
limit: initialLimit,
totalPages: 0,
hasMore: false,
})
const [traditionalResults, setTraditionalResults] = useState<ProjectListItem[]>(projects)
const [traditionalPagination, setTraditionalPagination] = useState<ProjectsPagination>({
total: pagination.total,
page: Math.max(1, pagination.page || initialPage),
limit: initialLimit,
totalPages: pagination.totalPages,
})
const [traditionalCurrentPage, setTraditionalCurrentPage] = useState(initialPage)
const [traditionalSort, setTraditionalSort] = useState<ProjectSortOption>(sort)
const [traditionalLimit, setTraditionalLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit)
const [loadingTraditional, setLoadingTraditional] = useState(false)
const [traditionalError, setTraditionalError] = useState<string | null>(null)
const replaceProjectsUrl = useCallback(
(
nextPage: number,
nextSort: ProjectSortOption,
nextIsAI: boolean,
nextLimit: (typeof PAGE_SIZE_OPTIONS)[number]
) => {
const query = buildProjectsQueryString({
search,
projectType,
selectedDomains,
selectedProductForms,
selectedTags,
sort: nextSort,
page: nextPage,
limit: nextLimit,
isAI: nextIsAI,
})
window.history.replaceState(window.history.state, '', `${pathname}?${query}`)
},
[pathname, projectType, search, selectedDomains, selectedProductForms, selectedTags]
)
useEffect(() => {
if (!isAI) return
const nextPage = Math.max(1, page || 1)
const nextLimit = normalizePageLimit(pagination.limit)
setAiCurrentPage(nextPage)
setAiSort(sort)
setAiLimit(nextLimit)
setAiPagination((prev) => ({
...prev,
page: nextPage,
limit: nextLimit,
totalPages: Math.max(prev.totalPages, nextPage),
}))
}, [
isAI,
page,
pagination.limit,
sort,
search,
projectType,
selectedDomainsKey,
selectedProductFormsKey,
selectedTagsKey,
])
useEffect(() => {
if (isAI) return
const nextPage = Math.max(1, page || 1)
const nextLimit = normalizePageLimit(pagination.limit)
setTraditionalResults(projects)
setTraditionalPagination({
total: pagination.total,
page: Math.max(1, pagination.page || nextPage),
limit: nextLimit,
totalPages: pagination.totalPages,
})
setTraditionalCurrentPage(nextPage)
setTraditionalSort(sort)
setTraditionalLimit(nextLimit)
setTraditionalError(null)
setLoadingTraditional(false)
}, [
isAI,
page,
pagination,
projects,
search,
sort,
projectType,
selectedDomainsKey,
selectedProductFormsKey,
selectedTagsKey,
])
const handleAiPageChange = useCallback(
(nextPage: number) => {
const safePage = Math.max(1, nextPage)
setAiCurrentPage(safePage)
replaceProjectsUrl(safePage, aiSort, true, aiLimit)
},
[aiLimit, aiSort, replaceProjectsUrl]
)
const handleAiSortChange = useCallback(
(nextSort: ProjectSortOption) => {
setAiSort(nextSort)
setAiCurrentPage(1)
replaceProjectsUrl(1, nextSort, true, aiLimit)
},
[aiLimit, replaceProjectsUrl]
)
const handleAiLimitChange = useCallback(
(nextLimit: number) => {
const safeLimit = normalizePageLimit(nextLimit)
setAiLimit(safeLimit)
setAiCurrentPage(1)
replaceProjectsUrl(1, aiSort, true, safeLimit)
},
[aiSort, replaceProjectsUrl]
)
const fetchTraditionalResults = useCallback(
async (
nextPage: number,
nextSort: ProjectSortOption,
nextLimit: (typeof PAGE_SIZE_OPTIONS)[number]
) => {
setLoadingTraditional(true)
setTraditionalError(null)
try {
const query = new URLSearchParams({
page: String(nextPage),
limit: String(nextLimit),
sort: nextSort,
})
const normalizedSearch = search.trim()
if (normalizedSearch) query.set('search', normalizedSearch)
if (projectType) query.set('projectType', projectType)
if (selectedDomains.length > 0) query.set('domains', selectedDomains.join(','))
if (selectedProductForms.length > 0) {
query.set('productForms', selectedProductForms.join(','))
}
if (selectedTags.length > 0) query.set('tags', selectedTags.join(','))
const response = await fetch(`/api/projects?${query.toString()}`, {
method: 'GET',
})
if (!response.ok) {
throw new Error(response.statusText || 'Projects fetch failed')
}
const data = await response.json()
const incomingProjects = Array.isArray(data?.projects)
? (data.projects as ProjectListItem[])
: []
const incomingPagination = data?.pagination as Partial<ProjectsPagination> | undefined
const normalizedTotal =
typeof incomingPagination?.total === 'number'
? incomingPagination.total
: incomingProjects.length
const normalizedTotalPages = Math.max(
0,
typeof incomingPagination?.totalPages === 'number'
? incomingPagination.totalPages
: normalizedTotal === 0
? 0
: Math.ceil(normalizedTotal / nextLimit)
)
const safePage = normalizedTotalPages === 0 ? 1 : Math.min(nextPage, normalizedTotalPages)
setTraditionalResults(incomingProjects)
setTraditionalPagination({
total: normalizedTotal,
page: safePage,
limit:
typeof incomingPagination?.limit === 'number'
? normalizePageLimit(incomingPagination.limit)
: nextLimit,
totalPages: normalizedTotalPages,
})
setTraditionalCurrentPage(safePage)
setTraditionalSort(nextSort)
setTraditionalLimit(nextLimit)
replaceProjectsUrl(safePage, nextSort, false, nextLimit)
} catch (error) {
setTraditionalError(error instanceof Error ? error.message : 'Projects fetch failed')
} finally {
setLoadingTraditional(false)
}
},
[projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
)
const handleTraditionalPageChange = useCallback(
(nextPage: number) => {
const safePage = Math.max(1, nextPage)
void fetchTraditionalResults(safePage, traditionalSort, traditionalLimit)
},
[fetchTraditionalResults, traditionalLimit, traditionalSort]
)
const handleTraditionalSortChange = useCallback(
(nextSort: ProjectSortOption) => {
void fetchTraditionalResults(1, nextSort, traditionalLimit)
},
[fetchTraditionalResults, traditionalLimit]
)
const handleTraditionalLimitChange = useCallback(
(nextLimit: number) => {
const safeLimit = normalizePageLimit(nextLimit)
void fetchTraditionalResults(1, traditionalSort, safeLimit)
},
[fetchTraditionalResults, traditionalSort]
)
useEffect(() => {
let cancelled = false
async function fetchAIResults() {
if (!isAI || !search.trim()) {
setAiResults([])
setAiError(null)
setLoadingAI(false)
return
}
setLoadingAI(true)
setAiError(null)
try {
const response = await fetch('/api/search/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search: search.trim(),
locale,
domains: selectedDomains,
productForms: selectedProductForms,
tags: selectedTags,
page: aiCurrentPage,
limit: aiLimit,
sort: aiSort,
}),
})
if (!response.ok) {
throw new Error(response.statusText || 'AI search failed')
}
const data = await response.json()
const normalizedResults: AISearchResult[] = Array.isArray(data.projects)
? (data.projects as RawAIProject[]).map((item) => ({
project: item,
similarity: Number(item.similarity) || 0,
}))
: []
const incomingPagination = data?.pagination as Partial<AISearchPagination> | undefined
const normalizedTotal =
typeof incomingPagination?.total === 'number' ? incomingPagination.total : normalizedResults.length
const normalizedTotalPages = Math.max(
0,
typeof incomingPagination?.totalPages === 'number'
? incomingPagination.totalPages
: normalizedTotal === 0
? 0
: Math.ceil(normalizedTotal / aiLimit)
)
const safePage = normalizedTotalPages === 0 ? 1 : Math.min(aiCurrentPage, normalizedTotalPages)
if (!cancelled) {
setAiResults(normalizedResults)
setAiPagination({
total: normalizedTotal,
page: safePage,
limit:
typeof incomingPagination?.limit === 'number'
? normalizePageLimit(incomingPagination.limit)
: aiLimit,
totalPages: normalizedTotalPages,
hasMore:
Boolean(incomingPagination?.hasMore) ||
(normalizedTotalPages > 0 && safePage < normalizedTotalPages),
})
if (safePage !== aiCurrentPage) {
setAiCurrentPage(safePage)
replaceProjectsUrl(safePage, aiSort, true, aiLimit)
}
}
} catch (error) {
if (!cancelled) {
setAiError(error instanceof Error ? error.message : 'AI search failed')
setAiResults([])
setAiPagination((prev) => ({
...prev,
total: 0,
totalPages: 0,
hasMore: false,
}))
}
} finally {
if (!cancelled) {
setLoadingAI(false)
}
}
}
void fetchAIResults()
return () => {
cancelled = true
}
}, [
aiCurrentPage,
aiLimit,
aiSort,
isAI,
locale,
replaceProjectsUrl,
search,
selectedDomains,
selectedProductForms,
selectedTags,
selectedDomainsKey,
selectedProductFormsKey,
selectedTagsKey,
])
const loading = isAI ? loadingAI : loadingTraditional
const activeSort = isAI ? aiSort : traditionalSort
const activePage = isAI ? aiCurrentPage : traditionalCurrentPage
const activeLimit = isAI ? aiLimit : traditionalLimit
const isStarSort = activeSort === 'stars_desc' || activeSort === 'stars_asc'
const currentStarSort: ProjectSortOption = activeSort === 'stars_asc' ? 'stars_asc' : 'stars_desc'
const nextStarSort: ProjectSortOption = currentStarSort === 'stars_desc' ? 'stars_asc' : 'stars_desc'
const starSortTarget: ProjectSortOption = activeSort === 'latest' ? 'stars_desc' : nextStarSort
const totalCount = isAI ? aiPagination.total : traditionalPagination.total
const visibleProjectsLabel = loading ? '...' : String(totalCount)
const projectsCountText =
locale === 'en' ? `${visibleProjectsLabel} projects` : `${visibleProjectsLabel} 个项目`
const currentPage = isAI ? aiPagination.page || activePage : traditionalPagination.page || activePage
const activeTotalPages = isAI ? aiPagination.totalPages : traditionalPagination.totalPages
const canGoPrev = currentPage > 1
const canGoNext = currentPage < activeTotalPages
const displayProjects = isAI ? [] : traditionalResults
const activeError = isAI ? aiError : traditionalError
const totalPagesForSummary = totalCount === 0 ? 0 : Math.max(1, activeTotalPages)
const currentPageForSummary = totalPagesForSummary === 0 ? 0 : Math.min(currentPage, totalPagesForSummary)
const paginationSummary =
totalPagesForSummary === 0
? ''
: locale === 'en'
? `Page ${currentPageForSummary} / ${totalPagesForSummary} · ${totalCount} total`
: `${currentPageForSummary} / ${totalPagesForSummary} 页 · 共 ${totalCount}`
const pageTokens = useMemo(
() => buildPageTokens(currentPage, activeTotalPages),
[currentPage, activeTotalPages]
)
return (
<>
<div className="mb-6 border-b border-black dark:border-white/20 pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<h2 className="font-display font-bold text-3xl uppercase tracking-tight">
{activeProjectTypeLabel || translations.allProjects}
</h2>
<div className="flex flex-col items-start gap-2 md:items-end">
<span className="font-display text-xs font-bold uppercase text-gray-500">
{projectsCountText}
</span>
<div className="inline-flex items-center gap-3">
<button
type="button"
onClick={() =>
isAI ? handleAiSortChange('latest') : handleTraditionalSortChange('latest')
}
disabled={loading}
className={`border-2 border-black dark:border-gray-500 px-3 py-1.5 font-display text-[11px] font-bold uppercase transition-colors disabled:opacity-50 ${
activeSort === 'latest'
? 'bg-black text-white dark:bg-primary dark:text-black'
: 'bg-white dark:bg-surface-dark text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10'
}`}
>
{translations.sortLatest}
</button>
<button
type="button"
onClick={() =>
isAI
? handleAiSortChange(starSortTarget)
: handleTraditionalSortChange(starSortTarget)
}
disabled={loading}
className={`border-2 border-black dark:border-gray-500 px-3 py-1.5 font-display text-[11px] font-bold uppercase transition-colors disabled:opacity-50 ${
isStarSort
? 'bg-black text-white dark:bg-primary dark:text-black'
: 'bg-white dark:bg-surface-dark text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10'
}`}
title={locale === 'en' ? 'Toggle star sort direction' : '切换 Star 排序方向'}
>
{currentStarSort === 'stars_desc' ? translations.sortStarsDesc : translations.sortStarsAsc}
</button>
</div>
</div>
</div>
</div>
{activeError ? (
<div className="mb-8 px-4 py-3 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 rounded-r">
<p className="text-red-700 dark:text-red-400 font-display text-sm">{activeError}</p>
</div>
) : null}
{isAI ? (
loadingAI ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.searching}</p>
</div>
) : (
<AISearchResults
results={aiResults}
locale={locale}
emptyText={translations.noResults}
translations={{ viewDetails: translations.viewDetails }}
/>
)
) : displayProjects.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.noProjects}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{displayProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
locale={locale}
translations={{ viewDetails: translations.viewDetails }}
/>
))}
</div>
)}
{totalCount > 0 && (
<div className="mt-16 border-2 border-black dark:border-white/20 bg-white dark:bg-surface-dark p-3 shadow-neo dark:shadow-none">
<div className="flex flex-col gap-4 md:grid md:grid-cols-[1fr_auto_1fr] md:items-center">
<div className="flex justify-center md:justify-start">
<div className="inline-flex items-center gap-2">
<span className="font-display text-xs font-bold uppercase text-gray-600 dark:text-gray-300">
{translations.itemsPerPage}
</span>
<div className="inline-flex overflow-hidden border-2 border-black dark:border-white/20">
{PAGE_SIZE_OPTIONS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === activeLimit}
onClick={() => {
if (loading || option === activeLimit) return
if (isAI) {
handleAiLimitChange(option)
return
}
handleTraditionalLimitChange(option)
}}
className={`min-w-10 px-3 py-2 font-display text-xs font-bold transition-colors ${
option === activeLimit
? 'bg-primary text-black'
: 'bg-white text-black hover:bg-gray-100 dark:bg-surface-dark dark:text-gray-100 dark:hover:bg-white/10'
}`}
>
{option}
</button>
))}
</div>
</div>
</div>
{activeTotalPages > 1 ? (
<div className="flex flex-wrap items-center justify-center gap-2">
{canGoPrev ? (
<>
<button
type="button"
onClick={() =>
isAI ? handleAiPageChange(1) : handleTraditionalPageChange(1)
}
title={translations.paginationFirst}
className="min-w-10 px-3 py-2 font-display text-sm font-bold border border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10"
>
«
</button>
<button
type="button"
onClick={() =>
isAI
? handleAiPageChange(currentPage - 1)
: handleTraditionalPageChange(currentPage - 1)
}
title={translations.paginationPrev}
className="min-w-10 px-3 py-2 font-display text-sm font-bold border border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10"
>
</button>
</>
) : null}
{pageTokens.map((token, index) => {
if (token === 'ellipsis') {
return (
<span
key={`ellipsis-${index}`}
className="min-w-10 px-2 py-2 text-center font-display text-sm font-bold"
>
...
</span>
)
}
return (
<button
key={token}
type="button"
onClick={() => {
if (loading || token === currentPage) return
if (isAI) {
handleAiPageChange(token)
return
}
handleTraditionalPageChange(token)
}}
className={`min-w-10 px-3 py-2 font-display text-sm font-bold border border-black dark:border-white/20 ${
token === currentPage
? 'bg-primary text-black'
: 'hover:bg-gray-100 dark:hover:bg-white/10'
}`}
>
{token}
</button>
)
})}
{canGoNext ? (
<>
<button
type="button"
onClick={() =>
isAI
? handleAiPageChange(currentPage + 1)
: handleTraditionalPageChange(currentPage + 1)
}
title={translations.paginationNext}
className="min-w-10 px-3 py-2 font-display text-sm font-bold border border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10"
>
</button>
<button
type="button"
onClick={() =>
isAI
? handleAiPageChange(activeTotalPages)
: handleTraditionalPageChange(activeTotalPages)
}
title={translations.paginationLast}
className="min-w-10 px-3 py-2 font-display text-sm font-bold border border-black dark:border-white/20 hover:bg-gray-100 dark:hover:bg-white/10"
>
»
</button>
</>
) : null}
</div>
) : (
<div className="hidden md:block" />
)}
<div className="flex justify-center md:justify-end">
{paginationSummary ? (
<p className="font-display text-xs font-bold uppercase text-gray-600 dark:text-gray-300">
{paginationSummary}
</p>
) : null}
</div>
</div>
</div>
)}
</>
)
}
+96 -100
View File
@@ -4,10 +4,11 @@ import {
getFixedProjectTypeFilters,
getProjects,
getTagCategoryGroups,
normalizeProjectSort,
} from '@/hooks/useProjects'
import { ProjectList } from '@/components/project/ProjectList'
import { TagFilterPanel } from '@/components/project/TagFilterPanel'
import { ProjectsPageClient } from './ProjectsPageClient'
import { ProjectsResultsClient } from './ProjectsResultsClient'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectsPageProps {
@@ -16,31 +17,26 @@ interface ProjectsPageProps {
search?: string
tag?: string
tags?: string
domain?: string
domains?: string
productForm?: string
productForms?: string
projectType?: string
sort?: string
page?: string
limit?: string
ai?: string
}>
}
function buildProjectsQueryString(options: {
search: string
projectType: string
selectedTags: string[]
page: number
}): string {
const query = new URLSearchParams()
const PAGE_LIMIT_OPTIONS = [10, 20, 50] as const
if (options.search) {
query.set('search', options.search)
function normalizePageLimit(value?: string): (typeof PAGE_LIMIT_OPTIONS)[number] {
const parsedValue = Number(value)
if (PAGE_LIMIT_OPTIONS.includes(parsedValue as (typeof PAGE_LIMIT_OPTIONS)[number])) {
return parsedValue as (typeof PAGE_LIMIT_OPTIONS)[number]
}
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()
return 20
}
export default async function ProjectsPage({
@@ -61,31 +57,56 @@ export default async function ProjectsPage({
.filter((item) => item.length > 0)
)
)
const selectedDomains = Array.from(
new Set(
[resolvedSearchParams.domain || '', ...(resolvedSearchParams.domains || '').split(',')]
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
)
const selectedProductForms = Array.from(
new Set(
[resolvedSearchParams.productForm || '', ...(resolvedSearchParams.productForms || '').split(',')]
.map((item) => item.trim())
.filter((item) => item.length > 0)
)
)
const projectTypeParam = resolvedSearchParams.projectType || ''
const activeProjectType = isFixedProjectTypeSlug(projectTypeParam)
? projectTypeParam
: ''
const isAIMode = resolvedSearchParams.ai === '1'
const activeSort = normalizeProjectSort(resolvedSearchParams.sort)
const page = Number(resolvedSearchParams.page) || 1
const limit = normalizePageLimit(resolvedSearchParams.limit)
const [projectsData, projectTypeOptions, tagCategoryGroups] = await Promise.all([
getProjects({
search,
tags: selectedTags,
domains: selectedDomains,
productForms: selectedProductForms,
projectType: activeProjectType,
sort: activeSort,
page,
limit,
}),
getFixedProjectTypeFilters(),
getTagCategoryGroups(),
])
const domainCategoryGroup =
tagCategoryGroups.find((group) => group.category === 'DOMAIN_SCENARIO') || null
const productFormCategoryGroup =
tagCategoryGroups.find((group) => group.category === 'PRODUCT_FORM') || null
const normalTagCategoryGroups = tagCategoryGroups.filter(
(group) => group.category !== 'DOMAIN_SCENARIO' && group.category !== 'PRODUCT_FORM'
)
const activeProjectTypeMeta = projectTypeOptions.find((type) => type.slug === activeProjectType)
// 准备翻译文本传递给客户端组件
const translations = {
viewDetails: tCommon('viewDetails'),
submitProjectTitle: tProject('submitProjectTitle'),
submitProjectDescription: tProject('submitProjectDescription'),
submitNow: tProject('submitNow'),
}
const activeProjectTypeLabel = activeProjectType
? locale === 'en'
? activeProjectTypeMeta?.nameEn || activeProjectType
: activeProjectTypeMeta?.name || activeProjectType
: tProject('allProjects')
return (
<main className="container mx-auto px-4 py-12 md:py-16 max-w-7xl">
@@ -94,8 +115,13 @@ export default async function ProjectsPage({
<Suspense fallback={<div className="h-20"></div>}>
<ProjectsPageClient
locale={locale}
isAI={isAIMode}
selectedTags={selectedTags}
selectedDomains={selectedDomains}
selectedProductForms={selectedProductForms}
projectType={activeProjectType}
sort={activeSort}
limit={limit}
searchPlaceholder={t('searchPlaceholder')}
searchLabel={tCommon('search')}
aiPlaceholder={t('aiSearchPlaceholder')}
@@ -103,7 +129,8 @@ export default async function ProjectsPage({
toggleToAI={tCommon('toggleToAI')}
toggleToTraditional={tCommon('toggleToTraditional')}
searching={tCommon('searching')}
translations={translations}
showFilterPanel={tProject('showFilterPanel')}
hideFilterPanel={tProject('hideFilterPanel')}
>
{/* 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">
@@ -115,11 +142,22 @@ export default async function ProjectsPage({
search={search}
activeProjectType={activeProjectType}
selectedTags={selectedTags}
selectedDomains={selectedDomains}
selectedProductForms={selectedProductForms}
sort={activeSort}
projectTypeOptions={projectTypeOptions}
tagCategoryGroups={tagCategoryGroups}
domainTags={domainCategoryGroup?.tags || []}
productFormTags={productFormCategoryGroup?.tags || []}
tagCategoryGroups={normalTagCategoryGroups}
translations={{
fixedProjectType: tProject('fixedProjectType'),
allTypes: tProject('allTypes'),
filterByDomain: tProject('filterByDomain'),
allDomains: tProject('allDomains'),
clearDomainFilters: tProject('clearDomainFilters'),
filterByProductForm: tProject('filterByProductForm'),
allProductForms: tProject('allProductForms'),
clearProductFormFilters: tProject('clearProductFormFilters'),
filterByTagCategory: tProject('filterByTagCategory'),
searchTagsPlaceholder: tProject('searchTagsPlaceholder'),
clearTagFilters: tProject('clearTagFilters'),
@@ -133,77 +171,35 @@ export default async function ProjectsPage({
</Suspense>
</div>
{/* 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">
{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
</span>
</div>
<ProjectList projects={projectsData.projects} locale={locale} />
{/* Pagination */}
{projectsData.pagination.totalPages > 1 && (
<div className="mt-16 flex justify-center">
<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={`?${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"
>
</a>
)}
{Array.from({ length: Math.min(5, projectsData.pagination.totalPages) }, (_, i) => {
const pageNum = Math.max(1, page - 2) + i
if (pageNum > projectsData.pagination.totalPages) return null
return (
<a
key={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'
: 'hover:bg-gray-100 dark:hover:bg-white/10'
} transition-colors`}
>
{pageNum}
</a>
)
})}
{page < projectsData.pagination.totalPages && (
<a
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"
>
</a>
)}
</div>
</div>
)}
<ProjectsResultsClient
locale={locale}
search={search}
isAI={isAIMode}
selectedTags={selectedTags}
selectedDomains={selectedDomains}
selectedProductForms={selectedProductForms}
projectType={activeProjectType}
sort={activeSort}
page={page}
activeProjectTypeLabel={activeProjectTypeLabel}
projects={projectsData.projects}
pagination={projectsData.pagination}
translations={{
allProjects: tProject('allProjects'),
sortLatest: tProject('sortLatest'),
sortStarsDesc: tProject('sortStarsDesc'),
sortStarsAsc: tProject('sortStarsAsc'),
itemsPerPage: tProject('itemsPerPage'),
paginationFirst: tProject('paginationFirst'),
paginationPrev: tProject('paginationPrev'),
paginationNext: tProject('paginationNext'),
paginationLast: tProject('paginationLast'),
viewDetails: tCommon('viewDetails'),
noProjects: tCommon('noProjects'),
noResults: tCommon('noResults'),
searching: tCommon('searching'),
}}
/>
</main>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { ZodError, z } from 'zod'
import { getProjects, PROJECT_SORT_OPTIONS } from '@/hooks/useProjects'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
const ProjectsQuerySchema = z.object({
search: z.string().trim().max(100).optional(),
tags: z.array(z.string().trim().min(1)).default([]),
domains: z.array(z.string().trim().min(1)).default([]),
productForms: z.array(z.string().trim().min(1)).default([]),
projectType: z.string().trim().optional(),
sort: z.enum(PROJECT_SORT_OPTIONS).default('latest'),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
})
function parseSlugList(searchParams: URLSearchParams, key: string, repeatedKey: string): string[] {
const csvValues = (searchParams.get(key) || '')
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0)
const repeatedValues = searchParams
.getAll(repeatedKey)
.map((value) => value.trim())
.filter((value) => value.length > 0)
return Array.from(new Set([...csvValues, ...repeatedValues]))
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl
const tags = parseSlugList(searchParams, 'tags', 'tag')
const domains = parseSlugList(searchParams, 'domains', 'domain')
const productForms = parseSlugList(searchParams, 'productForms', 'productForm')
const projectTypeCandidate = (searchParams.get('projectType') || '').trim()
const projectType = isFixedProjectTypeSlug(projectTypeCandidate)
? projectTypeCandidate
: undefined
const validatedQuery = ProjectsQuerySchema.parse({
search: searchParams.get('search') || undefined,
tags,
domains,
productForms,
projectType,
sort: searchParams.get('sort') || undefined,
page: searchParams.get('page') || undefined,
limit: searchParams.get('limit') || undefined,
})
const data = await getProjects({
search: validatedQuery.search,
tags: validatedQuery.tags,
domains: validatedQuery.domains,
productForms: validatedQuery.productForms,
projectType: validatedQuery.projectType,
sort: validatedQuery.sort,
page: validatedQuery.page,
limit: validatedQuery.limit,
})
return NextResponse.json(data)
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: error.errors },
{ status: 400 }
)
}
console.error('Projects list API error:', error)
return NextResponse.json(
{
error: 'Failed to fetch projects',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
+129 -10
View File
@@ -17,24 +17,69 @@ const N8NSearchResponseSchema = z.object({
similarity: z.number(),
})
),
pagination: z
.object({
total: z.number().int().nonnegative().optional(),
totalPages: z.number().int().nonnegative().optional(),
hasMore: z.boolean().optional(),
})
.optional(),
})
const AISearchSortSchema = z.enum(['latest', 'stars_desc', 'stars_asc'])
const AISearchRequestSchema = ProjectQuerySchema.extend({
sort: AISearchSortSchema.optional().default('latest'),
})
function getTimestamp(input: string | Date | null | undefined): number {
if (!input) return 0
const date = input instanceof Date ? input : new Date(input)
const timestamp = date.getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
function normalizeSlugList(values?: string[]): string[] {
return Array.from(
new Set(
(values || [])
.map((value) => String(value || '').trim())
.filter((value) => value.length > 0)
)
)
}
export async function POST(request: Request) {
try {
const body = await request.json()
// 验证查询参数
const validatedQuery = ProjectQuerySchema.parse(body)
const validatedQuery = AISearchRequestSchema.parse(body)
const page = Math.max(1, validatedQuery.page)
const limit = Math.max(1, validatedQuery.limit)
const fetchLimit = Math.min(100, Math.max(page * limit + 1, limit + 1))
const offset = (page - 1) * limit
const normalizedTags = normalizeSlugList(validatedQuery.tags)
const normalizedDomains = normalizeSlugList(validatedQuery.domains)
const normalizedProductForms = normalizeSlugList(validatedQuery.productForms)
// 构建 n8n webhook URL(使用 GET 请求 + query string
const searchParams = new URLSearchParams({
desc: validatedQuery.search || '',
limit: String(validatedQuery.limit || 20),
limit: String(fetchLimit),
page: String(page),
offset: String(offset),
})
// 如果有标签过滤,添加到参数中
if (validatedQuery.tags && validatedQuery.tags.length > 0) {
searchParams.set('tags', validatedQuery.tags.join(','))
if (normalizedTags.length > 0) {
searchParams.set('tags', normalizedTags.join(','))
}
if (normalizedDomains.length > 0) {
searchParams.set('domains', normalizedDomains.join(','))
}
if (normalizedProductForms.length > 0) {
searchParams.set('productForms', normalizedProductForms.join(','))
}
const n8nUrl = `${N8N_WEBHOOK_URL}?${searchParams.toString()}`
@@ -55,9 +100,19 @@ export async function POST(request: Request) {
// 解析 n8n 响应(只支持 {results: [{id, similarity}, ...]} 格式)
const parsed = N8NSearchResponseSchema.parse(n8nData)
const n8nResults = parsed.results
const n8nPagination = parsed.pagination
if (n8nResults.length === 0) {
return NextResponse.json({ projects: [], pagination: { total: 0, page: 1, limit: validatedQuery.limit || 20 } })
return NextResponse.json({
projects: [],
pagination: {
total: 0,
page,
limit,
totalPages: 0,
hasMore: false,
},
})
}
// 提取所有项目 ID
@@ -67,7 +122,7 @@ export async function POST(request: Request) {
const projects = await getProjectsByIds(projectIds)
// 将 similarity 合并到项目数据中,并按 n8n 返回的顺序排序
const results: AISearchResultItem[] = n8nResults
const mergedResults: AISearchResultItem[] = n8nResults
.map((n8nItem) => {
const project = projects.find((p) => p.id === n8nItem.id)
if (!project) return null
@@ -78,12 +133,76 @@ export async function POST(request: Request) {
})
.filter((item): item is AISearchResultItem => item !== null)
const sortedResults = [...mergedResults]
if (validatedQuery.sort === 'latest') {
sortedResults.sort((a, b) => getTimestamp(b.createdAt) - getTimestamp(a.createdAt))
} else {
sortedResults.sort((a, b) => {
const starsA = a.githubStars ?? 0
const starsB = b.githubStars ?? 0
const starDiff =
validatedQuery.sort === 'stars_asc' ? starsA - starsB : starsB - starsA
if (starDiff !== 0) {
return starDiff
}
return getTimestamp(b.createdAt) - getTimestamp(a.createdAt)
})
}
const filteredResults = sortedResults.filter((project) => {
const hasAllTags = normalizedTags.every((tagSlug) =>
project.tags.some((tag) => tag.slug === tagSlug)
)
const hasAllDomains = normalizedDomains.every((domainSlug) =>
project.tags.some(
(tag) => tag.category === 'DOMAIN_SCENARIO' && tag.slug === domainSlug
)
)
const hasAllProductForms = normalizedProductForms.every((productFormSlug) =>
project.tags.some(
(tag) => tag.category === 'PRODUCT_FORM' && tag.slug === productFormSlug
)
)
return hasAllTags && hasAllDomains && hasAllProductForms
})
const start = (page - 1) * limit
const end = start + limit
const computedHasMore = filteredResults.length > end
const pageProjects = filteredResults.slice(start, end)
const estimatedTotal = computedHasMore
? Math.max(end + 1, page * limit + 1)
: filteredResults.length
const minimumTotal = start + pageProjects.length
const resolvedTotal =
typeof n8nPagination?.total === 'number'
? Math.max(n8nPagination.total, minimumTotal)
: estimatedTotal
const resolvedTotalPages =
typeof n8nPagination?.totalPages === 'number'
? Math.max(
n8nPagination.totalPages,
resolvedTotal === 0 ? 0 : Math.ceil(resolvedTotal / limit)
)
: resolvedTotal === 0
? 0
: Math.ceil(resolvedTotal / limit)
const hasMore =
typeof n8nPagination?.hasMore === 'boolean'
? n8nPagination.hasMore
: resolvedTotalPages > 0
? page < resolvedTotalPages
: computedHasMore
return NextResponse.json({
projects: results,
projects: pageProjects,
pagination: {
total: results.length,
page: 1,
limit: validatedQuery.limit || 20,
total: resolvedTotal,
page,
limit,
totalPages: resolvedTotalPages,
hasMore,
},
})
+306 -5
View File
@@ -2,18 +2,34 @@
import { useMemo, useState } from 'react'
import Link from 'next/link'
import type { FilterTagCategoryGroup, FixedProjectTypeFilter } from '@/hooks/useProjects'
import type {
FilterTagCategoryGroup,
FixedProjectTypeFilter,
ProjectSortOption,
TagWithProjectCount,
} from '@/hooks/useProjects'
interface TagFilterPanelProps {
locale: string
search: string
activeProjectType?: string
selectedTags: string[]
selectedDomains: string[]
selectedProductForms: string[]
sort: ProjectSortOption
projectTypeOptions: FixedProjectTypeFilter[]
domainTags: TagWithProjectCount[]
productFormTags: TagWithProjectCount[]
tagCategoryGroups: FilterTagCategoryGroup[]
translations: {
fixedProjectType: string
allTypes: string
filterByDomain: string
allDomains: string
clearDomainFilters: string
filterByProductForm: string
allProductForms: string
clearProductFormFilters: string
filterByTagCategory: string
searchTagsPlaceholder: string
clearTagFilters: string
@@ -27,9 +43,15 @@ function buildProjectsUrl(params: {
locale: string
search: string
projectType?: string
selectedTags: string[]
selectedDomains?: string[]
selectedProductForms?: string[]
selectedTags?: string[]
sort: ProjectSortOption
}): string {
const query = new URLSearchParams()
const selectedDomains = params.selectedDomains || []
const selectedProductForms = params.selectedProductForms || []
const selectedTags = params.selectedTags || []
if (params.search) {
query.set('search', params.search)
@@ -39,8 +61,18 @@ function buildProjectsUrl(params: {
query.set('projectType', params.projectType)
}
if (params.selectedTags.length > 0) {
query.set('tags', params.selectedTags.join(','))
if (selectedDomains.length > 0) {
query.set('domains', selectedDomains.join(','))
}
if (selectedProductForms.length > 0) {
query.set('productForms', selectedProductForms.join(','))
}
if (selectedTags.length > 0) {
query.set('tags', selectedTags.join(','))
}
if (params.sort !== 'latest') {
query.set('sort', params.sort)
}
const queryString = query.toString()
@@ -53,8 +85,13 @@ export function TagFilterPanel({
locale,
search,
activeProjectType,
selectedTags,
selectedTags = [],
selectedDomains = [],
selectedProductForms = [],
sort,
projectTypeOptions,
domainTags,
productFormTags = [],
tagCategoryGroups,
translations,
}: TagFilterPanelProps) {
@@ -65,6 +102,11 @@ export function TagFilterPanel({
const [showAllTagsByGroup, setShowAllTagsByGroup] = useState<Record<string, boolean>>({})
const selectedTagSet = useMemo(() => new Set(selectedTags), [selectedTags])
const selectedDomainSet = useMemo(() => new Set(selectedDomains), [selectedDomains])
const selectedProductFormSet = useMemo(
() => new Set(selectedProductForms),
[selectedProductForms]
)
const tagBySlug = useMemo(
() =>
new Map(
@@ -74,6 +116,17 @@ export function TagFilterPanel({
),
[tagCategoryGroups]
)
const domainBySlug = useMemo(
() => new Map(domainTags.map((domainTag) => [domainTag.slug, domainTag] as const)),
[domainTags]
)
const productFormBySlug = useMemo(
() =>
new Map(
productFormTags.map((productFormTag) => [productFormTag.slug, productFormTag] as const)
),
[productFormTags]
)
const normalizedQuery = tagQuery.trim().toLowerCase()
const totalTagCount = useMemo(
() => tagCategoryGroups.reduce((total, group) => total + group.tags.length, 0),
@@ -104,14 +157,40 @@ export function TagFilterPanel({
const allTypesHref = buildProjectsUrl({
locale,
search,
selectedDomains,
selectedProductForms,
selectedTags,
sort,
})
const allDomainsHref = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains: [],
selectedProductForms,
selectedTags,
sort,
})
const allProductFormsHref = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms: [],
selectedTags,
sort,
})
const allTagsClearedHref = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms,
selectedTags: [],
sort,
})
const toggleTag = (slug: string) => {
@@ -122,7 +201,25 @@ export function TagFilterPanel({
return [...selectedTags, slug]
}
const toggleDomain = (slug: string) => {
if (selectedDomainSet.has(slug)) {
return selectedDomains.filter((item) => item !== slug)
}
return [...selectedDomains, slug]
}
const toggleProductForm = (slug: string) => {
if (selectedProductFormSet.has(slug)) {
return selectedProductForms.filter((item) => item !== slug)
}
return [...selectedProductForms, slug]
}
const hasSelectedTags = selectedTags.length > 0
const hasSelectedDomains = selectedDomains.length > 0
const hasSelectedProductForms = selectedProductForms.length > 0
const isSearching = normalizedQuery.length > 0
const shouldShowTagPanelBody = isTagPanelExpanded || hasSelectedTags || isSearching
@@ -143,6 +240,11 @@ export function TagFilterPanel({
return (
<div className="space-y-6">
<section>
<div className="mb-2">
<h4 className="font-display font-bold uppercase text-xs text-gray-700 dark:text-gray-200">
{translations.fixedProjectType}
</h4>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={allTypesHref}
@@ -160,7 +262,10 @@ export function TagFilterPanel({
locale,
search,
projectType: isActive ? undefined : typeOption.slug,
selectedDomains,
selectedProductForms,
selectedTags,
sort,
})
const displayName =
locale === 'en' && typeOption.nameEn ? typeOption.nameEn : typeOption.name
@@ -191,6 +296,196 @@ export function TagFilterPanel({
</div>
</section>
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<h4 className="font-display font-bold uppercase text-xs text-gray-700 dark:text-gray-200">
{translations.filterByDomain}
</h4>
{hasSelectedDomains ? (
<Link
href={allDomainsHref}
className="text-[10px] font-display font-bold uppercase text-gray-500 hover:text-black dark:hover:text-white"
>
{translations.clearDomainFilters}
</Link>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Link
href={allDomainsHref}
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] ${
!hasSelectedDomains
? '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.allDomains}
</Link>
{domainTags.map((domainTag) => {
const isActive = selectedDomainSet.has(domainTag.slug)
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains: toggleDomain(domainTag.slug),
selectedProductForms,
selectedTags,
sort,
})
const displayName =
locale === 'en' && domainTag.nameEn ? domainTag.nameEn : domainTag.name
return (
<Link
key={domainTag.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'
}`}
>
{domainTag._count.projects}
</span>
</Link>
)
})}
</div>
{hasSelectedDomains ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedDomains.map((domainSlug) => {
const matchedDomain = domainBySlug.get(domainSlug)
const displayName =
matchedDomain && locale === 'en' && matchedDomain.nameEn
? matchedDomain.nameEn
: matchedDomain?.name || domainSlug
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains: selectedDomains.filter((item) => item !== domainSlug),
selectedProductForms,
selectedTags,
sort,
})
return (
<Link
key={domainSlug}
href={href}
className="px-2 py-1 bg-black text-white border border-black text-[10px] font-display uppercase"
>
{displayName} ×
</Link>
)
})}
</div>
) : null}
</section>
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<h4 className="font-display font-bold uppercase text-xs text-gray-700 dark:text-gray-200">
{translations.filterByProductForm}
</h4>
{hasSelectedProductForms ? (
<Link
href={allProductFormsHref}
className="text-[10px] font-display font-bold uppercase text-gray-500 hover:text-black dark:hover:text-white"
>
{translations.clearProductFormFilters}
</Link>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Link
href={allProductFormsHref}
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] ${
!hasSelectedProductForms
? '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.allProductForms}
</Link>
{productFormTags.map((productFormTag) => {
const isActive = selectedProductFormSet.has(productFormTag.slug)
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms: toggleProductForm(productFormTag.slug),
selectedTags,
sort,
})
const displayName =
locale === 'en' && productFormTag.nameEn ? productFormTag.nameEn : productFormTag.name
return (
<Link
key={productFormTag.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'
}`}
>
{productFormTag._count.projects}
</span>
</Link>
)
})}
</div>
{hasSelectedProductForms ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedProductForms.map((productFormSlug) => {
const matchedProductForm = productFormBySlug.get(productFormSlug)
const displayName =
matchedProductForm && locale === 'en' && matchedProductForm.nameEn
? matchedProductForm.nameEn
: matchedProductForm?.name || productFormSlug
const href = buildProjectsUrl({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms: selectedProductForms.filter((item) => item !== productFormSlug),
selectedTags,
sort,
})
return (
<Link
key={productFormSlug}
href={href}
className="px-2 py-1 bg-black text-white border border-black text-[10px] font-display uppercase"
>
{displayName} ×
</Link>
)
})}
</div>
) : null}
</section>
<section>
<button
type="button"
@@ -258,7 +553,10 @@ export function TagFilterPanel({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms,
selectedTags: selectedTags.filter((item) => item !== tagSlug),
sort,
})
return (
@@ -335,7 +633,10 @@ export function TagFilterPanel({
locale,
search,
projectType: activeProjectType,
selectedDomains,
selectedProductForms,
selectedTags: toggleTag(tag.slug),
sort,
})
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
+10 -2
View File
@@ -15,6 +15,8 @@ interface AISearchBarProps {
searching: string
onSearch: (query: string, isAI: boolean) => void
loading?: boolean
initialAiMode?: boolean
className?: string
}
export function AISearchBar({
@@ -28,11 +30,13 @@ export function AISearchBar({
searching,
onSearch,
loading = false,
initialAiMode = false,
className,
}: AISearchBarProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [query, setQuery] = useState('')
const [aiMode, setAiMode] = useState(false)
const [aiMode, setAiMode] = useState(initialAiMode)
// 从 URL 参数初始化 query
useEffect(() => {
@@ -42,6 +46,10 @@ export function AISearchBar({
}
}, [searchParams])
useEffect(() => {
setAiMode(initialAiMode)
}, [initialAiMode])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
@@ -59,7 +67,7 @@ export function AISearchBar({
}
return (
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit} className={`max-w-2xl mx-auto ${className || ''}`}>
<div className="relative group">
{/* Glow effect on hover */}
<div className="absolute -inset-1 bg-black dark:bg-primary rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-200"></div>
+67 -3
View File
@@ -90,11 +90,25 @@ export type FixedProjectTypeFilter = {
projectCount: number
}
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
export function normalizeProjectSort(value?: string): ProjectSortOption {
const candidate = String(value || '').trim()
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
return candidate as ProjectSortOption
}
return 'latest'
}
export async function getProjects(options?: {
search?: string
tag?: string
tags?: string[]
domains?: string[]
productForms?: string[]
projectType?: string
sort?: ProjectSortOption
status?: 'ACTIVE' | 'ARCHIVED'
page?: number
limit?: number
@@ -111,7 +125,10 @@ export async function getProjects(options?: {
search,
tag,
tags = [],
domains = [],
productForms = [],
projectType,
sort = 'latest',
status = 'ACTIVE',
page = 1,
limit = 20,
@@ -132,14 +149,43 @@ export async function getProjects(options?: {
}
const andFilters: Prisma.ProjectWhereInput[] = []
const normalizedDomainSlugs = Array.from(
new Set(
domains
.map((value) => (value || '').trim())
.filter((value) => value.length > 0)
)
)
const normalizedProductFormSlugs = Array.from(
new Set(
productForms
.map((value) => (value || '').trim())
.filter((value) => value.length > 0)
)
)
const normalizedTagSlugs = Array.from(
new Set(
[tag, ...tags]
.map((value) => (value || '').trim())
.filter((value) => value.length > 0)
.filter((value) => !normalizedDomainSlugs.includes(value))
.filter((value) => !normalizedProductFormSlugs.includes(value))
)
)
for (const domainSlug of normalizedDomainSlugs) {
andFilters.push({
tags: {
some: {
tag: {
category: 'DOMAIN_SCENARIO',
slug: domainSlug,
},
},
},
})
}
for (const tagSlug of normalizedTagSlugs) {
andFilters.push({
tags: {
@@ -152,6 +198,19 @@ export async function getProjects(options?: {
})
}
for (const productFormSlug of normalizedProductFormSlugs) {
andFilters.push({
tags: {
some: {
tag: {
category: 'PRODUCT_FORM',
slug: productFormSlug,
},
},
},
})
}
if (projectType && isFixedProjectTypeSlug(projectType)) {
andFilters.push({
tags: {
@@ -168,6 +227,13 @@ export async function getProjects(options?: {
where.AND = andFilters
}
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
sort === 'stars_desc'
? [{ githubStars: 'desc' }, { createdAt: 'desc' }]
: sort === 'stars_asc'
? [{ githubStars: 'asc' }, { createdAt: 'desc' }]
: [{ createdAt: 'desc' }]
let projects: ProjectWithTagsAndLinks[] = []
let total = 0
@@ -184,9 +250,7 @@ export async function getProjects(options?: {
},
links: true,
},
orderBy: {
createdAt: 'desc',
},
orderBy,
skip: (page - 1) * limit,
take: limit,
}),
+10
View File
@@ -7,11 +7,21 @@ export function useSearch() {
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
const domains = (searchParams.get('domains') || '')
.split(',')
.map((domain) => domain.trim())
.filter((domain) => domain.length > 0)
const productForms = (searchParams.get('productForms') || '')
.split(',')
.map((productForm) => productForm.trim())
.filter((productForm) => productForm.length > 0)
return {
search: searchParams.get('search') || '',
tag: searchParams.get('tag') || '',
tags,
domains,
productForms,
projectType: searchParams.get('projectType') || '',
page: Number(searchParams.get('page')) || 1,
}
+2
View File
@@ -125,6 +125,8 @@ export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
export const ProjectQuerySchema = z.object({
search: z.string().min(2).max(100).optional(),
tags: z.array(z.string()).optional(),
domains: z.array(z.string()).optional(),
productForms: z.array(z.string()).optional(),
status: ProjectStatusEnum.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
+18 -1
View File
@@ -73,13 +73,30 @@
"browseByFilters": "Browse by Filters",
"fixedProjectType": "Project Type",
"allTypes": "All Types",
"filterByDomain": "Filter by Domain",
"allDomains": "All Domains",
"clearDomainFilters": "Clear Domain Filters",
"filterByProductForm": "Filter by Product Form",
"allProductForms": "All Product Forms",
"clearProductFormFilters": "Clear Product Form Filters",
"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"
"allProjects": "All Projects",
"sortLatest": "Latest",
"sortStarsDesc": "Star↓",
"sortStarsAsc": "Star↑",
"itemsPerPage": "Per page",
"paginationFirst": "First page",
"paginationPrev": "Previous page",
"paginationNext": "Next page",
"paginationLast": "Last page",
"paginationSummary": "Page {current} / {totalPages} · {total} total",
"showFilterPanel": "Show Filters",
"hideFilterPanel": "Hide Filters"
},
"navigation": {
"home": "Home",
+18 -1
View File
@@ -73,13 +73,30 @@
"browseByFilters": "按分类筛选",
"fixedProjectType": "固定项目分类",
"allTypes": "全部类型",
"filterByDomain": "按领域筛选",
"allDomains": "全部领域",
"clearDomainFilters": "清空领域筛选",
"filterByProductForm": "按产品形态筛选",
"allProductForms": "全部产品形态",
"clearProductFormFilters": "清空产品形态筛选",
"filterByTagCategory": "按标签分类筛选",
"searchTagsPlaceholder": "搜索标签...",
"clearTagFilters": "清空标签筛选",
"showMoreTags": "查看更多标签",
"showLessTags": "收起标签",
"noMatchingTags": "未找到匹配标签",
"allProjects": "全部项目"
"allProjects": "全部项目",
"sortLatest": "最新",
"sortStarsDesc": "Star↓",
"sortStarsAsc": "Star↑",
"itemsPerPage": "每页",
"paginationFirst": "首页",
"paginationPrev": "上一页",
"paginationNext": "下一页",
"paginationLast": "末页",
"paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条",
"showFilterPanel": "展开筛选器",
"hideFilterPanel": "收起筛选器"
},
"navigation": {
"home": "首页",