580 lines
21 KiB
TypeScript
580 lines
21 KiB
TypeScript
'use client'
|
||
|
||
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: {
|
||
id: string
|
||
previewUrl: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
materialType: string
|
||
renderer: string
|
||
authorName: string
|
||
brandName?: string | null
|
||
seriesName?: string | null
|
||
skuCode?: string | null
|
||
materialTexture?: string | null
|
||
renderTime?: string | null
|
||
imageResolution?: string | null
|
||
colorMode?: string | null
|
||
templateId?: string | null
|
||
platformName?: string | null
|
||
templateFields?: Record<string, any>
|
||
// 音频相关字段
|
||
audioDuration?: string | null
|
||
audioCategory?: string | null
|
||
audioScene?: string | null
|
||
}
|
||
onClose: () => void
|
||
onSuccess: () => void
|
||
isOpen?: boolean
|
||
}
|
||
|
||
type UploadTemplateOption = {
|
||
id: string
|
||
name: string
|
||
description: string | null
|
||
materialType?: string
|
||
fields: any[]
|
||
isBuiltin?: boolean
|
||
}
|
||
|
||
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>>({})
|
||
const [selectedTemplate, setSelectedTemplate] = useState<UploadTemplateOption | null>(null)
|
||
const [templateCategoryOptions, setTemplateCategoryOptions] = useState<Record<string, string[]>>({})
|
||
const [optionsLoading, setOptionsLoading] = useState(true)
|
||
const [updating, setUpdating] = useState(false)
|
||
const [error, setError] = useState('')
|
||
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 || '',
|
||
skuCode: material.skuCode || '',
|
||
materialTexture: material.materialTexture || '',
|
||
categoryName: material.categoryName || '',
|
||
sceneName: material.sceneName || '',
|
||
platformName: material.platformName || '',
|
||
materialType: material.materialType || '3DMAX',
|
||
renderer: material.renderer || 'CR',
|
||
renderTime: material.renderTime || '',
|
||
imageResolution: material.imageResolution || '',
|
||
colorMode: material.colorMode || '',
|
||
authorName: material.authorName || '',
|
||
// 音频相关字段
|
||
audioDuration: material.audioDuration || '',
|
||
audioCategory: material.audioCategory || '',
|
||
audioScene: material.audioScene || '',
|
||
}
|
||
setTemplateFieldValues(initialValues)
|
||
}, [material])
|
||
|
||
// 加载模板和分类选项
|
||
useEffect(() => {
|
||
const loadTemplateAndOptions = async () => {
|
||
setOptionsLoading(true)
|
||
try {
|
||
// 加载模板分类选项
|
||
const categoriesResponse = await fetch('/api/template-categories', { cache: 'no-store' })
|
||
if (categoriesResponse.ok) {
|
||
const categoriesData = await categoriesResponse.json()
|
||
const map = Array.isArray(categoriesData)
|
||
? categoriesData.reduce<Record<string, string[]>>((acc, field: any) => {
|
||
if (field?.fieldKey && Array.isArray(field.options)) {
|
||
acc[field.fieldKey] = field.options
|
||
}
|
||
return acc
|
||
}, {})
|
||
: {}
|
||
setTemplateCategoryOptions(map)
|
||
}
|
||
|
||
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 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 templates = await loadPublicTemplates()
|
||
const matchingTemplate = templates.find(
|
||
(t: any) => t.materialType === material.materialType && t.status === 'active'
|
||
)
|
||
if (matchingTemplate) {
|
||
setTemplateFromData(matchingTemplate)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[EditMaterialModal] Failed to load template:', error)
|
||
} finally {
|
||
setOptionsLoading(false)
|
||
}
|
||
}
|
||
loadTemplateAndOptions()
|
||
}, [material.templateId, material.materialType])
|
||
|
||
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
if (!file.type.startsWith('image/')) {
|
||
setError('请选择图片文件')
|
||
return
|
||
}
|
||
setPreviewImage(file)
|
||
setPreviewUrl(URL.createObjectURL(file))
|
||
setError('')
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
setSuccess('')
|
||
|
||
// 验证必填字段
|
||
const requiredFields = visibleTemplateFields.filter((f: any) => f.required)
|
||
for (const field of requiredFields) {
|
||
const value = templateFieldValues[field.key]
|
||
if (!hasFieldValue(value, field.type)) {
|
||
setError(`请填写必填字段:${field.label}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('id', material.id)
|
||
if (selectedTemplate?.id) {
|
||
formData.append('templateId', selectedTemplate.id)
|
||
}
|
||
|
||
// 添加所有模板字段值
|
||
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)
|
||
}
|
||
|
||
const response = await fetch('/api/materials/update', {
|
||
method: 'PUT',
|
||
body: formData,
|
||
})
|
||
|
||
if (response.ok) {
|
||
setSuccess('素材信息已更新')
|
||
setTimeout(() => {
|
||
onSuccess()
|
||
onClose()
|
||
}, 1000)
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '更新失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Update error:', error)
|
||
setError('更新失败,请稍后再试')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
// 渲染模板字段(和UploadModal相同的逻辑)
|
||
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
|
||
key={field.id}
|
||
className={isHalfWidth ? 'w-full sm:w-1/2 inline-block sm:pr-2' : 'w-full'}
|
||
>
|
||
<div className="space-y-2.5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<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={requiredBadgeClass}>
|
||
必填
|
||
</span>
|
||
)}
|
||
</div>
|
||
{field.type === 'text' || field.type === 'uppercase' ? (
|
||
<input
|
||
id={fieldInputId}
|
||
type="text"
|
||
value={fieldValue}
|
||
onChange={(e) => {
|
||
const value = field.type === 'uppercase'
|
||
? e.target.value.toUpperCase()
|
||
: e.target.value
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
placeholder={
|
||
field.key === 'imageResolution'
|
||
? '例如:5000 * 5000'
|
||
: `请输入${field.label || field.key}`
|
||
}
|
||
className={cn(
|
||
controlClass,
|
||
field.required ? requiredControlClass : null,
|
||
field.type === 'uppercase' && 'uppercase'
|
||
)}
|
||
/>
|
||
) : field.type === 'textarea' ? (
|
||
<textarea
|
||
id={fieldInputId}
|
||
value={fieldValue}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
placeholder={`请输入${field.label || field.key}`}
|
||
rows={2}
|
||
className={cn(
|
||
controlClass,
|
||
field.required ? requiredControlClass : null,
|
||
'resize-none'
|
||
)}
|
||
/>
|
||
) : field.type === 'number' ? (
|
||
<input
|
||
id={fieldInputId}
|
||
type="number"
|
||
value={fieldValue}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
placeholder={`请输入${field.label || field.key}`}
|
||
className={cn(
|
||
controlClass,
|
||
field.required ? requiredControlClass : null
|
||
)}
|
||
/>
|
||
) : field.type === 'select' ? (
|
||
<select
|
||
id={fieldInputId}
|
||
value={fieldValue}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
className={cn(
|
||
controlClass,
|
||
field.required ? requiredControlClass : null
|
||
)}
|
||
>
|
||
<option value="">请选择</option>
|
||
{(() => {
|
||
// 如果字段勾选了 addToCategory,优先使用从模板分类API获取的选项
|
||
if (field.addToCategory && templateCategoryOptions[field.key]) {
|
||
return templateCategoryOptions[field.key].map((opt: string) => (
|
||
<option key={opt} value={opt}>
|
||
{opt}
|
||
</option>
|
||
))
|
||
}
|
||
// 否则使用模板中的 optionsStatic
|
||
if (field.optionsStatic) {
|
||
try {
|
||
const options = typeof field.optionsStatic === 'string'
|
||
? JSON.parse(field.optionsStatic)
|
||
: field.optionsStatic
|
||
return Array.isArray(options)
|
||
? options.map((opt: string) => (
|
||
<option key={opt} value={opt}>
|
||
{opt}
|
||
</option>
|
||
))
|
||
: null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
return null
|
||
})()}
|
||
</select>
|
||
) : field.type === 'checkbox' ? (
|
||
<label className="flex items-center space-x-2">
|
||
<input
|
||
id={fieldInputId}
|
||
type="checkbox"
|
||
checked={fieldValue || false}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.checked,
|
||
})
|
||
}}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||
{field.label}
|
||
</span>
|
||
</label>
|
||
) : field.type === 'radio' ? (
|
||
<div className="space-y-2">
|
||
{field.optionsStatic ? (
|
||
(() => {
|
||
try {
|
||
const options = typeof field.optionsStatic === 'string'
|
||
? JSON.parse(field.optionsStatic)
|
||
: field.optionsStatic
|
||
return Array.isArray(options)
|
||
? options.map((opt: string) => (
|
||
<label key={opt} className="flex items-center space-x-2">
|
||
<input
|
||
type="radio"
|
||
name={field.key}
|
||
value={opt}
|
||
checked={fieldValue === opt}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||
{opt}
|
||
</span>
|
||
</label>
|
||
))
|
||
: null
|
||
} catch {
|
||
return null
|
||
}
|
||
})()
|
||
) : null}
|
||
</div>
|
||
) : field.type === 'date' ? (
|
||
<input
|
||
id={fieldInputId}
|
||
type="date"
|
||
value={fieldValue}
|
||
onChange={(e) => {
|
||
setTemplateFieldValues({
|
||
...templateFieldValues,
|
||
[field.key]: e.target.value,
|
||
})
|
||
}}
|
||
required={field.required}
|
||
className={cn(
|
||
controlClass,
|
||
field.required ? requiredControlClass : null
|
||
)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
closeOnOverlayClick={false}
|
||
size="large"
|
||
title="编辑素材"
|
||
bodyClassName="max-h-[70vh] overflow-y-auto"
|
||
>
|
||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||
{error && (
|
||
<div className={cn(
|
||
'px-4 py-3 rounded-lg border',
|
||
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||
'text-red-600 dark:text-red-400'
|
||
)}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
{success && (
|
||
<div className={cn(
|
||
'px-4 py-3 rounded-lg border',
|
||
'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800',
|
||
'text-emerald-700 dark:text-emerald-400'
|
||
)}>
|
||
{success}
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview Image - 和UploadModal相同的布局 */}
|
||
<div>
|
||
<label className={cn('block text-sm font-medium mb-2', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>
|
||
预览图
|
||
</label>
|
||
<div
|
||
onClick={() => previewInputRef.current?.click()}
|
||
className={cn(
|
||
'rounded-lg border border-dashed cursor-pointer flex flex-col sm:flex-row gap-4 items-center p-4 transition-colors',
|
||
theme === 'dark'
|
||
? 'border-slate-700 bg-slate-900 text-slate-100 hover:border-slate-500 hover:bg-slate-800/60'
|
||
: 'border-slate-300 bg-white text-slate-900 hover:border-slate-500 hover:bg-slate-50'
|
||
)}
|
||
>
|
||
<div className="relative w-28 h-28 sm:w-32 sm:h-32 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 flex-shrink-0">
|
||
<Image src={previewUrl} alt="预览图" fill unoptimized className="object-cover" />
|
||
</div>
|
||
<div className="flex-1 text-left space-y-1">
|
||
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-slate-200' : 'text-slate-800')}>
|
||
更换预览图
|
||
</p>
|
||
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
|
||
支持 JPG/PNG/WebP
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
previewInputRef.current?.click()
|
||
}}
|
||
className="inline-flex items-center text-xs font-medium text-slate-700 transition-colors hover:text-slate-900 dark:text-slate-300 dark:hover:text-white"
|
||
>
|
||
选择文件
|
||
<svg className="w-3.5 h-3.5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l3-3m-3 3l-3-3M5 20h14" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={previewInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handlePreviewChange}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
{/* 动态渲染模板字段 */}
|
||
{selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
|
||
模板字段
|
||
</h3>
|
||
<div className="space-y-4">
|
||
{visibleTemplateFields.map((field: any) => renderTemplateField(field))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : optionsLoading ? (
|
||
<div className="text-center py-8 text-slate-500 dark:text-slate-400">
|
||
加载模板中...
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-8 text-amber-500 dark:text-amber-400">
|
||
未找到对应的上传模板,请使用基础字段编辑
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div className="flex justify-end space-x-3 pt-6 pb-4 border-t border-slate-200 dark:border-slate-700 mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className={cn(styles.buttonStyles.secondary, 'min-w-[120px]')}
|
||
disabled={updating}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className={cn(styles.buttonStyles.primary, 'min-w-[140px]')}
|
||
disabled={updating}
|
||
>
|
||
{updating ? '更新中...' : '保存修改'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
)
|
||
}
|