From 2e2edac6af3880ef48bea11468e3056628309c76 Mon Sep 17 00:00:00 2001 From: mzaxd Date: Sun, 22 Feb 2026 11:18:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=AD=9B=E9=80=89=E7=BB=93=E6=9E=84=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A6=96=E5=B1=8F=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[locale]/projects/ProjectsPageClient.tsx | 225 +++-- .../projects/ProjectsResultsClient.tsx | 780 ++++++++++++++++++ src/app/[locale]/projects/page.tsx | 196 +++-- src/app/api/projects/route.ts | 82 ++ src/app/api/search/ai/route.ts | 139 +++- src/components/project/TagFilterPanel.tsx | 311 ++++++- src/components/search/AISearchBar.tsx | 12 +- src/hooks/useProjects.ts | 70 +- src/hooks/useSearch.ts | 10 + src/lib/validations.ts | 2 + src/messages/en.json | 19 +- src/messages/zh.json | 19 +- 12 files changed, 1623 insertions(+), 242 deletions(-) create mode 100644 src/app/[locale]/projects/ProjectsResultsClient.tsx create mode 100644 src/app/api/projects/route.ts diff --git a/src/app/[locale]/projects/ProjectsPageClient.tsx b/src/app/[locale]/projects/ProjectsPageClient.tsx index 4a66b9a..d105842 100644 --- a/src/app/[locale]/projects/ProjectsPageClient.tsx +++ b/src/app/[locale]/projects/ProjectsPageClient.tsx @@ -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([]) - const [isAIResult, setIsAIResult] = useState(false) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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 = ( + + ) + + if (isAI) { + return searchBar } return ( <> - {/* AI 搜索栏 */} - - - {/* Tag Cloud / AI 搜索结果 */} - {isAIResult ? ( - <> - {error && ( -
-

{error}

+
+
{searchBar}
+ +
+ +
{isFilterPanelExpanded ? children : null}
) } diff --git a/src/app/[locale]/projects/ProjectsResultsClient.tsx b/src/app/[locale]/projects/ProjectsResultsClient.tsx new file mode 100644 index 0000000..cbfa6ee --- /dev/null +++ b/src/app/[locale]/projects/ProjectsResultsClient.tsx @@ -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([]) + const [loadingAI, setLoadingAI] = useState(false) + const [aiError, setAiError] = useState(null) + const [aiCurrentPage, setAiCurrentPage] = useState(initialPage) + const [aiSort, setAiSort] = useState(sort) + const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit) + const [aiPagination, setAiPagination] = useState({ + total: 0, + page: initialPage, + limit: initialLimit, + totalPages: 0, + hasMore: false, + }) + + const [traditionalResults, setTraditionalResults] = useState(projects) + const [traditionalPagination, setTraditionalPagination] = useState({ + total: pagination.total, + page: Math.max(1, pagination.page || initialPage), + limit: initialLimit, + totalPages: pagination.totalPages, + }) + const [traditionalCurrentPage, setTraditionalCurrentPage] = useState(initialPage) + const [traditionalSort, setTraditionalSort] = useState(sort) + const [traditionalLimit, setTraditionalLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit) + const [loadingTraditional, setLoadingTraditional] = useState(false) + const [traditionalError, setTraditionalError] = useState(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 | 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 | 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 ( + <> +
+
+

+ {activeProjectTypeLabel || translations.allProjects} +

+
+ + {projectsCountText} + +
+ + +
+
+
+
+ + {activeError ? ( +
+

{activeError}

+
+ ) : null} + + {isAI ? ( + loadingAI ? ( +
+

{translations.searching}

+
+ ) : ( + + ) + ) : displayProjects.length === 0 ? ( +
+

{translations.noProjects}

+
+ ) : ( +
+ {displayProjects.map((project) => ( + + ))} +
+ )} + + {totalCount > 0 && ( +
+
+
+
+ + {translations.itemsPerPage} + +
+ {PAGE_SIZE_OPTIONS.map((option) => ( + + ))} +
+
+
+ + {activeTotalPages > 1 ? ( +
+ {canGoPrev ? ( + <> + + + + ) : null} + + {pageTokens.map((token, index) => { + if (token === 'ellipsis') { + return ( + + ... + + ) + } + + return ( + + ) + })} + + {canGoNext ? ( + <> + + + + ) : null} +
+ ) : ( +
+ )} + +
+ {paginationSummary ? ( +

+ {paginationSummary} +

+ ) : null} +
+
+
+ )} + + ) +} diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx index 1f63a96..fe75f61 100644 --- a/src/app/[locale]/projects/page.tsx +++ b/src/app/[locale]/projects/page.tsx @@ -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 (
@@ -94,8 +115,13 @@ export default async function ProjectsPage({
}> {/* Filters - rendered inside client component for non-AI mode */}
@@ -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({
- {/* Projects Section */} -
-

- {activeProjectType - ? locale === 'en' - ? activeProjectTypeMeta?.nameEn || activeProjectType - : activeProjectTypeMeta?.name || activeProjectType - : tProject('allProjects')} -

- - {projectsData.pagination.total} projects - -
- - - - {/* Pagination */} - {projectsData.pagination.totalPages > 1 && ( -
-
- {page > 1 && ( - - ← - - )} - {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 ( - - {pageNum} - - ) - })} - {page < projectsData.pagination.totalPages && ( - - → - - )} -
-
- )} + ) } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..12b5970 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/search/ai/route.ts b/src/app/api/search/ai/route.ts index c0391ce..9b798cf 100644 --- a/src/app/api/search/ai/route.ts +++ b/src/app/api/search/ai/route.ts @@ -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, }, }) diff --git a/src/components/project/TagFilterPanel.tsx b/src/components/project/TagFilterPanel.tsx index 9669fa1..ab9fddd 100644 --- a/src/components/project/TagFilterPanel.tsx +++ b/src/components/project/TagFilterPanel.tsx @@ -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>({}) 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 (
+
+

+ {translations.fixedProjectType} +

+
+
+
+

+ {translations.filterByDomain} +

+ {hasSelectedDomains ? ( + + {translations.clearDomainFilters} + + ) : null} +
+
+ + {translations.allDomains} + + {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 ( + + {displayName} + + {domainTag._count.projects} + + + ) + })} +
+ {hasSelectedDomains ? ( +
+ {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 ( + + {displayName} × + + ) + })} +
+ ) : null} +
+ +
+
+

+ {translations.filterByProductForm} +

+ {hasSelectedProductForms ? ( + + {translations.clearProductFormFilters} + + ) : null} +
+
+ + {translations.allProductForms} + + {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 ( + + {displayName} + + {productFormTag._count.projects} + + + ) + })} +
+ {hasSelectedProductForms ? ( +
+ {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 ( + + {displayName} × + + ) + })} +
+ ) : null} +
+