flnxi-Wu/components/EditMaterialModal.tsx

579 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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-xl 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-xl 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(
styles.inputStyles.base,
'border-2 border-dashed cursor-pointer flex flex-col sm:flex-row gap-4 items-center p-4',
'hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-slate-50/50 dark:hover:bg-slate-800/40'
)}
>
<div className="relative w-28 h-28 sm:w-32 sm:h-32 rounded-2xl 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')}>
推荐上传 600px 以上的 JPG/PNG/WebP,系统会自动等比压缩
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="inline-flex items-center text-xs font-medium text-blue-600 hover:text-blue-500 dark:text-blue-300 dark:hover:text-blue-200"
>
更换预览图
<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>
)
}