ui: compact task detail upload layout

This commit is contained in:
chenpei 2026-08-31 18:19:26 +08:00
parent 5a35321b3a
commit 8bea6d8d9a
4 changed files with 373 additions and 78 deletions

View File

@ -1961,9 +1961,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
role="dialog"
aria-modal="true"
aria-labelledby="upload-material-title"
className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-[12px] border border-[#e7e8ea] bg-white"
className={`max-h-[90vh] w-full overflow-y-auto rounded-[12px] border border-[#e7e8ea] bg-white ${materialType === '任务-详情' ? 'max-w-4xl' : 'max-w-2xl'}`}
>
<div data-upload-reveal className="border-b border-[#e7e8ea] px-8 pb-5 pt-7">
<div data-upload-reveal className={`border-b border-[#e7e8ea] ${materialType === '任务-详情' ? 'px-4 py-4 sm:px-6' : 'px-8 pb-5 pt-7'}`}>
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2
@ -1988,7 +1988,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
</div>
<form data-upload-reveal onSubmit={handleSubmit} className="space-y-6 px-8 py-6">
<form data-upload-reveal onSubmit={handleSubmit} className={materialType === '任务-详情' ? 'space-y-4 px-4 py-4 sm:px-6' : 'space-y-6 px-8 py-6'}>
<div>
<label htmlFor="upload-material-type" className={fieldLabelClass}>
素材类型 *
@ -2028,7 +2028,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
{/* 任务-详情上传组件 */}
{materialType === '任务-详情' && (
<div className="border-t border-[#e7e8ea] pt-6">
<div className="border-t border-[#e7e8ea] pt-4">
<ProductPackageUploader
onSuccess={() => {
onSuccess();

View File

@ -0,0 +1,193 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
const mockAddCategory = jest.fn()
const mockAddScene = jest.fn()
jest.mock('@/components/upload/category-handlers', () => ({
addCategory: (...args: unknown[]) => mockAddCategory(...args),
addScene: (...args: unknown[]) => mockAddScene(...args),
}))
jest.mock('@/components/upload/DetailImageSorter', () => ({
__esModule: true,
default: () => <div data-testid="detail-image-sorter" />,
}))
const defaultProps = {
onSuccess: jest.fn(),
onCancel: jest.fn(),
categories: [
{ id: 'cat-1', name: '店铺A' },
{ id: 'cat-2', name: '店铺B' },
],
scenes: [
{ id: 'scene-1', name: '场景A' },
{ id: 'scene-2', name: '场景B' },
],
platforms: [
{ id: 'platform-1', name: '淘宝' },
{ id: 'platform-2', name: '天猫' },
],
productCategories: [
{ id: 'pc-1', name: '沙发' },
{ id: 'pc-2', name: '餐桌' },
],
onRefreshCategories: jest.fn().mockResolvedValue(undefined),
onRefreshScenes: jest.fn().mockResolvedValue(undefined),
}
function installUsersFetch() {
const fetchMock = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/users/list') {
return Promise.resolve({
ok: true,
json: async () => [
{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' },
{ id: 'requester-2', username: 'operator-b', displayName: '运营乙' },
],
})
}
return Promise.reject(new Error(`测试未处理请求:${url}`))
})
global.fetch = fetchMock as unknown as typeof fetch
return fetchMock
}
async function renderUploader() {
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
}
describe('ProductPackageUploader compact layout interactions', () => {
const originalFetch = global.fetch
beforeEach(() => {
jest.clearAllMocks()
installUsersFetch()
mockAddCategory.mockImplementation(async (
name: string,
onSuccess?: (category: { id: string; name: string }) => Promise<void> | void,
) => {
await onSuccess?.({ id: 'cat-new', name })
})
mockAddScene.mockImplementation(async (
name: string,
onSuccess?: (scene: { id: string; name: string }) => Promise<void> | void,
) => {
await onSuccess?.({ id: 'scene-new', name })
})
})
afterAll(() => {
global.fetch = originalFetch
})
it('需求运营标签可访问,真实 UploadForm 的 SPU 品类 店铺 场景 平台字段仍可用', async () => {
const user = userEvent.setup()
await renderUploader()
const requester = screen.getByLabelText(/需求运营/) as HTMLInputElement
expect(requester).toBeInTheDocument()
await user.click(requester)
await user.type(requester, '运营甲')
await user.click(screen.getByRole('option', { name: /运营甲/ }))
expect(requester).toHaveValue('运营甲 (operator-a)')
const productModel = screen.getByLabelText(/SPU型号/) as HTMLInputElement
await user.type(productModel, 'df-a3dz0008')
expect(productModel).toHaveValue('DF-A3DZ0008')
await user.selectOptions(screen.getByLabelText(/品类/), '沙发')
expect(screen.getByLabelText(/品类/)).toHaveValue('沙发')
await user.selectOptions(screen.getByLabelText(/使用店铺/), '店铺B')
expect(screen.getByLabelText(/使用店铺/)).toHaveValue('店铺B')
await user.selectOptions(screen.getByLabelText(/场景类别/), '场景A')
expect(screen.getByLabelText(/场景类别/)).toHaveValue('场景A')
await user.selectOptions(screen.getByLabelText(/平台分类/), '淘宝')
expect(screen.getByLabelText(/平台分类/)).toHaveValue('淘宝')
})
it('内联新建店铺和场景输入可打开、添加、取消', async () => {
const user = userEvent.setup()
await renderUploader()
await user.click(screen.getByRole('button', { name: '新建店铺' }))
const categoryInput = screen.getByPlaceholderText('输入新店铺名称')
await user.type(categoryInput, '新店铺C')
await user.click(screen.getByRole('button', { name: '添加' }))
await waitFor(() => expect(mockAddCategory).toHaveBeenCalled())
expect(mockAddCategory).toHaveBeenCalledWith(
'新店铺C',
expect.any(Function),
expect.any(Function),
)
expect(defaultProps.onRefreshCategories).toHaveBeenCalled()
expect(screen.queryByPlaceholderText('输入新店铺名称')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '新建场景' }))
const sceneInput = screen.getByPlaceholderText('输入新场景类别')
await user.type(sceneInput, '新场景C')
await user.click(within(sceneInput.parentElement as HTMLElement).getByRole('button', { name: '取消' }))
expect(screen.queryByPlaceholderText('输入新场景类别')).not.toBeInTheDocument()
expect(mockAddScene).not.toHaveBeenCalled()
})
it('新品只能公开,优化时才可选私密,切回新品会恢复公开', async () => {
const user = userEvent.setup()
await renderUploader()
const demandType = screen.getByLabelText(/需求类型/) as HTMLSelectElement
const visibility = screen.getByLabelText(/可见性/) as HTMLSelectElement
const privateOption = screen.getByRole('option', { name: '私密' }) as HTMLOptionElement
expect(demandType.value).toBe('新品')
expect(visibility.value).toBe('public')
expect(privateOption.disabled).toBe(true)
expect(screen.getByText(/新品固定公开,仅优化需求支持私密/)).toBeInTheDocument()
await user.selectOptions(demandType, '优化')
expect(privateOption.disabled).toBe(false)
expect(screen.getByText('私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。')).toBeInTheDocument()
await user.selectOptions(visibility, 'private')
expect(visibility.value).toBe('private')
await user.selectOptions(demandType, '新品')
expect(visibility.value).toBe('public')
expect(privateOption.disabled).toBe(true)
})
it('四类文件选择入口都存在', async () => {
await renderUploader()
expect(screen.getByLabelText('主图')).toBeInTheDocument()
expect(screen.getByLabelText('SKU图')).toBeInTheDocument()
expect(screen.getByLabelText('详情图')).toBeInTheDocument()
expect(screen.getByLabelText(/PSD源文件/)).toBeInTheDocument()
})
it('校验失败时将错误提示滚动到可见位置', async () => {
const original = HTMLElement.prototype.scrollIntoView
const scrollIntoView = jest.fn()
HTMLElement.prototype.scrollIntoView = scrollIntoView
try {
const user = userEvent.setup()
await renderUploader()
await user.click(screen.getByRole('button', { name: '提交', exact: true }))
expect(screen.getByRole('alert')).toHaveTextContent('请选择需求运营人员')
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
} finally {
HTMLElement.prototype.scrollIntoView = original
}
})
})

View File

@ -0,0 +1,82 @@
/* 紧凑样式只作用于任务详情,不改变其它上传类型的共享字段。 */
.metadata > div,
.metadata :is(input[type='text'], select),
.productFields > div {
min-width: 0;
}
.metadata label {
margin-bottom: 4px;
font-size: 14px;
line-height: 20px;
}
.metadata :is(input[type='text'], select) {
min-height: 44px;
padding: 8px 12px;
border-radius: 8px;
font-size: 16px;
line-height: 24px;
}
.metadata input[role='combobox'] {
padding-right: 80px;
}
.productFields button {
min-height: 44px;
height: auto;
flex-shrink: 0;
padding: 8px 12px;
white-space: nowrap;
}
.references > div {
gap: 12px 16px;
}
.fileButton {
min-height: 44px;
flex-shrink: 0;
padding: 8px 12px;
border-radius: 8px;
background: #f3f4f6;
color: #374151;
font-size: 14px;
line-height: 20px;
}
.fileButton:hover {
background: #e5e7eb;
}
.fileButton:focus-visible {
outline: 2px solid #1a1c1d;
outline-offset: 2px;
}
.fileButton:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.feedback {
scroll-margin-block: 12px 88px;
}
@media (min-width: 640px) {
.references > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metadata :is(input[type='text'], select) {
min-height: 40px;
font-size: 14px;
line-height: 20px;
}
.productFields button,
.fileButton {
min-height: 40px;
}
}

View File

@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import styles from './ProductPackageUploader.module.css';
import { addCategory, addScene } from './category-handlers';
import SearchableUserSelect from '@/components/SearchableUserSelect';
import {
@ -54,7 +55,7 @@ const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
const TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000;
const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-apple border border-gray-300 bg-white px-4 py-2 text-gray-900 outline-none focus:border-gray-900 focus:ring-2 focus:ring-gray-200 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500';
const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 outline-none focus:border-gray-900 focus:ring-2 focus:ring-gray-200 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500';
function splitEmptyFiles(files: FileList | File[] | null): {
validFiles: File[];
@ -404,6 +405,7 @@ export default function ProductPackageUploader({
const skuInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const psdInputRef = useRef<HTMLInputElement>(null);
const errorRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
const cancelledRef = useRef(false);
const uploadStopRef = useRef(false);
@ -427,6 +429,10 @@ export default function ProductPackageUploader({
psd: [],
});
useEffect(() => {
if (error) errorRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [error]);
const abortActiveBrowserPreview = useCallback(() => {
const controller = browserPreviewAbortRef.current;
if (controller) controller.abort();
@ -1387,7 +1393,9 @@ export default function ProductPackageUploader({
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div className={`${styles.metadata} space-y-3`}>
<div className={styles.references}>
<TaskReferenceFields
idPrefix="product-package"
taskNo={taskNo}
@ -1396,12 +1404,15 @@ export default function ProductPackageUploader({
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={uploading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="min-w-0">
<label htmlFor="task-detail-requester" className="mb-1 block text-sm font-medium text-gray-700">
需求运营 <span className="text-red-500">*</span>
</label>
<SearchableUserSelect
id="task-detail-requester"
users={users}
value={taskDetailRequesterUserId}
onChange={(userId) => {
@ -1412,12 +1423,34 @@ export default function ProductPackageUploader({
required
disabled={uploading || usersLoading || Boolean(usersLoadError)}
/>
<p className={`mt-1 text-xs ${usersLoadError ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
<p className={`mt-1 text-xs leading-5 ${usersLoadError ? 'text-red-500' : 'text-gray-500'}`}>
{usersLoadError || '记录发起本次出图需求的运营人员,与素材上传人可以不同。'}
</p>
</div>
<div className="min-w-0">
<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
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
<div className={`${styles.productFields} grid gap-x-4 gap-y-3 sm:grid-cols-2`}>
<UploadForm
materialType="任务-详情"
productModel={productModel}
@ -1440,11 +1473,12 @@ export default function ProductPackageUploader({
onAddCategory={handleAddNewCategory}
onAddScene={handleAddNewScene}
/>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<label htmlFor="task-detail-demand-type" className="mb-2 block text-sm font-medium text-gray-700">
<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
@ -1465,7 +1499,7 @@ export default function ProductPackageUploader({
</div>
<div>
<label htmlFor="task-detail-visibility" className="mb-2 block text-sm font-medium text-gray-700">
<label htmlFor="task-detail-visibility" className="mb-1 block text-sm font-medium text-gray-700">
可见性
</label>
<select
@ -1482,41 +1516,21 @@ export default function ProductPackageUploader({
</div>
</div>
<p id="task-detail-visibility-hint" className="text-xs text-gray-500">
<p id="task-detail-visibility-hint" className="text-xs leading-5 text-gray-500">
{taskDetailPrivacyHint}
</p>
<div>
<label htmlFor="task-detail-source-type" className="mb-2 block text-sm font-medium text-gray-700">
出图方式 <span className="text-red-500">*</span>
</label>
<select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
<div className="space-y-3 border-t border-gray-200 pt-3">
<p className="text-xs leading-5 text-gray-500">
同时上传主图 + SKU 图 + 详情图时自动标记为整套
</p>
<div className="grid gap-4 sm:grid-cols-2">
{/* 主图上传 */}
<div>
<label htmlFor="main-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className={`min-w-0 ${mainImages.length || skuImages.length ? 'sm:col-span-2' : ''}`}>
<label htmlFor="main-images-input" className="mb-1 block text-sm font-medium text-gray-700">
主图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
@ -1525,12 +1539,12 @@ export default function ProductPackageUploader({
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择主图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
className={`rounded-lg border border-dashed p-2 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
@ -1551,7 +1565,7 @@ export default function ProductPackageUploader({
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div className={mainImages.length ? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5' : ''}>
{mainImages.map((img) => (
<div key={img.id} className="relative group">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
@ -1570,26 +1584,28 @@ export default function ProductPackageUploader({
))}
<button
type="button"
aria-label="添加主图"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="group w-full h-24 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
className={`group flex w-full items-center justify-center gap-2 rounded-lg bg-gray-50 text-sm text-gray-600 transition-colors hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-900 disabled:opacity-50 ${mainImages.length ? 'h-24 border border-dashed border-gray-300' : 'min-h-11 sm:min-h-10'}`}
>
<svg className="w-8 h-8 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加主图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{mainImages.length === 0 && <span>添加主图</span>}
</button>
</div>
</div>
</div>
{/* SKU图上传 */}
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
<div className={`min-w-0 ${mainImages.length || skuImages.length ? 'sm:col-span-2' : ''}`}>
<div className="mb-1 flex min-h-5 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700">
SKU图
</label>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<label className="flex min-h-11 cursor-pointer select-none items-center gap-2 text-sm leading-5 text-gray-600 sm:min-h-5">
<input
type="checkbox"
checked={noBarcodes}
@ -1606,12 +1622,12 @@ export default function ProductPackageUploader({
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 SKU 图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
className={`rounded-lg border border-dashed p-2 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isSkuDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
@ -1632,7 +1648,7 @@ export default function ProductPackageUploader({
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div className={skuImages.length ? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5' : ''}>
{skuImages.map((img) => (
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
<div className="relative w-full h-20 rounded-apple overflow-hidden border border-gray-200 dark:border-gray-600">
@ -1669,22 +1685,24 @@ export default function ProductPackageUploader({
))}
<button
type="button"
aria-label="添加SKU图"
onClick={() => skuInputRef.current?.click()}
disabled={uploading}
className="group w-full h-20 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
className={`group flex w-full items-center justify-center gap-2 rounded-lg bg-gray-50 text-sm text-gray-600 transition-colors hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-900 disabled:opacity-50 ${skuImages.length ? 'h-20 border border-dashed border-gray-300' : 'min-h-11 sm:min-h-10'}`}
>
<svg className="w-8 h-8 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加SKU图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{skuImages.length === 0 && <span>添加SKU图</span>}
</button>
</div>
</div>
</div>
{/* 详情图上传 */}
<div>
<label htmlFor="detail-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="min-w-0 sm:col-span-2">
<label htmlFor="detail-images-input" className="mb-1 block text-sm font-medium text-gray-700">
详情图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
@ -1693,12 +1711,12 @@ export default function ProductPackageUploader({
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择详情图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
className={`rounded-lg border border-dashed p-2 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isDetailDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
@ -1720,16 +1738,16 @@ export default function ProductPackageUploader({
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex items-center space-x-3 mb-3">
<div className={`flex flex-wrap items-center gap-x-3 gap-y-1 ${detailImages.length ? 'mb-3' : ''}`}>
<button
onClick={() => detailInputRef.current?.click()}
className="btn-secondary text-sm"
className={styles.fileButton}
disabled={uploading}
type="button"
>
{detailImages.length > 0 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs text-gray-400">JPG / PNG / GIF / WebP,支持拖拽</span>
<span className="text-xs leading-5 text-gray-500">JPG / PNG / GIF / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}
@ -1745,8 +1763,8 @@ export default function ProductPackageUploader({
</div>
{/* PSD上传 */}
<div>
<label htmlFor="psd-files-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="min-w-0 sm:col-span-2">
<label htmlFor="psd-files-input" className="mb-1 block text-sm font-medium text-gray-700">
PSD源文件(可选)
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
@ -1755,12 +1773,12 @@ export default function ProductPackageUploader({
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 PSD 源文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
className={`rounded-lg border border-dashed p-2 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isPsdDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsPsdDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
@ -1784,25 +1802,25 @@ export default function ProductPackageUploader({
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex items-center space-x-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<button
onClick={() => psdInputRef.current?.click()}
className="btn-secondary text-sm"
className={styles.fileButton}
disabled={uploading}
type="button"
>
{psdFiles.length > 0 ? `继续添加 (${psdFiles.length}个)` : '选择源文件'}
</button>
<span className="text-xs text-gray-400">{TASK_DETAIL_SOURCE_FILE_LABEL}</span>
<span className="min-w-0 break-words text-xs leading-5 text-gray-500">{TASK_DETAIL_SOURCE_FILE_LABEL}</span>
{psdFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 ml-2">
<div className="flex w-full min-w-0 flex-wrap gap-1.5">
{psdFiles.map((psdFile) => (
<span key={psdFile.id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-700 dark:text-gray-300">
{psdFile.file.name}
<span key={psdFile.id} className="inline-flex max-w-full items-center gap-1 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
<span className="min-w-0 break-all">{psdFile.file.name}</span>
<button
onClick={() => handleRemoveFile(psdFile.id, 'psd')}
aria-label={`删除源文件 ${psdFile.file.name}`}
className="text-red-400 hover:text-red-600"
className="flex h-8 w-8 shrink-0 items-center justify-center text-red-500 hover:text-red-600"
disabled={uploading}
type="button"
>
@ -1815,10 +1833,12 @@ export default function ProductPackageUploader({
</div>
</div>
</div>
</div>
</div>
{/* 错误提示 */}
{error && (
<div role="alert" aria-live="assertive" className="break-words p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
<div ref={errorRef} role="alert" aria-live="assertive" className={`${styles.feedback} break-words rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600`}>
{error}
</div>
)}
@ -1849,7 +1869,7 @@ export default function ProductPackageUploader({
)}
{/* 操作按钮 */}
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="sticky bottom-0 -mx-1 border-t border-gray-200 bg-white px-1 py-2">
<div className="flex justify-end space-x-3">
<button
onClick={uploading ? handleCancelUpload : onCancel}