完善模板字段前端交互并收敛预览样式
This commit is contained in:
parent
2ae7b9a067
commit
720c163a6b
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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('边界情况测试', () => {
|
||||
|
||||
247
components/__tests__/UploadModal.test.tsx
Normal file
247
components/__tests__/UploadModal.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user