'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 (
{label}
{visible.map((item) => )}
{items.length > 10 && }
)
}
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((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 moreTriggerRef = useRef(null)
const moreMenuRef = useRef(null)
const [fitCount, setFitCount] = useState(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('[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) => (
)
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 && createPortal(
{
if (event.key === 'Tab') setMoreOpen(false)
if (event.key === 'Escape') { event.stopPropagation(); setMoreOpen(false); moreTriggerRef.current?.focus() }
const buttons = Array.from(moreMenuRef.current?.querySelectorAll('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 (
)
})}
, document.body
)}
)}
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
))}
全部{countText(totals.materialType)}
{rankedTypes.map((type) => (
{type.label}{countText(type.count || 0)}
))}
更多00
共 {facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')} 条
{selectedMaterialType && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
排序方式
{!isFilterCollapsed && (
{(rankedPlatforms.length > 0 || facetsLoading) && }
{setSelectedLivePlatform && (
直播平台
{['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => )}
)}
{setLiveDateFrom && setLiveDateTo && (
)}
{(rankedProductCategories.length > 0 || facetsLoading) && }
上传时间
{yearFacets.map((item) => )}
{monthsInFocusedYear.length > 0 &&
{monthsInFocusedYear.map((item) => )}
}
数量随筛选条件更新,仅显示仍有结果的选项。
)}
>
)
}
export default memo(FilterPanel)