feat: unify and compact material upload controls
This commit is contained in:
parent
0baa951fd9
commit
756f9c9575
@ -1572,6 +1572,7 @@ export default function MaterialLibraryPage() {
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UploadModal
|
||||
materialTypeCounts={materialTypeCounts}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowUploadModal(false)
|
||||
|
||||
@ -1567,6 +1567,7 @@ export default function Home() {
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UploadModal
|
||||
materialTypeCounts={materialTypeCounts}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowUploadModal(false)
|
||||
|
||||
@ -56,7 +56,7 @@ describe('UploadModal - 任务-其他', () => {
|
||||
|
||||
expect(await screen.findByLabelText('任务号')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('钉钉审批编号')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('素材类型 *')).toHaveValue('3D场景')
|
||||
expect(screen.getByRole('button', { name: '素材类型 *' })).toHaveTextContent('3D场景')
|
||||
})
|
||||
|
||||
test('任务-其他应使用公共上传面板形式,任务号与钉钉审批编号并列显示', async () => {
|
||||
@ -64,8 +64,8 @@ describe('UploadModal - 任务-其他', () => {
|
||||
|
||||
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
|
||||
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
await user.selectOptions(materialTypeSelect, '任务-其他')
|
||||
await user.click(screen.getByRole('button', { name: '素材类型 *' }))
|
||||
await user.click(await screen.findByRole('option', { name: '任务-其他' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('任务号 *')).toBeInTheDocument()
|
||||
@ -86,8 +86,8 @@ describe('UploadModal - 任务-其他', () => {
|
||||
|
||||
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
|
||||
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
await user.selectOptions(materialTypeSelect, '任务-其他')
|
||||
await user.click(screen.getByRole('button', { name: '素材类型 *' }))
|
||||
await user.click(await screen.findByRole('option', { name: '任务-其他' }))
|
||||
|
||||
const selectButton = await screen.findByRole('button', { name: '选择素材文件' })
|
||||
const dropZone = selectButton.closest('[aria-disabled]') as HTMLDivElement
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useGSAP } from '@gsap/react'
|
||||
import gsap from 'gsap'
|
||||
import { FileUp } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { chunkUpload } from '@/lib/chunk-upload'
|
||||
import {
|
||||
@ -51,6 +52,7 @@ import { useCategoryData } from '@/hooks/useCategoryData'
|
||||
import { parseApiError } from '@/lib/error-handler'
|
||||
import { parseSPU } from '@/lib/spu-parser'
|
||||
import UploadForm from '@/components/upload/UploadForm'
|
||||
import MaterialTypeSelect from '@/components/upload/MaterialTypeSelect'
|
||||
import {
|
||||
BatchMusicList,
|
||||
BatchVideoList,
|
||||
@ -79,6 +81,7 @@ import {
|
||||
fieldLabelClass,
|
||||
inkButtonClass,
|
||||
outlineButtonClass,
|
||||
uploadAreaButtonClass,
|
||||
} from '@/components/upload/upload-field-styles'
|
||||
import {
|
||||
getSpuProductModelValidationError,
|
||||
@ -91,6 +94,7 @@ gsap.registerPlugin(useGSAP)
|
||||
interface UploadModalProps {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
materialTypeCounts?: Record<string, number>
|
||||
}
|
||||
|
||||
function getSupportedMainFileDescription(materialType: string): string {
|
||||
@ -123,7 +127,7 @@ function isPreviewOptionalMaterialType(materialType: string): boolean {
|
||||
return isVideoMaterialType(materialType) || PREVIEW_OPTIONAL_TYPES.includes(materialType)
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
export default function UploadModal({ onClose, onSuccess, materialTypeCounts = {} }: UploadModalProps) {
|
||||
const SPECIAL_UPLOAD_TYPES = ['任务-详情', '任务-视频截图', '任务-页面', '证书-检测报告'] as const
|
||||
const [previewImages, setPreviewImages] = useState<File[]>([]) // 预览图片数组(支持多图)
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 预览图片预览URLs
|
||||
@ -2009,7 +2013,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
aria-labelledby="upload-material-title"
|
||||
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] ${materialType === '任务-详情' ? 'px-4 py-4 sm:px-6' : 'px-8 pb-5 pt-7'}`}>
|
||||
<div data-upload-reveal className="border-b border-[#e7e8ea] px-4 py-4 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
@ -2019,7 +2023,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
上传素材
|
||||
</h2>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[#6b7075]">
|
||||
把文件拖进虚线框即可。内网请先切到顶部提示里的内网地址,上传会更快。
|
||||
按素材类型填写信息。内网请切到顶部提示的地址,上传更快。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -2034,30 +2038,21 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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'}>
|
||||
<form data-upload-reveal onSubmit={handleSubmit} className="space-y-5 px-4 py-5 sm:px-6">
|
||||
<div>
|
||||
<label htmlFor="upload-material-type" className={fieldLabelClass}>
|
||||
<label id="upload-material-type-label" htmlFor="upload-material-type" className={fieldLabelClass}>
|
||||
素材类型 *
|
||||
</label>
|
||||
<select
|
||||
<MaterialTypeSelect
|
||||
id="upload-material-type"
|
||||
value={materialType}
|
||||
onChange={(e) => setMaterialType(e.target.value)}
|
||||
onChange={setMaterialType}
|
||||
disabled={interactionLocked}
|
||||
className={fieldControlClass}
|
||||
>
|
||||
{availableMaterialTypes.length > 0 ? (
|
||||
availableMaterialTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{MATERIAL_TYPE_OPTIONS.filter((type) => !SPECIAL_UPLOAD_TYPES.includes(type as typeof SPECIAL_UPLOAD_TYPES[number])).map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
options={availableMaterialTypes.length > 0
|
||||
? availableMaterialTypes
|
||||
: MATERIAL_TYPE_OPTIONS.filter((type) => !SPECIAL_UPLOAD_TYPES.includes(type as typeof SPECIAL_UPLOAD_TYPES[number]))}
|
||||
counts={materialTypeCounts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!usesDedicatedUploader && (
|
||||
@ -2074,7 +2069,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
{/* 任务-详情上传组件 */}
|
||||
{materialType === '任务-详情' && (
|
||||
<div className="border-t border-[#e7e8ea] pt-4">
|
||||
<div>
|
||||
<ProductPackageUploader
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
@ -2093,7 +2088,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
{/* 任务-视频截图上传组件 */}
|
||||
{materialType === '任务-视频截图' && (
|
||||
<div className="border-t border-[#e7e8ea] pt-6">
|
||||
<div>
|
||||
<VideoScreenshotUploader
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
@ -2112,7 +2107,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
{/* 任务-页面上传组件 */}
|
||||
{materialType === '任务-页面' && (
|
||||
<div className="border-t border-[#e7e8ea] pt-6">
|
||||
<div>
|
||||
<TaskPageUploader
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
@ -2131,7 +2126,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
{/* 证书-检测报告上传组件 */}
|
||||
{materialType === '证书-检测报告' && (
|
||||
<div className="border-t border-[#e7e8ea] pt-6">
|
||||
<div>
|
||||
<CertificateReportUploader
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
@ -2340,11 +2335,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
{/* 品牌VI灵感提示:预览图会自动从PDF提取 */}
|
||||
{materialType === '品牌VI灵感' && (
|
||||
<div className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-4">
|
||||
<p className="text-[13px] leading-relaxed text-[#6b7075]">
|
||||
系统会从 PDF 第一页自动抽出预览图,不用再单独上传预览图或展示图。
|
||||
</p>
|
||||
</div>
|
||||
<p className={fieldHintClass}>未上传预览图时,系统会从 PDF 第一页自动提取。</p>
|
||||
)}
|
||||
|
||||
{/* 模特展示图片(仅模特类型显示) */}
|
||||
@ -2353,6 +2344,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
modelImageUrls={modelImageUrls}
|
||||
onModelImagesChange={handleModelImagesChange}
|
||||
onRemoveModelImage={removeModelImage}
|
||||
disabled={interactionLocked}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -2413,10 +2405,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
)}
|
||||
|
||||
{/* Material File(有专用上传器的任务类型不显示) */}
|
||||
{!usesDedicatedUploader && (
|
||||
{!usesDedicatedUploader && materialType !== '标准模型' && (
|
||||
<div>
|
||||
<label className={fieldLabelClass}>
|
||||
素材文件 {(materialType === '模特' || materialType === '标准模型') ? '(可选)' : '*'}
|
||||
素材文件 {materialType === '模特' ? '(可选)' : '*'}
|
||||
{materialType === '音乐' && (
|
||||
<span className={`ml-2 font-normal ${fieldHintClass}`}>
|
||||
(支持批量,最多100个)
|
||||
@ -2634,7 +2626,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
aria-disabled={interactionLocked}
|
||||
className={`p-6 text-center ${
|
||||
className={`p-3 ${
|
||||
interactionLocked ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${isDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
@ -2643,11 +2635,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{zipFile ? (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-[#1a1c1d]">{zipFile.name}</p>
|
||||
<p className="text-[13px] text-[#6b7075]">
|
||||
{formatBytes(zipFile.size)}
|
||||
</p>
|
||||
<div className="flex min-h-12 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<FileUp aria-hidden="true" className="h-5 w-5 shrink-0 text-[#6b7075]" />
|
||||
<p className="min-w-0 flex-1 truncate font-medium text-[#1a1c1d]">{zipFile.name}</p>
|
||||
<p className="text-[13px] text-[#6b7075]">{formatBytes(zipFile.size)}</p>
|
||||
{detectMaterialType(zipFile.name) && (
|
||||
<p className="text-[13px] text-[#157a4b]">
|
||||
已识别类型:{detectMaterialType(zipFile.name)}
|
||||
@ -2663,7 +2654,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
type="file"
|
||||
@ -2688,8 +2679,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
type="button"
|
||||
disabled={interactionLocked}
|
||||
onClick={() => batchFileInputRef.current?.click()}
|
||||
className={`${outlineButtonClass} mr-2`}
|
||||
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
<FileUp aria-hidden="true" className="h-4 w-4" />
|
||||
{materialType === '音乐' && '选择音乐文件(可多选)'}
|
||||
{materialType === '直播切片' && '选择视频文件(可多选,最多20个)'}
|
||||
{isBatchVideoMaterialType(materialType) && '选择视频文件(可多选,最多8个)'}
|
||||
@ -2702,11 +2694,12 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
type="button"
|
||||
disabled={interactionLocked}
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
className={outlineButtonClass}
|
||||
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
<FileUp aria-hidden="true" className="h-4 w-4" />
|
||||
选择素材文件
|
||||
</button>
|
||||
<p className={`mt-3 ${fieldHintClass}`}>
|
||||
<p className={`w-full ${fieldHintClass}`}>
|
||||
支持格式:
|
||||
{materialType === '音乐' && '音频文件(.mp3 / .wav / .flac / .m4a / .aac / .ogg)'}
|
||||
{isVideoMaterialType(materialType) &&
|
||||
|
||||
@ -55,6 +55,13 @@ function createDeferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function chooseVideoMaterialType(user: ReturnType<typeof userEvent.setup>) {
|
||||
const trigger = screen.getByRole('button', { name: '素材类型 *' })
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('option', { name: '淘系-主图视频' }))
|
||||
return trigger
|
||||
}
|
||||
|
||||
describe('UploadModal - 视频预览图处理状态', () => {
|
||||
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
|
||||
const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
|
||||
@ -88,8 +95,7 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
;(compressVideoPreviewImage as jest.Mock).mockReturnValue(deferredCompression.promise)
|
||||
|
||||
const view = render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
await user.selectOptions(materialTypeSelect, '淘系-主图视频')
|
||||
const materialTypeSelect = await chooseVideoMaterialType(user)
|
||||
|
||||
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
|
||||
fireEvent.change(previewInput, {
|
||||
@ -114,12 +120,12 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
})
|
||||
|
||||
test('处理中忽略同一控件的重复选择', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferredCompression = createDeferred<File>()
|
||||
;(compressVideoPreviewImage as jest.Mock).mockReturnValue(deferredCompression.promise)
|
||||
|
||||
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
fireEvent.change(materialTypeSelect, { target: { value: '淘系-主图视频' } })
|
||||
await chooseVideoMaterialType(user)
|
||||
|
||||
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
|
||||
fireEvent.change(previewInput, {
|
||||
@ -135,13 +141,13 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
})
|
||||
|
||||
test('处理中关闭后丢弃异步结果且不创建 Blob URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = jest.fn()
|
||||
const deferredCompression = createDeferred<File>()
|
||||
;(compressVideoPreviewImage as jest.Mock).mockReturnValue(deferredCompression.promise)
|
||||
|
||||
const view = render(<UploadModal onClose={onClose} onSuccess={jest.fn()} />)
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
fireEvent.change(materialTypeSelect, { target: { value: '淘系-主图视频' } })
|
||||
await chooseVideoMaterialType(user)
|
||||
|
||||
const previewInput = screen.getByLabelText(/视频预览图/) as HTMLInputElement
|
||||
fireEvent.change(previewInput, {
|
||||
@ -167,8 +173,7 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
;(compressVideoPreviewImage as jest.Mock).mockImplementation(async (file: File) => file)
|
||||
|
||||
render(<UploadModal onClose={onClose} onSuccess={onSuccess} />)
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
await user.selectOptions(materialTypeSelect, '淘系-主图视频')
|
||||
await chooseVideoMaterialType(user)
|
||||
|
||||
const mainDropZone = screen.getByRole('button', { name: '选择素材文件' }).closest('div.border-dashed')
|
||||
expect(mainDropZone).not.toBeNull()
|
||||
@ -184,8 +189,8 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
expect(await screen.findByText('批量上传模式(2 个文件)')).toBeInTheDocument()
|
||||
|
||||
const firstMetadataSelects = screen.getAllByRole('combobox')
|
||||
await user.selectOptions(firstMetadataSelects[0], '通用')
|
||||
await user.selectOptions(firstMetadataSelects[1], '通用')
|
||||
await user.selectOptions(firstMetadataSelects[2], '通用')
|
||||
|
||||
const firstPreviewInput = screen.getByLabelText(/^视频预览图/) as HTMLInputElement
|
||||
await waitFor(() => expect(firstPreviewInput).not.toBeDisabled())
|
||||
@ -201,8 +206,8 @@ describe('UploadModal - 视频预览图处理状态', () => {
|
||||
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], '通用')
|
||||
await user.selectOptions(secondMetadataSelects[4], '通用')
|
||||
await waitFor(() => expect(secondPreviewInput).not.toBeDisabled())
|
||||
fireEvent.change(secondPreviewInput, {
|
||||
target: {
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass } from './upload-field-styles';
|
||||
import { parseSPU } from '@/lib/spu-parser';
|
||||
import {
|
||||
hasDragLeftDropZone,
|
||||
@ -502,18 +504,12 @@ export default function AIOriginalUploader({
|
||||
|
||||
{/* 原图上传区 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
|
||||
</label>
|
||||
<label className={fieldLabelClass}>原图 *</label>
|
||||
<div
|
||||
aria-disabled={uploading}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
className={`p-3 ${
|
||||
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'
|
||||
}`}
|
||||
} ${isMainDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@ -527,44 +523,9 @@ export default function AIOriginalUploader({
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{mainImages.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={img.preview}
|
||||
alt={`原图 ${index + 1}`}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-32 object-cover rounded-apple"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMainImage(index)}
|
||||
disabled={uploading}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{formatBytes(img.file.size)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mainInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="group w-full h-32 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"
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持多选,或拖拽图片到此区域
|
||||
</p>
|
||||
<CompactImageFileGrid files={mainImages} label="原图" disabled={uploading} onAdd={() => mainInputRef.current?.click()} onRemove={removeMainImage} />
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>至少 1 张 · 可多选或拖入 · 自动生成预览图和压缩包</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { BatchFileItem } from './upload-types'
|
||||
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
|
||||
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer } from '@/lib/material-validation'
|
||||
@ -8,6 +9,7 @@ import { formatBytes } from './upload-utils'
|
||||
import { parseSPU } from '@/lib/spu-parser'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
import VideoPreviewImageUpload from './VideoPreviewImageUpload'
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, uploadAreaEmptyButtonClass } from './upload-field-styles'
|
||||
import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model'
|
||||
import {
|
||||
hasDragLeftDropZone,
|
||||
@ -98,7 +100,7 @@ export default function BatchFileList({
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<p className="text-sm font-medium text-[#1a1c1d]">
|
||||
批量上传模式({batchFiles.length} 个文件)
|
||||
</p>
|
||||
<button
|
||||
@ -110,19 +112,19 @@ export default function BatchFileList({
|
||||
退出批量模式
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 overflow-visible rounded-apple border border-gray-200 p-3 dark:border-gray-700 sm:max-h-[65vh] sm:overflow-y-auto sm:p-4">
|
||||
<div className="space-y-2 overflow-visible sm:max-h-[65vh] sm:overflow-y-auto">
|
||||
{batchFiles.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`rounded-apple overflow-hidden border ${
|
||||
className={`rounded-[8px] overflow-hidden border ${
|
||||
isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)
|
||||
? 'border-red-300 dark:border-red-700'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
: 'border-[#e7e8ea]'
|
||||
}`}
|
||||
>
|
||||
{/* 文件行(可点击展开/收起) */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
className="flex items-center justify-between bg-[#fafafa] p-3 cursor-pointer transition-colors hover:bg-[#f3f4f5]"
|
||||
onClick={() => onToggleExpand(index)}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
@ -138,7 +140,7 @@ export default function BatchFileList({
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{formatBytes(item.file.size)}{' '}
|
||||
{item.productModel
|
||||
? `· SPU: ${item.productModel}`
|
||||
@ -161,10 +163,10 @@ export default function BatchFileList({
|
||||
|
||||
{/* 展开的配置区域 */}
|
||||
{item.expanded && (
|
||||
<div className="p-4 bg-white dark:bg-gray-900 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-4 border-t border-[#e7e8ea] bg-white p-3">
|
||||
{/* SPU型号 */}
|
||||
<div>
|
||||
<label htmlFor={`batch-product-model-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor={`batch-product-model-${item.id}`} className={fieldLabelClass}>
|
||||
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
|
||||
</label>
|
||||
<input
|
||||
@ -215,11 +217,11 @@ export default function BatchFileList({
|
||||
/>
|
||||
) : !PREVIEW_OPTIONAL_TYPES.includes(materialType) && materialType !== '详情模板' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
预览图片 *
|
||||
</label>
|
||||
{item.previewImageUrl ? (
|
||||
<div className="relative w-20 h-20 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<div className="relative h-20 w-20 overflow-hidden rounded-[8px] border border-[#e7e8ea]">
|
||||
<Image
|
||||
src={item.previewImageUrl}
|
||||
alt="Preview"
|
||||
@ -234,7 +236,8 @@ export default function BatchFileList({
|
||||
e.stopPropagation()
|
||||
onRemovePreviewImage(index)
|
||||
}}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`移除文件 ${index + 1} 的预览图`}
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -242,13 +245,7 @@ export default function BatchFileList({
|
||||
) : (
|
||||
<div
|
||||
aria-disabled={disabled}
|
||||
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors ${
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
} ${
|
||||
batchPreviewDragging[item.id]
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
className={`p-3 ${disabled ? 'cursor-not-allowed opacity-60' : ''} ${batchPreviewDragging[item.id] ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => {
|
||||
prepareFileDrag(e, disabled)
|
||||
if (disabled) return
|
||||
@ -266,11 +263,6 @@ export default function BatchFileList({
|
||||
handlePreviewDrop(e, index)
|
||||
onPreviewDragLeave(item.id)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
document.getElementById(`batch-preview-${item.id}`)?.click()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@ -281,7 +273,15 @@ export default function BatchFileList({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">拖拽或点击选择图片</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => document.getElementById(`batch-preview-${item.id}`)?.click()}
|
||||
className={uploadAreaEmptyButtonClass}
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
添加预览图片
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -296,7 +296,7 @@ export default function BatchFileList({
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
使用店铺 *
|
||||
{isShopAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -308,7 +308,7 @@ export default function BatchFileList({
|
||||
value={item.categoryName}
|
||||
onChange={(e) => onUpdateCategory(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">选择使用店铺</option>
|
||||
@ -321,14 +321,14 @@ export default function BatchFileList({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
场景类别 *
|
||||
</label>
|
||||
<select
|
||||
value={item.sceneName}
|
||||
onChange={(e) => onUpdateScene(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">选择场景类别</option>
|
||||
@ -345,14 +345,14 @@ export default function BatchFileList({
|
||||
{/* 平台分类(可选,仅在后台配置了平台时显示,标准模型不显示) */}
|
||||
{platforms.length > 0 && materialType !== '标准模型' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
平台分类(可选)
|
||||
</label>
|
||||
<select
|
||||
value={item.platformName}
|
||||
onChange={(e) => onUpdatePlatform(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">不选择平台</option>
|
||||
@ -373,7 +373,7 @@ export default function BatchFileList({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
|
||||
{isCategoryAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -385,7 +385,7 @@ export default function BatchFileList({
|
||||
value={item.productCategoryName}
|
||||
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
|
||||
@ -401,14 +401,14 @@ export default function BatchFileList({
|
||||
{/* 渲染器(仅3D场景显示) */}
|
||||
{materialType === '3D场景' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className={fieldLabelClass}>
|
||||
渲染器 *
|
||||
</label>
|
||||
<select
|
||||
value={item.renderer || renderer}
|
||||
onChange={(e) => onUpdateRenderer(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="CR">CR</option>
|
||||
@ -425,4 +425,3 @@ export default function BatchFileList({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
validateCertificateReportMetadata,
|
||||
} from '@/lib/certificate-report'
|
||||
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
|
||||
import { FileUp } from 'lucide-react'
|
||||
import { dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass, uploadAreaButtonClass } from './upload-field-styles'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
@ -248,7 +250,7 @@ export default function CertificateReportUploader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
||||
{error}
|
||||
@ -278,20 +280,20 @@ export default function CertificateReportUploader({
|
||||
<div className={`grid grid-cols-1 gap-4 ${reportKind === CERTIFICATE_REPORT_KIND.FINISHED ? 'md:grid-cols-2' : ''}`}>
|
||||
{reportKind === CERTIFICATE_REPORT_KIND.FINISHED && (
|
||||
<div>
|
||||
<label htmlFor="certificate-report-spu" className="mb-2 block text-sm font-medium text-gray-700">SPU</label>
|
||||
<label htmlFor="certificate-report-spu" className={fieldLabelClass}>SPU</label>
|
||||
<input
|
||||
id="certificate-report-spu"
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(event) => setProductModel(event.target.value)}
|
||||
placeholder="没有 SPU 可以留空"
|
||||
className="input-apple"
|
||||
className={fieldControlClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="certificate-report-title" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="certificate-report-title" className={fieldLabelClass}>
|
||||
报告名称 / 检测对象{reportKind === CERTIFICATE_REPORT_KIND.RAW ? ' *' : ''}
|
||||
</label>
|
||||
<input
|
||||
@ -300,7 +302,7 @@ export default function CertificateReportUploader({
|
||||
value={reportTitle}
|
||||
onChange={(event) => setReportTitle(event.target.value)}
|
||||
placeholder="例如:E0级颗粒板检测报告"
|
||||
className="input-apple"
|
||||
className={fieldControlClass}
|
||||
required={reportKind === CERTIFICATE_REPORT_KIND.RAW}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
@ -311,11 +313,11 @@ export default function CertificateReportUploader({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">品类 *</label>
|
||||
<label className={fieldLabelClass}>品类 *</label>
|
||||
<select
|
||||
value={productCategoryName}
|
||||
onChange={(event) => setProductCategoryName(event.target.value)}
|
||||
className="input-apple"
|
||||
className={fieldControlClass}
|
||||
required
|
||||
>
|
||||
<option value="">请选择品类</option>
|
||||
@ -328,12 +330,12 @@ export default function CertificateReportUploader({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="certificate-report-platform" className="mb-2 block text-sm font-medium text-gray-700">平台分类</label>
|
||||
<label htmlFor="certificate-report-platform" className={fieldLabelClass}>平台分类</label>
|
||||
<select
|
||||
id="certificate-report-platform"
|
||||
value={platformName}
|
||||
onChange={(event) => setPlatformName(event.target.value)}
|
||||
className="input-apple"
|
||||
className={fieldControlClass}
|
||||
>
|
||||
<option value="">不选择</option>
|
||||
{CERTIFICATE_REPORT_PLATFORM_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
@ -343,32 +345,30 @@ export default function CertificateReportUploader({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="certificate-report-tested-on" className="mb-2 block text-sm font-medium text-gray-700">证书检测日期 *</label>
|
||||
<label htmlFor="certificate-report-tested-on" className={fieldLabelClass}>证书检测日期 *</label>
|
||||
<CertificateTestDatePicker id="certificate-report-tested-on" value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={uploading} />
|
||||
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="certificate-report-number" className="mb-2 block text-sm font-medium text-gray-700">证书编号 *</label>
|
||||
<input id="certificate-report-number" type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className="input-apple" required />
|
||||
<label htmlFor="certificate-report-number" className={fieldLabelClass}>证书编号 *</label>
|
||||
<input id="certificate-report-number" type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className={fieldControlClass} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="certificate-report-instructions" className="mb-2 block text-sm font-medium text-gray-700">证书使用说明 *</label>
|
||||
<textarea id="certificate-report-instructions" value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className="input-apple resize-y" required />
|
||||
<label htmlFor="certificate-report-instructions" className={fieldLabelClass}>证书使用说明 *</label>
|
||||
<textarea id="certificate-report-instructions" value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className={`${fieldControlClass} resize-y`} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="certificate-report-rules" className="mb-2 block text-sm font-medium text-gray-700">检测报告使用规则说明 *</label>
|
||||
<textarea id="certificate-report-rules" value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className="input-apple resize-y leading-relaxed" required />
|
||||
<label htmlFor="certificate-report-rules" className={fieldLabelClass}>检测报告使用规则说明 *</label>
|
||||
<textarea id="certificate-report-rules" value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className={`${fieldControlClass} resize-y leading-relaxed`} required />
|
||||
<p className="mt-1 text-xs text-gray-500">已预填完整说明,可按这份报告的实际情况修改</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
报告文件 *
|
||||
</label>
|
||||
<div className="rounded-apple border-2 border-dashed border-gray-300 p-6 text-center dark:border-gray-600">
|
||||
<label className={fieldLabelClass}>报告文件 *</label>
|
||||
<div className={`flex flex-wrap items-center gap-2 p-3 ${dropZoneIdleClass}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@ -376,28 +376,30 @@ export default function CertificateReportUploader({
|
||||
accept=".pdf,image/*"
|
||||
onChange={(event) => handleFileSelect(event.target.files)}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-secondary">
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading} className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}>
|
||||
<FileUp className="h-4 w-4" aria-hidden="true" />
|
||||
选择 PDF / 图片
|
||||
</button>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
可以混合上传多份 PDF 和多张图片,分享时会按这里的顺序展示
|
||||
<p className={fieldHintClass}>
|
||||
可混合多份 PDF 和图片 · 分享页按当前顺序展示
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded-apple border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div className="rounded-[8px] border border-[#e7e8ea] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#e7e8ea] px-3 py-2">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">文件列表</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{files.length} 个文件 · {totalSizeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div className="divide-y divide-[#e7e8ea]">
|
||||
{files.map((item, index) => (
|
||||
<div key={`${item.file.name}-${index}`} className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||
<div key={`${item.file.name}-${index}`} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-[8px] bg-[#f3f4f5]">
|
||||
{item.fileKind === 'image' && item.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.previewUrl} alt="" className="h-full w-full object-cover" />
|
||||
@ -436,11 +438,11 @@ export default function CertificateReportUploader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary" disabled={uploading}>
|
||||
<div className="flex items-center justify-end gap-3 border-t border-[#e7e8ea] pt-5">
|
||||
<button type="button" onClick={onCancel} className={outlineButtonClass} disabled={uploading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={handleSubmit} className="btn-primary" disabled={uploading}>
|
||||
<button type="button" onClick={handleSubmit} className={inkButtonClass} disabled={uploading}>
|
||||
{uploading ? '上传中...' : '创建分享素材'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
45
components/upload/CompactImageFileGrid.tsx
Normal file
45
components/upload/CompactImageFileGrid.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { uploadAreaAddTileClass, uploadAreaEmptyButtonClass, uploadAreaTileClass } from './upload-field-styles'
|
||||
|
||||
interface CompactImageFileGridProps {
|
||||
files: Array<{ file: File; preview: string }>
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onAdd: () => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
export default function CompactImageFileGrid({ files, label, disabled = false, onAdd, onRemove }: CompactImageFileGridProps) {
|
||||
return (
|
||||
<div className={files.length > 0 ? 'flex flex-wrap gap-2' : ''}>
|
||||
{files.map((item, index) => (
|
||||
<div key={`${item.file.name}-${index}`} className="group relative" title={item.file.name}>
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image src={item.preview} alt={`${label} ${index + 1}`} fill unoptimized sizes="80px" className="object-cover" />
|
||||
<span className="absolute bottom-0 right-0 rounded-tl-[6px] bg-[#1a1c1d]/80 px-1.5 py-0.5 text-xs text-white">{index + 1}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={disabled}
|
||||
aria-label={`移除${label} ${index + 1}`}
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={disabled}
|
||||
aria-label={`添加${label}`}
|
||||
className={files.length > 0 ? uploadAreaAddTileClass : uploadAreaEmptyButtonClass}
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
{files.length === 0 && `添加${label}`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -68,7 +68,7 @@ describe.each(uploaders)('%s拖拽上传', (_name, Uploader) => {
|
||||
}
|
||||
fireEvent.dragEnter(dropZone, dragData)
|
||||
fireEvent.dragOver(dropZone, dragData)
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const internalDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(internalDragLeave, {
|
||||
@ -78,7 +78,7 @@ describe.each(uploaders)('%s拖拽上传', (_name, Uploader) => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } },
|
||||
})
|
||||
fireEvent(dropZone, internalDragLeave)
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const outerDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(outerDragLeave, {
|
||||
@ -88,7 +88,7 @@ describe.each(uploaders)('%s拖拽上传', (_name, Uploader) => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } },
|
||||
})
|
||||
fireEvent(dropZone, outerDragLeave)
|
||||
expect(dropZone).not.toHaveClass('border-blue-500')
|
||||
expect(dropZone).not.toHaveClass('border-[#1677ff]')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
dropZoneActiveClass,
|
||||
dropZoneIdleClass,
|
||||
fieldHintClass,
|
||||
fieldLabelClass,
|
||||
uploadAreaButtonClass,
|
||||
uploadAreaEmptyButtonClass,
|
||||
uploadAreaTileClass,
|
||||
} from './upload-field-styles'
|
||||
|
||||
interface DisplayImageUploadProps {
|
||||
displayImageUrl: string
|
||||
@ -12,7 +23,7 @@ interface DisplayImageUploadProps {
|
||||
onDisplayImageDragLeave: (e: React.DragEvent) => void
|
||||
onDisplayImageDragOver: (e: React.DragEvent) => void
|
||||
onDisplayImageDrop: (e: React.DragEvent) => void
|
||||
materialType?: string // 素材类型,用于显示不同的提示文字
|
||||
materialType?: string
|
||||
}
|
||||
|
||||
export default function DisplayImageUpload({
|
||||
@ -27,79 +38,35 @@ export default function DisplayImageUpload({
|
||||
onDisplayImageDrop,
|
||||
materialType = '',
|
||||
}: DisplayImageUploadProps) {
|
||||
// 根据素材类型显示不同的标签和提示
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isBrandVI = materialType === '品牌VI灵感'
|
||||
const labelText = isBrandVI ? '展示图片(可选)' : '详情页展示图片 *'
|
||||
const hintText = isBrandVI
|
||||
? '上传品牌VI的展示图片,用于详情页预览。如果不上传,将直接显示PDF预览。'
|
||||
: '上传详情页/详情模板的展示图片,或拖拽图片到此区域'
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{labelText}
|
||||
</label>
|
||||
<label className={fieldLabelClass}>{isBrandVI ? '展示图片(可选)' : '详情页展示图片 *'}</label>
|
||||
<div
|
||||
ref={displayImageDropZoneRef}
|
||||
aria-disabled={disabled}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
disabled ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isDisplayImageDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
className={`${displayImageUrl ? 'flex flex-wrap items-center gap-3 p-3' : 'p-3'} ${disabled ? 'cursor-not-allowed opacity-60' : ''} ${isDisplayImageDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={onDisplayImageDragEnter}
|
||||
onDragLeave={onDisplayImageDragLeave}
|
||||
onDragOver={onDisplayImageDragOver}
|
||||
onDrop={onDisplayImageDrop}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{displayImageUrl ? (
|
||||
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<Image
|
||||
src={displayImageUrl}
|
||||
alt="Display"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
onChange={onDisplayImageChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
id="displayImageInput"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => document.getElementById('displayImageInput')?.click()}
|
||||
className="btn-secondary"
|
||||
disabled={disabled}
|
||||
>
|
||||
选择展示图片
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{hintText}
|
||||
</p>
|
||||
{!isBrandVI && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
💡 上传原图即可,系统会自动压缩和切片(高度超过1500px会自动切割)
|
||||
</p>
|
||||
)}
|
||||
{displayImageUrl && (
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image src={displayImageUrl} alt="已选择的详情页展示图片" fill unoptimized className="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept="image/*" onChange={onDisplayImageChange} className="hidden" disabled={disabled} />
|
||||
<button type="button" onClick={() => inputRef.current?.click()} className={displayImageUrl ? uploadAreaButtonClass : uploadAreaEmptyButtonClass} disabled={disabled}>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
{displayImageUrl ? '更换展示图片' : '选择展示图片'}
|
||||
</button>
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>
|
||||
{isBrandVI ? '不上传时直接显示 PDF 预览' : '支持拖入图片 · 原图会自动压缩,高图会自动切片'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
53
components/upload/MaterialTypeSelect.test.tsx
Normal file
53
components/upload/MaterialTypeSelect.test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import MaterialTypeSelect from '@/components/upload/MaterialTypeSelect'
|
||||
|
||||
const options = ['3D场景', '音乐', '直播切片', '任务-其他']
|
||||
|
||||
function renderSelect(counts: Record<string, number> = {}) {
|
||||
const onChange = jest.fn()
|
||||
render(
|
||||
<>
|
||||
<span id="upload-material-type-label">素材类型 *</span>
|
||||
<MaterialTypeSelect id="upload-material-type" value="3D场景" options={options} counts={counts} onChange={onChange} />
|
||||
</>,
|
||||
)
|
||||
return onChange
|
||||
}
|
||||
|
||||
test('按首页数量降序排列可选类型,同数量保持原顺序,选择后关闭', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = renderSelect({ '3D场景': 2, '音乐': 12, '直播切片': 12, '任务-其他': 0 })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '素材类型 *' }))
|
||||
const list = screen.getByRole('listbox', { name: '素材类型 *' })
|
||||
expect(within(list).getAllByRole('option').map((option) => option.getAttribute('aria-label')))
|
||||
.toEqual(['音乐', '直播切片', '3D场景', '任务-其他'])
|
||||
expect(within(list).getByText('按素材数量排序')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(list).getByRole('option', { name: '直播切片' }))
|
||||
expect(onChange).toHaveBeenCalledWith('直播切片')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('无数量时保留原顺序,键盘可操作并用 Escape 关闭', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = renderSelect()
|
||||
const trigger = screen.getByRole('button', { name: '素材类型 *' })
|
||||
|
||||
trigger.focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
const list = screen.getByRole('listbox')
|
||||
expect(within(list).getAllByRole('option').map((option) => option.getAttribute('aria-label'))).toEqual(options)
|
||||
expect(within(list).queryByText('按素材数量排序')).not.toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{End}{Enter}')
|
||||
expect(onChange).toHaveBeenCalledWith('任务-其他')
|
||||
expect(trigger).toHaveFocus()
|
||||
|
||||
await user.click(trigger)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
170
components/upload/MaterialTypeSelect.tsx
Normal file
170
components/upload/MaterialTypeSelect.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
'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)
|
||||
|
||||
interface MaterialTypeSelectProps {
|
||||
id: string
|
||||
value: string
|
||||
options: string[]
|
||||
counts: Record<string, number>
|
||||
onChange: (value: string) => void
|
||||
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])
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,78 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { dropZoneIdleClass, fieldHintClass, fieldLabelClass, uploadAreaAddTileClass, uploadAreaEmptyButtonClass, uploadAreaTileClass } from './upload-field-styles'
|
||||
|
||||
interface ModelImagesUploadProps {
|
||||
modelImageUrls: string[]
|
||||
onModelImagesChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onRemoveModelImage: (index: number) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ModelImagesUpload({
|
||||
modelImageUrls,
|
||||
onModelImagesChange,
|
||||
onRemoveModelImage,
|
||||
disabled = false,
|
||||
}: ModelImagesUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
模特展示图片 * (可上传多张)
|
||||
</label>
|
||||
<div className="border-2 border-dashed rounded-apple p-4 border-gray-300 dark:border-gray-600">
|
||||
<input
|
||||
type="file"
|
||||
onChange={onModelImagesChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
id="modelImagesInput"
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<label className={fieldLabelClass}>模特展示图片 *(可上传多张)</label>
|
||||
<div className={`p-3 ${dropZoneIdleClass} ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}>
|
||||
<input ref={inputRef} type="file" accept="image/*" multiple onChange={onModelImagesChange} className="hidden" disabled={disabled} />
|
||||
<div className={modelImageUrls.length > 0 ? 'flex flex-wrap gap-2' : ''}>
|
||||
{modelImageUrls.map((url, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`模特图 ${index + 1}`}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
||||
预览图
|
||||
</div>
|
||||
)}
|
||||
<div key={index} className="group relative">
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image src={url} alt={`模特图 ${index + 1}`} fill unoptimized className="object-cover" />
|
||||
{index === 0 && <span className="absolute inset-x-0 bottom-0 bg-[#1a1c1d]/80 py-0.5 text-center text-xs text-white">主图</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveModelImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
disabled={disabled}
|
||||
aria-label={`移除模特图 ${index + 1}`}
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => document.getElementById('modelImagesInput')?.click()}
|
||||
className="group w-full h-32 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"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="添加模特图片"
|
||||
className={modelImageUrls.length > 0 ? uploadAreaAddTileClass : uploadAreaEmptyButtonClass}
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
{modelImageUrls.length === 0 && '添加模特图片'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
可一次选择多张图片上传
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400">
|
||||
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>可一次选择多张 · 首张为预览图,高图会自动切片</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
|
||||
import {
|
||||
dropZoneActiveClass,
|
||||
dropZoneIdleClass,
|
||||
fieldHintClass,
|
||||
fieldLabelClass,
|
||||
uploadAreaAddTileClass,
|
||||
uploadAreaEmptyButtonClass,
|
||||
uploadAreaTileClass,
|
||||
} from './upload-field-styles'
|
||||
import VideoPreviewImageUpload from './VideoPreviewImageUpload'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
@ -72,7 +76,7 @@ export default function PreviewImageUpload({
|
||||
<div
|
||||
ref={previewDropZoneRef}
|
||||
aria-disabled={disabled}
|
||||
className={`p-4 ${
|
||||
className={`p-3 ${
|
||||
disabled ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${isPreviewDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={onPreviewDragEnter}
|
||||
@ -89,10 +93,10 @@ export default function PreviewImageUpload({
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className={previewUrls.length > 0 ? 'flex flex-wrap gap-2' : ''}>
|
||||
{previewUrls.map((url, index) => (
|
||||
<div key={index} className="group relative">
|
||||
<div className="relative h-32 w-full overflow-hidden rounded-[8px] border border-[#e7e8ea]">
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`预览图 ${index + 1}`}
|
||||
@ -101,8 +105,8 @@ export default function PreviewImageUpload({
|
||||
className="object-cover"
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div className="absolute left-1 top-1 bg-[#1a1c1d] px-2 py-0.5 text-[11px] tracking-[-0.02em] text-white">
|
||||
主预览图
|
||||
<div className="absolute bottom-0 inset-x-0 bg-[#1a1c1d]/80 py-0.5 text-center text-xs text-white">
|
||||
主图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -111,7 +115,7 @@ export default function PreviewImageUpload({
|
||||
onClick={() => onRemovePreviewImage(index)}
|
||||
disabled={disabled}
|
||||
aria-label={`移除预览图 ${index + 1}`}
|
||||
className="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-[#1a1c1d] text-white opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70"
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -121,22 +125,15 @@ export default function PreviewImageUpload({
|
||||
type="button"
|
||||
onClick={() => previewInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="group flex h-32 w-full items-center justify-center rounded-[8px] border border-dashed border-[#e7e8ea] bg-white transition-colors hover:border-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||||
className={previewUrls.length > 0 ? uploadAreaAddTileClass : uploadAreaEmptyButtonClass}
|
||||
aria-label="添加预览图片"
|
||||
>
|
||||
<svg className="h-8 w-8 text-[#86898e] transition-colors group-hover:text-[#1a1c1d]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
{previewUrls.length === 0 && <span>添加预览图片</span>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className={fieldHintClass}>
|
||||
支持 JPG、PNG、WebP,可一次选多张或拖进来。
|
||||
</p>
|
||||
<p className={fieldHintClass}>
|
||||
第一张图会作为主预览图。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>JPG、PNG、WebP · 可多选或拖入 · 首张为主预览图</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,18 +40,19 @@
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
color: #1a1c1d;
|
||||
border: 1px solid #e7e8ea;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.fileButton:hover {
|
||||
background: #e5e7eb;
|
||||
background: #edf0f2;
|
||||
}
|
||||
|
||||
.fileButton:focus-visible {
|
||||
outline: 2px solid #1a1c1d;
|
||||
outline: 2px solid #1677ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@ -70,13 +71,7 @@
|
||||
}
|
||||
|
||||
.metadata :is(input[type='text'], select) {
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.productFields button,
|
||||
.fileButton {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,7 +355,7 @@ describe('ProductPackageUploader', () => {
|
||||
clientY: 20,
|
||||
dataTransfer: { files: [], types: ['Files'] },
|
||||
})
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const internalDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(internalDragLeave, {
|
||||
@ -365,7 +365,7 @@ describe('ProductPackageUploader', () => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'] } },
|
||||
})
|
||||
fireEvent(dropZone, internalDragLeave)
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const outerDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(outerDragLeave, {
|
||||
@ -375,7 +375,7 @@ describe('ProductPackageUploader', () => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'] } },
|
||||
})
|
||||
fireEvent(dropZone, outerDragLeave)
|
||||
expect(dropZone).not.toHaveClass('border-blue-500')
|
||||
expect(dropZone).not.toHaveClass('border-[#1677ff]')
|
||||
})
|
||||
|
||||
test('任务图片选择框只允许浏览器处理链明确支持的格式', async () => {
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import DetailImageSorter from './DetailImageSorter';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
|
||||
import UploadForm from './UploadForm';
|
||||
import styles from './ProductPackageUploader.module.css';
|
||||
import { addCategory, addScene } from './category-handlers';
|
||||
@ -55,7 +56,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-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';
|
||||
const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-2 text-[#1a1c1d] outline-none focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 disabled:cursor-not-allowed disabled:opacity-60';
|
||||
|
||||
function splitEmptyFiles(files: FileList | File[] | null): {
|
||||
validFiles: File[];
|
||||
@ -1393,7 +1394,7 @@ export default function ProductPackageUploader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className={`${styles.metadata} space-y-3`}>
|
||||
<div className={styles.references}>
|
||||
<TaskReferenceFields
|
||||
@ -1539,13 +1540,9 @@ export default function ProductPackageUploader({
|
||||
tabIndex={uploading ? -1 : 0}
|
||||
aria-disabled={uploading}
|
||||
aria-label="选择主图文件"
|
||||
className={`rounded-lg border border-dashed p-2 transition-colors ${
|
||||
className={`p-2 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isMainDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
} ${isMainDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
|
||||
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
|
||||
onDragOver={handleDragOver}
|
||||
@ -1568,7 +1565,7 @@ export default function ProductPackageUploader({
|
||||
<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">
|
||||
<div className="relative w-full h-20 rounded-[8px] overflow-hidden border border-[#e7e8ea]">
|
||||
<AssetThumbnail asset={img} alt="" />
|
||||
</div>
|
||||
<button
|
||||
@ -1587,7 +1584,7 @@ export default function ProductPackageUploader({
|
||||
aria-label="添加主图"
|
||||
onClick={() => mainInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
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'}`}
|
||||
className={`group flex w-full items-center justify-center gap-2 rounded-[8px] bg-white text-sm text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50 ${mainImages.length ? 'h-20 border border-dashed border-[#cbd5e1]' : 'min-h-11'}`}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<title>添加主图</title>
|
||||
@ -1622,13 +1619,9 @@ export default function ProductPackageUploader({
|
||||
tabIndex={uploading ? -1 : 0}
|
||||
aria-disabled={uploading}
|
||||
aria-label="选择 SKU 图文件"
|
||||
className={`rounded-lg border border-dashed p-2 transition-colors ${
|
||||
className={`p-2 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isSkuDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
} ${isSkuDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
|
||||
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
|
||||
onDragOver={handleDragOver}
|
||||
@ -1651,7 +1644,7 @@ export default function ProductPackageUploader({
|
||||
<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">
|
||||
<div className="relative w-full h-20 rounded-[8px] overflow-hidden border border-[#e7e8ea]">
|
||||
<AssetThumbnail asset={img} alt="" />
|
||||
</div>
|
||||
<button
|
||||
@ -1688,7 +1681,7 @@ export default function ProductPackageUploader({
|
||||
aria-label="添加SKU图"
|
||||
onClick={() => skuInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
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'}`}
|
||||
className={`group flex w-full items-center justify-center gap-2 rounded-[8px] bg-white text-sm text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50 ${skuImages.length ? 'h-20 border border-dashed border-[#cbd5e1]' : 'min-h-11'}`}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<title>添加SKU图</title>
|
||||
@ -1711,13 +1704,9 @@ export default function ProductPackageUploader({
|
||||
tabIndex={uploading ? -1 : 0}
|
||||
aria-disabled={uploading}
|
||||
aria-label="选择详情图文件"
|
||||
className={`rounded-lg border border-dashed p-2 transition-colors ${
|
||||
className={`p-2 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isDetailDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
} ${isDetailDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
|
||||
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
|
||||
onDragOver={handleDragOver}
|
||||
@ -1773,13 +1762,9 @@ export default function ProductPackageUploader({
|
||||
tabIndex={uploading ? -1 : 0}
|
||||
aria-disabled={uploading}
|
||||
aria-label="选择 PSD 源文件"
|
||||
className={`rounded-lg border border-dashed p-2 transition-colors ${
|
||||
className={`p-2 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isPsdDragging
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
} ${isPsdDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => handleDragEnter(e, setIsPsdDragging)}
|
||||
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
|
||||
onDragOver={handleDragOver}
|
||||
@ -1869,18 +1854,18 @@ export default function ProductPackageUploader({
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="sticky bottom-0 -mx-1 border-t border-gray-200 bg-white px-1 py-2">
|
||||
<div className="sticky bottom-0 -mx-1 border-t border-[#e7e8ea] bg-white px-1 py-2">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={uploading ? handleCancelUpload : onCancel}
|
||||
className="btn-secondary"
|
||||
className={outlineButtonClass}
|
||||
type="button"
|
||||
>
|
||||
{uploading ? '取消上传' : '取消'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
className={inkButtonClass}
|
||||
disabled={uploading || usersLoading || Boolean(usersLoadError)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass } from './upload-field-styles';
|
||||
import { parseSPU } from '@/lib/spu-parser';
|
||||
import {
|
||||
hasDragLeftDropZone,
|
||||
@ -499,18 +501,12 @@ export default function RealPhotoUploader({
|
||||
|
||||
{/* 原图上传区 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
|
||||
</label>
|
||||
<label className={fieldLabelClass}>原图 *</label>
|
||||
<div
|
||||
aria-disabled={uploading}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
className={`p-3 ${
|
||||
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'
|
||||
}`}
|
||||
} ${isMainDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@ -524,44 +520,9 @@ export default function RealPhotoUploader({
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{mainImages.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={img.preview}
|
||||
alt={`原图 ${index + 1}`}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-32 object-cover rounded-apple"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMainImage(index)}
|
||||
disabled={uploading}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{formatBytes(img.file.size)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mainInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="group w-full h-32 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"
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持多选,或拖拽图片到此区域
|
||||
</p>
|
||||
<CompactImageFileGrid files={mainImages} label="原图" disabled={uploading} onAdd={() => mainInputRef.current?.click()} onRemove={removeMainImage} />
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>至少 1 张 · 可多选或拖入 · 自动生成预览图和压缩包</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@ -9,6 +9,7 @@ import Image from 'next/image'
|
||||
import { formatBytes } from '@/components/upload/upload-utils'
|
||||
import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model'
|
||||
import VideoPreviewImageUpload from './VideoPreviewImageUpload'
|
||||
import { fieldControlClass, fieldHintClass, fieldLabelClass, uploadAreaButtonClass, uploadAreaTileClass } from './upload-field-styles'
|
||||
|
||||
/**
|
||||
* 音乐/视频/品牌VI 批量文件项
|
||||
@ -47,14 +48,14 @@ export function BatchMusicList({ files, onUpdateProductModel, onDelete }: BatchM
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||||
<div className="space-y-2 max-h-[65vh] overflow-y-auto">
|
||||
{files.map((item, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{formatBytes(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@ -62,13 +63,13 @@ export function BatchMusicList({ files, onUpdateProductModel, onDelete }: BatchM
|
||||
type="text"
|
||||
value={item.productModel}
|
||||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||||
placeholder="SPU型号"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(index)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@ -114,15 +115,15 @@ export function BatchVideoList({
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3 overflow-visible rounded-apple border border-gray-200 p-4 dark:border-gray-700 sm:max-h-[65vh] sm:overflow-y-auto">
|
||||
<div className="space-y-2 overflow-visible sm:max-h-[65vh] sm:overflow-y-auto">
|
||||
{files.map((item, index) => (
|
||||
<div key={index} className="flex flex-col space-y-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div key={index} className="flex flex-col gap-2 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{formatBytes(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@ -135,17 +136,17 @@ export function BatchVideoList({
|
||||
value={item.productModel}
|
||||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-apple border px-3 py-1.5 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-700 dark:text-white ${
|
||||
className={`w-full rounded-[8px] border bg-white px-4 py-2.5 text-[14px] text-[#1a1c1d] outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
!isPlausibleSpuProductModel(item.productModel)
|
||||
? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-100 dark:border-red-600 dark:focus:ring-red-900'
|
||||
: 'border-gray-300 focus:border-gray-900 focus:ring-2 focus:ring-gray-200 dark:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-600'
|
||||
? 'border-[#b42318] focus:border-[#b42318]'
|
||||
: 'border-[#e7e8ea] focus:border-[#1a1c1d]'
|
||||
}`}
|
||||
placeholder="SPU型号"
|
||||
/>
|
||||
{!isPlausibleSpuProductModel(item.productModel) && (
|
||||
<p
|
||||
id={`batch-video-spu-error-${index}`}
|
||||
className="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
className="mt-1 text-[12px] text-[#b42318]"
|
||||
role="alert"
|
||||
>
|
||||
{getSpuProductModelValidationError(item.productModel)}
|
||||
@ -156,7 +157,7 @@ export function BatchVideoList({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onDelete(index)}
|
||||
className="text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@ -164,7 +165,7 @@ export function BatchVideoList({
|
||||
{/* 品类字段 */}
|
||||
{productCategories.length > 0 && onUpdateProductCategory && (
|
||||
<div>
|
||||
<label htmlFor={`batch-video-category-${index}`} className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor={`batch-video-category-${index}`} className={fieldLabelClass}>
|
||||
品类(可选)
|
||||
</label>
|
||||
<select
|
||||
@ -172,7 +173,7 @@ export function BatchVideoList({
|
||||
value={item.productCategoryName || ''}
|
||||
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={fieldControlClass}
|
||||
>
|
||||
<option value="">不选择品类</option>
|
||||
{productCategories.map((category) => (
|
||||
@ -213,14 +214,14 @@ export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage,
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||||
<div className="space-y-2 max-h-[65vh] overflow-y-auto">
|
||||
{files.map((item, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{formatBytes(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@ -228,7 +229,7 @@ export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage,
|
||||
type="text"
|
||||
value={item.productModel}
|
||||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||||
placeholder="SPU型号"
|
||||
/>
|
||||
<input
|
||||
@ -245,14 +246,14 @@ export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage,
|
||||
/>
|
||||
<label
|
||||
htmlFor={`3d-preview-${index}`}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer"
|
||||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
{item.previewImage ? '已选预览图' : '选择预览图'}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(index)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@ -274,14 +275,14 @@ export function BatchBrandVIList({ files, onUpdateProductModel, onDelete }: Batc
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||||
<div className="space-y-2 max-h-[65vh] overflow-y-auto">
|
||||
{files.map((item, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{formatBytes(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@ -289,13 +290,13 @@ export function BatchBrandVIList({ files, onUpdateProductModel, onDelete }: Batc
|
||||
type="text"
|
||||
value={item.productModel}
|
||||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||||
placeholder="SPU型号"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(index)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
@ -326,15 +327,15 @@ export function BatchBuyerShowUI({
|
||||
onAddBatch,
|
||||
}: BatchBuyerShowUIProps) {
|
||||
return (
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
{batches.map((batch, batchIndex) => (
|
||||
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div key={batchIndex} className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={batch.productModel}
|
||||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||||
placeholder="SPU型号 *"
|
||||
/>
|
||||
<input
|
||||
@ -347,23 +348,23 @@ export function BatchBuyerShowUI({
|
||||
/>
|
||||
<label
|
||||
htmlFor={`buyer-show-images-${batchIndex}`}
|
||||
className="btn-secondary text-sm"
|
||||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
选择图片(至少8张)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBatch(batchIndex)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
删除批次
|
||||
</button>
|
||||
</div>
|
||||
{batch.images.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{batch.images.map((img, imgIndex) => (
|
||||
<div key={imgIndex} className="relative group">
|
||||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image
|
||||
src={URL.createObjectURL(img)}
|
||||
alt={`图片 ${imgIndex + 1}`}
|
||||
@ -375,7 +376,8 @@ export function BatchBuyerShowUI({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
aria-label={`移除图片 ${imgIndex + 1}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -393,7 +395,7 @@ export function BatchBuyerShowUI({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddBatch}
|
||||
className="btn-secondary text-sm"
|
||||
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
+ 添加新批次
|
||||
</button>
|
||||
@ -421,15 +423,15 @@ export function BatchXiaohongshuUI({
|
||||
onAddBatch,
|
||||
}: BatchXiaohongshuUIProps) {
|
||||
return (
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
{batches.map((batch, batchIndex) => (
|
||||
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div key={batchIndex} className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={batch.productModel}
|
||||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||||
placeholder="SPU型号 *"
|
||||
/>
|
||||
<input
|
||||
@ -442,23 +444,23 @@ export function BatchXiaohongshuUI({
|
||||
/>
|
||||
<label
|
||||
htmlFor={`xiaohongshu-images-${batchIndex}`}
|
||||
className="btn-secondary text-sm"
|
||||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
选择图片
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBatch(batchIndex)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
>
|
||||
删除批次
|
||||
</button>
|
||||
</div>
|
||||
{batch.images.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{batch.images.map((img, imgIndex) => (
|
||||
<div key={imgIndex} className="relative group">
|
||||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<div className={uploadAreaTileClass}>
|
||||
<Image
|
||||
src={URL.createObjectURL(img)}
|
||||
alt={`图片 ${imgIndex + 1}`}
|
||||
@ -470,7 +472,8 @@ export function BatchXiaohongshuUI({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
aria-label={`移除图片 ${imgIndex + 1}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@ -483,7 +486,7 @@ export function BatchXiaohongshuUI({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddBatch}
|
||||
className="btn-secondary text-sm"
|
||||
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||||
>
|
||||
+ 添加新批次
|
||||
</button>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileArchive, Plus } from 'lucide-react'
|
||||
import {
|
||||
hasAllowedFileExtension,
|
||||
hasDragLeftDropZone,
|
||||
prepareFileDrag,
|
||||
} from './drag-drop-utils'
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass } from './upload-field-styles'
|
||||
|
||||
interface StandardModelSkuZipUploadProps {
|
||||
selectedFile: File | null
|
||||
@ -65,33 +67,16 @@ export default function StandardModelSkuZipUpload({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
模型压缩包 {selectedFile ? '已选择' : hasExistingFile ? '可重传' : '待上传'}
|
||||
</span>
|
||||
{uploading && (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
上传处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/30 dark:text-gray-300">
|
||||
{selectedFile
|
||||
? `待上传:${selectedFile.name}(${formatFileSize(selectedFile.size)})`
|
||||
: hasExistingFile
|
||||
? `当前模型:${existingFileName}(${formatFileSize(existingFileSize)})`
|
||||
: '当前还没有模型压缩包,请先选择要上传的文件。'}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="standard-model-sku-zip-upload" className={fieldLabelClass}>模型压缩包 *</label>
|
||||
|
||||
{(error || validationError) && (
|
||||
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
|
||||
<div className="mb-2 rounded-[8px] border border-[#fecdca] bg-[#fff1f0] px-3 py-2 text-sm text-[#b42318]">
|
||||
{error || validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<input
|
||||
id="standard-model-sku-zip-upload"
|
||||
type="file"
|
||||
@ -114,13 +99,9 @@ export default function StandardModelSkuZipUpload({
|
||||
<label
|
||||
htmlFor="standard-model-sku-zip-upload"
|
||||
aria-disabled={uploading}
|
||||
className={`group block min-h-40 rounded-2xl border-2 border-dashed p-4 transition-colors ${
|
||||
className={`group block p-3 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
} ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
} ${isDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(event) => {
|
||||
prepareFileDrag(event, uploading)
|
||||
if (uploading) return
|
||||
@ -137,13 +118,15 @@ export default function StandardModelSkuZipUpload({
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center text-center">
|
||||
<div className="relative flex w-full max-w-sm flex-col items-center rounded-2xl border border-gray-200 bg-white/90 px-4 py-6 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex min-h-12 items-center gap-3">
|
||||
<FileArchive className="h-5 w-5 shrink-0 text-[#6b7075]" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#1a1c1d]">{selectedFile.name}</span>
|
||||
<span className={fieldHintClass}>{formatFileSize(selectedFile.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除已选择的模型压缩包"
|
||||
disabled={uploading}
|
||||
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fff1f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@ -152,31 +135,18 @@ export default function StandardModelSkuZipUpload({
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<svg className="h-8 w-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="mt-3 max-w-full truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
<p className="mt-3 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
点击或拖拽可重新选择模型压缩包
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center text-center">
|
||||
<svg className="h-8 w-8 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">拖拽压缩包到这里,或点击上传</span>
|
||||
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">支持 ZIP / RAR / 7Z</span>
|
||||
<div className="flex min-h-12 items-center gap-3">
|
||||
<Plus className="h-5 w-5 text-[#6b7075]" aria-hidden="true" />
|
||||
<span className="text-sm font-medium text-[#1a1c1d]">选择模型压缩包,或拖入文件</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>
|
||||
ZIP、RAR、7Z{hasExistingFile && !selectedFile ? ` · 当前模型:${existingFileName}(${formatFileSize(existingFileSize)})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ describe('标准模型拖拽上传', () => {
|
||||
onRemoveFile={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const dropZone = container.querySelector('label[for="standard-model-sku-zip-upload"]') as HTMLLabelElement
|
||||
const dropZone = container.querySelector('label[aria-disabled][for="standard-model-sku-zip-upload"]') as HTMLLabelElement
|
||||
const invalidFile = new File(['text'], 'readme.txt', { type: 'text/plain' })
|
||||
|
||||
fireEvent.drop(dropZone, {
|
||||
@ -34,7 +34,7 @@ describe('标准模型拖拽上传', () => {
|
||||
onRemoveFile={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const dropZone = container.querySelector('label[for="standard-model-sku-zip-upload"]') as HTMLLabelElement
|
||||
const dropZone = container.querySelector('label[aria-disabled][for="standard-model-sku-zip-upload"]') as HTMLLabelElement
|
||||
const zipFile = new File(['zip'], 'model.zip', { type: 'application/zip' })
|
||||
|
||||
fireEvent.drop(dropZone, {
|
||||
@ -62,7 +62,7 @@ describe('标准模型拖拽上传', () => {
|
||||
fireEvent.dragEnter(dropZone, {
|
||||
dataTransfer: { files: [], types: ['Files'], dropEffect: 'none' },
|
||||
})
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const internalDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(internalDragLeave, {
|
||||
@ -70,7 +70,7 @@ describe('标准模型拖拽上传', () => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } },
|
||||
})
|
||||
fireEvent(dropZone, internalDragLeave)
|
||||
expect(dropZone).toHaveClass('border-blue-500')
|
||||
expect(dropZone).toHaveClass('border-[#1677ff]')
|
||||
|
||||
const outerDragLeave = createEvent.dragLeave(dropZone)
|
||||
Object.defineProperties(outerDragLeave, {
|
||||
@ -78,7 +78,7 @@ describe('标准模型拖拽上传', () => {
|
||||
dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } },
|
||||
})
|
||||
fireEvent(dropZone, outerDragLeave)
|
||||
expect(dropZone).not.toHaveClass('border-blue-500')
|
||||
expect(dropZone).not.toHaveClass('border-[#1677ff]')
|
||||
})
|
||||
|
||||
test('白底图上传中不应接受固定角度或其他图片拖入', () => {
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||||
type StandardModelWhiteBgKey,
|
||||
} from '@/lib/standard-model-white-background'
|
||||
import { hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass, uploadAreaTileClass } from './upload-field-styles'
|
||||
|
||||
interface OtherImagePreview {
|
||||
id: string
|
||||
@ -99,58 +101,32 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
固定角度 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
可分次补传
|
||||
</span>
|
||||
{otherImages.length > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
其他图片 {otherImages.length} 张
|
||||
</span>
|
||||
)}
|
||||
{uploading && (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
上传处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/30 dark:text-gray-300">
|
||||
{missingLabels.length > 0
|
||||
? `未上传:${missingLabels.join('、')}(可先保存,后续再补)`
|
||||
: '8 张固定角度已齐,可直接保存。'}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
|
||||
<div className="rounded-[8px] border border-[#fecdca] bg-[#fff1f0] px-3 py-2 text-sm text-[#b42318]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">固定角度</h4>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">可选,建议补齐 8 张</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h4 className={fieldLabelClass}>固定角度</h4>
|
||||
<span className={fieldHintClass}>已传 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length} · 可分次补传</span>
|
||||
</div>
|
||||
<p className={`mb-2 ${fieldHintClass}`}>
|
||||
{missingLabels.length > 0 ? `待补:${missingLabels.join('、')}` : '固定角度已齐'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => {
|
||||
const inputId = `white-bg-${view.key}`
|
||||
const imageUrl = imageUrls[view.key]
|
||||
const isDragging = draggingKey === view.key
|
||||
|
||||
return (
|
||||
<div key={view.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-200">{view.label}</p>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
可选
|
||||
</span>
|
||||
</div>
|
||||
<div key={view.key}>
|
||||
<p className="mb-1 text-sm font-medium text-[#1a1c1d]">{view.label}</p>
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
@ -171,23 +147,17 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
aria-disabled={uploading}
|
||||
className={`group block rounded-2xl border-2 border-dashed p-3 transition-colors ${
|
||||
className={`group block p-1.5 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
} ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: imageUrl
|
||||
? 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/30'
|
||||
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/50 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
} ${isDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(event) => handleDragEnter(event, view.key)}
|
||||
onDragLeave={(event) => handleDragLeave(event, view.key)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(event) => handleDrop(event, view.key)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="relative h-36 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<div className="relative h-16 overflow-hidden rounded-[6px]">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={view.label}
|
||||
@ -199,7 +169,7 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
type="button"
|
||||
aria-label={`删除${view.label}`}
|
||||
disabled={uploading}
|
||||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
|
||||
className="absolute right-1 top-1 inline-flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-sm font-semibold text-white transition hover:bg-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@ -209,14 +179,11 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">点击或拖拽可重新上传</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-36 flex-col items-center justify-center rounded-xl bg-white text-center dark:bg-gray-800">
|
||||
<svg className="h-6 w-6 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="mt-2 text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</span>
|
||||
<div className="flex h-16 items-center justify-center gap-1 text-sm text-[#6b7075]">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
<span>添加图片</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
@ -226,10 +193,10 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-gray-700">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">其他图片</h4>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">可选,多张</span>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h4 className={fieldLabelClass}>其他图片</h4>
|
||||
<span className={fieldHintClass}>可选 · 多张{otherImages.length > 0 ? ` · 已选 ${otherImages.length} 张` : ''}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@ -251,13 +218,9 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
<label
|
||||
htmlFor="white-bg-other-images"
|
||||
aria-disabled={uploading}
|
||||
className={`block min-h-28 rounded-2xl border-2 border-dashed px-4 py-5 transition-colors ${
|
||||
className={`block p-3 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
} ${
|
||||
isOtherDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
} ${isOtherDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(event) => {
|
||||
prepareFileDrag(event, uploading)
|
||||
if (uploading) return
|
||||
@ -274,11 +237,9 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
onDrop={handleOtherDrop}
|
||||
>
|
||||
{otherImages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{otherImages.map((image) => (
|
||||
<div key={image.id} className="rounded-2xl border border-gray-200 bg-white/80 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="relative h-32 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div key={image.id} className={uploadAreaTileClass} title={image.name}>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.name}
|
||||
@ -290,7 +251,7 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
type="button"
|
||||
aria-label={`删除${image.name}`}
|
||||
disabled={uploading}
|
||||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
|
||||
className="absolute right-1 top-1 inline-flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-sm font-semibold text-white transition hover:bg-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@ -299,26 +260,17 @@ export default function StandardModelWhiteBackgroundUpload({
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">{image.name}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex h-full min-h-[10rem] flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-white/70 px-4 text-center dark:border-gray-600 dark:bg-gray-800/50">
|
||||
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">继续添加</span>
|
||||
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">拖拽或点击追加</span>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-[8px] border border-dashed border-[#cbd5e1] bg-white text-sm text-[#1a1c1d]">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">继续添加</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-28 flex-col items-center justify-center text-center">
|
||||
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">拖拽图片到这里,或点击追加</span>
|
||||
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">支持一次添加多张其他图片</span>
|
||||
<div className="flex min-h-12 items-center gap-2 text-sm font-medium text-[#1a1c1d]">
|
||||
<Plus className="h-5 w-5 text-[#6b7075]" aria-hidden="true" />
|
||||
<span>添加其他图片,或拖入文件</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
import TaskReferenceFields from './TaskReferenceFields';
|
||||
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
|
||||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy';
|
||||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
|
||||
|
||||
interface AssetFile {
|
||||
file: File;
|
||||
@ -344,7 +346,7 @@ export default function TaskPageUploader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<TaskReferenceFields
|
||||
idPrefix="task-page"
|
||||
taskNo={taskNo}
|
||||
@ -356,7 +358,7 @@ export default function TaskPageUploader({
|
||||
|
||||
{/* SPU型号 */}
|
||||
<div>
|
||||
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="productModel" className={fieldLabelClass}>
|
||||
SPU型号 *
|
||||
</label>
|
||||
<input
|
||||
@ -368,14 +370,14 @@ export default function TaskPageUploader({
|
||||
setProductModel(value);
|
||||
}}
|
||||
placeholder="例如:XY-2024-001"
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={fieldControlClass}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 使用店铺 */}
|
||||
<div>
|
||||
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="categoryName" className={fieldLabelClass}>
|
||||
使用店铺 *
|
||||
{shopAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -384,18 +386,18 @@ export default function TaskPageUploader({
|
||||
)}
|
||||
</label>
|
||||
{showNewCategoryInput ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
placeholder="输入新店铺名称"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddNewCategory}
|
||||
className="btn-primary"
|
||||
className={inkButtonClass}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
@ -405,13 +407,13 @@ export default function TaskPageUploader({
|
||||
setShowNewCategoryInput(false);
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
className="btn-secondary"
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="categoryName"
|
||||
value={categoryName}
|
||||
@ -419,7 +421,7 @@ export default function TaskPageUploader({
|
||||
setCategoryName(e.target.value);
|
||||
setShopAutoFilled(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
>
|
||||
<option value="">选择使用店铺</option>
|
||||
{categories.map((category) => (
|
||||
@ -431,7 +433,7 @@ export default function TaskPageUploader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewCategoryInput(true)}
|
||||
className="btn-secondary whitespace-nowrap"
|
||||
className={`${outlineButtonClass} whitespace-nowrap`}
|
||||
>
|
||||
+ 新建店铺
|
||||
</button>
|
||||
@ -441,22 +443,22 @@ export default function TaskPageUploader({
|
||||
|
||||
{/* 场景类别 */}
|
||||
<div>
|
||||
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="sceneName" className={fieldLabelClass}>
|
||||
场景类别 *
|
||||
</label>
|
||||
{showNewSceneInput ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSceneName}
|
||||
onChange={(e) => setNewSceneName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
placeholder="输入新场景类别"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddNewScene}
|
||||
className="btn-primary"
|
||||
className={inkButtonClass}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
@ -466,18 +468,18 @@ export default function TaskPageUploader({
|
||||
setShowNewSceneInput(false);
|
||||
setNewSceneName('');
|
||||
}}
|
||||
className="btn-secondary"
|
||||
className={outlineButtonClass}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="sceneName"
|
||||
value={sceneName}
|
||||
onChange={(e) => setSceneName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
>
|
||||
<option value="">选择场景类别</option>
|
||||
{scenes.map((scene) => (
|
||||
@ -489,7 +491,7 @@ export default function TaskPageUploader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewSceneInput(true)}
|
||||
className="btn-secondary whitespace-nowrap"
|
||||
className={`${outlineButtonClass} whitespace-nowrap`}
|
||||
>
|
||||
+ 新建场景类别
|
||||
</button>
|
||||
@ -500,14 +502,14 @@ export default function TaskPageUploader({
|
||||
{/* 平台分类(可选) */}
|
||||
{platforms.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="platformName" className={fieldLabelClass}>
|
||||
平台分类(可选)
|
||||
</label>
|
||||
<select
|
||||
id="platformName"
|
||||
value={platformName}
|
||||
onChange={(e) => setPlatformName(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={fieldControlClass}
|
||||
>
|
||||
<option value="">不选择平台</option>
|
||||
{platforms.map((platform) => (
|
||||
@ -522,7 +524,7 @@ export default function TaskPageUploader({
|
||||
{/* 品类分类(可选) */}
|
||||
{productCategories.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="productCategoryName" className={fieldLabelClass}>
|
||||
品类(可选)
|
||||
{categoryAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -537,7 +539,7 @@ export default function TaskPageUploader({
|
||||
setProductCategoryName(e.target.value);
|
||||
setCategoryAutoFilled(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={fieldControlClass}
|
||||
>
|
||||
<option value="">不选择品类</option>
|
||||
{productCategories.map((category) => (
|
||||
@ -551,18 +553,12 @@ export default function TaskPageUploader({
|
||||
|
||||
{/* 预览图上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
预览图 *
|
||||
</label>
|
||||
<label className={fieldLabelClass}>预览图 *</label>
|
||||
<div
|
||||
aria-disabled={uploading}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
className={`p-3 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isPreviewDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
} ${isPreviewDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragEnter={(e) => {
|
||||
prepareFileDrag(e, uploading);
|
||||
if (uploading) return;
|
||||
@ -595,45 +591,9 @@ export default function TaskPageUploader({
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{previewImages.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<img src={img.preview} alt={`预览图 ${index + 1}`} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs rounded px-1">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removePreviewImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{img.file.name} ({formatBytes(img.file.size)})
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => previewInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="group w-full h-32 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"
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
|
||||
</p>
|
||||
</div>
|
||||
<CompactImageFileGrid files={previewImages} label="预览图" disabled={uploading} onAdd={() => previewInputRef.current?.click()} onRemove={removePreviewImage} />
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>JPG、PNG、WebP · 可多选或拖入</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
@ -662,10 +622,10 @@ export default function TaskPageUploader({
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<div className="flex justify-end gap-3 border-t border-[#e7e8ea] pt-5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="btn-secondary"
|
||||
className={outlineButtonClass}
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
>
|
||||
@ -673,7 +633,7 @@ export default function TaskPageUploader({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
className={inkButtonClass}
|
||||
disabled={uploading}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass } from './upload-field-styles';
|
||||
import { parseSPU } from '@/lib/spu-parser';
|
||||
import {
|
||||
hasDragLeftDropZone,
|
||||
@ -502,18 +504,12 @@ export default function ThreeDOriginalUploader({
|
||||
|
||||
{/* 原图上传区 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
|
||||
</label>
|
||||
<label className={fieldLabelClass}>原图 *</label>
|
||||
<div
|
||||
aria-disabled={uploading}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
className={`p-3 ${
|
||||
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'
|
||||
}`}
|
||||
} ${isMainDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@ -527,44 +523,9 @@ export default function ThreeDOriginalUploader({
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{mainImages.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={img.preview}
|
||||
alt={`原图 ${index + 1}`}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-32 object-cover rounded-apple"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMainImage(index)}
|
||||
disabled={uploading}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{formatBytes(img.file.size)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mainInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="group w-full h-32 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"
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持多选,或拖拽图片到此区域
|
||||
</p>
|
||||
<CompactImageFileGrid files={mainImages} label="原图" disabled={uploading} onAdd={() => mainInputRef.current?.click()} onRemove={removeMainImage} />
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>至少 1 张 · 可多选或拖入 · 自动生成预览图和压缩包</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
|
||||
@ -507,17 +507,9 @@ export default function UploadForm({
|
||||
|
||||
{/* 标准模型说明 */}
|
||||
{requiresProductCategoryName(materialType) && (
|
||||
<div className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-4">
|
||||
<p className="mb-2 text-[13px] font-medium text-[#1a1c1d]">
|
||||
标准模型怎么传
|
||||
</p>
|
||||
<ul className={`list-inside list-disc space-y-1 ${fieldHintClass}`}>
|
||||
<li>以产品型号(SPU)为基准创建模型合集</li>
|
||||
<li>上传时只需填写 SPU 型号和预览图</li>
|
||||
<li>创建后在详情页添加各个 SKU 的模型文件</li>
|
||||
<li>每个 SKU 需要单独的预览图和压缩包</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className={fieldHintClass}>
|
||||
此处创建模型合集;各 SKU 的预览图和压缩包请在创建后到详情页添加。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -4,16 +4,16 @@ import { useEffect, useId, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useGSAP } from '@gsap/react'
|
||||
import gsap from 'gsap'
|
||||
import { ImagePlus, LoaderCircle, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { LoaderCircle, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
|
||||
import {
|
||||
handleDropZoneKeyDown,
|
||||
hasDragLeftDropZone,
|
||||
prepareFileDrag,
|
||||
validateImageFiles,
|
||||
} from './drag-drop-utils'
|
||||
import { assertVideoPreviewImageIsSafeToDecode } from './image-processor'
|
||||
import { formatBytes } from './upload-utils'
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass, uploadAreaButtonClass, uploadAreaEmptyButtonClass } from './upload-field-styles'
|
||||
|
||||
const VIDEO_PREVIEW_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'] as const
|
||||
const VIDEO_PREVIEW_MAX_BYTES = 50 * 1024 * 1024
|
||||
@ -152,12 +152,12 @@ export default function VideoPreviewImageUpload({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" aria-busy={isProcessing}>
|
||||
<div aria-busy={isProcessing}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
视频预览图 <span className="font-normal text-gray-500 dark:text-gray-400">(选填)</span>
|
||||
<label htmlFor={inputId} className={fieldLabelClass}>
|
||||
视频预览图 <span className="font-normal text-[#6b7075]">(选填)</span>
|
||||
</label>
|
||||
<p id={`${inputId}-help`} className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p id={`${inputId}-help`} className={fieldHintClass}>
|
||||
未填写时自动从视频截取
|
||||
</p>
|
||||
</div>
|
||||
@ -176,39 +176,35 @@ export default function VideoPreviewImageUpload({
|
||||
{previewUrl ? (
|
||||
<div
|
||||
ref={previewPanelRef}
|
||||
className={`flex flex-col gap-3 rounded-apple border p-3 sm:flex-row sm:items-center ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/40'
|
||||
} ${disabled ? 'opacity-60' : ''}`}
|
||||
className={`flex flex-wrap items-center gap-3 p-3 ${isDragging ? dropZoneActiveClass : dropZoneIdleClass} ${disabled ? 'opacity-60' : ''}`}
|
||||
{...dropHandlers}
|
||||
>
|
||||
<div className={`relative shrink-0 overflow-hidden rounded-apple bg-gray-200 dark:bg-gray-700 ${compact ? 'h-20 w-32' : 'aspect-video w-full sm:w-44'}`}>
|
||||
<div className={`relative shrink-0 overflow-hidden rounded-[8px] bg-[#edf0f2] ${compact ? 'h-16 w-28' : 'h-20 w-32'}`}>
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="已选择的视频预览图"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
sizes={compact ? '128px' : '(max-width: 640px) 100vw, 176px'}
|
||||
sizes={compact ? '112px' : '128px'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||||
{fileName || '已选择预览图'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className={fieldHintClass}>
|
||||
{fileSize ? `${formatBytes(fileSize)} · ` : ''}手动图片将优先展示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 self-end sm:self-center">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFilePicker}
|
||||
disabled={disabled || isProcessing}
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-apple px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus-visible:ring-offset-gray-900"
|
||||
className={uploadAreaButtonClass}
|
||||
>
|
||||
{isProcessing ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="h-4 w-4" aria-hidden="true" />}
|
||||
更换
|
||||
@ -217,7 +213,7 @@ export default function VideoPreviewImageUpload({
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={disabled || isProcessing}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-apple text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-950/30 dark:focus-visible:ring-offset-gray-900"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-[8px] text-[#b42318] transition-colors hover:bg-[#fff1f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="移除视频预览图"
|
||||
title="移除视频预览图"
|
||||
>
|
||||
@ -227,32 +223,23 @@ export default function VideoPreviewImageUpload({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled || isProcessing ? -1 : 0}
|
||||
aria-disabled={disabled || isProcessing}
|
||||
aria-describedby={`${inputId}-help`}
|
||||
className={`flex min-h-24 items-center gap-3 rounded-apple border-2 border-dashed px-4 py-3 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900/30 dark:hover:border-gray-500 dark:hover:bg-gray-900/60'
|
||||
} ${disabled || isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
onClick={openFilePicker}
|
||||
onKeyDown={(event) => handleDropZoneKeyDown(event, openFilePicker)}
|
||||
className={`p-3 ${isDragging ? dropZoneActiveClass : dropZoneIdleClass} ${disabled || isProcessing ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
{...dropHandlers}
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-apple bg-white text-gray-600 shadow-sm dark:bg-gray-800 dark:text-gray-300">
|
||||
{isProcessing ? <LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /> : <ImagePlus className="h-5 w-5" aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{isProcessing ? '正在处理预览图...' : '添加预览图'}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
JPG、PNG 或 WebP,最大 50MB,也可拖拽到这里
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFilePicker}
|
||||
disabled={disabled || isProcessing}
|
||||
aria-describedby={`${inputId}-help`}
|
||||
className={uploadAreaEmptyButtonClass}
|
||||
>
|
||||
{isProcessing ? <LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /> : <Plus className="h-5 w-5" aria-hidden="true" />}
|
||||
{isProcessing ? '正在处理图片...' : '添加预览图'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!previewUrl && <p className={`mt-1.5 ${fieldHintClass}`}>JPG、PNG、WebP · 最大 50MB · 可拖入</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||||
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
|
||||
import { parseSPU } from '@/lib/spu-parser';
|
||||
import {
|
||||
hasDragLeftDropZone,
|
||||
@ -354,7 +356,7 @@ export default function VideoScreenshotUploader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<TaskReferenceFields
|
||||
idPrefix="video-screenshot"
|
||||
taskNo={taskNo}
|
||||
@ -366,7 +368,7 @@ export default function VideoScreenshotUploader({
|
||||
|
||||
{/* SPU型号 */}
|
||||
<div>
|
||||
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="productModel" className={fieldLabelClass}>
|
||||
SPU型号 *
|
||||
</label>
|
||||
<input
|
||||
@ -378,7 +380,7 @@ export default function VideoScreenshotUploader({
|
||||
setProductModel(value);
|
||||
}}
|
||||
placeholder="例如:XY-2024-001"
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={fieldControlClass}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
@ -386,7 +388,7 @@ export default function VideoScreenshotUploader({
|
||||
{/* 品类分类 */}
|
||||
{productCategories.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="productCategoryName" className={fieldLabelClass}>
|
||||
品类(可选)
|
||||
{categoryAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -401,7 +403,7 @@ export default function VideoScreenshotUploader({
|
||||
setProductCategoryName(e.target.value);
|
||||
setCategoryAutoFilled(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
className={fieldControlClass}
|
||||
disabled={uploading}
|
||||
>
|
||||
<option value="">不选择品类</option>
|
||||
@ -414,7 +416,7 @@ export default function VideoScreenshotUploader({
|
||||
|
||||
{/* 使用店铺 */}
|
||||
<div>
|
||||
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="categoryName" className={fieldLabelClass}>
|
||||
使用店铺 *
|
||||
{shopAutoFilled && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
@ -423,20 +425,20 @@ export default function VideoScreenshotUploader({
|
||||
)}
|
||||
</label>
|
||||
{showNewCategoryInput ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
placeholder="输入新店铺名称"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<button type="button" onClick={handleAddNewCategory} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
|
||||
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
|
||||
<button type="button" onClick={handleAddNewCategory} className={inkButtonClass} disabled={uploading}>添加</button>
|
||||
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className={outlineButtonClass} disabled={uploading}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="categoryName"
|
||||
value={categoryName}
|
||||
@ -444,7 +446,7 @@ export default function VideoScreenshotUploader({
|
||||
setCategoryName(e.target.value);
|
||||
setShopAutoFilled(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
disabled={uploading}
|
||||
>
|
||||
<option value="">选择使用店铺</option>
|
||||
@ -452,36 +454,36 @@ export default function VideoScreenshotUploader({
|
||||
<option key={category.id} value={category.name}>{category.name}</option>
|
||||
))}
|
||||
</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>
|
||||
<button type="button" onClick={() => setShowNewCategoryInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建店铺</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 场景类别 */}
|
||||
<div>
|
||||
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="sceneName" className={fieldLabelClass}>
|
||||
场景类别 *
|
||||
</label>
|
||||
{showNewSceneInput ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSceneName}
|
||||
onChange={(e) => setNewSceneName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
placeholder="输入新场景类别"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<button type="button" onClick={handleAddNewScene} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
|
||||
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
|
||||
<button type="button" onClick={handleAddNewScene} className={inkButtonClass} disabled={uploading}>添加</button>
|
||||
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className={outlineButtonClass} disabled={uploading}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="sceneName"
|
||||
value={sceneName}
|
||||
onChange={(e) => setSceneName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
|
||||
className={`flex-1 ${fieldControlClass}`}
|
||||
disabled={uploading}
|
||||
>
|
||||
<option value="">选择场景类别</option>
|
||||
@ -489,7 +491,7 @@ export default function VideoScreenshotUploader({
|
||||
<option key={scene.id} value={scene.name}>{scene.name}</option>
|
||||
))}
|
||||
</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>
|
||||
<button type="button" onClick={() => setShowNewSceneInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建场景类别</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -497,14 +499,14 @@ export default function VideoScreenshotUploader({
|
||||
{/* 平台分类 */}
|
||||
{platforms.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label htmlFor="platformName" className={fieldLabelClass}>
|
||||
平台分类(可选)
|
||||
</label>
|
||||
<select
|
||||
id="platformName"
|
||||
value={platformName}
|
||||
onChange={(e) => setPlatformName(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
|
||||
className={fieldControlClass}
|
||||
disabled={uploading}
|
||||
>
|
||||
<option value="">不选择平台</option>
|
||||
@ -517,18 +519,12 @@ export default function VideoScreenshotUploader({
|
||||
|
||||
{/* 视频截图上传区 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
视频截图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
|
||||
</label>
|
||||
<label className={fieldLabelClass}>视频截图 *</label>
|
||||
<div
|
||||
aria-disabled={uploading}
|
||||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||||
className={`p-3 ${
|
||||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||||
} ${
|
||||
isScreenshotDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
} ${isScreenshotDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@ -542,44 +538,9 @@ export default function VideoScreenshotUploader({
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{screenshots.map((img, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<Image
|
||||
src={img.preview}
|
||||
alt={`截图 ${index + 1}`}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-32 object-cover rounded-apple"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScreenshot(index)}
|
||||
disabled={uploading}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{formatBytes(img.file.size)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => screenshotInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="group w-full h-32 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"
|
||||
>
|
||||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持多选,或拖拽图片到此区域
|
||||
</p>
|
||||
<CompactImageFileGrid files={screenshots} label="截图" disabled={uploading} onAdd={() => screenshotInputRef.current?.click()} onRemove={removeScreenshot} />
|
||||
</div>
|
||||
<p className={`mt-1.5 ${fieldHintClass}`}>至少 1 张 · 可多选或拖入 · 自动生成预览图和压缩包</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
@ -605,9 +566,9 @@ export default function VideoScreenshotUploader({
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
|
||||
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
|
||||
<div className="flex justify-end gap-3 border-t border-[#e7e8ea] pt-5">
|
||||
<button onClick={onCancel} className={outlineButtonClass} disabled={uploading} type="button">取消</button>
|
||||
<button onClick={handleSubmit} className={inkButtonClass} disabled={uploading} type="button">
|
||||
{uploading ? '上传中...' : '提交'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
export const fieldLabelClass =
|
||||
'mb-2 block text-[13px] font-medium tracking-[-0.02em] text-[#1a1c1d]'
|
||||
'mb-2 block text-[14px] font-medium tracking-[-0.02em] text-[#1a1c1d]'
|
||||
|
||||
export const fieldHintClass = 'text-[12px] leading-relaxed text-[#86898e]'
|
||||
export const fieldHintClass = 'text-[12px] leading-relaxed text-[#6b7075]'
|
||||
|
||||
export const fieldControlClass =
|
||||
'w-full rounded-[8px] border border-[#e7e8ea] bg-white px-4 py-2.5 text-[14px] tracking-[-0.02em] text-[#1a1c1d] placeholder:text-[#86898e] outline-none transition-colors focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
'w-full rounded-[8px] border border-[#e7e8ea] bg-white px-4 py-2.5 text-[14px] tracking-[-0.02em] text-[#1a1c1d] placeholder:text-[#6b7075] outline-none transition-colors focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
|
||||
export const fieldErrorClass = 'mt-1 text-[13px] text-[#b42318]'
|
||||
|
||||
@ -15,7 +15,19 @@ export const outlineButtonClass =
|
||||
'inline-flex h-11 items-center justify-center rounded-[8px] border border-[#1a1c1d] bg-white px-6 text-[14px] font-medium tracking-[-0.02em] text-[#1a1c1d] transition-colors hover:bg-[#1a1c1d] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const dropZoneIdleClass =
|
||||
'rounded-[12px] border border-dashed border-[#e7e8ea] bg-[#fafafa] transition-colors'
|
||||
'rounded-[8px] border border-dashed border-[#cbd5e1] bg-[#fafafa] transition-colors hover:border-[#8b95a1]'
|
||||
|
||||
export const dropZoneActiveClass =
|
||||
'rounded-[12px] border border-dashed border-[#1a1c1d] bg-[#eef0fa] transition-colors'
|
||||
'rounded-[8px] border border-dashed border-[#1677ff] bg-[#eef4fb] transition-colors'
|
||||
|
||||
export const uploadAreaButtonClass =
|
||||
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[8px] px-3 text-[14px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const uploadAreaTileClass =
|
||||
'relative h-20 w-20 shrink-0 overflow-hidden rounded-[8px] border border-[#e7e8ea] bg-white'
|
||||
|
||||
export const uploadAreaEmptyButtonClass =
|
||||
'flex min-h-12 w-full items-center justify-center gap-2 rounded-[8px] text-[14px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const uploadAreaAddTileClass =
|
||||
'flex h-20 w-20 items-center justify-center rounded-[8px] border border-dashed border-[#cbd5e1] bg-white text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user