378 lines
21 KiB
TypeScript
378 lines
21 KiB
TypeScript
'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-[#86898e] 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-[#86898e] hover:text-[#1a1c1d]'
|
||
const countMuted = (active: boolean) => (active ? 'text-[#6b7075]' : '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(<T extends { count: number }>(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<string, number>()
|
||
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<HTMLDivElement>(null)
|
||
const measureRef = useRef<HTMLDivElement>(null)
|
||
const moreWrapRef = useRef<HTMLDivElement>(null)
|
||
const [fitCount, setFitCount] = useState<number | null>(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) => (
|
||
<button onClick={onClick} className={`${tabBase} ${active ? tabActive : tabInactive}`}>
|
||
{label}
|
||
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(active)}`}>{countText(count)}</span>
|
||
{active && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
|
||
</button>
|
||
)
|
||
|
||
const moreActive = overflowTypes.some((type) => type.value === selectedMaterialType)
|
||
|
||
return (
|
||
<>
|
||
<div className="mb-6">
|
||
<div className="flex items-end justify-between gap-6 border-b border-[#e7e8ea]">
|
||
<div ref={rowRef} className="relative min-w-0 flex-1">
|
||
<div className="flex items-end gap-x-8" aria-busy={facetsLoading}>
|
||
{renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))}
|
||
{visibleTypes.map((type) => (
|
||
<span key={type.value}>
|
||
{renderTypeTab(type.label, type.count || 0, selectedMaterialType === type.value, () => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value))}
|
||
</span>
|
||
))}
|
||
{overflowTypes.length > 0 && (
|
||
<div ref={moreWrapRef} className="relative shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMoreOpen((open) => !open)}
|
||
aria-expanded={moreOpen}
|
||
aria-haspopup="listbox"
|
||
className={`${tabBase} ${moreActive || moreOpen ? tabActive : tabInactive}`}
|
||
>
|
||
更多
|
||
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(moreActive || moreOpen)}`}>{overflowTypes.length}</span>
|
||
{(moreActive || moreOpen) && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
|
||
</button>
|
||
{moreOpen && (
|
||
<div role="listbox" className="absolute right-0 top-full z-40 mt-2 max-h-[320px] min-w-[240px] overflow-y-auto border border-[#e7e8ea] bg-white py-1">
|
||
{overflowTypes.map((type) => {
|
||
const active = selectedMaterialType === type.value
|
||
return (
|
||
<button
|
||
key={type.value}
|
||
role="option"
|
||
aria-selected={active}
|
||
onClick={() => setSelectedMaterialType(active ? '' : type.value)}
|
||
className={`flex w-full items-baseline justify-between gap-6 px-4 py-2.5 text-left text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 ${active ? 'text-[#1a1c1d]' : 'text-[#6b7075] hover:bg-[#fafafa] hover:text-[#1a1c1d]'}`}
|
||
>
|
||
<span>{type.label}</span>
|
||
<span className={`font-en text-[13px] tabular-nums ${countMuted(active)}`}>{countText(type.count || 0)}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
|
||
<span key={item} className="mb-2 h-4 w-16 bg-[#eceef0]" aria-hidden="true" />
|
||
))}
|
||
</div>
|
||
<div aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden">
|
||
<div ref={measureRef} className="flex w-max items-end gap-x-8 whitespace-nowrap">
|
||
<span className={tabBase}>全部<span className="ml-2 font-en text-[14px] tabular-nums">{countText(totals.materialType)}</span></span>
|
||
{rankedTypes.map((type) => (
|
||
<span key={type.value} className={tabBase}>{type.label}<span className="ml-2 font-en text-[14px] tabular-nums">{countText(type.count || 0)}</span></span>
|
||
))}
|
||
<span className={tabBase}>更多<span className="ml-2 font-en text-[14px] tabular-nums">00</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsFilterCollapsed(!isFilterCollapsed)}
|
||
aria-label={isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
|
||
className="mb-2 shrink-0 whitespace-nowrap bg-[#fafafa] pl-4 text-[13px] tracking-[-0.02em] text-[#6b7075] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
|
||
>
|
||
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
|
||
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1.5 font-en text-[11px] text-[#5a66b8]">{activeFilterCount}</span>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mb-8 flex flex-wrap items-baseline justify-between gap-3">
|
||
<p className="text-[13px] text-[#86898e]">
|
||
共 <span className="font-en tabular-nums text-[#6b7075]">{facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')}</span> 条
|
||
{selectedMaterialType && <span className="sr-only">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
|
||
</p>
|
||
<div className="flex items-center gap-4 text-[13px]" role="group" aria-labelledby="materials-sort-label">
|
||
<h2 id="materials-sort-label" className="sr-only">排序方式</h2>
|
||
<button onClick={() => handleSortChange('createdAt')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'createdAt' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
|
||
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
|
||
{sortBy === 'createdAt' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
|
||
</button>
|
||
<button onClick={() => handleSortChange('downloadCount')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'downloadCount' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
|
||
{sortBy === 'downloadCount' && sortOrder === 'asc' ? '下载最少' : '下载最多'}
|
||
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
|
||
<div className="mb-6">
|
||
<h2 className="mb-3 text-[13px] text-[#6b7075]">使用店铺</h2>
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<button onClick={() => setSelectedCategory('')} className={`${chipBase} ${selectedCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === '')}`}>{countText(totals.category)}</span></button>
|
||
{rankedCategories.map((item) => <button key={item.id} onClick={() => setSelectedCategory(item.name === selectedCategory ? '' : item.name)} className={`${chipBase} ${selectedCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
|
||
</div>
|
||
</div>
|
||
<div className="mb-6">
|
||
<h2 className="mb-3 text-[13px] text-[#6b7075]">场景类别</h2>
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<button onClick={() => setSelectedScene('')} className={`${chipBase} ${selectedScene === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === '')}`}>{countText(totals.scene)}</span></button>
|
||
{rankedScenes.map((item) => <button key={item.id} onClick={() => setSelectedScene(item.name === selectedScene ? '' : item.name)} className={`${chipBase} ${selectedScene === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === item.name)}`}>{countText(item.count || 0)}</span></button>)}
|
||
</div>
|
||
</div>
|
||
{(rankedPlatforms.length > 0 || facetsLoading) && <div className="mb-6">
|
||
<h2 className="mb-3 text-[13px] text-[#6b7075]">平台分类</h2>
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<button onClick={() => setSelectedPlatform('')} className={`${chipBase} ${selectedPlatform === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === '')}`}>{countText(totals.platform)}</span></button>
|
||
{rankedPlatforms.map((item) => <button key={item.id} onClick={() => setSelectedPlatform(item.name === selectedPlatform ? '' : item.name)} className={`${chipBase} ${selectedPlatform === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === item.name)}`}>{countText(item.count || 0)}</span></button>)}
|
||
</div>
|
||
</div>}
|
||
{(rankedProductCategories.length > 0 || facetsLoading) && <div className="mb-6">
|
||
<h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<button onClick={() => setSelectedProductCategory('')} className={`${chipBase} ${selectedProductCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === '')}`}>{countText(totals.productCategory)}</span></button>
|
||
{rankedProductCategories.map((item) => <button key={item.id} onClick={() => setSelectedProductCategory(item.name === selectedProductCategory ? '' : item.name)} className={`${chipBase} ${selectedProductCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
|
||
</div>
|
||
</div>}
|
||
<div className="mb-6">
|
||
<h2 className="mb-3 text-[13px] text-[#6b7075]">上传时间</h2>
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<button onClick={() => setSelectedMonth('')} className={`${chipBase} ${selectedMonth === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === '')}`}>{countText(totals.month)}</span></button>
|
||
{yearFacets.map((item) => (
|
||
<button
|
||
key={item.year}
|
||
onClick={() => setSelectedMonth(selectedMonth === item.year ? '' : item.year)}
|
||
className={`${chipBase} ${selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`) ? chipActive : chipInactive}`}
|
||
>
|
||
{item.year}
|
||
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`))}`}>{countText(item.count)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{monthsInFocusedYear.length > 0 && (
|
||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2">
|
||
{monthsInFocusedYear.map((item) => {
|
||
const monthNumber = Number.parseInt(item.value.slice(5), 10)
|
||
return (
|
||
<button
|
||
key={item.value}
|
||
onClick={() => setSelectedMonth(item.value === selectedMonth ? '' : item.value)}
|
||
className={`${chipBase} ${selectedMonth === item.value ? chipActive : chipInactive}`}
|
||
>
|
||
{monthNumber}月
|
||
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.value)}`}>{countText(item.count)}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="mb-6 text-xs text-[#86898e]">选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。</p>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default memo(FilterPanel)
|