From 24fe0de19d863773da32d7d0f7981bf0c202c371 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 7 Oct 2026 13:36:37 +0800 Subject: [PATCH] fix: unify material filters and dropdown controls --- app/assets/material-library/page.tsx | 135 +++-------- app/complete-profile/page.tsx | 6 +- app/edit/[id]/page.tsx | 38 ++-- .../PerformanceSurveyAdminConfig.test.tsx | 47 ++-- .../PerformanceSurveyAdminConfig.tsx | 26 ++- .../PerformanceSurveyAdminPeople.test.tsx | 3 +- .../PerformanceSurveyAdminPeople.tsx | 10 +- ...formanceSurveyDashboardNavigation.test.tsx | 12 +- app/performance-survey/[[...path]]/page.tsx | 18 +- components/DepartmentPermissionsTab.tsx | 6 +- components/SuperAdminsTab.tsx | 10 +- components/UploadModal.task-other.test.tsx | 6 +- components/UploadModal.video-preview.test.tsx | 27 +-- components/admin/DataStatisticsTab.tsx | 10 +- components/admin/DepartmentManagement.tsx | 15 +- components/admin/NotificationCenterPanel.tsx | 14 +- .../BrandKnowledgeDashboard.test.tsx | 47 ++-- .../BrandKnowledgeDashboard.tsx | 8 +- .../BrandKnowledgeUploadDialog.tsx | 6 +- .../MonthlyLaunchPeriodFilter.test.tsx | 12 +- .../MonthlyLaunchPeriodFilter.tsx | 6 +- .../OnTimeDeliveryInsights.test.tsx | 3 +- .../PerformanceConversionDetail.tsx | 6 +- .../UserPermissionsDashboard.tsx | 6 +- components/home/FilterPanel.test.tsx | 43 ++++ components/home/FilterPanel.tsx | 212 ++++++++---------- components/home/MaterialCard.tsx | 2 +- components/ui/Select.test.tsx | 61 +++++ components/ui/Select.tsx | 154 +++++++++++++ components/ui/useAnchoredPopover.ts | 34 +++ components/upload/AIOriginalUploader.tsx | 18 +- components/upload/BatchFileList.tsx | 22 +- components/upload/CertificateDatePicker.tsx | 89 +++++--- .../upload/CertificateReportUploader.test.tsx | 11 +- .../upload/CertificateReportUploader.tsx | 10 +- components/upload/LiveClipFields.tsx | 11 +- components/upload/MaterialTypeSelect.test.tsx | 4 +- components/upload/MaterialTypeSelect.tsx | 165 +------------- .../ProductPackageUploader.layout.test.tsx | 23 +- .../upload/ProductPackageUploader.test.tsx | 28 ++- components/upload/ProductPackageUploader.tsx | 15 +- components/upload/RealPhotoUploader.tsx | 18 +- components/upload/SpecialBatchUploadUI.tsx | 7 +- components/upload/TaskPageUploader.tsx | 18 +- .../upload/TaskPageUploader.upload.test.tsx | 11 +- components/upload/ThreeDOriginalUploader.tsx | 18 +- components/upload/UploadForm.tsx | 23 +- components/upload/VideoScreenshotUploader.tsx | 18 +- components/work-review/WorkReviewBoard.tsx | 6 +- docs/素材库筛选与下拉控件验收-2026-10-07.md | 40 ++++ lib/__tests__/material-edit-page.test.tsx | 7 +- test-utils/select.ts | 30 +++ 52 files changed, 905 insertions(+), 670 deletions(-) create mode 100644 components/home/FilterPanel.test.tsx create mode 100644 components/ui/Select.test.tsx create mode 100644 components/ui/Select.tsx create mode 100644 components/ui/useAnchoredPopover.ts create mode 100644 docs/素材库筛选与下拉控件验收-2026-10-07.md create mode 100644 test-utils/select.ts diff --git a/app/assets/material-library/page.tsx b/app/assets/material-library/page.tsx index 4ca4148..7051555 100644 --- a/app/assets/material-library/page.tsx +++ b/app/assets/material-library/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } f import { useSession } from 'next-auth/react' import { useRouter, usePathname } from 'next/navigation' import Link from 'next/link' +import { X } from 'lucide-react' import UploadModal from '@/components/UploadModal' import RankingPanel from '@/components/RankingPanel' import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell' @@ -256,6 +257,9 @@ export default function MaterialLibraryPage() { selectedScene, selectedMaterialType, selectedPlatform, + selectedLivePlatform, + liveDateFrom, + liveDateTo, selectedProductCategory, selectedMonth, sortBy, @@ -303,6 +307,9 @@ export default function MaterialLibraryPage() { if (category) setSelectedCategory(category) if (scene) setSelectedScene(scene) if (platform) setSelectedPlatform(platform) + if (params.get('livePlatform')) setSelectedLivePlatform(params.get('livePlatform')!) + if (params.get('liveDateFrom')) setLiveDateFrom(params.get('liveDateFrom')!) + if (params.get('liveDateTo')) setLiveDateTo(params.get('liveDateTo')!) if (productCategory) setSelectedProductCategory(productCategory) if (month) setSelectedMonth(month) if (search) setSearchKeyword(search) @@ -331,7 +338,7 @@ export default function MaterialLibraryPage() { const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname window.history.replaceState({}, '', newUrl) - }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) + }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedLivePlatform, liveDateFrom, liveDateTo, selectedProductCategory, selectedMonth, sortBy, sortOrder]) useEffect(() => { fetchThemeColor() @@ -418,6 +425,9 @@ export default function MaterialLibraryPage() { if (cache.selectedScene) setSelectedScene(cache.selectedScene) if (cache.selectedMaterialType) setSelectedMaterialType(normalizeVideoMaterialType(cache.selectedMaterialType)) if (cache.selectedPlatform) setSelectedPlatform(cache.selectedPlatform) + if (cache.selectedLivePlatform) setSelectedLivePlatform(cache.selectedLivePlatform) + if (cache.liveDateFrom) setLiveDateFrom(cache.liveDateFrom) + if (cache.liveDateTo) setLiveDateTo(cache.liveDateTo) if (cache.selectedProductCategory) setSelectedProductCategory(cache.selectedProductCategory) if (cache.selectedMonth) setSelectedMonth(cache.selectedMonth) if (cache.sortBy) setSortBy(cache.sortBy) @@ -555,7 +565,7 @@ export default function MaterialLibraryPage() { fetchMaterials(1, false) }, 100) return () => clearTimeout(timer) - }, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) + }, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedLivePlatform, liveDateFrom, liveDateTo, selectedProductCategory, selectedMonth, sortBy, sortOrder]) // 获取主题颜色 const fetchThemeColor = async () => { @@ -1276,6 +1286,9 @@ export default function MaterialLibraryPage() { selectedScene: selectedScene, selectedMaterialType: selectedMaterialType, selectedPlatform: selectedPlatform, + selectedLivePlatform, + liveDateFrom, + liveDateTo, selectedProductCategory: selectedProductCategory, selectedMonth: selectedMonth, sortBy: sortBy, @@ -1347,6 +1360,17 @@ export default function MaterialLibraryPage() { return null } + const activeFilters = [ + { label: '搜索', value: searchKeyword, clear: () => setSearchKeyword('') }, + { label: '素材类型', value: selectedMaterialType, clear: () => setSelectedMaterialType('') }, + { label: '店铺', value: selectedCategory, clear: () => handleCategoryChange('') }, + { label: '场景', value: selectedScene, clear: () => setSelectedScene('') }, + { label: '平台分类', value: selectedPlatform, clear: () => setSelectedPlatform('') }, + { label: '直播平台', value: selectedLivePlatform, clear: () => setSelectedLivePlatform('') }, + { label: '直播日期', value: liveDateFrom || liveDateTo ? (liveDateFrom || '不限') + ' 至 ' + (liveDateTo || '不限') : '', clear: () => { setLiveDateFrom(''); setLiveDateTo('') } }, + { label: '产品类别', value: selectedProductCategory, clear: () => setSelectedProductCategory('') }, + { label: '上传时间', value: months.find((month) => month.value === selectedMonth)?.label || selectedMonth, clear: () => setSelectedMonth('') }, + ].filter((item) => item.value) const canAccessBrandCenter = session.user.canAccessBrandCenter === true return ( @@ -1423,103 +1447,14 @@ export default function MaterialLibraryPage() { themeColor={themeColor} /> - {/* Active Filters Display */} - {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && ( -
-
- 当前筛选: - {searchKeyword && ( - - 搜索: {searchKeyword} - - - )} - {selectedMaterialType && ( - - 素材类型: {selectedMaterialType} - - - )} - {selectedCategory && ( - - 店铺: {selectedCategory} - - - )} - {selectedScene && ( - - 场景类别: {selectedScene} - - - )} - {selectedPlatform && ( - - 平台分类: {selectedPlatform} - - - )} - {selectedProductCategory && ( - - 产品类别: {selectedProductCategory} - - - )} - {selectedMonth && ( - - 月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} - - - )} - -
+ {activeFilters.length > 0 && ( +
+ 已选条件 + {activeFilters.map((item) => + {item.label}:{item.value} + + )} +
)}
@@ -1541,7 +1476,7 @@ export default function MaterialLibraryPage() { downloadProgress={downloadProgress} themeColor={themeColor} searchKeyword={searchKeyword} - hasFilters={!!(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth)} + hasFilters={activeFilters.length > 0} onAudioPlay={handleAudioPlay} onDownload={handleDownloadByDirectLink} onLike={handleLike} diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index 87994dc..650a518 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useEffect, useState, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' @@ -238,7 +240,7 @@ function CompleteProfileContent() { - + diff --git a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx index 4fbd58c..17ea747 100644 --- a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx +++ b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import Image from 'next/image' import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react' import { AlertCircle, FileText, FolderOpen, ImagePlus, Upload, X } from 'lucide-react' @@ -466,8 +468,8 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return {isUploading && uploadProgress !== null &&
主文件上传进度{uploadProgress}%{uploadSpeedLabel ? ` · ${uploadSpeedLabel}` : ''}
} -
-
+
+

预览图片 (可选)

{previewImage && }
event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); void setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ?
{previewUrl ? {`预览图:${previewImage.name}`} :

{previewImage.name}

{formatFileSize(previewImage.size)} · {previewSlices.length === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}

:

拖放预览图片到这里,或选择图片

支持 JPG、PNG、WebP,单张不超过 50MB

}
diff --git a/components/department-dashboard/MonthlyLaunchPeriodFilter.test.tsx b/components/department-dashboard/MonthlyLaunchPeriodFilter.test.tsx index 5857ff9..5a23dd1 100644 --- a/components/department-dashboard/MonthlyLaunchPeriodFilter.test.tsx +++ b/components/department-dashboard/MonthlyLaunchPeriodFilter.test.tsx @@ -1,3 +1,4 @@ +import { changeField, openSelect } from '@/test-utils/select' import { fireEvent, render, screen } from '@testing-library/react' import MonthlyLaunchPeriodFilter from './MonthlyLaunchPeriodFilter' @@ -29,11 +30,12 @@ describe('MonthlyLaunchPeriodFilter', () => { it('shows available month presets and navigates with a month query', () => { render() + openSelect(screen.getByRole('combobox', { name: '选择统计月份' })) expect(screen.getByRole('option', { name: '本月' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '上月' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '自定义日期' })).toBeInTheDocument() - fireEvent.change(screen.getByRole('combobox', { name: '选择统计月份' }), { + changeField(screen.getByRole('combobox', { name: '选择统计月份' }), { target: { value: '2026-07' }, }) @@ -45,8 +47,8 @@ describe('MonthlyLaunchPeriodFilter', () => { it('submits an inclusive custom date range', () => { render() - fireEvent.change(screen.getByLabelText(/统计开始日期/), { target: { value: '2026-06-15' } }) - fireEvent.change(screen.getByLabelText(/统计结束日期/), { target: { value: '2026-06-30' } }) + changeField(screen.getByLabelText(/统计开始日期/), { target: { value: '2026-06-15' } }) + changeField(screen.getByLabelText(/统计结束日期/), { target: { value: '2026-06-30' } }) expect(screen.getByRole('combobox', { name: '选择统计月份' })).toHaveValue('custom') fireEvent.submit(screen.getByRole('form', { name: '本月上新日期筛选' })) @@ -58,8 +60,8 @@ describe('MonthlyLaunchPeriodFilter', () => { it('disables apply when the end date is before the start date', () => { render() - fireEvent.change(screen.getByLabelText(/统计开始日期/), { target: { value: '2026-08-20' } }) - fireEvent.change(screen.getByLabelText(/统计结束日期/), { target: { value: '2026-08-10' } }) + changeField(screen.getByLabelText(/统计开始日期/), { target: { value: '2026-08-20' } }) + changeField(screen.getByLabelText(/统计结束日期/), { target: { value: '2026-08-10' } }) expect(screen.getByRole('button', { name: /应用筛选/ })).toBeDisabled() expect(mockPush).not.toHaveBeenCalled() diff --git a/components/department-dashboard/MonthlyLaunchPeriodFilter.tsx b/components/department-dashboard/MonthlyLaunchPeriodFilter.tsx index 91db683..8075acb 100644 --- a/components/department-dashboard/MonthlyLaunchPeriodFilter.tsx +++ b/components/department-dashboard/MonthlyLaunchPeriodFilter.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { CalendarDays, Filter } from 'lucide-react' import { FormEvent, useEffect, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' @@ -72,7 +74,7 @@ export default function MonthlyLaunchPeriodFilter({ >
diff --git a/components/department-dashboard/UserPermissionsDashboard.tsx b/components/department-dashboard/UserPermissionsDashboard.tsx index 56ad227..77fa105 100644 --- a/components/department-dashboard/UserPermissionsDashboard.tsx +++ b/components/department-dashboard/UserPermissionsDashboard.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useCallback, useEffect, useState } from 'react' import Link from 'next/link' import { useSession } from 'next-auth/react' @@ -209,7 +211,7 @@ export default function UserPermissionsDashboard() { {extraGroups.length > 0 && ( )} diff --git a/components/home/FilterPanel.test.tsx b/components/home/FilterPanel.test.tsx new file mode 100644 index 0000000..5df66f1 --- /dev/null +++ b/components/home/FilterPanel.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import FilterPanel from './FilterPanel' + +const props = { + selectedMaterialType: '', setSelectedMaterialType: jest.fn(), selectedCategory: '', setSelectedCategory: jest.fn(), + selectedScene: '', setSelectedScene: jest.fn(), selectedPlatform: '', setSelectedPlatform: jest.fn(), + selectedLivePlatform: '', setSelectedLivePlatform: jest.fn(), liveDateFrom: '', setLiveDateFrom: jest.fn(), liveDateTo: '', setLiveDateTo: jest.fn(), + selectedProductCategory: '', setSelectedProductCategory: jest.fn(), selectedMonth: '', setSelectedMonth: jest.fn(), + sortBy: 'createdAt', setSortBy: jest.fn(), sortOrder: 'desc' as const, setSortOrder: jest.fn(), + materialTypes: [{ value: '任务-详情', label: '任务-详情', count: 30 }], + categories: Array.from({ length: 14 }, (_, index) => ({ id: String(index), name: `店铺${index}`, count: 30 - index })), + scenes: [], platforms: [], productCategories: [], months: [{ value: '2026-09', label: '2026年9月', count: 20 }], + totalMaterialsCount: 30, materialsCount: 30, isFilterCollapsed: false, setIsFilterCollapsed: jest.fn(), themeColor: '#1a1c1d', +} + +beforeEach(() => jest.clearAllMocks()) + +test('长选项列表收起时仍展示已选项,展开后可切换,计数与年份月份语义保留', async () => { + const user = userEvent.setup() + render() + const shops = screen.getByRole('group', { name: '使用店铺' }) + expect(within(shops).getByRole('button', { name: '店铺13 17' })).toHaveAttribute('aria-pressed', 'true') + expect(within(shops).queryByRole('button', { name: '店铺12 18' })).not.toBeInTheDocument() + await user.click(within(shops).getByRole('button', { name: '更多 3' })) + await user.click(within(shops).getByRole('button', { name: '店铺12 18' })) + expect(props.setSelectedCategory).toHaveBeenCalledWith('店铺12') + expect(screen.getByRole('button', { name: '9月 20' })).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: '2026年 20' })) + expect(props.setSelectedMonth).toHaveBeenCalledWith('2026') +}) + +test('日历限制倒置范围,返回统一日期;直播日期和平台也计入收起后的条件数量', async () => { + const user = userEvent.setup() + const { rerender } = render() + await user.click(screen.getByRole('button', { name: /直播结束日期/ })) + expect(screen.getByRole('button', { name: '2026年9月19日' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: '2026年9月24日' })) + expect(props.setLiveDateTo).toHaveBeenCalledWith('2026-09-24') + rerender() + expect(screen.queryByRole('region', { name: '素材筛选条件' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '展开更多筛选' })).toHaveTextContent('2') +}) diff --git a/components/home/FilterPanel.tsx b/components/home/FilterPanel.tsx index a632d39..42ee1d4 100644 --- a/components/home/FilterPanel.tsx +++ b/components/home/FilterPanel.tsx @@ -1,6 +1,30 @@ '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 @@ -96,14 +120,11 @@ function FilterPanel(props: FilterPanelProps) { productCategory: facetTotals?.productCategory ?? totalMaterialsCount, month: facetTotals?.month ?? totalMaterialsCount, } - const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length + 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 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 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) @@ -137,8 +158,11 @@ function FilterPanel(props: FilterPanelProps) { 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 @@ -200,11 +224,12 @@ function FilterPanel(props: FilterPanelProps) { useEffect(() => { if (!moreOpen) return + moreMenuRef.current?.querySelector('[aria-selected="true"], button')?.focus() const onPointer = (event: MouseEvent) => { - if (!moreWrapRef.current?.contains(event.target as Node)) setMoreOpen(false) + 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) + if (event.key === 'Escape') { setMoreOpen(false); moreTriggerRef.current?.focus() } } document.addEventListener('mousedown', onPointer) document.addEventListener('keydown', onKey) @@ -219,7 +244,7 @@ function FilterPanel(props: FilterPanelProps) { }, [selectedMaterialType]) const renderTypeTab = (label: string, count: number, active: boolean, onClick: () => void) => ( - - {moreOpen && ( -
+ {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 )} )} @@ -289,16 +323,20 @@ function FilterPanel(props: FilterPanelProps) { -
+

共 {facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')} 条 {selectedMaterialType && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}} @@ -315,109 +353,45 @@ function FilterPanel(props: FilterPanelProps) {

-
-
-

使用店铺

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

场景类别

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

平台分类

-
- - {rankedPlatforms.map((item) => )} -
-
} - {setSelectedLivePlatform && ( -
-

直播平台

-
- {['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => ( - - ))} -
-
- )} - {(setLiveDateFrom || setLiveDateTo) && ( -
-

直播日期

-
- setLiveDateFrom?.(e.target.value)} - className={`${chipBase} cursor-text`} - /> - 至 - setLiveDateTo?.(e.target.value)} - className={`${chipBase} cursor-text`} - /> -
-
- )} - {(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 ( - - ) - })} + {!isFilterCollapsed && ( +
+ + + {(rankedPlatforms.length > 0 || facetsLoading) && } + {setSelectedLivePlatform && ( +
+

直播平台

+
+ {['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => )} +
)} -
-

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

-
+ {setLiveDateFrom && setLiveDateTo && ( +
+

直播日期

+
+ + 至 + +
+
+ )} + {(rankedProductCategories.length > 0 || facetsLoading) && } +
+

上传时间

+
+
+ + {yearFacets.map((item) => )} +
+ {monthsInFocusedYear.length > 0 &&
+ {monthsInFocusedYear.map((item) => )} +
} +
+
+

数量随筛选条件更新,仅显示仍有结果的选项。

+ + )} ) } diff --git a/components/home/MaterialCard.tsx b/components/home/MaterialCard.tsx index cd2ce4d..72b9b59 100644 --- a/components/home/MaterialCard.tsx +++ b/components/home/MaterialCard.tsx @@ -347,7 +347,7 @@ export default function MaterialCard({
{/* Preview Image/Video */}
isVideoType && videoPreviewUrl && handleVideoMouseEnter()} onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()} diff --git a/components/ui/Select.test.tsx b/components/ui/Select.test.tsx new file mode 100644 index 0000000..c362e45 --- /dev/null +++ b/components/ui/Select.test.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Select from './Select' + +test('选项标签与提交值不同,仍调用原生表单回调并提交正确值', async () => { + const changed = jest.fn() + function Form() { + const [value, setValue] = useState('') + return
+ } + const user = userEvent.setup() + render(
) + await user.click(screen.getByRole('combobox', { name: '出图方式' })) + await user.click(screen.getByRole('option', { name: '实拍' })) + expect(changed).toHaveBeenCalledWith('photo') + expect(new FormData(screen.getByRole('form') as HTMLFormElement).get('method')).toBe('photo') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(screen.getByRole('combobox')).toHaveFocus() +}) + +test('键盘跳过禁用选项,Escape 只关闭菜单并恢复焦点', async () => { + const user = userEvent.setup(), changed = jest.fn(), parentKey = jest.fn() + render(
) + screen.getByRole('combobox').focus() + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}') + expect(changed).toHaveBeenCalledTimes(1) + expect(screen.getByRole('combobox')).toHaveTextContent('AI') + await user.click(screen.getByRole('combobox')) + parentKey.mockClear() + await user.keyboard('{Escape}') + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + expect(screen.getByRole('combobox')).toHaveFocus() + expect(parentKey).not.toHaveBeenCalled() +}) + +test('保留必填校验,原生校验失败将焦点交给可见控件', () => { + render(
) + act(() => { expect((screen.getByRole('form') as HTMLFormElement).checkValidity()).toBe(false) }) + expect(screen.getByRole('combobox')).toHaveFocus() + expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true') +}) + +test('支持回调内重置非受控字段与表单 reset', async () => { + const user = userEvent.setup() + const { container } = render(
) + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: 'AI' })) + expect(screen.getByRole('combobox')).toHaveTextContent('请选择') + fireEvent.reset(container.querySelector('form')!) +}) + +test('禁用状态不打开菜单;长选项和空列表也可显示', async () => { + const user = userEvent.setup() + const { rerender } = render() + await user.click(screen.getByRole('combobox')) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + rerender( triggerRef.current?.focus()} + onChange={(event) => { onChange?.(event); setInternalValue(event.currentTarget.value); setInvalid(false) }} + onInvalid={(event) => { onInvalid?.(event); event.preventDefault(); setInvalid(true); triggerRef.current?.focus() }}> + {children} + + {open && createPortal( +
+ {menuHint &&

{menuHint}

} + {options.length ? options.map((option, index) => ( +
choose(option)} onPointerMove={() => !option.disabled && setActive(index)} + className={`flex min-h-11 items-center gap-2 rounded-[8px] px-3 py-2 text-sm ${option.disabled ? 'cursor-not-allowed text-[#66758b] opacity-60' : 'cursor-pointer'} ${index === active && !option.disabled ? 'bg-[#eef2f6]' : ''} ${option.value === selected?.value ? 'font-semibold' : ''}`}> +
+ )) :

暂无可选项

} +
, document.body + )} + + ) +} diff --git a/components/ui/useAnchoredPopover.ts b/components/ui/useAnchoredPopover.ts new file mode 100644 index 0000000..b79bbe1 --- /dev/null +++ b/components/ui/useAnchoredPopover.ts @@ -0,0 +1,34 @@ +'use client' + +import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react' + +/** Keep portalled controls inside the viewport, including within scrolling dialogs. */ +export function useAnchoredPopover(open: boolean, anchor: RefObject, width?: number, height = 320) { + const [position, setPosition] = useState({ visibility: 'hidden' }) + useLayoutEffect(() => { + if (!open) return + const update = () => { + if (!anchor.current) return + const rect = anchor.current.getBoundingClientRect() + const menuWidth = Math.min(width ?? rect.width, window.innerWidth - 24) + const below = window.innerHeight - rect.bottom - 20 + const above = rect.top - 20 + const flip = below < Math.min(height, 240) && above > below + setPosition({ + position: 'fixed', + width: menuWidth, + left: Math.max(12, Math.min(rect.left, window.innerWidth - menuWidth - 12)), + ...(flip ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }), + maxHeight: Math.max(120, Math.min(height, flip ? above : below)), + }) + } + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [open, anchor, width, height]) + return position +} diff --git a/components/upload/AIOriginalUploader.tsx b/components/upload/AIOriginalUploader.tsx index 77bcd6f..ea16319 100644 --- a/components/upload/AIOriginalUploader.tsx +++ b/components/upload/AIOriginalUploader.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import CompactImageFileGrid from './CompactImageFileGrid'; @@ -381,7 +383,7 @@ export default function AIOriginalUploader({ )} - +
)} @@ -424,7 +426,7 @@ export default function AIOriginalUploader({
) : (
- +
)} @@ -464,7 +466,7 @@ export default function AIOriginalUploader({ ) : (
- +
)} @@ -487,7 +489,7 @@ export default function AIOriginalUploader({ - + )} diff --git a/components/upload/BatchFileList.tsx b/components/upload/BatchFileList.tsx index b033a45..178e878 100644 --- a/components/upload/BatchFileList.tsx +++ b/components/upload/BatchFileList.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import Image from 'next/image' import { Plus } from 'lucide-react' import type { BatchFileItem } from './upload-types' @@ -304,7 +306,7 @@ export default function BatchFileList({ )} - +
- +
)})()} @@ -348,7 +350,7 @@ export default function BatchFileList({ - + )} @@ -381,7 +383,7 @@ export default function BatchFileList({ )} - + )})()} @@ -404,7 +406,7 @@ export default function BatchFileList({ - + )} diff --git a/components/upload/CertificateDatePicker.tsx b/components/upload/CertificateDatePicker.tsx index 12dde98..3792897 100644 --- a/components/upload/CertificateDatePicker.tsx +++ b/components/upload/CertificateDatePicker.tsx @@ -3,6 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { addDays, addMonths, addYears, differenceInCalendarDays, endOfMonth, endOfWeek, format, isSameDay, isSameMonth, isToday, isValid, parseISO, startOfMonth, startOfWeek, subMonths, subYears } from 'date-fns' import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react' +import { createPortal } from 'react-dom' +import { useAnchoredPopover } from '@/components/ui/useAnchoredPopover' +import { fieldControlClass } from './upload-field-styles' const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'] @@ -20,9 +23,12 @@ interface CertificateDatePickerProps { disabled?: boolean placeholder?: string suggestedDate?: string + min?: string + max?: string + className?: string } -export default function CertificateDatePicker({ id, label, value, onChange, disabled = false, placeholder = '选择日期', suggestedDate = '' }: CertificateDatePickerProps) { +export default function CertificateDatePicker({ id, label, value, onChange, disabled = false, placeholder = '选择日期', suggestedDate = '', min = '', max = '', className = '' }: CertificateDatePickerProps) { const containerRef = useRef(null) const triggerRef = useRef(null) const calendarRef = useRef(null) @@ -31,16 +37,33 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa const [isOpen, setIsOpen] = useState(false) const [isMonthPickerOpen, setIsMonthPickerOpen] = useState(false) const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(calendarDate || new Date())) + const position = useAnchoredPopover(isOpen, triggerRef, 320, 460) + const outsideRange = (date: Date) => { + const key = format(date, 'yyyy-MM-dd') + return !!((min && key < min) || (max && key > max)) + } + + useEffect(() => { if (disabled) setIsOpen(false) }, [disabled]) useEffect(() => { if (calendarDate) setVisibleMonth(startOfMonth(calendarDate)) }, [calendarDate]) + useEffect(() => { + if (!isOpen || !calendarRef.current) return + const calendar = calendarRef.current + if (calendar.contains(document.activeElement)) return + const preferred = calendar.querySelector(`[data-date="${format(calendarDate || new Date(), 'yyyy-MM-dd')}"]:not([disabled])`) + const firstDay = calendar.querySelector(`[data-date^="${format(visibleMonth, 'yyyy-MM')}"]:not([disabled])`) + const focusTarget = preferred || firstDay || calendar.querySelector('button:not([disabled])') + focusTarget?.focus() + }, [isOpen, isMonthPickerOpen, calendarDate, visibleMonth]) + useEffect(() => { if (!isOpen) return const handlePointerDown = (event: PointerEvent) => { - if (!containerRef.current?.contains(event.target as Node)) setIsOpen(false) + if (!containerRef.current?.contains(event.target as Node) && !calendarRef.current?.contains(event.target as Node)) setIsOpen(false) } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -57,25 +80,6 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa } }, [isOpen]) - useEffect(() => { - if (!isOpen || !calendarRef.current) return - - const calendar = calendarRef.current - let scrollParent = calendar.parentElement - while (scrollParent) { - const overflowY = window.getComputedStyle(scrollParent).overflowY - if ((overflowY === 'auto' || overflowY === 'scroll') && scrollParent.scrollHeight > scrollParent.clientHeight) break - scrollParent = scrollParent.parentElement - } - - const visibleBottom = scrollParent?.getBoundingClientRect().bottom ?? window.innerHeight - const hiddenHeight = calendar.getBoundingClientRect().bottom - visibleBottom + 12 - if (hiddenHeight > 0) { - if (scrollParent) scrollParent.scrollTop += hiddenHeight - else window.scrollBy(0, hiddenHeight) - } - }, [isOpen, isMonthPickerOpen, visibleMonth]) - const days = useMemo(() => { const firstDay = startOfWeek(visibleMonth, { weekStartsOn: 1 }) const lastDay = endOfWeek(endOfMonth(visibleMonth), { weekStartsOn: 1 }) @@ -83,13 +87,16 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa }, [visibleMonth]) const selectDate = (date: Date) => { + if (outsideRange(date)) return onChange(format(date, 'yyyy-MM-dd')) setIsOpen(false) triggerRef.current?.focus() } return ( -
+
{ + if (event.key === 'Escape' && isOpen) { event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus() } + }}> - {isOpen && ( + {isOpen && createPortal( , document.body )}
) diff --git a/components/upload/CertificateReportUploader.test.tsx b/components/upload/CertificateReportUploader.test.tsx index fb8012c..1a32213 100644 --- a/components/upload/CertificateReportUploader.test.tsx +++ b/components/upload/CertificateReportUploader.test.tsx @@ -1,3 +1,4 @@ +import { changeField } from '@/test-utils/select' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import CertificateReportUploader from './CertificateReportUploader' import { chunkUpload } from '@/lib/chunk-upload' @@ -10,7 +11,7 @@ test('成品只填写 SPU 时不能上传,须先填写报告名称', () => { global.fetch = jest.fn() try { render() - fireEvent.change(screen.getByLabelText('SPU'), { target: { value: 'SPU-001' } }) + changeField(screen.getByLabelText('SPU'), { target: { value: 'SPU-001' } }) fireEvent.click(screen.getByRole('button', { name: '创建分享素材' })) expect(screen.getByText('请填写报告名称 / 检测对象')).toBeInTheDocument() expect(global.fetch).not.toHaveBeenCalled() @@ -32,9 +33,9 @@ test.each([['成品', true], ['成品', false], ['原材料', true], ['原材料 expect(reportTitleInput.compareDocumentPosition(screen.getByLabelText('SPU')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() } expect(screen.getByText('上传后将作为素材名称显示')).toBeInTheDocument() - fireEvent.change(screen.getByLabelText(/报告名称 \/ 检测对象/), { target: { value: '板材检测报告' } }) - fireEvent.change(screen.getByLabelText('品类 *'), { target: { value: '板材' } }) - fireEvent.change(screen.getByLabelText('证书编号 *'), { target: { value: 'CMA-001' } }) + changeField(screen.getByLabelText(/报告名称 \/ 检测对象/), { target: { value: '板材检测报告' } }) + changeField(screen.getByLabelText('品类 *'), { target: { value: '板材' } }) + changeField(screen.getByLabelText('证书编号 *'), { target: { value: 'CMA-001' } }) fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ })) fireEvent.click(screen.getByRole('button', { name: '2026年9月23日' })) expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('默认 2027-09-23') @@ -46,7 +47,7 @@ test.each([['成品', true], ['成品', false], ['原材料', true], ['原材料 expect(screen.getAllByRole('textbox').filter((input) => input.tagName === 'TEXTAREA')).toHaveLength(1) expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES) const pdf = new File(['%PDF-1.4'], '报告.pdf', { type: 'application/pdf' }) - fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: [pdf] } }) + changeField(container.querySelector('input[type="file"]')!, { target: { files: [pdf] } }) jest.useRealTimers() global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ materialId: 'report-1' }) }) ;(chunkUpload as jest.Mock).mockResolvedValue({ uploadId: 'file-1' }) diff --git a/components/upload/CertificateReportUploader.tsx b/components/upload/CertificateReportUploader.tsx index bfb4d60..495d3fa 100644 --- a/components/upload/CertificateReportUploader.tsx +++ b/components/upload/CertificateReportUploader.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useMemo, useRef, useState } from 'react' import { CERTIFICATE_REPORT_KIND, @@ -319,7 +321,7 @@ export default function CertificateReportUploader({
- +
- +
diff --git a/components/upload/LiveClipFields.tsx b/components/upload/LiveClipFields.tsx index 2e0c250..b4ba490 100644 --- a/components/upload/LiveClipFields.tsx +++ b/components/upload/LiveClipFields.tsx @@ -1,9 +1,12 @@ + /** * 直播切片专用字段:直播日期 / 主播(可关联用户)/ 直播平台 */ 'use client' +import Select from '@/components/ui/Select' + import { useEffect, useState } from 'react' import { fieldControlClass, @@ -104,7 +107,7 @@ export default function LiveClipFields({ - +
- +
) diff --git a/components/upload/MaterialTypeSelect.test.tsx b/components/upload/MaterialTypeSelect.test.tsx index 1f41592..5fe6cde 100644 --- a/components/upload/MaterialTypeSelect.test.tsx +++ b/components/upload/MaterialTypeSelect.test.tsx @@ -20,7 +20,7 @@ test('按首页数量降序排列可选类型,同数量保持原顺序,选 const user = userEvent.setup() const onChange = renderSelect({ '3D场景': 2, '音乐': 12, '直播切片': 12, '任务-其他': 0 }) - await user.click(screen.getByRole('button', { name: '素材类型 *' })) + await user.click(screen.getByRole('combobox', { name: '素材类型 *' })) const list = screen.getByRole('listbox', { name: '素材类型 *' }) expect(within(list).getAllByRole('option').map((option) => option.getAttribute('aria-label'))) .toEqual(['音乐', '直播切片', '3D场景', '任务-其他']) @@ -34,7 +34,7 @@ test('按首页数量降序排列可选类型,同数量保持原顺序,选 test('无数量时保留原顺序,键盘可操作并用 Escape 关闭', async () => { const user = userEvent.setup() const onChange = renderSelect() - const trigger = screen.getByRole('button', { name: '素材类型 *' }) + const trigger = screen.getByRole('combobox', { name: '素材类型 *' }) trigger.focus() await user.keyboard('{ArrowDown}') diff --git a/components/upload/MaterialTypeSelect.tsx b/components/upload/MaterialTypeSelect.tsx index 3722c61..24bc4a0 100644 --- a/components/upload/MaterialTypeSelect.tsx +++ b/components/upload/MaterialTypeSelect.tsx @@ -1,12 +1,7 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useGSAP } from '@gsap/react' -import gsap from 'gsap' -import { Check, ChevronDown } from 'lucide-react' -import { fieldControlClass } from '@/components/upload/upload-field-styles' - -gsap.registerPlugin(useGSAP) +import { useMemo } from 'react' +import Select from '@/components/ui/Select' interface MaterialTypeSelectProps { id: string @@ -17,154 +12,16 @@ interface MaterialTypeSelectProps { disabled?: boolean } -export default function MaterialTypeSelect({ - id, - value, - options, - counts, - onChange, - disabled = false, -}: MaterialTypeSelectProps) { - const containerRef = useRef(null) - const triggerRef = useRef(null) - const menuRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(0) - - const rankedOptions = useMemo(() => { - if (!options.some((option) => (counts[option] || 0) > 0)) return options - return [...options].sort((left, right) => (counts[right] || 0) - (counts[left] || 0)) - }, [options, counts]) +export default function MaterialTypeSelect({ id, value, options, counts, onChange, disabled = false }: MaterialTypeSelectProps) { + const rankedOptions = useMemo(() => options.some((option) => (counts[option] || 0) > 0) + ? [...options].sort((left, right) => (counts[right] || 0) - (counts[left] || 0)) + : options, [options, counts]) const hasCounts = rankedOptions.some((option) => (counts[option] || 0) > 0) - - useEffect(() => { - if (!isOpen) return - setActiveIndex(Math.max(0, rankedOptions.indexOf(value))) - menuRef.current?.focus() - }, [isOpen, value, rankedOptions]) - - useEffect(() => { - if (!isOpen) return - const onPointerDown = (event: PointerEvent) => { - if (!containerRef.current?.contains(event.target as Node)) setIsOpen(false) - } - document.addEventListener('pointerdown', onPointerDown) - return () => document.removeEventListener('pointerdown', onPointerDown) - }, [isOpen]) - - useEffect(() => { - if (disabled) setIsOpen(false) - }, [disabled]) - - useEffect(() => { - if (!isOpen) return - menuRef.current?.querySelector(`[data-option-index="${activeIndex}"]`)?.scrollIntoView?.({ block: 'nearest' }) - }, [activeIndex, isOpen]) - - useGSAP(() => { - if (!isOpen || !menuRef.current || typeof window.matchMedia !== 'function') return - const motion = gsap.matchMedia() - motion.add('(prefers-reduced-motion: no-preference)', () => { - gsap.fromTo(menuRef.current, { autoAlpha: 0, y: -6 }, { - autoAlpha: 1, - y: 0, - duration: 0.16, - ease: 'power2.out', - clearProps: 'opacity,visibility,transform', - }) - }) - return () => motion.revert() - }, { scope: containerRef, dependencies: [isOpen], revertOnUpdate: true }) - - const close = () => { - setIsOpen(false) - triggerRef.current?.focus() - } - - const select = (nextValue: string) => { - onChange(nextValue) - close() - } - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { - event.preventDefault() - close() - return - } - if (event.key === 'Tab' && isOpen) { - setIsOpen(false) - return - } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - if (!isOpen) { - setIsOpen(true) - } else { - setActiveIndex((index) => Math.max(0, Math.min(rankedOptions.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))) - } - return - } - if (!isOpen) return - if (event.key === 'Home' || event.key === 'End') { - event.preventDefault() - setActiveIndex(event.key === 'Home' ? 0 : rankedOptions.length - 1) - } else if ((event.key === 'Enter' || event.key === ' ') && rankedOptions[activeIndex]) { - event.preventDefault() - select(rankedOptions[activeIndex]) - } - } - return ( -
- - - {isOpen && ( -
- {hasCounts &&
按素材数量排序
} - {rankedOptions.map((option, index) => ( -
select(option)} - onPointerMove={() => setActiveIndex(index)} - className={`flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[14px] tracking-[-0.02em] ${index === activeIndex ? 'bg-[#f3f4f5]' : 'hover:bg-[#f3f4f5]'} ${option === value ? 'font-medium' : ''}`} - > - - - {hasCounts && {(counts[option] || 0).toLocaleString('zh-CN')}} -
- ))} -
- )} -
+ ) } diff --git a/components/upload/ProductPackageUploader.layout.test.tsx b/components/upload/ProductPackageUploader.layout.test.tsx index d1f8a5e..944a4d0 100644 --- a/components/upload/ProductPackageUploader.layout.test.tsx +++ b/components/upload/ProductPackageUploader.layout.test.tsx @@ -1,3 +1,4 @@ +import { selectOption, openSelect } from '@/test-utils/select' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -102,16 +103,16 @@ describe('ProductPackageUploader compact layout interactions', () => { await user.type(productModel, 'df-a3dz0008') expect(productModel).toHaveValue('DF-A3DZ0008') - await user.selectOptions(screen.getByLabelText(/品类/), '沙发') + await selectOption(screen.getByLabelText(/品类/), '沙发') expect(screen.getByLabelText(/品类/)).toHaveValue('沙发') - await user.selectOptions(screen.getByLabelText(/使用店铺/), '店铺B') + await selectOption(screen.getByLabelText(/使用店铺/), '店铺B') expect(screen.getByLabelText(/使用店铺/)).toHaveValue('店铺B') - await user.selectOptions(screen.getByLabelText(/场景类别/), '场景A') + await selectOption(screen.getByLabelText(/场景类别/), '场景A') expect(screen.getByLabelText(/场景类别/)).toHaveValue('场景A') - await user.selectOptions(screen.getByLabelText(/平台分类/), '淘宝') + await selectOption(screen.getByLabelText(/平台分类/), '淘宝') expect(screen.getByLabelText(/平台分类/)).toHaveValue('淘宝') }) @@ -148,23 +149,23 @@ describe('ProductPackageUploader compact layout interactions', () => { const demandType = screen.getByLabelText(/需求类型/) as HTMLSelectElement const visibility = screen.getByLabelText(/可见性/) as HTMLSelectElement - const privateOption = screen.getByRole('option', { name: '私密' }) as HTMLOptionElement + const privateOption = () => { openSelect(visibility); return screen.getByRole('option', { name: '私密' }) } expect(demandType.value).toBe('新品') expect(visibility.value).toBe('public') - expect(privateOption.disabled).toBe(true) + expect(privateOption()).toHaveAttribute('aria-disabled', 'true') expect(screen.getByText(/新品固定公开,仅优化需求支持私密/)).toBeInTheDocument() - await user.selectOptions(demandType, '优化') - expect(privateOption.disabled).toBe(false) + await selectOption(demandType, '优化') + expect(privateOption()).toHaveAttribute('aria-disabled', 'false') expect(screen.getByText('私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。')).toBeInTheDocument() - await user.selectOptions(visibility, 'private') + await selectOption(visibility, 'private') expect(visibility.value).toBe('private') - await user.selectOptions(demandType, '新品') + await selectOption(demandType, '新品') expect(visibility.value).toBe('public') - expect(privateOption.disabled).toBe(true) + expect(privateOption()).toHaveAttribute('aria-disabled', 'true') }) it('四类文件选择入口都存在', async () => { diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 68c769d..e44ebbe 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -1,3 +1,4 @@ +import { changeField, selectOption, openSelect } from '@/test-utils/select' import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -93,7 +94,7 @@ async function fillRequiredTaskFields( await user.click(screen.getByRole('option', { name: /运营甲/ })) await user.click(screen.getByRole('button', { name: '设置测试 SPU' })) await user.click(screen.getByRole('button', { name: '设置测试店铺' })) - await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍') + await selectOption(screen.getByLabelText(/出图方式/), '实拍') await user.upload(screen.getByLabelText('主图'), files) } @@ -226,9 +227,11 @@ describe('ProductPackageUploader', () => { const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement expect(demandTypeSelect.value).toBe('新品') expect(visibilitySelect.value).toBe('public') - expect(screen.getByRole('option', { name: '私密' })).toBeDisabled() + openSelect(visibilitySelect) + expect(screen.getByRole('option', { name: '私密' })).toHaveAttribute('aria-disabled', 'true') expect(select.value).toBe('') - expect(select).toBeRequired() + expect(select).toHaveAttribute('aria-required', 'true') + openSelect(select) expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument() @@ -268,14 +271,15 @@ describe('ProductPackageUploader', () => { const demandTypeSelect = screen.getByLabelText(/需求类型/) as HTMLSelectElement const visibilitySelect = screen.getByLabelText(/可见性/) as HTMLSelectElement - await user.selectOptions(demandTypeSelect, '优化') - expect(screen.getByRole('option', { name: '私密' })).not.toBeDisabled() + await selectOption(demandTypeSelect, '优化') + openSelect(visibilitySelect) + expect(screen.getByRole('option', { name: '私密' })).toHaveAttribute('aria-disabled', 'false') expect(screen.getByText('私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。')).toBeInTheDocument() - await user.selectOptions(visibilitySelect, 'private') + await selectOption(visibilitySelect, 'private') expect(visibilitySelect.value).toBe('private') - await user.selectOptions(demandTypeSelect, '新品') + await selectOption(demandTypeSelect, '新品') expect(visibilitySelect.value).toBe('public') await user.click(screen.getByRole('button', { name: '提交' })) @@ -477,8 +481,8 @@ describe('ProductPackageUploader', () => { const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' }) act(() => { - fireEvent.change(input, { target: { files: [first] } }) - fireEvent.change(input, { target: { files: [second, third] } }) + changeField(input, { target: { files: [first] } }) + changeField(input, { target: { files: [second, third] } }) }) await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2)) @@ -514,8 +518,8 @@ describe('ProductPackageUploader', () => { const first = new File(['first'], 'queue-first.jpg', { type: 'image/jpeg' }) const second = new File(['second'], 'queue-second.jpg', { type: 'image/jpeg' }) - fireEvent.change(input, { target: { files: [first] } }) - fireEvent.change(input, { target: { files: [second] } }) + changeField(input, { target: { files: [first] } }) + changeField(input, { target: { files: [second] } }) await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(1)) expect(screen.getByRole('img', { name: 'queue-first.jpg:正在生成缩略图' })).toBeInTheDocument() @@ -998,7 +1002,7 @@ describe('ProductPackageUploader', () => { await user.click(screen.getByRole('option', { name: /运营甲/ })) await user.click(screen.getByRole('button', { name: '设置测试 SPU' })) await user.click(screen.getByRole('button', { name: '设置测试店铺' })) - await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍') + await selectOption(screen.getByLabelText(/出图方式/), '实拍') await user.upload( screen.getByLabelText('主图'), new File(['image'], 'photo.jpg', { type: 'image/jpeg' }), diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 345df31..0659dc7 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -1,9 +1,12 @@ + /** * 任务-详情上传组件 */ 'use client'; +import Select from '@/components/ui/Select' + import { useCallback, useEffect, useRef, useState } from 'react'; import DetailImageSorter from './DetailImageSorter'; import { dropZoneActiveClass, dropZoneIdleClass, inkButtonClass, outlineButtonClass } from './upload-field-styles'; @@ -1432,7 +1435,7 @@ export default function ProductPackageUploader({ - + @@ -1482,7 +1485,7 @@ export default function ProductPackageUploader({ - +
- +
diff --git a/components/upload/RealPhotoUploader.tsx b/components/upload/RealPhotoUploader.tsx index 4d3ec9c..2e81378 100644 --- a/components/upload/RealPhotoUploader.tsx +++ b/components/upload/RealPhotoUploader.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import CompactImageFileGrid from './CompactImageFileGrid'; @@ -378,7 +380,7 @@ export default function RealPhotoUploader({ )} - + )} @@ -421,7 +423,7 @@ export default function RealPhotoUploader({ ) : (
- +
)} @@ -461,7 +463,7 @@ export default function RealPhotoUploader({ ) : (
- +
)} @@ -484,7 +486,7 @@ export default function RealPhotoUploader({ - + )} diff --git a/components/upload/SpecialBatchUploadUI.tsx b/components/upload/SpecialBatchUploadUI.tsx index ade0d13..66afba6 100644 --- a/components/upload/SpecialBatchUploadUI.tsx +++ b/components/upload/SpecialBatchUploadUI.tsx @@ -1,3 +1,4 @@ + /** * 特殊批量上传UI组件 * 包含音乐、视频、3D场景、品牌VI、买家秀、小红书的批量上传界面 @@ -5,6 +6,8 @@ 'use client' +import Select from '@/components/ui/Select' + import Image from 'next/image' import { formatBytes } from '@/components/upload/upload-utils' import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model' @@ -168,7 +171,7 @@ export function BatchVideoList({ - + )} ) : (
- +
) : (
- +
)} @@ -532,7 +534,7 @@ export default function TaskPageUploader({ )} - + )} diff --git a/components/upload/TaskPageUploader.upload.test.tsx b/components/upload/TaskPageUploader.upload.test.tsx index b71386d..4989aaf 100644 --- a/components/upload/TaskPageUploader.upload.test.tsx +++ b/components/upload/TaskPageUploader.upload.test.tsx @@ -1,3 +1,4 @@ +import { changeField } from '@/test-utils/select' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import TaskPageUploader from '@/components/upload/TaskPageUploader' @@ -57,13 +58,13 @@ describe('TaskPageUploader upload contract', () => { />, ) - fireEvent.change(screen.getByLabelText(/SPU型号/), { target: { value: 'SJ0371' } }) - fireEvent.change(screen.getByLabelText(/使用店铺/), { target: { value: '索菲亚' } }) - fireEvent.change(screen.getByLabelText(/场景类别/), { target: { value: '书房' } }) + changeField(screen.getByLabelText(/SPU型号/), { target: { value: 'SJ0371' } }) + changeField(screen.getByLabelText(/使用店铺/), { target: { value: '索菲亚' } }) + changeField(screen.getByLabelText(/场景类别/), { target: { value: '书房' } }) const imageInput = container.querySelector('input[type="file"][accept="image/*"]') as HTMLInputElement const image = new File(['preview'], 'page-1.jpg', { type: 'image/jpeg' }) - fireEvent.change(imageInput, { target: { files: [image] } }) + changeField(imageInput, { target: { files: [image] } }) fireEvent.click(screen.getByRole('button', { name: '提交' })) await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) @@ -107,7 +108,7 @@ describe('TaskPageUploader upload contract', () => { value: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + 1, }) - fireEvent.change(imageInput, { target: { files: [oversized] } }) + changeField(imageInput, { target: { files: [oversized] } }) expect(screen.getByText(/too-large\.jpg/)).toHaveTextContent('超过 100MB') expect(screen.queryByAltText('预览图 1')).not.toBeInTheDocument() diff --git a/components/upload/ThreeDOriginalUploader.tsx b/components/upload/ThreeDOriginalUploader.tsx index b2317b2..5cdc9d9 100644 --- a/components/upload/ThreeDOriginalUploader.tsx +++ b/components/upload/ThreeDOriginalUploader.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import CompactImageFileGrid from './CompactImageFileGrid'; @@ -381,7 +383,7 @@ export default function ThreeDOriginalUploader({ )} - + )} @@ -424,7 +426,7 @@ export default function ThreeDOriginalUploader({ ) : (
- +
)} @@ -464,7 +466,7 @@ export default function ThreeDOriginalUploader({ ) : (
- +
)} @@ -487,7 +489,7 @@ export default function ThreeDOriginalUploader({ - + )} diff --git a/components/upload/UploadForm.tsx b/components/upload/UploadForm.tsx index 16d98ea..794f6b6 100644 --- a/components/upload/UploadForm.tsx +++ b/components/upload/UploadForm.tsx @@ -1,3 +1,4 @@ + /** * 上传表单字段组件 * 包含 SPU、店铺、场景、平台、品类、渲染器等表单字段 @@ -5,6 +6,8 @@ 'use client' +import Select from '@/components/ui/Select' + import { useState, useEffect, useRef } from 'react' import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation' import { @@ -301,7 +304,7 @@ export default function UploadForm({ )} - + )} @@ -360,7 +363,7 @@ export default function UploadForm({ ) : (
- +
) : (
- +
)} @@ -492,7 +495,7 @@ export default function UploadForm({ - + )} diff --git a/components/upload/VideoScreenshotUploader.tsx b/components/upload/VideoScreenshotUploader.tsx index ff4d4b5..91925ba 100644 --- a/components/upload/VideoScreenshotUploader.tsx +++ b/components/upload/VideoScreenshotUploader.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import CompactImageFileGrid from './CompactImageFileGrid'; @@ -396,7 +398,7 @@ export default function VideoScreenshotUploader({ )} - + )} @@ -439,7 +441,7 @@ export default function VideoScreenshotUploader({ ) : (
- +
)} @@ -479,7 +481,7 @@ export default function VideoScreenshotUploader({ ) : (
- +
)} @@ -502,7 +504,7 @@ export default function VideoScreenshotUploader({ - + )} diff --git a/components/work-review/WorkReviewBoard.tsx b/components/work-review/WorkReviewBoard.tsx index 2335f6d..e990d37 100644 --- a/components/work-review/WorkReviewBoard.tsx +++ b/components/work-review/WorkReviewBoard.tsx @@ -1,5 +1,7 @@ 'use client' +import Select from '@/components/ui/Select' + import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' @@ -410,7 +412,7 @@ export default function WorkReviewBoard() {