fix: unify material filters and dropdown controls

This commit is contained in:
WuYongJie 2026-10-07 13:36:37 +08:00
parent 4653a56392
commit 24fe0de19d
52 changed files with 905 additions and 670 deletions

View File

@ -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) && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-apple p-3 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap">
<span className="font-medium text-blue-600 dark:text-blue-400">当前筛选:</span>
{searchKeyword && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
搜索: {searchKeyword}
<button
onClick={() => setSearchKeyword('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedMaterialType && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
素材类型: {selectedMaterialType}
<button
onClick={() => setSelectedMaterialType('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedCategory && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
店铺: {selectedCategory}
<button
onClick={() => handleCategoryChange('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedScene && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
场景类别: {selectedScene}
<button
onClick={() => setSelectedScene('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedPlatform && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
平台分类: {selectedPlatform}
<button
onClick={() => setSelectedPlatform('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedProductCategory && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
产品类别: {selectedProductCategory}
<button
onClick={() => setSelectedProductCategory('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
{selectedMonth && (
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth}
<button
onClick={() => setSelectedMonth('')}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</span>
)}
<button
onClick={() => {
setSearchKeyword('')
setSelectedMaterialType('')
setSelectedCategory('')
setSelectedScene('')
setSelectedPlatform('')
setSelectedProductCategory('')
setSelectedMonth('')
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline ml-2 font-medium"
>
清除所有筛选
</button>
</div>
{activeFilters.length > 0 && (
<div aria-label="当前筛选条件" className="mb-4 flex flex-wrap items-center gap-2">
<span className="mr-1 text-sm text-[#52565b]">已选条件</span>
{activeFilters.map((item) => <span key={item.label} className="inline-flex max-w-full items-center gap-1 rounded-lg border border-[#cfd8e3] bg-white py-1 pl-3 pr-1 text-sm text-[#101828]">
<span className="break-words">{item.label}:{item.value}</span>
<button type="button" aria-label={'清除' + item.label + '筛选'} onClick={item.clear} className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-[#66758b] hover:bg-[#eef2f6] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button>
</span>)}
<button type="button" onClick={() => activeFilters.forEach((item) => item.clear())} className="min-h-9 rounded-lg px-2 text-sm font-medium text-[#176fc1] hover:bg-[#e6f4ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">清除所有筛选</button>
</div>
)}
</div>
@ -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}

View File

@ -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() {
<label className="block text-sm font-medium text-gray-700 mb-2">
所属部门 *
</label>
<select
<Select
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="input-apple"
@ -250,7 +252,7 @@ function CompleteProfileContent() {
{dept.name}
</option>
))}
</select>
</Select>
</div>
<button

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
@ -801,11 +803,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{isCertificateReport && (
<div>
<label htmlFor={`edit-material-${params.id}-report-kind`} className="mb-2 block text-sm font-medium text-gray-700">报告类别 *</label>
<select id={`edit-material-${params.id}-report-kind`} value={reportKind} onChange={(event) => setReportKind(event.target.value)} className="input-apple" required>
<Select id={`edit-material-${params.id}-report-kind`} value={reportKind} onChange={(event) => setReportKind(event.target.value)} className="input-apple" required>
<option value="">请选择报告类别</option>
<option value={CERTIFICATE_REPORT_KIND.FINISHED}>成品</option>
<option value={CERTIFICATE_REPORT_KIND.RAW}>原材料</option>
</select>
</Select>
{!reportKind && <p className="mt-1 text-xs text-gray-500">历史报告需确认类别后再保存</p>}
</div>
)}
@ -852,7 +854,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
</div>
<div>
<label htmlFor={`edit-material-${params.id}-live-platform`} className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">直播平台</label>
<select
<Select
id={`edit-material-${params.id}-live-platform`}
value={livePlatform}
onChange={(e) => setLivePlatform(e.target.value)}
@ -864,7 +866,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<option value="视频号">视频号</option>
<option value="淘宝直播">淘宝直播</option>
<option value="B站">B站</option>
</select>
</Select>
</div>
</div>
)}
@ -896,7 +898,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
素材类型 *
</label>
<select
<Select
id={`edit-material-${params.id}-type`}
value={materialType}
onChange={(event) => {
@ -915,7 +917,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{type}
</option>
))}
</select>
</Select>
</div>
{showCategoryScene && (
@ -927,7 +929,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
店铺 *
</label>
<select
<Select
id={`edit-material-${params.id}-category`}
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
@ -950,7 +952,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{category.name}
</option>
))}
</select>
</Select>
</div>
<div>
@ -960,7 +962,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
场景 *
</label>
<select
<Select
id={`edit-material-${params.id}-scene`}
value={sceneName}
onChange={(event) => setSceneName(event.target.value)}
@ -983,7 +985,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{scene.name}
</option>
))}
</select>
</Select>
</div>
</>
)}
@ -995,7 +997,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
平台分类
</label>
<select
<Select
id={`edit-material-${params.id}-platform`}
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
@ -1026,7 +1028,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
))}
</>
)}
</select>
</Select>
</div>
<div>
@ -1036,7 +1038,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
品类{requiresProductCategoryName(materialType) || isCertificateReport ? ' *' : ''}
</label>
<select
<Select
id={`edit-material-${params.id}-product-category`}
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
@ -1058,7 +1060,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{category.name}
</option>
))}
</select>
</Select>
</div>
{isCertificateReport && (
@ -1096,7 +1098,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
渲染器 *
</label>
<select
<Select
id={`edit-material-${params.id}-renderer`}
value={renderer}
onChange={(event) => setRenderer(event.target.value)}
@ -1107,7 +1109,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</Select>
</div>
)}
@ -1131,7 +1133,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
>
出图方式
</label>
<select
<Select
id={`edit-material-${params.id}-source-type`}
value={taskDetailSourceType}
onChange={(event) => setTaskDetailSourceType(event.target.value)}
@ -1143,7 +1145,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{option}
</option>
))}
</select>
</Select>
</div>
<div>

View File

@ -1,3 +1,4 @@
import { changeField, openSelect } from '@/test-utils/select'
/** @jest-environment jsdom */
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@ -168,7 +169,7 @@ describe('PerformanceSurveyAdminConfig', () => {
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn().mockReturnValue({ matches: false }),
value: jest.fn().mockReturnValue({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() }),
})
})
@ -227,7 +228,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument())
expect(screen.getByText(/1\/2 个岗位设置已生效/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
changeField(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
})
@ -251,7 +252,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
changeField(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
@ -300,7 +301,7 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(dialog.parentElement).toHaveClass('backdrop-blur-[2px]')
expect(dialogQueries.getByRole('button', { name: '删除岗位' })).toBeDisabled()
fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '岗位合并' } })
changeField(dialogQueries.getByLabelText(/删除原因/), { target: { value: '岗位合并' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除岗位' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
@ -334,7 +335,7 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(screen.getByText('课程分享')).toBeInTheDocument()
expect(screen.queryByText('视觉案例评分')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('添加考核项'), { target: { value: '视觉案例' } })
changeField(screen.getByLabelText('添加考核项'), { target: { value: '视觉案例' } })
fireEvent.click(screen.getByRole('button', { name: /视觉案例评分/ }))
expect(screen.getByText('视觉案例评分')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
@ -383,7 +384,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改岗位' }))
fireEvent.change(screen.getByLabelText('添加考核项'), { target: { value: '品牌创意部门' } })
changeField(screen.getByLabelText('添加考核项'), { target: { value: '品牌创意部门' } })
const option = screen.getByRole('button', { name: /品牌创意部门.*已发布/ })
expect(option).toBeInTheDocument()
@ -400,7 +401,7 @@ describe('PerformanceSurveyAdminConfig', () => {
fireEvent.click(screen.getByRole('button', { name: '新增岗位' }))
expect(screen.queryByLabelText('新岗位代码')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('新岗位名称'), { target: { value: '新设计岗位' } })
changeField(screen.getByLabelText('新岗位名称'), { target: { value: '新设计岗位' } })
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/position-groups', expect.objectContaining({
@ -434,8 +435,8 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getAllByText('张三').length).toBeGreaterThan(0))
fireEvent.click(screen.getByLabelText('选择 张三'))
fireEvent.change(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
fireEvent.change(screen.getByLabelText('本次调整原因'), { target: { value: '部门异动' } })
changeField(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
changeField(screen.getByLabelText('本次调整原因'), { target: { value: '部门异动' } })
fireEvent.click(screen.getByRole('button', { name: '应用分配' }))
await waitFor(() => {
@ -460,7 +461,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /视觉案例评分.*可用/ })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '修改模板' }))
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
changeField(screen.getByLabelText('模板名称'), { target: { value: '新视觉评分' } })
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1',
@ -600,7 +601,7 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(dialog).not.toHaveTextContent('如果岗位仍在使用该模板')
expect(dialog).not.toHaveTextContent('原因将记录在管理操作日志中')
expect(dialog.parentElement).toHaveClass('backdrop-blur-[2px]')
fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '模板重复' } })
changeField(dialogQueries.getByLabelText(/删除原因/), { target: { value: '模板重复' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' }))
expect(await dialogQueries.findByRole('alert')).toHaveTextContent('模板仍被岗位使用')
@ -685,14 +686,14 @@ describe('PerformanceSurveyAdminConfig', () => {
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
const reviewerSelect = screen.getByLabelText('阶段 1 评分人类型 1')
expect(reviewerSelect).toHaveValue('direct_manager')
expect(within(reviewerSelect).getByRole('option', { name: '直属主管' })).toBeInTheDocument()
expect(within(reviewerSelect).getByRole('option', { name: '元泽' })).toBeInTheDocument()
expect(within(reviewerSelect).getByRole('option', { name: '任何人(发起时选择)' })).toBeInTheDocument()
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '直属主管' })).toBeInTheDocument()
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '元泽' })).toBeInTheDocument()
expect(within(openSelect(reviewerSelect)).getByRole('option', { name: '任何人(发起时选择)' })).toBeInTheDocument()
fireEvent.change(reviewerSelect, { target: { value: 'fixed_employee' } })
changeField(reviewerSelect, { target: { value: 'fixed_employee' } })
await waitFor(() => expect(screen.getByLabelText('选择元泽人员档案')).toHaveValue('元泽'))
fireEvent.change(reviewerSelect, { target: { value: 'manual_selection' } })
changeField(reviewerSelect, { target: { value: 'manual_selection' } })
await waitFor(() => expect(reviewerSelect).toHaveValue('manual_selection'))
})
@ -719,8 +720,8 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByRole('button', { name: '3. 评分人设置' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '3. 评分人设置' }))
fireEvent.change(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
fireEvent.change(screen.getByLabelText('阶段 1 名称'), { target: { value: '终审修改' } })
changeField(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
changeField(screen.getByLabelText('阶段 1 名称'), { target: { value: '终审修改' } })
await act(async () => {
resolveEmployees?.([{ id: 'employee-yuanze', displayName: '元泽' }])
@ -759,7 +760,7 @@ describe('PerformanceSurveyAdminConfig', () => {
const templateSelect = await screen.findByLabelText('选择模板')
expect(templateSelect).toHaveValue('template-1')
fireEvent.change(templateSelect, { target: { value: 'template-2' } })
changeField(templateSelect, { target: { value: 'template-2' } })
await waitFor(() => expect(templateSelect).toHaveValue('template-2'))
expect(screen.getByLabelText('模板名称')).toHaveValue('课程分享')
@ -835,12 +836,12 @@ describe('PerformanceSurveyAdminConfig', () => {
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByLabelText('模板名称')).toHaveValue('视觉案例评分'))
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '' } })
changeField(screen.getByLabelText('模板名称'), { target: { value: '' } })
expect(screen.getByText('请填写模板名称')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '发布模板' })).toBeDisabled()
expect(screen.getByText('未保存')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('模板名称'), { target: { value: '视觉案例评分新版' } })
changeField(screen.getByLabelText('模板名称'), { target: { value: '视觉案例评分新版' } })
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1',
@ -863,11 +864,11 @@ describe('PerformanceSurveyAdminConfig', () => {
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
fireEvent.click(await screen.findByRole('button', { name: '3. 评分人设置' }))
fireEvent.change(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
changeField(screen.getByLabelText('阶段 1 评分人类型 1'), { target: { value: 'fixed_employee' } })
const picker = await screen.findByRole('combobox', { name: '选择元泽人员档案' })
await waitFor(() => expect(picker).toHaveValue('元泽'))
fireEvent.change(picker, { target: { value: '元' } })
changeField(picker, { target: { value: '元' } })
expect(picker).toHaveAttribute('aria-expanded', 'true')
await waitFor(() => expect(screen.getByRole('listbox', { name: '人员搜索结果' })).toBeInTheDocument())
await waitFor(() => expect(screen.getByText('找到 1 人')).toBeInTheDocument())

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import {
useConfirmActionDialog,
@ -1564,7 +1566,7 @@ function TemplateConfigWorkspace({
<div className="p-3 md:hidden">
<label className="grid gap-2 text-[13px] font-bold text-[#1a1c1c]">
<span className="sr-only">选择模板</span>
<select
<Select
aria-label="选择模板"
value={selectedTemplateId}
onChange={(event) => void selectTemplate(event.target.value)}
@ -1576,7 +1578,7 @@ function TemplateConfigWorkspace({
{getTemplateDisplayName(template)}
</option>
))}
</select>
</Select>
</label>
<div className="mt-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
<span className="min-w-0 truncate">{selectedTemplateName}</span>
@ -1719,7 +1721,7 @@ function TemplateConfigWorkspace({
/>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
考核内容类型
<select
<Select
aria-label="考核内容类型"
value={definition.surveyType}
disabled={!editable}
@ -1729,7 +1731,7 @@ function TemplateConfigWorkspace({
<option value="person_period">员工工作表现</option>
<option value="visual_case">作品或案例</option>
<option value="item_course">课程或事项</option>
</select>
</Select>
</label>
{definition.surveyType === 'visual_case' && (
<AdminField
@ -2219,7 +2221,7 @@ function WorkflowEditor({
<div className="mt-3 grid gap-2">
{stage.reviewers.map((reviewer, reviewerIndex) => (
<div key={`${stage.key}-${reviewerIndex}`} className="grid gap-2 sm:grid-cols-[180px_minmax(180px,1fr)_72px]">
<select
<Select
aria-label={`阶段 ${stageIndex + 1} 评分人类型 ${reviewerIndex + 1}`}
value={reviewer.source}
disabled={!editable}
@ -2234,7 +2236,7 @@ function WorkflowEditor({
<option value="fixed_employee">元泽</option>
<option value="manual_selection">任何人(发起时选择)</option>
<option value="self">本人自评</option>
</select>
</Select>
{reviewer.source === 'fixed_employee' ? (
<FixedReviewerPicker
value={reviewer}
@ -2291,7 +2293,7 @@ function WorkflowEditor({
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
最终成绩规则
<select
<Select
value={definition.workflow.scoring.mode}
disabled={!editable}
onChange={(event) => onChange({
@ -2307,21 +2309,21 @@ function WorkflowEditor({
>
<option value="average_reviewers">所有指定阶段评分取平均</option>
<option value="final_reviewer">以指定终审人评分为准</option>
</select>
</Select>
</label>
{definition.workflow.scoring.mode === 'final_reviewer' && (
<>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审阶段
<select value={definition.workflow.scoring.stageKey || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', stageKey: event.target.value } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
<Select value={definition.workflow.scoring.stageKey || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', stageKey: event.target.value } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{stages.map((stage) => <option key={stage.key} value={stage.key}>{stage.label}</option>)}
</select>
</Select>
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
终审人来源
<select value={definition.workflow.scoring.reviewerSource || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', reviewerSource: event.target.value as ReviewerSource } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
<Select value={definition.workflow.scoring.reviewerSource || ''} disabled={!editable} onChange={(event) => onChange({ ...definition, workflow: { ...definition.workflow, scoring: { ...definition.workflow.scoring, mode: 'final_reviewer', reviewerSource: event.target.value as ReviewerSource } } })} className="h-11 rounded border border-[#e2e2e2] bg-white px-3 text-[12px] font-semibold disabled:bg-[#f4f4f4]">
{(stages.find((stage) => stage.key === definition.workflow.scoring.stageKey)?.reviewers || []).map((reviewer) => <option key={reviewer.source} value={reviewer.source}>{reviewer.employeeName || reviewer.label}</option>)}
</select>
</Select>
</label>
</>
)}

View File

@ -1,3 +1,4 @@
import { changeField } from '@/test-utils/select'
/** @jest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@ -90,7 +91,7 @@ describe('PerformanceSurveyAdminPeople', () => {
fireEvent.click(screen.getByLabelText('选择 李四'))
expect(screen.getByRole('region', { name: '批量分配所选人员' })).toHaveTextContent('已选择 2 人')
fireEvent.change(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
changeField(screen.getByLabelText('批量分配操作'), { target: { value: 'group-1' } })
expect(screen.getByPlaceholderText('本次调整原因(选填)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用分配' }))

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export type PerformanceSurveyAdminRequest = <T>(path: string, options?: RequestInit) => Promise<T>
@ -424,7 +426,7 @@ export function PerformanceSurveyAdminPeople({
</label>
<label className="grid gap-1 text-[12px] font-semibold text-[#5f5e5e]">
分配状态
<select
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}
className="h-11 rounded border border-[#c2c6d8] bg-white px-3 text-[13px] text-[#1a1c1c] outline-none focus:border-[#0050cb]"
@ -433,7 +435,7 @@ export function PerformanceSurveyAdminPeople({
<option value="assigned">已分配</option>
<option value="unassigned">待分配</option>
<option value="excluded">不参与考核</option>
</select>
</Select>
</label>
<button
type="button"
@ -646,7 +648,7 @@ export function PerformanceSurveyAdminPeople({
</div>
<div className="grid gap-2 sm:flex sm:min-w-0 sm:flex-1">
<div className="grid min-w-0 gap-2 sm:flex sm:flex-1">
<select
<Select
aria-label="批量分配操作"
value={assignmentValue}
onChange={(event) => setAssignmentValue(event.target.value)}
@ -658,7 +660,7 @@ export function PerformanceSurveyAdminPeople({
))}
<option value="__excluded__">不参与考核</option>
<option value="__automatic__">恢复自动匹配</option>
</select>
</Select>
<input
aria-label="本次调整原因"
value={reason}

View File

@ -1,3 +1,4 @@
import { changeField, openSelect } from '@/test-utils/select'
/** @jest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@ -159,7 +160,7 @@ describe('考核调研顶层导航保护', () => {
}) as jest.Mock
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn().mockReturnValue({ matches: false }),
value: jest.fn().mockReturnValue({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() }),
})
})
@ -179,7 +180,7 @@ describe('考核调研顶层导航保护', () => {
expect(requestedUrls.some((url) => url.includes('/departments?purpose=evaluator'))).toBe(false)
expect(requestedUrls.some((url) => url.endsWith('/surveys'))).toBe(false)
fireEvent.change(nameInput, { target: { value: '品牌设计师(修改中)' } })
changeField(nameInput, { target: { value: '品牌设计师(修改中)' } })
expect(await screen.findByText('未保存')).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: '个人看板' })[0])
@ -260,6 +261,7 @@ describe('考核调研顶层导航保护', () => {
expect(await screen.findByText('品牌设计师')).toBeInTheDocument()
expect(screen.queryByRole('combobox', { name: '考核岗位' })).not.toBeInTheDocument()
expect(screen.getByText('已按被考核人的人员分配自动关联,发起时无需选择。')).toBeInTheDocument()
openSelect(await screen.findByRole('combobox', { name: '考评模板' }))
expect(await screen.findByRole('option', { name: '品牌调性视觉评分(综合主管)' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '视觉评分(综合主管)' })).not.toBeInTheDocument()
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
@ -348,9 +350,9 @@ describe('考核调研顶层导航保护', () => {
render(<PerformanceSurveyPage />)
const positionSelect = await screen.findByRole('combobox', { name: '考核岗位' })
expect(within(positionSelect).getByRole('option', { name: '全部岗位' })).toBeInTheDocument()
expect(within(openSelect(positionSelect)).getByRole('option', { name: '全部岗位' })).toBeInTheDocument()
fireEvent.change(positionSelect, { target: { value: 'all' } })
changeField(positionSelect, { target: { value: 'all' } })
expect(await screen.findByText('2 个已生效岗位 · 2 个考核项')).toBeInTheDocument()
await waitFor(() => expect(
@ -760,7 +762,7 @@ describe('考核调研顶层导航保护', () => {
expect(screen.getByRole('alertdialog')).toHaveTextContent('删除后不可恢复,但可以重新发起该月该项调研')
const confirmButton = screen.getByRole('button', { name: '确认彻底删除' })
expect(confirmButton).toBeDisabled()
fireEvent.change(screen.getByRole('textbox', { name: /删除原因/ }), {
changeField(screen.getByRole('textbox', { name: /删除原因/ }), {
target: { value: '岗位归属错误' },
})
expect(confirmButton).toBeEnabled()

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import Image from 'next/image'
import Link from 'next/link'
@ -1476,7 +1478,7 @@ function PositionGroupSelect({
return (
<label className="grid min-w-0 gap-1">
<span className="sr-only">考核岗位</span>
<select
<Select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 min-w-0 rounded border border-[#e2e2e2] bg-white px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
@ -1488,7 +1490,7 @@ function PositionGroupSelect({
{group.name}{group.status === 'ready' ? ` · ${group.templateCount}项` : ' · 待配置'}
</option>
))}
</select>
</Select>
</label>
)
}
@ -3924,7 +3926,7 @@ function CreateSurveyForm({
<TextInput label="截止时间(可选)" value={deadlineAt} onChange={setDeadlineAt} type="datetime-local" />
<label className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
<select
<Select
aria-label="考评模板"
value={selectedTemplate}
onChange={(event) => setTemplateOptionKey(event.target.value)}
@ -3942,7 +3944,7 @@ function CreateSurveyForm({
{template.name || template.shortName || template.code}
</option>
))}
</select>
</Select>
{editingDraft && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
草稿已生成评价任务,模板、被考核人与评分流程保持原设置。
@ -5263,7 +5265,7 @@ function SurveySubmit({ surveyId, session, canCreateSurvey }: { surveyId: string
{targets.length > 1 && (
<label className="mb-4 grid gap-2 rounded-xl border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<span className="font-semibold text-[#4b5565]">切换评价对象</span>
<select
<Select
value={activeTargetId}
onChange={(event) => changeTarget(event.target.value)}
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
@ -5271,7 +5273,7 @@ function SurveySubmit({ surveyId, session, canCreateSurvey }: { surveyId: string
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</Select>
</label>
)}
@ -5695,7 +5697,7 @@ function VisualCaseSurvey({
{targets.length > 1 && (
<label className="mb-4 grid gap-2 rounded-xl border border-[#e3eaf3] bg-white p-4 text-sm shadow-[0_12px_34px_rgba(15,23,42,0.05)]">
<span className="font-semibold text-[#4b5565]">切换对象</span>
<select
<Select
value={activeTargetId}
onChange={(event) => setTargetId(event.target.value)}
className="min-h-11 rounded-[10px] border border-[#d9e0ea] bg-white px-3 py-2 text-base font-medium text-[#111827] outline-none transition focus:border-[#1677ff] focus:ring-4 focus:ring-[#1677ff]/10"
@ -5703,7 +5705,7 @@ function VisualCaseSurvey({
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</Select>
</label>
)}

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useEffect, useState, useMemo } from 'react'
import { parseFetchError, handleError } from '@/lib/error-handler'
@ -871,7 +873,7 @@ export default function DepartmentPermissionsTab({
将 <span className="font-medium">{linkTargetDept}</span> 的权限关联到以下部门:
</p>
<div className="mb-4">
<select
<Select
value={selectedLinkSource}
onChange={(e) => setSelectedLinkSource(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none"
@ -884,7 +886,7 @@ export default function DepartmentPermissionsTab({
{dept.name}
</option>
))}
</select>
</Select>
</div>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-800 dark:text-blue-200">

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useState } from 'react'
import { parseFetchError } from '@/lib/error-handler'
import { BellRing, Copy, Eye, EyeOff, KeyRound, ShieldCheck, Webhook } from 'lucide-react'
@ -1114,7 +1116,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
鉴权方式
</label>
<select
<Select
value={notificationTargetForm.authType}
onChange={(e) =>
setNotificationTargetForm((current) => ({
@ -1127,7 +1129,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
<option value="none">无鉴权</option>
<option value="bearer">Bearer</option>
<option value="header">自定义 Header</option>
</select>
</Select>
</div>
{notificationTargetForm.authType === 'bearer' && (
@ -1272,7 +1274,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
</p>
</div>
<div className="flex gap-2">
<select
<Select
value={notificationDeliveryStatusFilter}
onChange={(e) => setNotificationDeliveryStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@ -1282,7 +1284,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
{option.label}
</option>
))}
</select>
</Select>
<button
onClick={() => fetchNotificationDeliveries(notificationDeliveryStatusFilter)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300 transition-colors"

View File

@ -56,7 +56,7 @@ describe('UploadModal - 任务-其他', () => {
expect(await screen.findByLabelText('任务号')).toBeInTheDocument()
expect(screen.getByLabelText('钉钉审批编号')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '素材类型 *' })).toHaveTextContent('3D场景')
expect(screen.getByRole('combobox', { name: '素材类型 *' })).toHaveTextContent('3D场景')
})
test('任务-其他应使用公共上传面板形式,任务号与钉钉审批编号并列显示', async () => {
@ -64,7 +64,7 @@ describe('UploadModal - 任务-其他', () => {
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
await user.click(screen.getByRole('button', { name: '素材类型 *' }))
await user.click(screen.getByRole('combobox', { name: '素材类型 *' }))
await user.click(await screen.findByRole('option', { name: '任务-其他' }))
await waitFor(() => {
@ -86,7 +86,7 @@ describe('UploadModal - 任务-其他', () => {
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
await user.click(screen.getByRole('button', { name: '素材类型 *' }))
await user.click(screen.getByRole('combobox', { name: '素材类型 *' }))
await user.click(await screen.findByRole('option', { name: '任务-其他' }))
const selectButton = await screen.findByRole('button', { name: '选择素材文件' })

View File

@ -1,3 +1,4 @@
import { changeField, selectOption } from '@/test-utils/select'
/* eslint-disable @next/next/no-img-element, jsx-a11y/alt-text */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -56,7 +57,7 @@ function createDeferred<T>() {
}
async function chooseVideoMaterialType(user: ReturnType<typeof userEvent.setup>) {
const trigger = screen.getByRole('button', { name: '素材类型 *' })
const trigger = screen.getByRole('combobox', { name: '素材类型 *' })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: '淘系-主图视频' }))
return trigger
@ -98,7 +99,7 @@ describe('UploadModal - 视频预览图处理状态', () => {
const materialTypeSelect = await chooseVideoMaterialType(user)
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
fireEvent.change(previewInput, {
changeField(previewInput, {
target: { files: [new File(['cover'], 'cover.png', { type: 'image/png' })] },
})
@ -128,10 +129,10 @@ describe('UploadModal - 视频预览图处理状态', () => {
await chooseVideoMaterialType(user)
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
fireEvent.change(previewInput, {
changeField(previewInput, {
target: { files: [new File(['cover-1'], 'cover-1.png', { type: 'image/png' })] },
})
fireEvent.change(previewInput, {
changeField(previewInput, {
target: { files: [new File(['cover-2'], 'cover-2.png', { type: 'image/png' })] },
})
@ -150,7 +151,7 @@ describe('UploadModal - 视频预览图处理状态', () => {
await chooseVideoMaterialType(user)
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
fireEvent.change(previewInput, {
changeField(previewInput, {
target: { files: [new File(['cover'], 'cover.png', { type: 'image/png' })] },
})
await waitFor(() => expect(compressVideoPreviewImage).toHaveBeenCalledTimes(1))
@ -188,13 +189,13 @@ describe('UploadModal - 视频预览图处理状态', () => {
expect(await screen.findByText('批量上传模式(2 个文件)')).toBeInTheDocument()
const firstMetadataSelects = screen.getAllByRole('combobox')
await user.selectOptions(firstMetadataSelects[0], '通用')
await user.selectOptions(firstMetadataSelects[1], '通用')
const firstMetadataSelects = screen.getAllByRole('combobox').filter((control) => control.id !== 'upload-material-type')
await selectOption(firstMetadataSelects[0], '通用')
await selectOption(firstMetadataSelects[1], '通用')
const firstPreviewInput = screen.getByLabelText(/^视频预览图/) as HTMLInputElement
await waitFor(() => expect(firstPreviewInput).not.toBeDisabled())
fireEvent.change(firstPreviewInput, {
changeField(firstPreviewInput, {
target: {
files: [new File(['cover-0'], 'cover-0.png', { type: 'image/png' })],
},
@ -205,11 +206,11 @@ describe('UploadModal - 视频预览图处理状态', () => {
const previewInputs = await screen.findAllByLabelText(/^视频预览图/)
expect(previewInputs).toHaveLength(2)
const secondPreviewInput = previewInputs[1] as HTMLInputElement
const secondMetadataSelects = screen.getAllByRole('combobox')
await user.selectOptions(secondMetadataSelects[2], '通用')
await user.selectOptions(secondMetadataSelects[3], '通用')
const secondMetadataSelects = screen.getAllByRole('combobox').filter((control) => control.id !== 'upload-material-type')
await selectOption(secondMetadataSelects[2], '通用')
await selectOption(secondMetadataSelects[3], '通用')
await waitFor(() => expect(secondPreviewInput).not.toBeDisabled())
fireEvent.change(secondPreviewInput, {
changeField(secondPreviewInput, {
target: {
files: [new File(['cover-1'], 'cover-1.png', { type: 'image/png' })],
},

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import TrendLineChart from './TrendLineChart'
import {
@ -1104,7 +1106,7 @@ export default function DataStatisticsTab({
{canSelectDepartment ? (
<div>
<label htmlFor="stats-department-filter" className="block text-sm text-gray-600 dark:text-gray-400 mb-1">部门</label>
<select
<Select
id="stats-department-filter"
value={departmentKey}
onChange={async (event) => {
@ -1120,7 +1122,7 @@ export default function DataStatisticsTab({
{department.departmentName}
</option>
))}
</select>
</Select>
</div>
) : (
<div className="md:col-span-2 xl:col-span-1 flex items-end">
@ -1382,7 +1384,7 @@ export default function DataStatisticsTab({
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="stats-log-action" className="block text-sm text-gray-600 dark:text-gray-400 mb-1.5">操作类型</label>
<select
<Select
id="stats-log-action"
value={logAction}
onChange={(event) => {
@ -1396,7 +1398,7 @@ export default function DataStatisticsTab({
{option.label}
</option>
))}
</select>
</Select>
</div>
<div className="md:col-span-2">

View File

@ -1,3 +1,4 @@
/**
* 部门管理组件(阶段三更新:支持层级结构、编辑和创建子部门)
* 提取自 app/admin/page.tsx 的部门管理功能
@ -5,6 +6,8 @@
'use client'
import Select from '@/components/ui/Select'
import { useState, useMemo, useEffect } from 'react'
import type { Department } from '@/hooks/useAdminState'
import DepartmentTreeSelect from '@/components/DepartmentTreeSelect'
@ -556,7 +559,7 @@ export default function DepartmentManagement({
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
状态
</label>
<select
<Select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white"
@ -564,7 +567,7 @@ export default function DepartmentManagement({
<option value="active">启用</option>
<option value="disabled">禁用</option>
<option value="archived">归档</option>
</select>
</Select>
</div>
</div>
@ -587,7 +590,7 @@ export default function DepartmentManagement({
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
部门管理员
</label>
<select
<Select
value={editForm.managerId}
onChange={(e) => setEditForm({ ...editForm, managerId: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white"
@ -599,7 +602,7 @@ export default function DepartmentManagement({
{user.realName || user.username} ({user.email})
</option>
))}
</select>
</Select>
</div>
<div>
@ -701,7 +704,7 @@ export default function DepartmentManagement({
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
部门管理员
</label>
<select
<Select
value={childForm.managerId}
onChange={(e) => setChildForm({ ...childForm, managerId: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white"
@ -713,7 +716,7 @@ export default function DepartmentManagement({
{user.realName || user.username} ({user.email})
</option>
))}
</select>
</Select>
</div>
<div>

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
@ -858,15 +860,15 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="test-project" className="block text-[13px] font-semibold text-[#344054]">接入项目</label>
<select id="test-project" value={testProjectCode} onChange={(event) => setTestProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
<Select id="test-project" value={testProjectCode} onChange={(event) => setTestProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
{projects.filter((project) => project.isActive).map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
</select>
</Select>
</div>
<div>
<label htmlFor="test-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">收件人方式</label>
<select id="test-recipient-mode" value={testRecipientMode} onChange={(event) => { setTestRecipientMode(event.target.value as RecipientMode); setTestRecipient('') }} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
<Select id="test-recipient-mode" value={testRecipientMode} onChange={(event) => { setTestRecipientMode(event.target.value as RecipientMode); setTestRecipient('') }} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
<option value="alias">准确花名</option><option value="userId">精确钉钉 userId</option>
</select>
</Select>
</div>
<div>
<label htmlFor="test-recipient" className="block text-[13px] font-semibold text-[#344054]">真实收件人</label>
@ -894,8 +896,8 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div><h3 id="notification-deliveries-heading" className="text-[16px] font-semibold text-[#101828]">投递记录</h3><p className="mt-1 text-[13px] leading-5 text-[#66758b]">优先展示可理解的状态与处理建议。发送结果不确定时必须先人工核验。</p></div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="sr-only" htmlFor="delivery-project-filter">按项目筛选</label><select id="delivery-project-filter" value={deliveryProjectFilter} onChange={(event) => setDeliveryProjectFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="all">全部项目</option>{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name}</option>)}</select>
<label className="sr-only" htmlFor="delivery-status-filter">按状态筛选</label><select id="delivery-status-filter" value={deliveryStatusFilter} onChange={(event) => setDeliveryStatusFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">{DELIVERY_STATUSES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
<label className="sr-only" htmlFor="delivery-project-filter">按项目筛选</label><Select id="delivery-project-filter" value={deliveryProjectFilter} onChange={(event) => setDeliveryProjectFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="all">全部项目</option>{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name}</option>)}</Select>
<label className="sr-only" htmlFor="delivery-status-filter">按状态筛选</label><Select id="delivery-status-filter" value={deliveryStatusFilter} onChange={(event) => setDeliveryStatusFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">{DELIVERY_STATUSES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</Select>
</div>
</div>
{deliveriesError ? <div role="alert" className="mt-5 rounded-[8px] border border-[#f3c7c1] bg-[#fef6f5] px-4 py-3 text-[13px] leading-6 text-[#8f2119]">{deliveriesError}。没有自动重发任何通知,请稍后刷新。</div> : deliveriesLoading ? <div className="mt-5 space-y-2">{[0,1,2].map((item) => <div key={item} className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />)}</div> : deliveries.length === 0 ? <div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-8 text-center text-[13px] text-[#66758b]">当前筛选条件下没有投递记录。发送测试通知后可在这里查看状态。</div> : (

View File

@ -1,3 +1,4 @@
import { changeField, openSelect } from '@/test-utils/select'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import BrandKnowledgeDashboard from './BrandKnowledgeDashboard'
import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types'
@ -89,7 +90,7 @@ describe('BrandKnowledgeDashboard', () => {
fireEvent.click(screen.getByRole('button', { name: '主图规范' }))
expect(screen.getByText('主图规范暂未上传内容')).toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox', { name: '搜索品牌知识' }), { target: { value: '索菲亚' } })
changeField(screen.getByRole('searchbox', { name: '搜索品牌知识' }), { target: { value: '索菲亚' } })
expect(screen.getByText('没有找到与“索菲亚”匹配的知识内容')).toBeInTheDocument()
})
@ -124,7 +125,7 @@ describe('BrandKnowledgeDashboard', () => {
render(<BrandKnowledgeDashboard />)
expect(await screen.findByRole('heading', { level: 3, name: '升降桌主图模版-索菲亚' })).toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox', { name: '搜索品牌知识' }), { target: { value: '主图模板' } })
changeField(screen.getByRole('searchbox', { name: '搜索品牌知识' }), { target: { value: '主图模板' } })
expect(screen.getByRole('heading', { level: 3, name: '升降桌主图模版-索菲亚' })).toBeInTheDocument()
})
@ -277,11 +278,12 @@ describe('BrandKnowledgeDashboard', () => {
fireEvent.click(await screen.findByRole('button', { name: '编辑索菲亚图像影像规范' }))
const editDialog = screen.getByRole('dialog', { name: '编辑品牌知识' })
fireEvent.change(within(editDialog).getByLabelText('知识标题'), { target: { value: '更新后的规范' } })
fireEvent.change(within(editDialog).getByLabelText('知识类型'), { target: { value: '主图模板' } })
fireEvent.change(within(editDialog).getByLabelText('关联品牌'), { target: { value: '亿家达' } })
await within(editDialog).findByRole('option', { name: '床类' })
fireEvent.change(within(editDialog).getByLabelText('品类'), { target: { value: '床类' } })
changeField(within(editDialog).getByLabelText('知识标题'), { target: { value: '更新后的规范' } })
changeField(within(editDialog).getByLabelText('知识类型'), { target: { value: '主图模板' } })
changeField(within(editDialog).getByLabelText('关联品牌'), { target: { value: '亿家达' } })
openSelect(within(editDialog).getByLabelText('品类'))
await screen.findByRole('option', { name: '床类' })
changeField(within(editDialog).getByLabelText('品类'), { target: { value: '床类' } })
fireEvent.click(within(editDialog).getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/department-dashboard/brand-knowledge/knowledge-1',
@ -332,13 +334,14 @@ describe('BrandKnowledgeDashboard', () => {
'sku模板', '主图视频规范', '主图视频模板', '影像规范', '品牌VI',
])
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
openSelect(screen.getByLabelText(/品类/))
await waitFor(() => expect(screen.getByRole('option', { name: '床类' })).toBeInTheDocument())
fireEvent.change(screen.getByLabelText(/品类/), { target: { value: '床类' } })
changeField(screen.getByRole('combobox', { name: /品类/ }), { target: { value: '床类' } })
const knowledgeFile = new File(['品牌资料'], '品牌手册.pdf', { type: 'application/pdf' })
const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' })
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } })
fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
changeField(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } })
changeField(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
expect(screen.getByText('品牌手册.pdf')).toBeInTheDocument()
expect(await screen.findByText('预览图.png')).toBeInTheDocument()
expect(await screen.findByRole('img', { name: '预览图:预览图.png' })).toHaveClass('object-contain', 'object-center')
@ -394,7 +397,7 @@ describe('BrandKnowledgeDashboard', () => {
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' })
fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
changeField(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
expect(await screen.findByText('10 B · 单张预览')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('图片高度不超过 3000px,已保留为单张预览')
@ -425,8 +428,8 @@ describe('BrandKnowledgeDashboard', () => {
render(<BrandKnowledgeDashboard />)
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
fireEvent.change(screen.getByLabelText('选择知识文件'), {
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('选择知识文件'), {
target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] },
})
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
@ -458,8 +461,8 @@ describe('BrandKnowledgeDashboard', () => {
render(<BrandKnowledgeDashboard />)
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
fireEvent.change(screen.getByLabelText('选择知识文件'), {
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('选择知识文件'), {
target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] },
})
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
@ -498,8 +501,8 @@ describe('BrandKnowledgeDashboard', () => {
const firstRender = render(<BrandKnowledgeDashboard />)
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [file] } })
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('选择知识文件'), { target: { files: [file] } })
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
expect(await screen.findByText(/服务器处理超时(504)/)).toBeInTheDocument()
expect(localStorage.length).toBe(1)
@ -507,8 +510,8 @@ describe('BrandKnowledgeDashboard', () => {
render(<BrandKnowledgeDashboard />)
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [file] } })
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('选择知识文件'), { target: { files: [file] } })
expect(within(screen.getByRole('dialog', { name: '上传知识' })).getByRole('status')).toHaveTextContent('已恢复这个文件完成的分片上传')
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
@ -528,8 +531,8 @@ describe('BrandKnowledgeDashboard', () => {
render(<BrandKnowledgeDashboard />)
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
fireEvent.change(screen.getByLabelText('选择知识文件'), {
changeField(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
changeField(screen.getByLabelText('选择知识文件'), {
target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] },
})
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import Image from 'next/image'
import Link from 'next/link'
@ -333,9 +335,9 @@ function EditDialog({ item, onClose, onSave }: {
{error && <p className="mt-4 rounded-lg border border-[#f4c7c3] bg-[#fff2f0] px-4 py-3 text-sm text-[#b42318]" role="alert">{error}</p>}
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input autoFocus value={metadata.title} onChange={(event) => updateMetadata('title', event.currentTarget.value)} maxLength={200} className="h-11 w-full rounded-lg border border-[#d0d5dd] px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20" /></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={metadata.knowledgeType} onChange={(event) => updateMetadata('knowledgeType', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌</span><select value={metadata.brand} onChange={(event) => updateMetadata('brand', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label>
<label className="sm:col-span-2"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类</span><select value={metadata.productCategoryName} onChange={(event) => updateMetadata('productCategoryName', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20"><option value="">不选择品类</option>{categoryOptions.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><Select value={metadata.knowledgeType} onChange={(event) => updateMetadata('knowledgeType', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌</span><Select value={metadata.brand} onChange={(event) => updateMetadata('brand', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label>
<label className="sm:col-span-2"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类</span><Select value={metadata.productCategoryName} onChange={(event) => updateMetadata('productCategoryName', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20"><option value="">不选择品类</option>{categoryOptions.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</Select></label>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row">
<button type="button" onClick={onClose} disabled={isSaving} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#344054] hover:bg-[#f8fafc] disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button>

View File

@ -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 && <div className="space-y-2" role="progressbar" aria-label="主文件上传进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={uploadProgress} aria-valuetext={`${uploadProgress}%${uploadSpeedLabel ? `,${uploadSpeedLabel}` : ''}`}><div className="flex items-center justify-between gap-4 text-sm text-[#4f6077]"><span>主文件上传进度</span><span className="shrink-0 tabular-nums">{uploadProgress}%{uploadSpeedLabel ? ` · ${uploadSpeedLabel}` : ''}</span></div><div className="h-2 overflow-hidden rounded-full bg-[#e2e8f0]"><div className="h-full rounded-full bg-[#247fd3] transition-[width] duration-200" style={{ width: `${uploadProgress}%` }} /></div></div>}
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><Select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><Select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><Select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</Select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => 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]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ? <div className="flex items-center gap-4"><div className="relative flex h-28 w-40 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">{previewUrl ? <Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-contain object-center" /> : <ImagePlus className="h-6 w-6 text-[#8a99ad]" aria-hidden="true" />}</div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · {previewSlices.length === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}</p><button type="button" onClick={() => previewInputRef.current?.click()} disabled={Boolean(previewProgress && previewSlices.length === 0 && !previewUsesServerFallback)} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 50MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>

View File

@ -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(<MonthlyLaunchPeriodFilter summary={summary} options={options} />)
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(<MonthlyLaunchPeriodFilter summary={summary} options={options} />)
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(<MonthlyLaunchPeriodFilter summary={summary} options={options} />)
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()

View File

@ -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({
>
<label className="flex min-w-0 flex-1 flex-col gap-1.5 sm:min-w-[180px] sm:flex-none">
<span className="text-xs font-bold text-slate-500">快捷月份</span>
<select
<Select
value={preset}
onChange={(event) => handlePresetChange(event.target.value)}
aria-label="选择统计月份"
@ -80,7 +82,7 @@ export default function MonthlyLaunchPeriodFilter({
>
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
<option value="custom">自定义日期</option>
</select>
</Select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5 sm:min-w-[180px] sm:flex-none">

View File

@ -1,3 +1,4 @@
import { changeField } from '@/test-utils/select'
import { fireEvent, render, screen } from '@testing-library/react'
import type { OnTimeDeliveryWorkflowItem } from '@/lib/ding-workflow-sync'
import OnTimeDeliveryInsightsView from './OnTimeDeliveryInsights'
@ -184,7 +185,7 @@ describe('OnTimeDeliveryInsightsView', () => {
)
expect(screen.getByRole('form', { name: '按时交付日期筛选' })).toBeInTheDocument()
fireEvent.change(screen.getByRole('combobox', { name: '选择统计月份' }), {
changeField(screen.getByRole('combobox', { name: '选择统计月份' }), {
target: { value: '2026-07' },
})
expect(mockPush).toHaveBeenCalledWith(

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
import {
@ -219,13 +221,13 @@ function DetailContent({ data }: { data: PerformanceConversionTimeseries }) {
</div>
<label className="flex flex-col gap-1 text-xs font-bold text-slate-600 sm:flex-row sm:items-center sm:gap-2">
趋势指标
<select
<Select
value={metricKey}
onChange={(event) => setMetricKey(event.target.value as PerformanceConversionMetricKey)}
className="h-10 rounded-lg border border-slate-300 bg-white px-3 text-sm font-semibold text-slate-800 outline-none focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"
>
{PERFORMANCE_CONVERSION_METRICS.map((metric) => <option key={metric.key} value={metric.key}>{metric.group} · {metric.label}</option>)}
</select>
</Select>
</label>
</div>
<TrendChart daily={data.daily} metricKey={metricKey} />

View File

@ -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 && (
<label className="mt-3 block">
<span className="sr-only">再加一组</span>
<select
<Select
disabled={saving}
defaultValue=""
onChange={(event) => {
@ -230,7 +232,7 @@ export default function UserPermissionsDashboard() {
{group.name}
</option>
))}
</select>
</Select>
</label>
)}
</div>

View File

@ -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(<FilterPanel {...props} selectedCategory="店铺13" selectedMonth="2026-09" />)
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(<FilterPanel {...props} liveDateFrom="2026-09-20" liveDateTo="2026-09-25" selectedLivePlatform="抖音" />)
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(<FilterPanel {...props} isFilterCollapsed liveDateFrom="2026-09-20" selectedLivePlatform="抖音" />)
expect(screen.queryByRole('region', { name: '素材筛选条件' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '展开更多筛选' })).toHaveTextContent('2')
})

View File

@ -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 (
<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
@ -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<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
@ -200,11 +224,12 @@ function FilterPanel(props: FilterPanelProps) {
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)) 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) => (
<button onClick={onClick} className={`${tabBase} ${active ? tabActive : tabInactive}`}>
<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]" />}
@ -230,9 +255,9 @@ function FilterPanel(props: FilterPanelProps) {
return (
<>
<div className="mb-6">
<div className="flex items-end justify-between gap-6 border-b border-[#e7e8ea]">
<div ref={rowRef} className="relative min-w-0 flex-1">
<div className="relative z-10 mb-4">
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3 border-b border-[#e7e8ea]">
<div ref={rowRef} className="relative min-w-0 basis-full sm:basis-auto sm:flex-1">
<div className="flex items-end gap-x-8" aria-busy={facetsLoading}>
{renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))}
{visibleTypes.map((type) => (
@ -243,34 +268,43 @@ function FilterPanel(props: FilterPanelProps) {
{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 && (
<div role="listbox" className="absolute right-0 top-full z-40 mt-2 max-h-[320px] min-w-[240px] overflow-y-auto border border-[#e7e8ea] bg-white py-1">
{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)}
className={`flex w-full items-baseline justify-between gap-6 px-4 py-2.5 text-left text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 ${active ? 'text-[#1a1c1d]' : 'text-[#6b7075] hover:bg-[#fafafa] hover:text-[#1a1c1d]'}`}
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>
</div>, document.body
)}
</div>
)}
@ -289,16 +323,20 @@ function FilterPanel(props: FilterPanelProps) {
</div>
</div>
<button
type="button"
onClick={() => setIsFilterCollapsed(!isFilterCollapsed)}
aria-label={isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
className="mb-2 shrink-0 whitespace-nowrap bg-[#fafafa] pl-4 text-[13px] tracking-[-0.02em] text-[#6b7075] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
aria-expanded={!isFilterCollapsed}
aria-controls="material-filter-fields"
className="mb-2 inline-flex min-h-9 shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm 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>
</div>
<div className="mb-8 flex flex-wrap items-baseline justify-between gap-3">
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-3">
<p className="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>}
@ -315,109 +353,45 @@ function FilterPanel(props: FilterPanelProps) {
</button>
</div>
</div>
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">使用店铺</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedCategory('')} className={`${chipBase} ${selectedCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 ${countClass}`}>{countText(totals.category)}</span></button>
{rankedCategories.map((item) => <button key={item.id} onClick={() => setSelectedCategory(item.name === selectedCategory ? '' : item.name)} className={`${chipBase} ${selectedCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 ${countClass}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">场景类别</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedScene('')} className={`${chipBase} ${selectedScene === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 ${countClass}`}>{countText(totals.scene)}</span></button>
{rankedScenes.map((item) => <button key={item.id} onClick={() => setSelectedScene(item.name === selectedScene ? '' : item.name)} className={`${chipBase} ${selectedScene === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 ${countClass}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>
{(rankedPlatforms.length > 0 || facetsLoading) && <div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">平台分类</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedPlatform('')} className={`${chipBase} ${selectedPlatform === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 ${countClass}`}>{countText(totals.platform)}</span></button>
{rankedPlatforms.map((item) => <button key={item.id} onClick={() => setSelectedPlatform(item.name === selectedPlatform ? '' : item.name)} className={`${chipBase} ${selectedPlatform === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 ${countClass}`}>{countText(item.count || 0)}</span></button>)}
</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="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">直播平台</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{['', '抖音', '快手', '视频号', '淘宝直播', 'B站'].map((name) => (
<button
key={name || 'all'}
type="button"
onClick={() => setSelectedLivePlatform(name)}
className={`${chipBase} ${selectedLivePlatform === name ? chipActive : chipInactive}`}
>
{name || '全部'}
</button>
))}
<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="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">直播日期</h2>
<div className="flex flex-wrap items-center gap-2">
<input
type="date"
aria-label="直播日期起"
value={liveDateFrom}
onChange={(e) => setLiveDateFrom?.(e.target.value)}
className={`${chipBase} cursor-text`}
/>
<span className="text-[12px] text-[#86898e]">至</span>
<input
type="date"
aria-label="直播日期止"
value={liveDateTo}
onChange={(e) => setLiveDateTo?.(e.target.value)}
className={`${chipBase} cursor-text`}
/>
{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) && <div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedProductCategory('')} className={`${chipBase} ${selectedProductCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 ${countClass}`}>{countText(totals.productCategory)}</span></button>
{rankedProductCategories.map((item) => <button key={item.id} onClick={() => setSelectedProductCategory(item.name === selectedProductCategory ? '' : item.name)} className={`${chipBase} ${selectedProductCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 ${countClass}`}>{countText(item.count || 0)}</span></button>)}
{(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 className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">上传时间</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedMonth('')} className={`${chipBase} ${selectedMonth === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 ${countClass}`}>{countText(totals.month)}</span></button>
{yearFacets.map((item) => (
<button
key={item.year}
onClick={() => setSelectedMonth(selectedMonth === item.year ? '' : item.year)}
className={`${chipBase} ${selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`) ? chipActive : chipInactive}`}
>
{item.year}
<span className={`ml-1.5 ${countClass}`}>{countText(item.count)}</span>
</button>
))}
</div>
{monthsInFocusedYear.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2">
{monthsInFocusedYear.map((item) => {
const monthNumber = Number.parseInt(item.value.slice(5), 10)
return (
<button
key={item.value}
onClick={() => setSelectedMonth(item.value === selectedMonth ? '' : item.value)}
className={`${chipBase} ${selectedMonth === item.value ? chipActive : chipInactive}`}
>
{monthNumber}月
<span className={`ml-1.5 ${countClass}`}>{countText(item.count)}</span>
</button>
)
})}
</div>
<p className="border-t border-[#e7e8ea] py-3 text-xs leading-relaxed text-[#66758b]">数量随筛选条件更新,仅显示仍有结果的选项。</p>
</section>
)}
</div>
<p className="mb-6 text-xs text-[#86898e]">选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。</p>
</div>
</>
)
}

View File

@ -347,7 +347,7 @@ export default function MaterialCard({
<article data-card className="bg-transparent">
{/* Preview Image/Video */}
<div
className="relative h-[400px] w-full cursor-pointer overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-[#f3f4f5] group"
className="relative isolate h-[400px] w-full cursor-pointer overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-[#f3f4f5] group"
data-material-link="true"
onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()}
onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()}

View File

@ -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 <form aria-label="上传"><label htmlFor="method">出图方式</label><Select id="method" name="method" required value={value} onChange={(event) => { changed(event.target.value); setValue(event.currentTarget.value) }}><option value="">请选择</option><option value="photo">实拍</option><option value="ai">AI</option></Select></form>
}
const user = userEvent.setup()
render(<Form />)
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(<div onKeyDown={parentKey}><Select aria-label="方式" defaultValue="photo" onChange={changed}><option value="photo">实拍</option><option value="disabled" disabled>禁用项</option><option value="ai">AI</option></Select></div>)
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(<form aria-label="表单"><Select required name="method" aria-label="方式" value=""><option value="">请选择</option><option value="ai">AI</option></Select></form>)
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(<form><Select name="method" defaultValue="" onChange={(event) => { event.target.value = '' }}><option value="">请选择</option><option value="ai">AI</option></Select></form>)
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(<Select disabled aria-label="方式"><option>实拍</option></Select>)
await user.click(screen.getByRole('combobox'))
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
rerender(<Select aria-label="方式" />)
await user.click(screen.getByRole('combobox'))
expect(within(screen.getByRole('listbox')).getByText('暂无可选项')).toBeInTheDocument()
})

154
components/ui/Select.tsx Normal file
View File

@ -0,0 +1,154 @@
'use client'
import React, { Children, isValidElement, useEffect, useId, useMemo, useRef, useState, type SelectHTMLAttributes } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown } from 'lucide-react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { useAnchoredPopover } from './useAnchoredPopover'
import { fieldControlClass } from '@/components/upload/upload-field-styles'
gsap.registerPlugin(useGSAP)
interface Option { value: string; label: string; disabled: boolean }
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
optionCounts?: Record<string, number>
menuHint?: string
}
function nodeText(node: React.ReactNode): string {
return Children.toArray(node).map((child) => isValidElement<{ children?: React.ReactNode }>(child) ? nodeText(child.props.children) : String(child)).join('')
}
function collectOptions(children: React.ReactNode, disabled = false): Option[] {
return Children.toArray(children).flatMap((child) => {
if (!isValidElement<{ children?: React.ReactNode; value?: string | number; label?: string; disabled?: boolean; hidden?: boolean }>(child)) return []
if (child.type === 'option') return child.props.hidden ? [] : [{
value: String(child.props.value ?? nodeText(child.props.children)),
label: child.props.label ?? nodeText(child.props.children),
disabled: disabled || !!child.props.disabled,
}]
return collectOptions(child.props.children, disabled || !!child.props.disabled)
})
}
/** The native select owns form values, validation and change events; the visible control owns presentation. */
export default function Select({ children, className, style, onChange, onInvalid, optionCounts, menuHint, ...props }: SelectProps) {
const generatedId = useId()
const id = props.id || `select-${generatedId}`
const nativeRef = useRef<HTMLSelectElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [active, setActive] = useState(0)
const [internalValue, setInternalValue] = useState(String(props.defaultValue ?? ''))
const [label, setLabel] = useState('')
const [invalid, setInvalid] = useState(false)
const options = useMemo(() => collectOptions(children), [children])
const value = props.value !== undefined ? String(props.value) : internalValue
const selected = options.find((option) => option.value === value) || options[0]
const position = useAnchoredPopover(open, triggerRef)
useEffect(() => {
const native = nativeRef.current
if (!native) return
setLabel(Array.from(triggerRef.current?.labels || []).map((item) => {
const copy = item.cloneNode(true) as HTMLElement
copy.querySelectorAll('select, button, input, textarea, [aria-hidden="true"]').forEach((control) => control.remove())
return copy.textContent?.trim()
}).filter(Boolean).join(' '))
if (props.value === undefined) setInternalValue(native.value)
const form = native.form
const reset = () => queueMicrotask(() => { setInternalValue(native.value); setInvalid(false); setOpen(false) })
form?.addEventListener('reset', reset)
return () => form?.removeEventListener('reset', reset)
}, [props.value, children])
useEffect(() => { if (props.disabled) setOpen(false) }, [props.disabled])
useEffect(() => {
if (!open) return
const pointer = (event: PointerEvent) => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) setOpen(false)
}
document.addEventListener('pointerdown', pointer)
return () => document.removeEventListener('pointerdown', pointer)
}, [open])
useEffect(() => {
if (open) menuRef.current?.querySelector<HTMLElement>(`[data-option-index="${active}"]`)?.scrollIntoView?.({ block: 'nearest' })
}, [open, active])
useGSAP(() => {
if (!open || !menuRef.current || typeof window.matchMedia !== 'function') return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(menuRef.current, { y: -4, opacity: 0.8 }, { y: 0, opacity: 1, duration: 0.16, ease: 'expo.out', clearProps: 'transform,opacity' })
})
return () => motion.revert()
}, { scope: menuRef, dependencies: [open], revertOnUpdate: true })
const choose = (option: Option) => {
if (option.disabled || !nativeRef.current) return
const native = nativeRef.current
native.value = option.value
native.dispatchEvent(new Event('change', { bubbles: true }))
setInternalValue(native.value) // Also respects callers that reset an uncontrolled select in onChange.
setInvalid(false)
setOpen(false)
triggerRef.current?.focus()
}
const show = () => {
const index = options.findIndex((option) => option.value === value && !option.disabled)
setActive(index >= 0 ? index : Math.max(0, options.findIndex((option) => !option.disabled)))
setOpen(true)
}
const keyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Tab') { setOpen(false); return }
if (event.key === 'Escape') { if (open) { event.preventDefault(); event.stopPropagation(); setOpen(false) }; return }
const enabled = options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0)
if (!enabled.length) return
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault()
if (!open) { show(); return }
const current = enabled.indexOf(active)
setActive(event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled[enabled.length - 1] : enabled[(current + (event.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length])
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (open && options[active]) choose(options[active]); else show()
} else if (event.key.length === 1) {
const index = options.findIndex((option) => !option.disabled && option.label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()))
if (index >= 0) { event.preventDefault(); if (!open) show(); setActive(index) }
}
}
return (
<span className={`relative inline-grid min-w-0 max-w-full align-middle ${className?.includes('w-full') || !className ? 'w-full' : ''}`}>
<button ref={triggerRef} id={id} type="button" role="combobox" value={selected?.value || ''} disabled={props.disabled} style={style}
aria-label={props['aria-label'] || label || selected?.label || '选择选项'} aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']} aria-required={props.required} aria-invalid={invalid || props['aria-invalid']}
aria-expanded={open} aria-controls={`${id}-options`} aria-haspopup="listbox" aria-activedescendant={open ? `${id}-option-${active}` : undefined}
onClick={() => open ? setOpen(false) : show()} onKeyDown={keyDown}
className={`${className || fieldControlClass} flex min-h-10 min-w-0 items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60 ${invalid ? 'ring-2 ring-[#b42318]' : ''}`}>
<span className="truncate">{selected?.label || '请选择'}</span>
<ChevronDown aria-hidden="true" className={`h-4 w-4 shrink-0 text-[#66758b] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
<select {...props} id={`${id}-native`} aria-label={undefined} aria-labelledby={undefined} ref={nativeRef} tabIndex={-1} aria-hidden="true" className="sr-only" onFocus={() => 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}
</select>
{open && createPortal(
<div ref={menuRef} id={`${id}-options`} role="listbox" aria-label={props['aria-label'] || label || '选项'} aria-labelledby={props['aria-labelledby']}
style={position} className="z-[500] overflow-y-auto overscroll-contain rounded-[12px] bg-white p-1.5 text-[#101828] shadow-[0_12px_32px_rgba(16,24,40,0.18)] ring-1 ring-[#e2e8f0]">
{menuHint && <p className="px-3 py-1.5 text-sm text-[#66758b]">{menuHint}</p>}
{options.length ? options.map((option, index) => (
<div key={`${option.value}-${index}`} id={`${id}-option-${index}`} data-option-index={index} role="option" aria-label={option.label} aria-selected={option.value === selected?.value} aria-disabled={option.disabled}
onClick={() => 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' : ''}`}>
<Check aria-hidden="true" className={`h-4 w-4 shrink-0 ${option.value === selected?.value ? '' : 'invisible'}`} />
<span className="min-w-0 break-words">{option.label}</span>
{optionCounts && <span className="ml-auto shrink-0 text-xs font-normal tabular-nums text-[#66758b]">{(optionCounts[option.value] || 0).toLocaleString('zh-CN')}</span>}
</div>
)) : <p className="px-3 py-2 text-sm text-[#66758b]">暂无可选项</p>}
</div>, document.body
)}
</span>
)
}

View File

@ -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<HTMLElement>, width?: number, height = 320) {
const [position, setPosition] = useState<CSSProperties>({ 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
}

View File

@ -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({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -395,7 +397,7 @@ export default function AIOriginalUploader({
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
</div>
)}
@ -424,7 +426,7 @@ export default function AIOriginalUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -438,7 +440,7 @@ export default function AIOriginalUploader({
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
@ -464,7 +466,7 @@ export default function AIOriginalUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
@ -475,7 +477,7 @@ export default function AIOriginalUploader({
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
@ -487,7 +489,7 @@ export default function AIOriginalUploader({
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
@ -498,7 +500,7 @@ export default function AIOriginalUploader({
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</Select>
</div>
)}

View File

@ -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({
</span>
)}
</label>
<select
<Select
value={item.categoryName}
onChange={(e) => onUpdateCategory(index, e.target.value)}
disabled={disabled}
@ -317,14 +319,14 @@ export default function BatchFileList({
{category.name}
</option>
))}
</select>
</Select>
</div>
<div>
<label className={fieldLabelClass}>
场景类别 *
</label>
<select
<Select
value={item.sceneName}
onChange={(e) => onUpdateScene(index, e.target.value)}
disabled={disabled}
@ -337,7 +339,7 @@ export default function BatchFileList({
{scene.name}
</option>
))}
</select>
</Select>
</div>
</>
)})()}
@ -348,7 +350,7 @@ export default function BatchFileList({
<label className={fieldLabelClass}>
平台分类(可选)
</label>
<select
<Select
value={item.platformName}
onChange={(e) => onUpdatePlatform(index, e.target.value)}
disabled={disabled}
@ -361,7 +363,7 @@ export default function BatchFileList({
{platform.name}
</option>
))}
</select>
</Select>
</div>
)}
@ -381,7 +383,7 @@ export default function BatchFileList({
</span>
)}
</label>
<select
<Select
value={item.productCategoryName}
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
disabled={disabled}
@ -394,7 +396,7 @@ export default function BatchFileList({
{category.name}
</option>
))}
</select>
</Select>
</div>
)})()}
@ -404,7 +406,7 @@ export default function BatchFileList({
<label className={fieldLabelClass}>
渲染器 *
</label>
<select
<Select
value={item.renderer || renderer}
onChange={(e) => onUpdateRenderer(index, e.target.value)}
disabled={disabled}
@ -414,7 +416,7 @@ export default function BatchFileList({
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</Select>
</div>
)}
</div>

View File

@ -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<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const calendarRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(`[data-date="${format(calendarDate || new Date(), 'yyyy-MM-dd')}"]:not([disabled])`)
const firstDay = calendar.querySelector<HTMLButtonElement>(`[data-date^="${format(visibleMonth, 'yyyy-MM')}"]:not([disabled])`)
const focusTarget = preferred || firstDay || calendar.querySelector<HTMLButtonElement>('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 (
<div ref={containerRef} className="relative">
<div ref={containerRef} className={`relative min-w-0 ${className}`} onKeyDown={(event) => {
if (event.key === 'Escape' && isOpen) { event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus() }
}}>
<button
ref={triggerRef}
id={id}
@ -103,21 +110,41 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa
if (!isOpen) setIsMonthPickerOpen(false)
setIsOpen((open) => !open)
}}
className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60"
style={{ paddingInline: 12, gap: 8 }}
className={`${fieldControlClass} flex min-h-11 items-center justify-between gap-3 text-left`}
>
<span className={selectedDate ? 'text-[#1a1c1d]' : 'text-gray-500'}>
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : placeholder}
<span className={`min-w-0 truncate ${selectedDate ? 'text-[#1a1c1d]' : 'text-[#66758b]'}`}>
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : placeholder}
</span>
<CalendarDays className="h-[18px] w-[18px] shrink-0 text-[#5c6470]" aria-hidden="true" />
</button>
{isOpen && (
{isOpen && createPortal(
<div
ref={calendarRef}
id={`${id}-calendar`}
role="dialog"
aria-label={`选择${label}`}
className="absolute left-1/2 top-full z-40 mt-2 w-[320px] max-w-[calc(100vw-3rem)] -translate-x-1/2 rounded-[12px] border border-[#e7e8ea] bg-white p-3 text-[#1a1c1d] shadow-[0_12px_28px_rgba(26,28,29,0.10)]"
style={position}
onKeyDown={(event) => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus() }
if (event.key === 'Tab') {
const buttons = Array.from(calendarRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') || [])
if ((event.shiftKey && event.target === buttons[0]) || (!event.shiftKey && event.target === buttons[buttons.length - 1])) {
event.preventDefault(); event.stopPropagation(); setIsOpen(false); triggerRef.current?.focus()
}
}
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
const key = (event.target as HTMLElement).dataset.date
if (key && offsets[event.key]) {
event.preventDefault()
const next = addDays(parseISO(key), offsets[event.key])
if (outsideRange(next)) return
setVisibleMonth(startOfMonth(next))
requestAnimationFrame(() => calendarRef.current?.querySelector<HTMLButtonElement>(`[data-date="${format(next, 'yyyy-MM-dd')}"]`)?.focus())
}
}}
className="z-[500] overflow-y-auto overscroll-contain rounded-[12px] bg-white p-3 text-[#1a1c1d] shadow-[0_12px_32px_rgba(16,24,40,0.18)] ring-1 ring-[#e2e8f0]"
>
<div className="mb-3 flex items-center justify-between gap-2">
<button type="button" aria-label={isMonthPickerOpen ? '上一年' : '上个月'} onClick={() => setVisibleMonth((month) => isMonthPickerOpen ? subYears(month, 1) : subMonths(month, 1))} className="flex h-9 w-9 items-center justify-center rounded-[8px] text-[#5c6470] hover:bg-[#f3f4f5] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
@ -153,11 +180,13 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa
<button
key={dateKey}
type="button"
data-date={dateKey}
disabled={outsideRange(day)}
aria-label={format(day, 'yyyy年M月d日')}
aria-pressed={isSelected}
aria-current={isToday(day) ? 'date' : undefined}
onClick={() => selectDate(day)}
className={`flex h-10 items-center justify-center rounded-[8px] text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${isSelected ? 'bg-[#1a1c1d] font-semibold text-white' : inMonth ? 'text-[#1a1c1d] hover:bg-[#f3f4f5]' : 'text-[#9da3ad] hover:bg-[#f3f4f5]'}`}
className={`flex h-10 items-center justify-center rounded-[8px] text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-30 ${isSelected ? 'bg-[#1a1c1d] font-semibold text-white' : inMonth ? 'text-[#1a1c1d] hover:bg-[#f3f4f5]' : 'text-[#66758b] hover:bg-[#f3f4f5]'}`}
>
{format(day, 'd')}
</button>
@ -168,9 +197,9 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa
)}
<div className="mt-2 flex items-center justify-between border-t border-[#e7e8ea] pt-2">
<button type="button" onClick={() => { onChange(''); setIsOpen(false); triggerRef.current?.focus() }} className="rounded-[6px] px-2 py-1 text-sm text-[#5c6470] hover:bg-[#f3f4f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">清除</button>
<button type="button" onClick={() => selectDate(new Date())} className="rounded-[6px] px-2 py-1 text-sm font-medium text-[#315f91] hover:bg-[#eef4fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">今天</button>
</div>
<button type="button" disabled={outsideRange(new Date())} onClick={() => selectDate(new Date())} className="rounded-[6px] px-2 py-1 text-sm font-medium text-[#315f91] hover:bg-[#eef4fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-40">今天</button>
</div>
</div>, document.body
)}
</div>
)

View File

@ -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(<CertificateReportUploader onSuccess={jest.fn()} onCancel={jest.fn()} productCategories={[]} />)
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' })

View File

@ -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({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="certificate-report-category" className={fieldLabelClass}>品类 *</label>
<select
<Select
id="certificate-report-category"
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
@ -332,12 +334,12 @@ export default function CertificateReportUploader({
{category.name}
</option>
))}
</select>
</Select>
</div>
<div>
<label htmlFor="certificate-report-platform" className={fieldLabelClass}>平台分类</label>
<select
<Select
id="certificate-report-platform"
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
@ -345,7 +347,7 @@ export default function CertificateReportUploader({
>
<option value="">不选择</option>
{CERTIFICATE_REPORT_PLATFORM_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</Select>
</div>
</div>

View File

@ -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({
<label htmlFor="liveHostUserId" className={`${fieldLabelClass} mt-4 block`}>
关联用户 <span className={`ml-1 font-normal ${fieldHintClass}`}>(可选,便于按人筛选)</span>
</label>
<select
<Select
id="liveHostUserId"
value={liveHostUserId}
onChange={(e) => {
@ -125,14 +128,14 @@ export default function LiveClipFields({
{user.displayName}
</option>
))}
</select>
</Select>
</div>
<div>
<label htmlFor="livePlatform" className={fieldLabelClass}>
直播平台
</label>
<select
<Select
id="livePlatform"
value={livePlatform}
onChange={(e) => onLivePlatformChange(e.target.value)}
@ -144,7 +147,7 @@ export default function LiveClipFields({
{platform}
</option>
))}
</select>
</Select>
</div>
</>
)

View File

@ -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}')

View File

@ -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<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(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<HTMLElement>(`[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 (
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
id={id}
type="button"
disabled={disabled}
aria-labelledby={`${id}-label`}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={`${id}-options`}
onClick={() => setIsOpen((open) => !open)}
className={`${fieldControlClass} flex min-h-11 items-center justify-between gap-3 text-left`}
>
<span className="truncate">{value}</span>
<ChevronDown aria-hidden="true" className={`h-4 w-4 shrink-0 text-[#6b7075] transition-transform duration-150 ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div
ref={menuRef}
id={`${id}-options`}
role="listbox"
tabIndex={-1}
aria-labelledby={`${id}-label`}
aria-activedescendant={`${id}-option-${activeIndex}`}
className="absolute left-0 right-0 top-full z-40 mt-2 max-h-80 overflow-y-auto overscroll-contain rounded-[12px] border border-[#e7e8ea] bg-white p-1.5 text-[#1a1c1d] shadow-[0_12px_28px_rgba(26,28,29,0.12)] outline-none"
>
{hasCounts && <div className="px-2.5 py-1.5 text-sm font-medium text-[#6b7075]">按素材数量排序</div>}
{rankedOptions.map((option, index) => (
<div
key={option}
id={`${id}-option-${index}`}
data-option-index={index}
role="option"
aria-label={option}
aria-selected={option === value}
onClick={() => 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' : ''}`}
>
<span className="flex min-w-0 items-center gap-2">
<Check aria-hidden="true" className={`h-4 w-4 shrink-0 ${option === value ? 'text-[#1a1c1d]' : 'opacity-0'}`} />
<span className="truncate">{option}</span>
</span>
{hasCounts && <span className="font-en shrink-0 text-sm tabular-nums text-[#6b7075]">{(counts[option] || 0).toLocaleString('zh-CN')}</span>}
</div>
))}
</div>
)}
</div>
<Select id={id} value={value} aria-labelledby={id + '-label'} disabled={disabled}
optionCounts={hasCounts ? counts : undefined} menuHint={hasCounts ? '按素材数量排序' : undefined}
onChange={(event) => onChange(event.target.value)}>
{rankedOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</Select>
)
}

View File

@ -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 () => {

View File

@ -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' }),

View File

@ -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({
<label htmlFor="task-detail-source-type" className="mb-1 block text-sm font-medium text-gray-700">
出图方式 <span className="text-red-500">*</span>
</label>
<select
<Select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
@ -1446,7 +1449,7 @@ export default function ProductPackageUploader({
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</Select>
</div>
</div>
@ -1482,7 +1485,7 @@ export default function ProductPackageUploader({
<label htmlFor="task-detail-demand-type" className="mb-1 block text-sm font-medium text-gray-700">
需求类型 <span className="text-red-500">*</span>
</label>
<select
<Select
id="task-detail-demand-type"
value={taskDetailDemandType}
onChange={(e) => handleTaskDetailDemandTypeChange(e.target.value as TaskDetailDemandType)}
@ -1496,14 +1499,14 @@ export default function ProductPackageUploader({
{option}
</option>
))}
</select>
</Select>
</div>
<div>
<label htmlFor="task-detail-visibility" className="mb-1 block text-sm font-medium text-gray-700">
可见性
</label>
<select
<Select
id="task-detail-visibility"
value={taskDetailVisibilityValue}
onChange={(e) => handleTaskDetailVisibilityChange(e.target.value as 'public' | 'private')}
@ -1513,7 +1516,7 @@ export default function ProductPackageUploader({
>
<option value="public">公开</option>
<option value="private" disabled={!isOptimizationDemand}>私密</option>
</select>
</Select>
</div>
</div>

View File

@ -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({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -392,7 +394,7 @@ export default function RealPhotoUploader({
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
</div>
)}
@ -421,7 +423,7 @@ export default function RealPhotoUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -435,7 +437,7 @@ export default function RealPhotoUploader({
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
@ -461,7 +463,7 @@ export default function RealPhotoUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
@ -472,7 +474,7 @@ export default function RealPhotoUploader({
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
@ -484,7 +486,7 @@ export default function RealPhotoUploader({
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
@ -495,7 +497,7 @@ export default function RealPhotoUploader({
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</Select>
</div>
)}

View File

@ -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({
<label htmlFor={`batch-video-category-${index}`} className={fieldLabelClass}>
品类(可选)
</label>
<select
<Select
id={`batch-video-category-${index}`}
value={item.productCategoryName || ''}
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
@ -181,7 +184,7 @@ export function BatchVideoList({
{category.name}
</option>
))}
</select>
</Select>
</div>
)}
<VideoPreviewImageUpload

View File

@ -1,5 +1,7 @@
'use client'
import Select from '@/components/ui/Select'
import { useState, useRef, useEffect } from 'react';
import { parseSPU } from '@/lib/spu-parser';
import {
@ -414,7 +416,7 @@ export default function TaskPageUploader({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -429,7 +431,7 @@ export default function TaskPageUploader({
{category.name}
</option>
))}
</select>
</Select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
@ -475,7 +477,7 @@ export default function TaskPageUploader({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
@ -487,7 +489,7 @@ export default function TaskPageUploader({
{scene.name}
</option>
))}
</select>
</Select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
@ -505,7 +507,7 @@ export default function TaskPageUploader({
<label htmlFor="platformName" className={fieldLabelClass}>
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
@ -517,7 +519,7 @@ export default function TaskPageUploader({
{platform.name}
</option>
))}
</select>
</Select>
</div>
)}
@ -532,7 +534,7 @@ export default function TaskPageUploader({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -547,7 +549,7 @@ export default function TaskPageUploader({
{category.name}
</option>
))}
</select>
</Select>
</div>
)}

View File

@ -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()

View File

@ -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({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -395,7 +397,7 @@ export default function ThreeDOriginalUploader({
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
</div>
)}
@ -424,7 +426,7 @@ export default function ThreeDOriginalUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -438,7 +440,7 @@ export default function ThreeDOriginalUploader({
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
@ -464,7 +466,7 @@ export default function ThreeDOriginalUploader({
</div>
) : (
<div className="flex space-x-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
@ -475,7 +477,7 @@ export default function ThreeDOriginalUploader({
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
@ -487,7 +489,7 @@ export default function ThreeDOriginalUploader({
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
@ -498,7 +500,7 @@ export default function ThreeDOriginalUploader({
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</Select>
</div>
)}

View File

@ -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({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -316,7 +319,7 @@ export default function UploadForm({
{category.name}
</option>
))}
</select>
</Select>
</div>
)}
@ -360,7 +363,7 @@ export default function UploadForm({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -382,7 +385,7 @@ export default function UploadForm({
))}
</>
)}
</select>
</Select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
@ -431,7 +434,7 @@ export default function UploadForm({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => onSceneChange(e.target.value)}
@ -450,7 +453,7 @@ export default function UploadForm({
))}
</>
)}
</select>
</Select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
@ -470,7 +473,7 @@ export default function UploadForm({
<label htmlFor="platformName" className={fieldLabelClass}>
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => onPlatformChange(e.target.value)}
@ -482,7 +485,7 @@ export default function UploadForm({
{platform.name}
</option>
))}
</select>
</Select>
</div>
)}
@ -492,7 +495,7 @@ export default function UploadForm({
<label htmlFor="renderer" className={fieldLabelClass}>
渲染器 *
</label>
<select
<Select
id="renderer"
value={renderer}
onChange={(e) => onRendererChange(e.target.value)}
@ -501,7 +504,7 @@ export default function UploadForm({
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</Select>
</div>
)}

View File

@ -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({
</span>
)}
</label>
<select
<Select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
@ -410,7 +412,7 @@ export default function VideoScreenshotUploader({
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
</div>
)}
@ -439,7 +441,7 @@ export default function VideoScreenshotUploader({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="categoryName"
value={categoryName}
onChange={(e) => {
@ -453,7 +455,7 @@ export default function VideoScreenshotUploader({
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建店铺</button>
</div>
)}
@ -479,7 +481,7 @@ export default function VideoScreenshotUploader({
</div>
) : (
<div className="flex gap-2">
<select
<Select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
@ -490,7 +492,7 @@ export default function VideoScreenshotUploader({
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
</Select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建场景类别</button>
</div>
)}
@ -502,7 +504,7 @@ export default function VideoScreenshotUploader({
<label htmlFor="platformName" className={fieldLabelClass}>
平台分类(可选)
</label>
<select
<Select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
@ -513,7 +515,7 @@ export default function VideoScreenshotUploader({
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</Select>
</div>
)}

View File

@ -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() {
</label>
<label>
<span className="sr-only">状态筛选</span>
<select
<Select
value={statusFilter}
onChange={(event) => {
setStatusFilter(normalizeStatus(event.target.value))
@ -420,7 +422,7 @@ export default function WorkReviewBoard() {
className="h-11 w-full rounded border border-[#d7dde7] bg-white px-3 text-[13px] font-semibold text-[#344054] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff]"
>
{statusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</Select>
</label>
<button
type="button"

View File

@ -0,0 +1,40 @@
# 素材库筛选与下拉控件验收
日期:2026-10-07。状态:代码修改与本地验证完成,尚未提交 Git 或部署。
## 修改范围
- 素材卡片预览区建立独立层叠上下文,角标保留在卡片内;固定顶栏和“更多素材类型”菜单显示在角标上方。
- 排查 `app`、`components` 的原生下拉框,将 27 个源文件内的 82 处控件接入 `components/ui/Select.tsx`;素材类型选择器也复用它。菜单统一主题、键盘操作、禁用选项、点击外部关闭及 GSAP 开合动画,并遵循减少动态效果偏好。
- 隐藏的原生 select 继续负责表单值、change 回调、必填校验与 reset;可见按钮负责交互。现有专用搜索、人员和树形选择器保留业务交互。
- 筛选区统一标签列、选项状态和分组间距;长列表可展开,收起时保留已选项;已选条件支持逐项清除。
- 直播日期复用项目日历,统一为 `YYYY-MM-DD`,限制开始日期不能晚于结束日期;弹层随视口边界调整位置。
- 修复直播平台、直播日期变化未触发列表请求,以及刷新未恢复这些条件的问题;补齐 URL 与页面状态缓存。
## 验证证据
- `npm run typecheck` 通过。
- 相关回归测试:34 组、309 项全部通过,涵盖上传字段及参数、编辑检测报告、筛选、知识库、考核与权限表单、键盘操作和必填校验。
- `git diff --check` 通过。
- 全项目原生下拉框复查:除共享组件内的隐藏表单字段,`app`、`components` 生产 TSX 中没有原生 `<select>`。
- 实际项目页面在独立本地验收环境渲染,使用示例接口数据,未连接生产服务。桌面 1440×1000、手机 390×844 验证菜单主题、日期范围、完整日期文字、弹层边界、键盘方向键与 Esc、上传面板及角标层级;未发现横向溢出或浏览器控制台警告/错误。
- 实际浏览器选择日期后,请求包含 `liveDateFrom=2026-10-01&liveDateTo=2026-10-07`;刷新页面后日期及请求参数仍保留。
- 命中层级检查:卡片角标滚动进入顶栏范围时,顶栏在前;“更多素材类型”菜单与角标重叠时,菜单选项在前。
- Impeccable polish 检查完成。扫描的字体提示涉及原有页面字号;唯一非 advisory 提示为 `DataStatisticsTab.tsx` 既有紫色图表渐变,已核对 HEAD 中存在,本次仅替换其下拉框,保留既有图表语义。
## 本地验收截图
- `tmp/material-ui-qa/desktop-filter.jpg`
- `tmp/material-ui-qa/mobile-filter.jpg`
- `tmp/material-ui-qa/desktop-type-menu.jpg`
- `tmp/material-ui-qa/desktop-header-layer.jpg`
`tmp/material-ui-qa` 为忽略的临时验收目录;验收服务器已停止。验证使用本地示例数据,不代表已完成生产登录后的验收。原有任务详情图片来源修复的未提交代码未纳入本次前端修改。
## 发布前复核
- 合入远端 `4653a5639286b30df63aa879fff2e56cc18d1755` 的业绩转化统计功能,保留其行为,仅替换趋势指标下拉控件。
- 34 组、310 项相关回归测试通过;Prisma Client 校验及类型检查通过。
- 重新从当前源码生成隔离预览,核对桌面 1440×1000 与手机 390×844 的日期范围和日历边界。手机日历位于视口内,无页面横向溢出,浏览器无警告或错误。
- 本次提交仅包含界面修复、对应测试和本验收记录;历史来源映射代码、运维文档及直播切片交接稿仍保留为未提交工作。
- 发布仍需以精确提交 SHA 通过标准部署脚本完成,并以公网健康接口返回的 `deploymentCommit` 确认结果。

View File

@ -1,3 +1,4 @@
import { selectOption } from '@/test-utils/select'
/** @jest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
@ -202,13 +203,13 @@ describe('EditMaterialPage', () => {
const kindSelect = await screen.findByLabelText('报告类别 *')
expect(screen.getByLabelText('SPU')).toHaveValue('SPU-001')
expect(screen.getByLabelText('证书检测日期 *')).toHaveTextContent('2027年9月23日')
expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('2028年9月23日')
expect(screen.getByLabelText('证书检测日期 *')).toHaveTextContent('2027-09-23')
expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('2028-09-23')
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段')
expect(screen.getByLabelText('平台分类')).toHaveValue('品牌')
await user.selectOptions(kindSelect, '原材料')
await selectOption(kindSelect, '原材料')
expect(screen.queryByLabelText('SPU')).not.toBeInTheDocument()
expect(screen.getByLabelText('报告名称 / 检测对象 *')).toHaveValue('板材检测报告')
await user.click(screen.getByLabelText('证书到期时间(选填)'))

30
test-utils/select.ts Normal file
View File

@ -0,0 +1,30 @@
import { fireEvent } from '@testing-library/react'
/** Exercise the visible menu while selecting the same value that the form will submit. */
export function selectOption(control: HTMLElement, value: string) {
const native = control.tagName === 'SELECT' ? control as HTMLSelectElement : control.parentElement?.querySelector('select')
if (!native) throw new Error('选择控件没有表单字段')
if (control.tagName === 'SELECT') return fireEvent.change(control, { target: { value } })
const options = Array.from(native.options).filter((option) => !option.hidden)
const index = options.findIndex((option) => option.value === value)
if (index < 0) throw new Error(`没有可选值:${value}`)
const menu = openSelect(control)
const option = menu?.querySelector<HTMLElement>(`[data-option-index="${index}"]`)
if (!option) throw new Error(`菜单没有可选值:${value}`)
fireEvent.click(option)
}
/** Text and file fields continue to use change; custom single selects use their real menu. */
export function changeField(element: Parameters<typeof fireEvent.change>[0], init: Parameters<typeof fireEvent.change>[1]) {
if (element instanceof HTMLElement && element.getAttribute('role') === 'combobox' && element.tagName === 'BUTTON') {
return selectOption(element, String((init as { target?: { value?: unknown } })?.target?.value ?? ''))
}
return fireEvent.change(element, init)
}
export function openSelect(control: HTMLElement) {
if (control.getAttribute('aria-expanded') !== 'true') { fireEvent.pointerDown(control); fireEvent.click(control) }
const menu = document.getElementById(control.getAttribute('aria-controls')!)
if (!menu) throw new Error('菜单未展开')
return menu
}