feat: 升级项目列表筛选结构并优化首屏体验
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "首页",
|
||||
|
||||
Reference in New Issue
Block a user