ui: compact task detail upload layout
This commit is contained in:
parent
5a35321b3a
commit
8bea6d8d9a
@ -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();
|
||||
|
||||
193
components/upload/ProductPackageUploader.layout.test.tsx
Normal file
193
components/upload/ProductPackageUploader.layout.test.tsx
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
82
components/upload/ProductPackageUploader.module.css
Normal file
82
components/upload/ProductPackageUploader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
<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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user