'use client' import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' export interface CategoryItem { id: string name: string count: number } export interface MonthItem { value: string label: string count: number } export interface FacetTotals { materialType: number category: number scene: number platform: number productCategory: number month: number } interface FilterPanelProps { selectedMaterialType: string setSelectedMaterialType: (value: string) => void selectedCategory: string setSelectedCategory: (value: string) => void selectedScene: string setSelectedScene: (value: string) => void selectedPlatform: string setSelectedPlatform: (value: string) => void selectedProductCategory: string setSelectedProductCategory: (value: string) => void selectedMonth: string setSelectedMonth: (value: string) => void sortBy: string setSortBy: (value: string) => void sortOrder: 'desc' | 'asc' setSortOrder: (value: 'desc' | 'asc') => void materialTypes: { value: string; label: string; count?: number }[] categories: CategoryItem[] scenes: CategoryItem[] platforms: CategoryItem[] productCategories: CategoryItem[] months: MonthItem[] totalMaterialsCount: number materialsCount: number facetTotals?: FacetTotals isFilterCollapsed: boolean setIsFilterCollapsed: (value: boolean) => void themeColor: string facetsLoading?: boolean } function FilterPanel(props: FilterPanelProps) { const { selectedMaterialType, setSelectedMaterialType, selectedCategory, setSelectedCategory, selectedScene, setSelectedScene, selectedPlatform, setSelectedPlatform, selectedProductCategory, setSelectedProductCategory, selectedMonth, setSelectedMonth, sortBy, setSortBy, sortOrder, setSortOrder, materialTypes, categories, scenes, platforms, productCategories, months, totalMaterialsCount, materialsCount, facetTotals, isFilterCollapsed, setIsFilterCollapsed, facetsLoading = false, } = props const handleSortChange = useCallback((newSortBy: string) => { if (sortBy === newSortBy) { setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc') } else { setSortBy(newSortBy) setSortOrder('desc') } }, [sortBy, sortOrder, setSortBy, setSortOrder]) const totals = { materialType: facetTotals?.materialType ?? totalMaterialsCount, category: facetTotals?.category ?? totalMaterialsCount, scene: facetTotals?.scene ?? totalMaterialsCount, platform: facetTotals?.platform ?? totalMaterialsCount, productCategory: facetTotals?.productCategory ?? totalMaterialsCount, month: facetTotals?.month ?? totalMaterialsCount, } const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length const tabBase = 'relative shrink-0 whitespace-nowrap pb-3 text-[15px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm' const tabActive = 'text-[#1a1c1d]' const tabInactive = 'text-[#6b7075] hover:text-[#1a1c1d]' const chipBase = 'text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm' const chipActive = 'text-[#1a1c1d]' const chipInactive = 'text-[#6b7075] hover:text-[#1a1c1d]' const countClass = 'font-en text-[12px] font-medium tabular-nums tracking-[0.08em] text-[#86898e]' const countText = (value: number) => (facetsLoading ? '…' : value.toLocaleString('zh-CN')) const rankedTypes = useMemo(() => { const hasCounts = materialTypes.some((type) => (type.count || 0) > 0) if (!hasCounts) return materialTypes return [...materialTypes].sort((left, right) => (right.count || 0) - (left.count || 0)) }, [materialTypes]) const rankByCount = useCallback((items: T[]) => { if (!items.some((item) => (item.count || 0) > 0)) return items return [...items].sort((left, right) => (right.count || 0) - (left.count || 0)) }, []) const rankedCategories = useMemo(() => rankByCount(categories), [categories, rankByCount]) const rankedScenes = useMemo(() => rankByCount(scenes), [scenes, rankByCount]) const rankedPlatforms = useMemo(() => rankByCount(platforms), [platforms, rankByCount]) const rankedProductCategories = useMemo(() => rankByCount(productCategories), [productCategories, rankByCount]) const yearFacets = useMemo(() => { const yearMap = new Map() for (const item of months) { const year = item.value.slice(0, 4) if (!/^\d{4}$/.test(year)) continue yearMap.set(year, (yearMap.get(year) || 0) + item.count) } return Array.from(yearMap.entries()) .map(([year, count]) => ({ year, count })) .sort((left, right) => right.year.localeCompare(left.year)) }, [months]) const focusedYear = selectedMonth.slice(0, 4) || yearFacets[0]?.year || '' const monthsInFocusedYear = useMemo( () => months.filter((item) => item.value.startsWith(`${focusedYear}-`)).sort((left, right) => right.value.localeCompare(left.value)), [months, focusedYear], ) const rowRef = useRef(null) const measureRef = useRef(null) const moreWrapRef = useRef(null) const [fitCount, setFitCount] = useState(null) const [moreOpen, setMoreOpen] = useState(false) const measureFit = useCallback(() => { const row = rowRef.current const measure = measureRef.current if (!row || !measure) return const available = row.clientWidth if (available < 8) { setFitCount(null) return } const kids = Array.from(measure.children) as HTMLElement[] if (kids.length < 2) { setFitCount(rankedTypes.length) return } const moreWidth = kids[kids.length - 1].offsetWidth const typeEls = kids.slice(1, -1) const gap = 32 let used = kids[0].offsetWidth let count = 0 for (let index = 0; index < typeEls.length; index += 1) { const remaining = typeEls.length - (index + 1) const extra = remaining > 0 ? gap + moreWidth : 0 if (used + gap + typeEls[index].offsetWidth + extra <= available) { used += gap + typeEls[index].offsetWidth count += 1 } else { break } } setFitCount(count) }, [rankedTypes.length]) useLayoutEffect(() => { measureFit() const row = rowRef.current if (!row || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(() => measureFit()) observer.observe(row) return () => observer.disconnect() }, [measureFit, rankedTypes, totals.materialType, facetsLoading]) const { visibleTypes, overflowTypes } = useMemo(() => { if (fitCount === null || fitCount >= rankedTypes.length) { return { visibleTypes: rankedTypes, overflowTypes: [] as typeof rankedTypes } } let visible = rankedTypes.slice(0, fitCount) let overflow = rankedTypes.slice(fitCount) if (selectedMaterialType) { const hiddenSelected = overflow.find((type) => type.value === selectedMaterialType) if (hiddenSelected && visible.length > 0) { const displaced = visible[visible.length - 1] visible = [...visible.slice(0, -1), hiddenSelected] overflow = [displaced, ...overflow.filter((type) => type.value !== selectedMaterialType)] } } return { visibleTypes: visible, overflowTypes: overflow } }, [fitCount, rankedTypes, selectedMaterialType]) useEffect(() => { if (!moreOpen) return const onPointer = (event: MouseEvent) => { if (!moreWrapRef.current?.contains(event.target as Node)) setMoreOpen(false) } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setMoreOpen(false) } document.addEventListener('mousedown', onPointer) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onPointer) document.removeEventListener('keydown', onKey) } }, [moreOpen]) useEffect(() => { setMoreOpen(false) }, [selectedMaterialType]) const renderTypeTab = (label: string, count: number, active: boolean, onClick: () => void) => ( ) const moreActive = overflowTypes.some((type) => type.value === selectedMaterialType) return ( <>
{renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))} {visibleTypes.map((type) => ( {renderTypeTab(type.label, type.count || 0, selectedMaterialType === type.value, () => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value))} ))} {overflowTypes.length > 0 && (
{moreOpen && (
{overflowTypes.map((type) => { const active = selectedMaterialType === type.value return ( ) })}
)}
)} {facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (

共 {facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')} 条 {selectedMaterialType && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}

排序方式

使用店铺

{rankedCategories.map((item) => )}

场景类别

{rankedScenes.map((item) => )}
{(rankedPlatforms.length > 0 || facetsLoading) &&

平台分类

{rankedPlatforms.map((item) => )}
} {(rankedProductCategories.length > 0 || facetsLoading) &&

产品类别

{rankedProductCategories.map((item) => )}
}

上传时间

{yearFacets.map((item) => ( ))}
{monthsInFocusedYear.length > 0 && (
{monthsInFocusedYear.map((item) => { const monthNumber = Number.parseInt(item.value.slice(5), 10) return ( ) })}
)}

选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。

) } export default memo(FilterPanel)