完善模板字段前端交互并收敛预览样式

This commit is contained in:
WuYongJie 2026-05-25 16:45:46 +08:00
parent 2ae7b9a067
commit 720c163a6b
5 changed files with 958 additions and 139 deletions

View File

@ -1,9 +1,10 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import Image from 'next/image'
import Modal from '@/components/ui/Modal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from '@/lib/field-conditions'
interface EditMaterialModalProps {
material: {
@ -24,6 +25,7 @@ interface EditMaterialModalProps {
colorMode?: string | null
templateId?: string | null
platformName?: string | null
templateFields?: Record<string, any>
// 音频相关字段
audioDuration?: string | null
audioCategory?: string | null
@ -46,6 +48,11 @@ type UploadTemplateOption = {
export default function EditMaterialModal({ material, onClose, onSuccess, isOpen = true }: EditMaterialModalProps) {
const styles = useThemeStyles()
const theme = useTheme()
const controlClass =
'w-full px-4 py-2.5 text-sm rounded-lg outline-none transition-colors border bg-white dark:bg-slate-800 text-slate-900 dark:text-white border-slate-300 dark:border-slate-700 focus-visible:ring-2 focus-visible:ring-slate-200 dark:focus-visible:ring-slate-700 focus-visible:border-slate-400 dark:focus-visible:border-slate-500 placeholder:text-slate-400 dark:placeholder:text-slate-500'
const requiredControlClass = 'bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600'
const requiredBadgeClass =
'inline-flex items-center px-2 py-0.5 rounded-md border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-[10px] font-medium text-slate-600 dark:text-slate-300'
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl)
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, any>>({})
@ -57,10 +64,15 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
const [success, setSuccess] = useState('')
const previewInputRef = useRef<HTMLInputElement>(null)
const visibleTemplateFields = useMemo(() => {
if (!selectedTemplate?.fields?.length) return []
return getVisibleTemplateFields(selectedTemplate.fields, templateFieldValues)
}, [selectedTemplate, templateFieldValues])
// 初始化字段值
useEffect(() => {
const initialValues: Record<string, any> = {
...(material.templateFields || {}),
productModel: material.productModel,
brandName: material.brandName || '',
seriesName: material.seriesName || '',
@ -103,39 +115,48 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
setTemplateCategoryOptions(map)
}
// 如果有templateId,加载模板
const setTemplateFromData = (templateData: any) => {
setSelectedTemplate({
id: templateData.id,
name: templateData.name,
description: templateData.description,
materialType: templateData.materialType,
fields: templateData.fields || [],
isBuiltin: templateData.isBuiltin,
})
}
const loadPublicTemplates = async () => {
const templatesResponse = await fetch('/api/upload-templates')
if (!templatesResponse.ok) {
return []
}
const templatesData = await templatesResponse.json()
return Array.isArray(templatesData) ? templatesData : []
}
// 如果有templateId,优先从普通用户可访问的模板列表加载
if (material.templateId) {
const templateResponse = await fetch(`/api/admin/upload-templates/${material.templateId}`)
if (templateResponse.ok) {
const templateData = await templateResponse.json()
setSelectedTemplate({
id: templateData.id,
name: templateData.name,
description: templateData.description,
materialType: templateData.materialType,
fields: templateData.fields || [],
isBuiltin: templateData.isBuiltin,
})
const templates = await loadPublicTemplates()
const matchingTemplate = templates.find((t: any) => t.id === material.templateId)
if (matchingTemplate) {
setTemplateFromData(matchingTemplate)
} else {
const templateResponse = await fetch(`/api/admin/upload-templates/${material.templateId}`)
if (templateResponse.ok) {
const templateData = await templateResponse.json()
setTemplateFromData(templateData)
}
}
} else {
// 如果没有templateId,尝试根据materialType查找匹配的模板
const templatesResponse = await fetch('/api/upload-templates')
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json()
const templates = Array.isArray(templatesData) ? templatesData : []
const matchingTemplate = templates.find(
(t: any) => t.materialType === material.materialType && t.status === 'active'
)
if (matchingTemplate) {
setSelectedTemplate({
id: matchingTemplate.id,
name: matchingTemplate.name,
description: matchingTemplate.description,
materialType: matchingTemplate.materialType,
fields: matchingTemplate.fields || [],
isBuiltin: matchingTemplate.isBuiltin,
})
}
const templates = await loadPublicTemplates()
const matchingTemplate = templates.find(
(t: any) => t.materialType === material.materialType && t.status === 'active'
)
if (matchingTemplate) {
setTemplateFromData(matchingTemplate)
}
}
} catch (error) {
@ -166,10 +187,10 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
setSuccess('')
// 验证必填字段
const requiredFields = selectedTemplate?.fields.filter((f: any) => f.required) || []
const requiredFields = visibleTemplateFields.filter((f: any) => f.required)
for (const field of requiredFields) {
const value = templateFieldValues[field.key]
if (!value || (typeof value === 'string' && value.trim() === '')) {
if (!hasFieldValue(value, field.type)) {
setError(`请填写必填字段:${field.label}`)
return
}
@ -180,13 +201,21 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
try {
const formData = new FormData()
formData.append('id', material.id)
if (selectedTemplate?.id) {
formData.append('templateId', selectedTemplate.id)
}
// 添加所有模板字段值
Object.entries(templateFieldValues).forEach(([key, value]) => {
const visibleTemplateFieldValues = selectedTemplate?.fields?.length
? filterVisibleFieldValues(selectedTemplate.fields, templateFieldValues)
: templateFieldValues
Object.entries(visibleTemplateFieldValues).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, String(value))
}
})
formData.append('templateFields', JSON.stringify(visibleTemplateFieldValues))
if (previewImage) {
formData.append('previewImage', previewImage)
@ -219,6 +248,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
const renderTemplateField = (field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
const fieldInputId = `edit-template-field-${field.id || field.key}`
return (
<div
@ -227,17 +257,21 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">
<label
htmlFor={field.type === 'radio' ? undefined : fieldInputId}
className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight"
>
{field.label}
</label>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
<span className={requiredBadgeClass}>
必填
</span>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
id={fieldInputId}
type="text"
value={fieldValue}
onChange={(e) => {
@ -256,15 +290,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
: `请输入${field.label || field.key}`
}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80',
controlClass,
field.required ? requiredControlClass : null,
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
@ -276,14 +309,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
placeholder={`请输入${field.label || field.key}`}
rows={2}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 resize-none',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
controlClass,
field.required ? requiredControlClass : null,
'resize-none'
)}
/>
) : field.type === 'number' ? (
<input
id={fieldInputId}
type="number"
value={fieldValue}
onChange={(e) => {
@ -295,14 +328,13 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
required={field.required}
placeholder={`请输入${field.label || field.key}`}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
controlClass,
field.required ? requiredControlClass : null
)}
/>
) : field.type === 'select' ? (
<select
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
@ -312,10 +344,8 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
controlClass,
field.required ? requiredControlClass : null
)}
>
<option value="">请选择</option>
@ -351,6 +381,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
) : field.type === 'checkbox' ? (
<label className="flex items-center space-x-2">
<input
id={fieldInputId}
type="checkbox"
checked={fieldValue || false}
onChange={(e) => {
@ -361,7 +392,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
}}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
<span className="text-sm text-slate-700 dark:text-slate-300">
{field.label}
</span>
</label>
@ -404,6 +435,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
</div>
) : field.type === 'date' ? (
<input
id={fieldInputId}
type="date"
value={fieldValue}
onChange={(e) => {
@ -414,10 +446,8 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
controlClass,
field.required ? requiredControlClass : null
)}
/>
) : null}
@ -435,7 +465,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
title="编辑素材"
bodyClassName="max-h-[70vh] overflow-y-auto"
>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
{error && (
<div className={cn(
'px-4 py-3 rounded-xl border',
@ -510,7 +540,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess, isOpen
模板字段
</h3>
<div className="space-y-4">
{selectedTemplate.fields.map((field: any) => renderTemplateField(field))}
{visibleTemplateFields.map((field: any) => renderTemplateField(field))}
</div>
</div>
</div>

View File

@ -51,7 +51,7 @@ interface PreviewMaterialInfo {
createdAt?: string
zipFileName?: string
zipFileSize?: string | number
rankingBadges?: Array<{ label: string; gradient: string }>
rankingBadges?: Array<{ label: string }>
isPremium?: boolean
isFavorited?: boolean
user?: {
@ -272,13 +272,10 @@ export default function MaterialPreviewModal({
const rankingBadges = material?.rankingBadges ?? []
const downloadBadges = rankingBadges.filter((badge) => badge.label.includes('下载'))
const likeBadges = rankingBadges.filter((badge) => badge.label.includes('点赞'))
const renderBadgeChip = (badge: { label: string; gradient: string }) => (
const renderBadgeChip = (badge: { label: string }) => (
<span
key={badge.label}
className={cn(
'px-3 py-1 text-[11px] font-semibold text-white rounded-full shadow-sm bg-gradient-to-r',
badge.gradient
)}
className="px-3 py-1 text-[11px] font-semibold text-white rounded-full shadow-sm bg-slate-900/80"
>
{badge.label}
</span>
@ -323,14 +320,14 @@ export default function MaterialPreviewModal({
>
<div className={cn('w-auto', isAudioMaterial ? 'min-w-[450px]' : 'max-w-[600px]')}>
{isAudioMaterial ? (
// 音频素材播放器 - 带圆形频谱可视化
// 音频素材播放器
<div
className={cn(
'w-full min-w-[450px] rounded-[32px] overflow-hidden shadow-2xl',
'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'
'w-full min-w-[450px] rounded-xl overflow-hidden shadow-sm border',
theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-white border-slate-200'
)}
>
{/* 顶部 - 圆形频谱可视化区域 */}
{/* 顶部 - 音频预览区域 */}
<div className="relative flex items-center justify-center py-8">
<AudioVisualizer
audioElement={audioElement}
@ -343,13 +340,20 @@ export default function MaterialPreviewModal({
{/* 音频信息区域 */}
<div className="px-8 py-4 text-center">
<h4 className="text-xl font-bold text-white mb-2 truncate">
<h4 className={cn('text-xl font-semibold mb-2 truncate', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{material?.productModel || '音频素材'}
</h4>
{/* 时长显示 */}
{material?.audioDuration && (
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 text-white/80 text-sm mb-4">
<div
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm mb-4 border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@ -361,12 +365,26 @@ export default function MaterialPreviewModal({
{(material?.audioCategory || material?.audioScene) && (
<div className="flex flex-wrap gap-2 justify-center mb-4">
{material?.audioCategory && (
<span className="px-3 py-1 text-xs font-medium bg-blue-500/20 text-blue-300 rounded-full border border-blue-500/30">
<span
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
{material.audioCategory}
</span>
)}
{material?.audioScene && (
<span className="px-3 py-1 text-xs font-medium bg-emerald-500/20 text-emerald-300 rounded-full border border-emerald-500/30">
<span
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
{material.audioScene}
</span>
)}
@ -376,7 +394,12 @@ export default function MaterialPreviewModal({
{/* 音频播放器区域 */}
<div className="px-6 pb-8">
<div className="bg-white/5 rounded-2xl p-4 backdrop-blur-sm border border-white/10">
<div
className={cn(
'rounded-lg p-4 border',
theme === 'dark' ? 'bg-slate-950 border-slate-700' : 'bg-slate-50 border-slate-200'
)}
>
{material?.audioUrl || material?.id ? (
<audio
ref={(el) => {
@ -390,9 +413,6 @@ export default function MaterialPreviewModal({
controlsList="nodownload"
crossOrigin="anonymous"
className="w-full h-12 rounded-lg"
style={{
filter: 'invert(1) hue-rotate(180deg) brightness(0.9)',
}}
onPlay={() => setIsAudioPlaying(true)}
onPause={() => setIsAudioPlaying(false)}
onEnded={() => setIsAudioPlaying(false)}
@ -402,7 +422,7 @@ export default function MaterialPreviewModal({
您的浏览器不支持音频播放。
</audio>
) : (
<div className="flex items-center justify-center h-12 text-white/50 text-sm">
<div className={cn('flex items-center justify-center h-12 text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
暂无可播放的音频
</div>
)}
@ -410,7 +430,7 @@ export default function MaterialPreviewModal({
{/* 文件信息 */}
{fileSizeLabel && (
<div className="mt-4 text-center text-white/50 text-xs">
<div className={cn('mt-4 text-center text-xs', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
文件大小: {fileSizeLabel}
</div>
)}
@ -420,7 +440,7 @@ export default function MaterialPreviewModal({
videoSource ? (
<div
className={cn(
'w-full rounded-[32px] overflow-hidden shadow-inner border leading-[0]',
'w-full rounded-2xl overflow-hidden shadow-sm border leading-[0]',
borderColor,
bgColor
)}
@ -437,7 +457,7 @@ export default function MaterialPreviewModal({
height: 'auto',
objectFit: 'contain',
display: 'block',
borderRadius: '32px',
borderRadius: '1rem',
}}
>
<source src={videoSource} {...(videoSourceMimeType ? { type: videoSourceMimeType } : {})} />
@ -445,7 +465,7 @@ export default function MaterialPreviewModal({
</video>
</div>
) : (
<div className="w-full h-[360px] flex flex-col items-center justify-center text-center px-8 bg-gradient-to-b from-slate-900 to-slate-800 text-white rounded-[32px]">
<div className="w-full h-[360px] flex flex-col items-center justify-center text-center px-8 bg-slate-900 text-white rounded-2xl">
<svg className="w-10 h-10 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.868v4.264a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M4.5 6.75v10.5a1.5 1.5 0 001.5 1.5h12a1.5 1.5 0 001.5-1.5V6.75A1.5 1.5 0 0018 5.25H6a1.5 1.5 0 00-1.5 1.5z" />
@ -459,7 +479,7 @@ export default function MaterialPreviewModal({
)
) : galleryImages.length > 0 ? (
isLongForm ? (
<div className={cn('w-full rounded-[32px] overflow-hidden shadow-inner leading-[0]', theme === 'dark' ? 'bg-slate-900/70' : 'bg-white/95')}>
<div className={cn('w-full rounded-2xl overflow-hidden shadow-sm leading-[0]', theme === 'dark' ? 'bg-slate-900/70' : 'bg-white/95')}>
{galleryImages.map((img, index) => (
<div
key={`${img}-${index}`}
@ -479,7 +499,7 @@ export default function MaterialPreviewModal({
) : (
<div
className={cn(
'w-full rounded-[32px] overflow-hidden shadow-inner border cursor-pointer',
'w-full rounded-2xl overflow-hidden shadow-sm border cursor-pointer',
borderColor,
bgColor
)}
@ -495,7 +515,7 @@ export default function MaterialPreviewModal({
)
) : (
<div className={cn(
'w-full h-64 rounded-[32px] flex items-center justify-center border border-dashed',
'w-full h-64 rounded-2xl flex items-center justify-center border border-dashed',
bgColor,
borderColor,
textSecondary
@ -626,7 +646,7 @@ export default function MaterialPreviewModal({
{/* 全图查看模态框 */}
{showFullImage && fullImageUrl && (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-md"
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => {
setShowFullImage(false)
setFullImageUrl(null)
@ -637,7 +657,7 @@ export default function MaterialPreviewModal({
<img
src={fullImageUrl || ''}
alt="Full preview"
className="max-w-full max-h-[95vh] object-contain rounded-lg"
className="max-w-full max-h-[95vh] object-contain rounded-xl"
onClick={(e) => e.stopPropagation()}
/>
<button
@ -658,4 +678,3 @@ export default function MaterialPreviewModal({
</Modal>
)
}

View File

@ -13,11 +13,12 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import Image from 'next/image'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import Modal from '@/components/ui/Modal'
import { parseAudioFile, isAudioFile, AUDIO_ACCEPT_TYPES, type AudioMetadataBrowser } from '@/lib/audio-parser-browser'
import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from '@/lib/field-conditions'
interface UploadModalProps {
onClose: () => void
@ -47,6 +48,9 @@ const normalizeMaterialKind = (value?: string | null): MaterialKind => {
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const styles = useThemeStyles()
const modalControlClass =
'w-full px-4 py-2.5 text-sm rounded-lg outline-none transition-colors border bg-white dark:bg-slate-800 text-slate-900 dark:text-white border-slate-300 dark:border-slate-700 focus-visible:ring-2 focus-visible:ring-slate-200 dark:focus-visible:ring-slate-700 focus-visible:border-slate-900 dark:focus-visible:border-slate-500 placeholder:text-slate-400 dark:placeholder:text-slate-500'
const requiredControlClass = 'bg-slate-50 dark:bg-slate-800 border-slate-400 dark:border-slate-600'
const [previewImages, setPreviewImages] = useState<File[]>([]) // 多预览图(最多5张)
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 多预览图预览URLs
const [displayImage, setDisplayImage] = useState<File | null>(null) // PSD展示图片
@ -414,11 +418,24 @@ const isVideoFile = (file: File) => {
const response = await fetch('/api/upload-templates')
if (response.ok) {
const data = await response.json()
setTemplates(data || [])
console.log('[UploadModal] Templates loaded:', data?.length || 0)
const loadedTemplates = Array.isArray(data) ? data : []
setTemplates(loadedTemplates)
console.log('[UploadModal] Templates loaded:', loadedTemplates.length)
// 如果有模板且只有一个,自动选择
if (data && data.length === 1) {
handleTemplateChange(data[0].id)
if (loadedTemplates.length === 1) {
const template = loadedTemplates[0]
setSelectedTemplateId(template.id)
setSelectedTemplate(template)
const initialValues: Record<string, any> = {}
template.fields.forEach((field: any) => {
if (field.type === 'checkbox') {
initialValues[field.key] = false
} else {
initialValues[field.key] = ''
}
})
setTemplateFieldValues(initialValues)
setMaterialType(template.materialType || '其他')
}
} else {
const errorData = await response.json().catch(() => ({ error: '未知错误' }))
@ -475,6 +492,12 @@ const isVideoFile = (file: File) => {
}
}
const buildTemplateFieldsPayload = useCallback(() => {
if (!selectedTemplate?.fields?.length) return {}
return filterVisibleFieldValues(selectedTemplate.fields, templateFieldValues)
}, [selectedTemplate, templateFieldValues])
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
@ -865,11 +888,11 @@ const isVideoFile = (file: File) => {
const submittingIsVideo = submittingMaterialKind === 'VIDEO'
// 验证模板必填字段
if (selectedTemplate && selectedTemplate.fields) {
for (const field of selectedTemplate.fields) {
if (visibleTemplateFields.length > 0) {
for (const field of visibleTemplateFields) {
if (field.required) {
const value = templateFieldValues[field.key]
if (!value || (typeof value === 'string' && value.trim() === '')) {
if (!hasFieldValue(value, field.type)) {
setError(`请填写必填字段:${field.label}`)
return
}
@ -911,7 +934,7 @@ const isVideoFile = (file: File) => {
// 视频素材的封面图是可选的(如果没有上传,系统会自动从视频中截取)
// 音频素材的预览图也是可选的(使用占位符)
// 对于非视频、非音频、非PSD类型,必须有预览图
if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== 'PSD' && previewImages.length === 0) {
if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== 'PSD' && currentMaterialType !== '材质' && previewImages.length === 0) {
setError('请上传预览图')
return
}
@ -970,6 +993,7 @@ const isVideoFile = (file: File) => {
formData.append('colorMode', templateFieldValues.colorMode || '')
formData.append('materialKind', submittingMaterialKind)
formData.append('templateId', selectedTemplateId)
formData.append('templateFields', JSON.stringify(buildTemplateFieldsPayload()))
// 提交作者名称(如果模板字段中有的话)
if (templateFieldValues.authorName) {
formData.append('authorName', templateFieldValues.authorName)
@ -1077,7 +1101,7 @@ const isVideoFile = (file: File) => {
// 如果上传进度已达到100%,可能实际已成功
if (uploadProgress === 100 && isConnectionReset) {
errorMsg = '⚠️ 上传可能已完成!\n\n由于网络原因,无法确认最终状态。\n\n请执行以下操作:\n1. 刷新页面查看文件是否已上传\n2. 如果文件已存在,说明上传成功\n3. 如果未看到文件,请重新上传\n\n提示:使用内网地址上传更稳定'
errorMsg = '上传可能已完成。\n\n由于网络原因,无法确认最终状态。\n\n请执行以下操作:\n1. 刷新页面查看文件是否已上传\n2. 如果文件已存在,说明上传成功\n3. 如果未看到文件,请重新上传\n\n提示:使用内网地址上传更稳定'
// 3秒后自动关闭弹窗并刷新
setTimeout(() => {
onClose()
@ -1132,11 +1156,19 @@ const isVideoFile = (file: File) => {
const currentMaterialKind = normalizeMaterialKind(
selectedTemplate?.materialKind || (resolvedMaterialType === '视频' ? 'VIDEO' : resolvedMaterialType === '音频' ? 'AUDIO' : 'IMAGE')
)
const visibleTemplateFields = useMemo(() => {
if (!selectedTemplate?.fields?.length) return []
return getVisibleTemplateFields(selectedTemplate.fields, templateFieldValues)
}, [selectedTemplate, templateFieldValues])
const visibleTemplateFieldKeys = useMemo(
() => new Set(visibleTemplateFields.map((field: any) => field.key)),
[visibleTemplateFields]
)
const isVideoTemplate = currentMaterialKind === 'VIDEO'
const isAudioTemplate = currentMaterialKind === 'AUDIO'
// 视频模板和音频模板的预览图是可选的
// 只有非视频、非音频、非PSD类型才需要必填预览图
const previewRequiredInUi = !isVideoTemplate && !isAudioTemplate && resolvedMaterialType !== 'PSD'
const previewRequiredInUi = !isVideoTemplate && !isAudioTemplate && resolvedMaterialType !== 'PSD' && resolvedMaterialType !== '材质'
const isPostProcessing = uploading && uploadProgress === 100 && !uploadComplete
const postProcessingMessage =
pendingMaterialType === 'PSD'
@ -1177,7 +1209,7 @@ const isVideoFile = (file: File) => {
<strong>上传进度:</strong>{savedUploadData.uploadProgress || 0}%
</p>
<p className="text-xs text-yellow-600 dark:text-yellow-400">
⚠️ 文件需要重新选择,但表单内容已保存
文件需要重新选择,但表单内容已保存
</p>
</div>
<div className="flex gap-3 justify-end">
@ -1214,23 +1246,23 @@ const isVideoFile = (file: File) => {
>
<div
className={cn(
'rounded-apple max-h-[80vh] shadow-apple-lg flex flex-col relative w-full max-w-2xl mx-auto',
'rounded-xl max-h-[80vh] shadow-lg flex flex-col relative w-full max-w-2xl mx-auto',
styles.cardStyles.base
)}
style={{ overflow: 'hidden' }}
>
<div className="absolute top-0 left-0 right-0 px-6 py-2.5 border-b border-white/20 dark:border-slate-700/30 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl z-10 rounded-t-apple">
<div className="flex-shrink-0 px-6 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 z-10">
<div className="flex items-center justify-between">
<div className="flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-white">上传素材</h2>
<div className="space-y-0 mt-1">
<p className="text-[10px] text-slate-500 dark:text-slate-400">
💡 提示:可拖拽文件到对应的虚线框内上传
提示:可拖拽文件到对应区域上传
</p>
{uploading && (
<p className="text-[10px] text-blue-600 dark:text-blue-400">
💾 上传进度已自动保存,关闭网页后可恢复
上传进度已自动保存,关闭网页后可恢复
</p>
)}
</div>
@ -1239,14 +1271,14 @@ const isVideoFile = (file: File) => {
<button
type="button"
onClick={handleCancelUpload}
className="px-4 py-2 text-base font-medium rounded-apple border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
className="px-4 py-2 text-base font-medium rounded-lg border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
form="upload-form"
className="px-4 py-2 text-base font-medium rounded-apple bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 text-base font-medium rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={uploading}
>
{submitButtonLabel}
@ -1256,8 +1288,8 @@ const isVideoFile = (file: File) => {
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="pt-[72px] sm:pt-[80px]">
<form id="upload-form" onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<form id="upload-form" onSubmit={handleSubmit} className="p-6 space-y-6" noValidate>
{/* 素材模板 - 第一栏,让用户先选择模板 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
@ -1268,7 +1300,7 @@ const isVideoFile = (file: File) => {
value={selectedTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
className={cn(
'w-full px-4 py-2 rounded-apple border outline-none',
'w-full px-4 py-2 rounded-lg border outline-none',
styles.inputStyles.base
)}
required
@ -1317,7 +1349,7 @@ const isVideoFile = (file: File) => {
</label>
<div
ref={previewDropZoneRef}
className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer"
className="border border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-colors hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 cursor-pointer"
onDragEnter={handlePreviewDragEnter}
onDragLeave={handlePreviewDragLeave}
onDragOver={handlePreviewDragOver}
@ -1378,7 +1410,7 @@ const isVideoFile = (file: File) => {
className={cn(
'relative group cursor-grab active:cursor-grabbing',
draggingPreviewIndex === index &&
'ring-2 ring-indigo-500 rounded-xl'
'ring-2 ring-slate-500 rounded-xl'
)}
>
<div className="relative w-full aspect-square rounded-xl overflow-hidden border-2 border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800">
@ -1449,7 +1481,7 @@ const isVideoFile = (file: File) => {
<div
ref={displayImageDropZoneRef}
className={cn(
'border-2 border-dashed rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer',
'border border-dashed rounded-2xl p-4 transition-colors hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 cursor-pointer',
isDisplayImageDragging ? 'border-blue-500 bg-blue-50/50 dark:bg-blue-900/20' : 'border-slate-300/60 dark:border-slate-600/60'
)}
onDragEnter={handleDisplayImageDragEnter}
@ -1464,7 +1496,7 @@ const isVideoFile = (file: File) => {
<Image src={displayImageUrl} alt="PSD 展示图" fill unoptimized className="object-cover" />
</div>
) : (
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<svg className="w-10 h-10 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
@ -1480,7 +1512,7 @@ const isVideoFile = (file: File) => {
/>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传 PSD 展示图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">建议上传详情页第一屏,支持 JPG/PNG/WebP</p>
<p className="text-[10px] text-blue-600 dark:text-blue-400 mt-1">💡 第一张图会自动作为预览图,超高图片会自动切割</p>
<p className="text-[10px] text-blue-600 dark:text-blue-400 mt-1">第一张图会自动作为预览图,超高图片会自动切割</p>
</div>
</div>
</div>
@ -1496,13 +1528,13 @@ const isVideoFile = (file: File) => {
return <span className="text-red-500">*</span>
}
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特' ? '(可选)' : <span className="text-red-500">*</span>
return currentMaterialType === '模特' || currentMaterialType === '材质' ? '(可选)' : <span className="text-red-500">*</span>
})()}
{isParsingAudio && <span className="ml-2 text-blue-500 text-[10px]">正在解析音频信息...</span>}
</label>
<div
ref={dropZoneRef}
className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer"
className="border border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-colors hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 cursor-pointer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@ -1588,7 +1620,7 @@ const isVideoFile = (file: File) => {
{/* Error Message - 移到素材文件上传区域下方 */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple mt-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mt-4">
{error}
</div>
)}
@ -1602,9 +1634,10 @@ const isVideoFile = (file: File) => {
模板字段
</h3>
<div className="space-y-4">
{selectedTemplate.fields.map((field: any) => {
{visibleTemplateFields.map((field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
const fieldInputId = `upload-template-field-${field.id || field.key}`
return (
<div
@ -1613,17 +1646,21 @@ const isVideoFile = (file: File) => {
>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">
<label
htmlFor={field.type === 'radio' ? undefined : fieldInputId}
className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight"
>
{field.label}
</label>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
<span className="inline-flex items-center px-2 py-0.5 rounded-md border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-[11px] font-medium text-slate-600 dark:text-slate-300">
必填
</span>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
id={fieldInputId}
type="text"
value={fieldValue}
onChange={(e) => {
@ -1642,15 +1679,14 @@ const isVideoFile = (file: File) => {
: `请输入${field.label || field.key}`
}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80',
modalControlClass,
field.required ? requiredControlClass : null,
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
@ -1662,14 +1698,14 @@ const isVideoFile = (file: File) => {
placeholder={`请输入${field.label || field.key}`}
rows={2}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 resize-none placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
modalControlClass,
'resize-none',
field.required ? requiredControlClass : null
)}
/>
) : field.type === 'number' ? (
<input
id={fieldInputId}
type="number"
value={fieldValue}
onChange={(e) => {
@ -1681,14 +1717,13 @@ const isVideoFile = (file: File) => {
required={field.required}
placeholder={`请输入${field.label || field.key}`}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
modalControlClass,
field.required ? requiredControlClass : null
)}
/>
) : field.type === 'select' ? (
<select
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
@ -1698,10 +1733,8 @@ const isVideoFile = (file: File) => {
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
modalControlClass,
field.required ? requiredControlClass : null
)}
>
<option value="">请选择</option>
@ -1737,6 +1770,7 @@ const isVideoFile = (file: File) => {
) : field.type === 'checkbox' ? (
<label className="flex items-center space-x-2">
<input
id={fieldInputId}
type="checkbox"
checked={fieldValue || false}
onChange={(e) => {
@ -1790,6 +1824,7 @@ const isVideoFile = (file: File) => {
</div>
) : field.type === 'date' ? (
<input
id={fieldInputId}
type="date"
value={fieldValue}
onChange={(e) => {
@ -1800,10 +1835,8 @@ const isVideoFile = (file: File) => {
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
modalControlClass,
field.required ? requiredControlClass : null
)}
/>
) : null}

View File

@ -71,6 +71,26 @@ describe('EditMaterialModal 组件', () => {
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/admin/upload-templates/template-1')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: 'template-1',
name: '测试模板',
description: '测试模板描述',
materialType: '3DMAX',
fields: [
{
id: 'field-1',
key: 'customField',
label: '自定义字段',
type: 'text',
required: false,
},
],
}),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
@ -312,6 +332,476 @@ describe('EditMaterialModal 组件', () => {
expect(updateCalls.length).toBeGreaterThan(0)
})
})
it('有 templateId 时应优先从普通模板列表加载并提交模板字段', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([
{
id: 'template-1',
name: '测试模板',
description: '测试模板描述',
materialType: '3DMAX',
status: 'active',
fields: [
{
id: 'field-1',
key: 'customField',
label: '自定义字段',
type: 'text',
required: false,
},
],
},
]),
})
}
if (url.includes('/api/admin/upload-templates/template-1')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: 'template-1',
name: '测试模板',
description: '测试模板描述',
materialType: '3DMAX',
fields: [
{
id: 'field-1',
key: 'customField',
label: '自定义字段',
type: 'text',
required: false,
},
],
}),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={{
...baseMaterial,
templateId: 'template-1',
templateFields: {
customField: '已有自定义值',
},
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
await waitFor(() => {
expect(screen.getByLabelText('自定义字段')).toBeInTheDocument()
})
expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates')
expect(mockFetch).not.toHaveBeenCalledWith('/api/admin/upload-templates/template-1')
fireEvent.click(screen.getByText('保存修改'))
await waitFor(() => {
const updateCall = mockFetch.mock.calls.find(
(call: any[]) => call[0].includes('/api/materials/update')
)
expect(updateCall).toBeTruthy()
const formData = updateCall?.[1]?.body as FormData
expect(formData.get('templateId')).toBe('template-1')
expect(formData.get('templateFields')).toContain('customField')
expect(formData.get('templateFields')).toContain('已有自定义值')
})
})
it('普通模板列表找不到模板时才回退请求管理员模板详情', async () => {
render(
<EditMaterialModal
material={{
...baseMaterial,
templateId: 'template-1',
templateFields: {
customField: '已有自定义值',
},
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates')
expect(mockFetch).toHaveBeenCalledWith('/api/admin/upload-templates/template-1')
})
expect(screen.getByLabelText('自定义字段')).toBeInTheDocument()
})
it('普通模板列表和管理员模板详情都不可用时应降级到基础字段编辑', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/admin/upload-templates/template-1')) {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ error: '无权限' }),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={{
...baseMaterial,
templateId: 'template-1',
templateFields: {
customField: '已有自定义值',
},
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
expect(await screen.findByText('未找到对应的上传模板,请使用基础字段编辑')).toBeInTheDocument()
expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates')
expect(mockFetch).toHaveBeenCalledWith('/api/admin/upload-templates/template-1')
expect(screen.queryByLabelText('自定义字段')).not.toBeInTheDocument()
})
it('保存接口返回无权限时应把后端错误提示展示给用户', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ error: '您没有权限编辑此素材' }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={baseMaterial}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/upload-templates')
})
fireEvent.click(screen.getByText('保存修改'))
expect(await screen.findByText('您没有权限编辑此素材')).toBeInTheDocument()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it('必填 checkbox 未勾选也应视为已填写并提交 false', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([
{
id: 'template-1',
name: '勾选模板',
description: null,
materialType: '3DMAX',
status: 'active',
fields: [
{
id: 'field-product',
key: 'productModel',
label: '产品型号',
type: 'text',
required: true,
},
{
id: 'field-checkbox',
key: 'allowUse',
label: '允许使用',
type: 'checkbox',
required: true,
},
],
},
]),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={{
...baseMaterial,
templateId: 'template-1',
templateFields: {
allowUse: false,
},
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
expect(await screen.findByLabelText('允许使用')).not.toBeChecked()
fireEvent.click(screen.getByText('保存修改'))
await waitFor(() => {
const updateCall = mockFetch.mock.calls.find(
(call: any[]) => call[0].includes('/api/materials/update')
)
expect(updateCall).toBeTruthy()
const formData = updateCall?.[1]?.body as FormData
const payload = JSON.parse(String(formData.get('templateFields')))
expect(payload.allowUse).toBe(false)
})
expect(screen.queryByText('请填写必填字段:允许使用')).not.toBeInTheDocument()
})
it('条件不满足时应隐藏字段且不提交隐藏字段值', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/admin/upload-templates/template-1')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: 'template-1',
name: '条件模板',
description: '测试条件显示',
materialType: '3DMAX',
fields: [
{
id: 'field-product',
key: 'productModel',
label: '产品型号',
type: 'text',
required: true,
},
{
id: 'field-conditional',
key: 'conditionalCode',
label: '条件编码',
type: 'text',
required: true,
conditions: JSON.stringify({
logic: 'AND',
conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }],
}),
},
],
}),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={{
...baseMaterial,
productModel: '普通产品',
templateId: 'template-1',
templateFields: {
conditionalCode: '隐藏旧值',
},
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
await waitFor(() => {
expect(screen.getByLabelText('产品型号')).toBeInTheDocument()
})
expect(screen.queryByLabelText('条件编码')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('保存修改'))
await waitFor(() => {
const updateCall = mockFetch.mock.calls.find(
(call: any[]) => call[0].includes('/api/materials/update')
)
expect(updateCall).toBeTruthy()
const formData = updateCall?.[1]?.body as FormData
const payload = JSON.parse(String(formData.get('templateFields')))
expect(payload.productModel).toBe('普通产品')
expect(payload.conditionalCode).toBeUndefined()
})
})
it('依赖字段变化后应显示条件字段并参与必填校验', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/template-categories')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
if (url.includes('/api/admin/upload-templates/template-1')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: 'template-1',
name: '条件模板',
description: '测试条件显示',
materialType: '3DMAX',
fields: [
{
id: 'field-product',
key: 'productModel',
label: '产品型号',
type: 'text',
required: true,
},
{
id: 'field-conditional',
key: 'conditionalCode',
label: '条件编码',
type: 'text',
required: true,
conditions: JSON.stringify({
logic: 'AND',
conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }],
}),
},
],
}),
})
}
if (url.includes('/api/materials/update')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(
<EditMaterialModal
material={{
...baseMaterial,
productModel: '普通产品',
templateId: 'template-1',
}}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
isOpen={true}
/>
)
const productInput = await screen.findByLabelText('产品型号')
fireEvent.change(productInput, { target: { value: '需要编码' } })
await waitFor(() => {
expect(screen.getByLabelText('条件编码')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('保存修改'))
expect(await screen.findByText('请填写必填字段:条件编码')).toBeInTheDocument()
expect(
mockFetch.mock.calls.some((call: any[]) => call[0].includes('/api/materials/update'))
).toBe(false)
})
})
describe('边界情况测试', () => {

View File

@ -0,0 +1,247 @@
/**
* UploadModal 条件字段测试
* @jest-environment jsdom
*/
import React from 'react'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import '@testing-library/jest-dom'
jest.mock('next/image', () => ({
__esModule: true,
default: (props: any) => {
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
return <img {...props} />
},
}))
jest.mock('@/lib/audio-parser-browser', () => ({
parseAudioFile: jest.fn(),
isAudioFile: jest.fn(() => false),
AUDIO_ACCEPT_TYPES: 'audio/*',
}))
import UploadModal from '../UploadModal'
const mockFetch = jest.fn()
global.fetch = mockFetch
global.URL.createObjectURL = jest.fn(() => 'blob:test-url')
global.URL.revokeObjectURL = jest.fn()
const materialTemplate = {
id: 'template-1',
name: '条件材质模板',
description: null,
materialType: '材质',
materialKind: 'IMAGE',
status: 'active',
isBuiltin: false,
fields: [
{
id: 'field-product',
key: 'productModel',
label: '材质名称',
type: 'text',
required: true,
},
{
id: 'field-conditional',
key: 'conditionalCode',
label: '条件编码',
type: 'text',
required: true,
conditions: JSON.stringify({
logic: 'AND',
conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }],
}),
},
],
}
const checkboxTemplate = {
...materialTemplate,
fields: [
{
id: 'field-product',
key: 'productModel',
label: '材质名称',
type: 'text',
required: true,
},
{
id: 'field-checkbox',
key: 'allowUse',
label: '允许使用',
type: 'checkbox',
required: true,
},
],
}
class MockXMLHttpRequest {
static instances: MockXMLHttpRequest[] = []
upload = {
addEventListener: jest.fn(),
}
timeout = 0
status = 201
responseText = '{"success":true}'
method = ''
url = ''
body: FormData | null = null
private listeners: Record<string, Array<() => void>> = {}
constructor() {
MockXMLHttpRequest.instances.push(this)
}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.body = body
this.listeners.load?.forEach((listener) => listener())
}
abort() {
this.listeners.abort?.forEach((listener) => listener())
}
addEventListener(event: string, listener: () => void) {
this.listeners[event] = this.listeners[event] || []
this.listeners[event].push(listener)
}
}
describe('UploadModal 条件字段', () => {
const onClose = jest.fn()
const onSuccess = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
MockXMLHttpRequest.instances = []
;(global as any).XMLHttpRequest = MockXMLHttpRequest
window.localStorage.clear()
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([materialTemplate]),
})
}
if (
url.includes('/api/template-categories') ||
url.includes('/api/categories') ||
url.includes('/api/scenes') ||
url.includes('/api/platforms')
) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
})
it('条件不满足时应隐藏字段且不提交隐藏字段', async () => {
render(<UploadModal onClose={onClose} onSuccess={onSuccess} />)
await waitFor(() => {
expect(screen.getByLabelText('材质名称')).toBeInTheDocument()
})
expect(screen.queryByLabelText('条件编码')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('材质名称'), {
target: { value: '普通材质' },
})
await act(async () => {
fireEvent.click(screen.getByText('上传'))
})
await waitFor(() => {
expect(MockXMLHttpRequest.instances.length).toBe(1)
})
const sentBody = MockXMLHttpRequest.instances[0].body
expect(sentBody?.get('templateId')).toBe('template-1')
const templateFields = JSON.parse(String(sentBody?.get('templateFields')))
expect(templateFields.productModel).toBe('普通材质')
expect(templateFields.conditionalCode).toBeUndefined()
})
it('依赖字段变化后应显示条件字段并参与必填校验', async () => {
render(<UploadModal onClose={onClose} onSuccess={onSuccess} />)
const productInput = await screen.findByLabelText('材质名称')
fireEvent.change(productInput, { target: { value: '需要编码' } })
await waitFor(() => {
expect(screen.getByLabelText('条件编码')).toBeInTheDocument()
})
await act(async () => {
fireEvent.click(screen.getByText('上传'))
})
expect(await screen.findByText('请填写必填字段:条件编码')).toBeInTheDocument()
expect(MockXMLHttpRequest.instances.length).toBe(0)
})
it('必填 checkbox 未勾选也应视为已填写并允许提交 false', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/api/upload-templates')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([checkboxTemplate]),
})
}
if (
url.includes('/api/template-categories') ||
url.includes('/api/categories') ||
url.includes('/api/scenes') ||
url.includes('/api/platforms')
) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
}
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'Not found' }),
})
})
render(<UploadModal onClose={onClose} onSuccess={onSuccess} />)
const productInput = await screen.findByLabelText('材质名称')
fireEvent.change(productInput, { target: { value: '普通材质' } })
expect(screen.getByLabelText('允许使用')).not.toBeChecked()
await act(async () => {
fireEvent.click(screen.getByText('上传'))
})
await waitFor(() => {
expect(MockXMLHttpRequest.instances.length).toBe(1)
})
expect(screen.queryByText('请填写必填字段:允许使用')).not.toBeInTheDocument()
const sentBody = MockXMLHttpRequest.instances[0].body
const templateFields = JSON.parse(String(sentBody?.get('templateFields')))
expect(templateFields.allowUse).toBe(false)
})
})