flnxi-Wu/components/EditMaterialModal.tsx
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

549 lines
22 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 } from 'react'
import Image from 'next/image'
import Modal from '@/components/ui/Modal'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
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
// 音频相关字段
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 [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)
// 初始化字段值
useEffect(() => {
const initialValues: Record<string, any> = {
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)
}
// 如果有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,
})
}
} 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,
})
}
}
}
} 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 = selectedTemplate?.fields.filter((f: any) => f.required) || []
for (const field of requiredFields) {
const value = templateFieldValues[field.key]
if (!value || (typeof value === 'string' && value.trim() === '')) {
setError(`请填写必填字段:${field.label}`)
return
}
}
setUpdating(true)
try {
const formData = new FormData()
formData.append('id', material.id)
// 添加所有模板字段值
Object.entries(templateFieldValues).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, String(value))
}
})
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'
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 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>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
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(
'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',
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
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'
)}
/>
) : field.type === 'number' ? (
<input
type="number"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
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'
)}
/>
) : field.type === 'select' ? (
<select
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
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'
)}
>
<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
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
type="date"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
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'
)}
/>
) : 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">
{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">
{selectedTemplate.fields.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>
)
}