544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect } from 'react'
|
||
import Image from 'next/image'
|
||
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, shouldPassRenderer } from '@/lib/material-validation'
|
||
|
||
interface EditMaterialModalProps {
|
||
material: {
|
||
id: string
|
||
previewUrl: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string | null
|
||
productCategoryName?: string | null
|
||
materialType: string
|
||
renderer: string
|
||
authorName: string
|
||
}
|
||
onClose: () => void
|
||
onSuccess: () => void
|
||
}
|
||
|
||
export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) {
|
||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl)
|
||
const [productModel, setProductModel] = useState(material.productModel)
|
||
const [categoryName, setCategoryName] = useState(material.categoryName)
|
||
const [sceneName, setSceneName] = useState(material.sceneName)
|
||
const [platformName, setPlatformName] = useState(material.platformName || '')
|
||
const [productCategoryName, setProductCategoryName] = useState(material.productCategoryName || '')
|
||
const [materialType, setMaterialType] = useState(material.materialType || '3D场景')
|
||
const [renderer, setRenderer] = useState(material.renderer || 'CR')
|
||
const [authorName, setAuthorName] = useState(material.authorName)
|
||
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
|
||
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
|
||
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
|
||
const [newCategoryName, setNewCategoryName] = useState('')
|
||
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
|
||
const [newSceneName, setNewSceneName] = useState('')
|
||
const [updating, setUpdating] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const previewInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
// 加载分类和场景列表
|
||
useEffect(() => {
|
||
fetchCategories()
|
||
fetchScenes()
|
||
fetchPlatforms()
|
||
fetchProductCategories()
|
||
}, [])
|
||
|
||
const fetchCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching categories:', error)
|
||
}
|
||
}
|
||
|
||
const fetchScenes = async () => {
|
||
try {
|
||
const response = await fetch('/api/scenes')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setScenes(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching scenes:', error)
|
||
}
|
||
}
|
||
|
||
const fetchPlatforms = async () => {
|
||
try {
|
||
const response = await fetch('/api/platforms')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setPlatforms(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching platforms:', error)
|
||
}
|
||
}
|
||
|
||
const fetchProductCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/product-categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setProductCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching product categories:', error)
|
||
}
|
||
}
|
||
|
||
const handleAddNewCategory = async () => {
|
||
if (!newCategoryName.trim()) {
|
||
setError('请输入一级分类名称')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/categories', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ name: newCategoryName.trim() }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const newCategory = await response.json()
|
||
await fetchCategories()
|
||
setCategoryName(newCategory.name)
|
||
setNewCategoryName('')
|
||
setShowNewCategoryInput(false)
|
||
setError('')
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '添加一级分类失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding category:', error)
|
||
const { parseApiError } = await import('@/lib/error-handler')
|
||
const { message } = parseApiError(error)
|
||
setError(message)
|
||
}
|
||
}
|
||
|
||
const handleAddNewScene = async () => {
|
||
if (!newSceneName.trim()) {
|
||
setError('请输入场景名称')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/scenes', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ name: newSceneName.trim() }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const newScene = await response.json()
|
||
await fetchScenes()
|
||
setSceneName(newScene.name)
|
||
setNewSceneName('')
|
||
setShowNewSceneInput(false)
|
||
setError('')
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '添加场景失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding scene:', error)
|
||
setError('添加场景失败')
|
||
}
|
||
}
|
||
|
||
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('')
|
||
|
||
// 基础必填字段验证
|
||
if (!productModel || !materialType || !authorName) {
|
||
setError('请填写所有必填字段')
|
||
return
|
||
}
|
||
|
||
// 使用统一的验证逻辑
|
||
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
|
||
setError('请填写所有必填字段(店铺、场景)')
|
||
return
|
||
}
|
||
|
||
if (requiresProductCategoryName(materialType) && !productCategoryName) {
|
||
setError('标准模型类型必须选择品类')
|
||
return
|
||
}
|
||
|
||
if (requiresRenderer(materialType) && !renderer) {
|
||
setError('3D场景类型必须指定渲染器')
|
||
return
|
||
}
|
||
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('id', material.id)
|
||
formData.append('productModel', productModel)
|
||
formData.append('categoryName', categoryName)
|
||
formData.append('sceneName', sceneName)
|
||
if (platformName) formData.append('platformName', platformName)
|
||
if (productCategoryName) formData.append('productCategoryName', productCategoryName)
|
||
formData.append('materialType', materialType)
|
||
// 统一处理renderer字段:根据素材类型判断是否需要传递
|
||
const rendererInfo = shouldPassRenderer(materialType, renderer)
|
||
if (rendererInfo.shouldPass) {
|
||
formData.append('renderer', rendererInfo.value)
|
||
}
|
||
formData.append('authorName', authorName)
|
||
|
||
if (previewImage) {
|
||
formData.append('previewImage', previewImage)
|
||
}
|
||
|
||
const response = await fetch('/api/materials/update', {
|
||
method: 'PUT',
|
||
body: formData,
|
||
})
|
||
|
||
if (response.ok) {
|
||
onSuccess()
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '更新失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Update error:', error)
|
||
setError('更新失败,请稍后再试')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg">
|
||
<div className="p-6 border-b border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-2xl font-bold text-gray-900">编辑素材</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview Image */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
预览图
|
||
</label>
|
||
<div
|
||
onClick={() => previewInputRef.current?.click()}
|
||
className="border-2 border-dashed border-gray-300 rounded-apple p-4 text-center cursor-pointer hover:border-primary-500 transition-colors"
|
||
>
|
||
<div className="relative w-full h-48">
|
||
<Image
|
||
src={previewUrl}
|
||
alt="预览"
|
||
fill
|
||
unoptimized
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-2">
|
||
点击更换预览图(可选)
|
||
</p>
|
||
</div>
|
||
<input
|
||
ref={previewInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handlePreviewChange}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
{/* Product Model */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
商品型号 *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(e) => {
|
||
// 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写
|
||
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
|
||
setProductModel(value)
|
||
}}
|
||
className="input-apple"
|
||
placeholder="例如:XY-2024-001"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Category Name (一级分类) */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
一级分类(使用店铺) *
|
||
</label>
|
||
|
||
{!showNewCategoryInput ? (
|
||
<div className="space-y-2">
|
||
<select
|
||
value={categoryName}
|
||
onChange={(e) => {
|
||
if (e.target.value === '__add_new__') {
|
||
setShowNewCategoryInput(true)
|
||
setCategoryName('')
|
||
} else {
|
||
setCategoryName(e.target.value)
|
||
}
|
||
}}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="">请选择一级分类</option>
|
||
{categories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
<option value="__add_new__">➕ 添加新分类</option>
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={newCategoryName}
|
||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||
className="input-apple flex-1"
|
||
placeholder="输入新的一级分类名称"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewCategory}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-apple hover:bg-blue-700 transition-colors"
|
||
>
|
||
确认
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewCategoryInput(false)
|
||
setNewCategoryName('')
|
||
setError('')
|
||
}}
|
||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-apple hover:bg-gray-200 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Scene Name (二级分类) */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
二级分类(场景名称) *
|
||
</label>
|
||
|
||
{!showNewSceneInput ? (
|
||
<div className="space-y-2">
|
||
<select
|
||
value={sceneName}
|
||
onChange={(e) => {
|
||
if (e.target.value === '__add_new__') {
|
||
setShowNewSceneInput(true)
|
||
setSceneName('')
|
||
} else {
|
||
setSceneName(e.target.value)
|
||
}
|
||
}}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="">请选择场景</option>
|
||
{scenes.map((scene) => (
|
||
<option key={scene.id} value={scene.name}>
|
||
{scene.name}
|
||
</option>
|
||
))}
|
||
<option value="__add_new__">+ 添加新场景</option>
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<div className="flex space-x-2">
|
||
<input
|
||
type="text"
|
||
value={newSceneName}
|
||
onChange={(e) => setNewSceneName(e.target.value)}
|
||
className="input-apple"
|
||
placeholder="输入新场景名称"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewScene}
|
||
className="btn-primary whitespace-nowrap"
|
||
>
|
||
确定
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewSceneInput(false)
|
||
setNewSceneName('')
|
||
setError('')
|
||
}}
|
||
className="btn-secondary whitespace-nowrap"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500">
|
||
添加后将自动加入到场景列表中
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Material Type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
素材类型 *
|
||
</label>
|
||
<select
|
||
value={materialType}
|
||
onChange={(e) => setMaterialType(e.target.value)}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="3D场景">3D场景</option>
|
||
<option value="详情页">详情页</option>
|
||
<option value="详情模板">详情模板</option>
|
||
<option value="主图模版">主图模版</option>
|
||
<option value="SKU模版">SKU模版</option>
|
||
<option value="主图视频">主图视频</option>
|
||
<option value="安装视频">安装视频</option>
|
||
<option value="种草视频">种草视频</option>
|
||
<option value="模特">模特</option>
|
||
<option value="实拍图片">实拍图片</option>
|
||
<option value="买家秀图片">买家秀图片</option>
|
||
<option value="小红书种草">小红书种草</option>
|
||
<option value="音乐">音乐</option>
|
||
<option value="C4D模型">C4D模型</option>
|
||
<option value="标准饰品">标准饰品</option>
|
||
<option value="品牌VI灵感">品牌VI灵感</option>
|
||
<option value="矢量文件">矢量文件</option>
|
||
<option value="CAD">CAD</option>
|
||
<option value="标准模型">标准模型</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Renderer (仅3D场景显示) */}
|
||
{requiresRenderer(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
渲染器 *
|
||
</label>
|
||
<select
|
||
value={renderer}
|
||
onChange={(e) => setRenderer(e.target.value)}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="CR">CR</option>
|
||
<option value="VR">VR</option>
|
||
<option value="其它">其它</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Author Name */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
作者花名 *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={authorName}
|
||
onChange={(e) => setAuthorName(e.target.value)}
|
||
className="input-apple"
|
||
placeholder="作者花名"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex justify-end space-x-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="btn-secondary"
|
||
disabled={updating}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary"
|
||
disabled={updating}
|
||
>
|
||
{updating ? '更新中...' : '保存修改'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|