402 lines
24 KiB
TypeScript
402 lines
24 KiB
TypeScript
'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)
|