feat: unify and compact material upload controls

This commit is contained in:
WuYongJie 2026-09-24 15:00:20 +08:00
parent 0baa951fd9
commit 756f9c9575
29 changed files with 738 additions and 824 deletions

View File

@ -1572,6 +1572,7 @@ export default function MaterialLibraryPage() {
{/* Upload Modal */}
{showUploadModal && (
<UploadModal
materialTypeCounts={materialTypeCounts}
onClose={() => setShowUploadModal(false)}
onSuccess={() => {
setShowUploadModal(false)

View File

@ -1567,6 +1567,7 @@ export default function Home() {
{/* Upload Modal */}
{showUploadModal && (
<UploadModal
materialTypeCounts={materialTypeCounts}
onClose={() => setShowUploadModal(false)}
onSuccess={() => {
setShowUploadModal(false)

View File

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

View File

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

View File

@ -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: {

View File

@ -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>
{/* 错误提示 */}

View File

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

View File

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

View 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>
)
}

View File

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

View File

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

View 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()
})

View 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>
)
}

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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"
>

View File

@ -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>
{/* 错误提示 */}

View File

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

View File

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

View File

@ -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('白底图上传中不应接受固定角度或其他图片拖入', () => {

View File

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

View File

@ -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"
>

View File

@ -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>
{/* 错误提示 */}

View File

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

View File

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

View File

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

View File

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