gongxiangp2/components/home/FilterPanel.tsx

402 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, SlidersHorizontal } from 'lucide-react'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
import { useAnchoredPopover } from '@/components/ui/useAnchoredPopover'
const filterChoice = 'inline-flex min-h-9 items-center gap-2 rounded-[8px] border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2'
const filterSelected = 'border-[#1a1c1d] bg-[#1a1c1d] font-medium text-white'
const filterIdle = 'border-transparent bg-[#f3f4f5] text-[#52565b] hover:border-[#cfd8e3] hover:bg-white hover:text-[#1a1c1d]'
function FacetRow({ label, items, selected, onChange, total, loading }: { label: string; items: CategoryItem[]; selected: string; onChange: (value: string) => void; total: number; loading: boolean }) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? items : items.filter((item, index) => index < 10 || item.name === selected)
const count = (value: number) => loading ? '…' : value.toLocaleString('zh-CN')
return (
<div className="grid gap-2.5 border-b border-[#e7e8ea] py-3.5 last:border-b-0 sm:grid-cols-[88px_minmax(0,1fr)] sm:gap-4">
<h2 className="pt-1.5 text-sm font-medium text-[#52565b]">{label}</h2>
<div role="group" aria-label={label} aria-busy={loading} className="flex min-w-0 flex-wrap items-center gap-2">
<button type="button" aria-pressed={!selected} onClick={() => onChange('')} className={`${filterChoice} ${!selected ? filterSelected : filterIdle}`}>全部<span className="text-xs tabular-nums">{count(total)}</span></button>
{visible.map((item) => <button type="button" key={item.id} aria-pressed={selected === item.name} onClick={() => onChange(selected === item.name ? '' : item.name)} className={`${filterChoice} ${selected === item.name ? filterSelected : filterIdle}`}>{item.name}<span className="text-xs tabular-nums">{count(item.count)}</span></button>)}
{items.length > 10 && <button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)} className="inline-flex min-h-9 items-center gap-1 rounded-lg px-2 text-sm text-[#52565b] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">{expanded ? '收起' : `更多 ${items.length - visible.length}`}<ChevronDown aria-hidden="true" className={`h-3.5 w-3.5 ${expanded ? 'rotate-180' : ''}`} /></button>}
</div>
</div>
)
}
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
selectedLivePlatform?: string
setSelectedLivePlatform?: (value: string) => void
liveDateFrom?: string
setLiveDateFrom?: (value: string) => void
liveDateTo?: string
setLiveDateTo?: (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,
selectedLivePlatform = '', setSelectedLivePlatform,
liveDateFrom = '', setLiveDateFrom,
liveDateTo = '', setLiveDateTo,
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, selectedLivePlatform, liveDateFrom || liveDateTo].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 countClass = 'font-en text-[12px] font-medium tabular-nums tracking-[0.08em] text-[#66758b]'
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 moreTriggerRef = useRef<HTMLButtonElement>(null)
const moreMenuRef = useRef<HTMLDivElement>(null)
const [fitCount, setFitCount] = useState<number | null>(null)
const [moreOpen, setMoreOpen] = useState(false)
const morePosition = useAnchoredPopover(moreOpen, moreTriggerRef, 264)
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
moreMenuRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"], button')?.focus()
const onPointer = (event: MouseEvent) => {
if (!moreWrapRef.current?.contains(event.target as Node) && !moreMenuRef.current?.contains(event.target as Node)) setMoreOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { setMoreOpen(false); moreTriggerRef.current?.focus() }
}
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 type="button" aria-pressed={active} onClick={onClick} className={`${tabBase} ${active ? tabActive : tabInactive}`}>
{label}
<span className={`ml-2 ${countClass}`}>{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="relative z-10 mb-4">
<div className="border-b border-[#e7e8ea]">
<div ref={rowRef} className="relative min-w-0">
<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
ref={moreTriggerRef}
type="button"
onClick={() => setMoreOpen((open) => !open)}
aria-expanded={moreOpen}
aria-haspopup="listbox"
onKeyDown={(event) => { if (event.key === 'ArrowDown') { event.preventDefault(); setMoreOpen(true); moreMenuRef.current?.querySelector<HTMLButtonElement>('button')?.focus() } }}
className={`${tabBase} ${moreActive || moreOpen ? tabActive : tabInactive}`}
>
更多
<span className={`ml-2 ${countClass}`}>{overflowTypes.length}</span>
{(moreActive || moreOpen) && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
</button>
{moreOpen && createPortal(
<div ref={moreMenuRef} role="listbox" aria-label="更多素材类型" style={morePosition} onKeyDown={(event) => {
if (event.key === 'Tab') setMoreOpen(false)
if (event.key === 'Escape') { event.stopPropagation(); setMoreOpen(false); moreTriggerRef.current?.focus() }
const buttons = Array.from(moreMenuRef.current?.querySelectorAll<HTMLButtonElement>('button') || [])
const index = buttons.indexOf(event.target as HTMLButtonElement)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); buttons[(index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus() }
}} className="z-[500] overflow-y-auto overscroll-contain rounded-[12px] bg-white p-1.5 shadow-[0_12px_32px_rgba(16,24,40,0.18)] ring-1 ring-[#e2e8f0]">
{overflowTypes.map((type) => {
const active = selectedMaterialType === type.value
return (
<button
type="button"
key={type.value}
role="option"
aria-selected={active}
onClick={() => { setSelectedMaterialType(active ? '' : type.value); setMoreOpen(false); moreTriggerRef.current?.focus() }}
className={`flex min-h-11 w-full items-baseline justify-between gap-4 rounded-lg px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${active ? 'bg-[#eef2f6] font-medium text-[#1a1c1d]' : 'text-[#52565b] hover:bg-[#f3f4f5] hover:text-[#1a1c1d]'}`}
>
<span>{type.label}</span>
<span className={countClass}>{countText(type.count || 0)}</span>
</button>
)
})}
</div>, document.body
)}
</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 ${countClass}`}>{countText(totals.materialType)}</span></span>
{rankedTypes.map((type) => (
<span key={type.value} className={tabBase}>{type.label}<span className={`ml-2 ${countClass}`}>{countText(type.count || 0)}</span></span>
))}
<span className={tabBase}>更多<span className={`ml-2 ${countClass}`}>00</span></span>
</div>
</div>
</div>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<p className="shrink-0 whitespace-nowrap text-[13px] text-[#86898e]">
共 <span className="font-en text-[15px] font-medium tabular-nums tracking-[0.06em] text-[#1a1c1d]">{facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')}</span> 条
{selectedMaterialType && <span className="sr-only">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-x-2 gap-y-1 sm:gap-x-4">
<button
type="button"
onClick={() => setIsFilterCollapsed(!isFilterCollapsed)}
aria-label={isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
aria-expanded={!isFilterCollapsed}
aria-controls="material-filter-fields"
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg px-2 text-[13px] text-[#52565b] transition-colors hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1.5 font-en text-[11px] text-[#5a66b8]">{activeFilterCount}</span>}
</button>
<div className="flex shrink-0 items-center gap-3 text-[13px] sm:gap-4" role="group" aria-labelledby="materials-sort-label">
<h2 id="materials-sort-label" className="sr-only">排序方式</h2>
<button onClick={() => handleSortChange('createdAt')} className={`flex min-h-11 items-center gap-1 whitespace-nowrap 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 min-h-11 items-center gap-1 whitespace-nowrap 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>
{!isFilterCollapsed && (
<section id="material-filter-fields" aria-label="素材筛选条件" className="mb-5 rounded-[12px] border border-[#e7e8ea] bg-white px-4 py-1 sm:px-5">
<FacetRow label="使用店铺" items={rankedCategories} selected={selectedCategory} onChange={setSelectedCategory} total={totals.category} loading={facetsLoading} />
<FacetRow label="场景类别" items={rankedScenes} selected={selectedScene} onChange={setSelectedScene} total={totals.scene} loading={facetsLoading} />
{(rankedPlatforms.length > 0 || facetsLoading) && <FacetRow label="平台分类" items={rankedPlatforms} selected={selectedPlatform} onChange={setSelectedPlatform} total={totals.platform} loading={facetsLoading} />}
{setSelectedLivePlatform && (
<div className="grid gap-2.5 border-b border-[#e7e8ea] py-3.5 sm:grid-cols-[88px_minmax(0,1fr)] sm:gap-4">
<h2 className="pt-1.5 text-sm font-medium text-[#52565b]">直播平台</h2>
<div role="group" aria-label="直播平台" className="flex flex-wrap gap-2">
{['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => <button type="button" key={name || 'all'} aria-pressed={selectedLivePlatform === name} onClick={() => setSelectedLivePlatform(name === selectedLivePlatform ? '' : name)} className={`${filterChoice} ${selectedLivePlatform === name ? filterSelected : filterIdle}`}>{name || '全部'}</button>)}
</div>
</div>
)}
{setLiveDateFrom && setLiveDateTo && (
<div className="grid gap-2.5 border-b border-[#e7e8ea] py-3.5 sm:grid-cols-[88px_minmax(0,1fr)] sm:gap-4">
<h2 className="pt-3 text-sm font-medium text-[#52565b]">直播日期</h2>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 sm:max-w-lg">
<CertificateDatePicker id="filter-live-from" label="直播开始日期" value={liveDateFrom} onChange={setLiveDateFrom} max={liveDateTo} placeholder="开始日期" />
<span className="text-xs text-[#66758b]">至</span>
<CertificateDatePicker id="filter-live-to" label="直播结束日期" value={liveDateTo} onChange={setLiveDateTo} min={liveDateFrom} placeholder="结束日期" />
</div>
</div>
)}
{(rankedProductCategories.length > 0 || facetsLoading) && <FacetRow label="产品类别" items={rankedProductCategories} selected={selectedProductCategory} onChange={setSelectedProductCategory} total={totals.productCategory} loading={facetsLoading} />}
<div className="grid gap-2.5 py-3.5 sm:grid-cols-[88px_minmax(0,1fr)] sm:gap-4">
<h2 className="pt-1.5 text-sm font-medium text-[#52565b]">上传时间</h2>
<div className="min-w-0 space-y-2">
<div role="group" aria-label="上传年份" className="flex flex-wrap gap-2">
<button type="button" aria-pressed={!selectedMonth} onClick={() => setSelectedMonth('')} className={`${filterChoice} ${!selectedMonth ? filterSelected : filterIdle}`}>全部<span className="text-xs tabular-nums">{countText(totals.month)}</span></button>
{yearFacets.map((item) => <button type="button" key={item.year} aria-pressed={selectedMonth.startsWith(item.year)} onClick={() => setSelectedMonth(selectedMonth === item.year ? '' : item.year)} className={`${filterChoice} ${selectedMonth.startsWith(item.year) ? filterSelected : filterIdle}`}>{item.year}年<span className="text-xs tabular-nums">{countText(item.count)}</span></button>)}
</div>
{monthsInFocusedYear.length > 0 && <div role="group" aria-label={focusedYear + '年上传月份'} className="flex flex-wrap gap-2">
{monthsInFocusedYear.map((item) => <button type="button" key={item.value} aria-pressed={selectedMonth === item.value} onClick={() => setSelectedMonth(item.value === selectedMonth ? '' : item.value)} className={`${filterChoice} ${selectedMonth === item.value ? filterSelected : filterIdle}`}>{Number.parseInt(item.value.slice(5), 10)}月<span className="text-xs tabular-nums">{countText(item.count)}</span></button>)}
</div>}
</div>
</div>
<p className="border-t border-[#e7e8ea] py-3 text-xs leading-relaxed text-[#66758b]">数量随筛选条件更新,仅显示仍有结果的选项。</p>
</section>
)}
</>
)
}
export default memo(FilterPanel)