504 lines
20 KiB
TypeScript
504 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import SearchableUserSelect from '@/components/SearchableUserSelect'
|
||
import {
|
||
canManuallyReplacePreview,
|
||
getSelectableMaterialTypeOptions,
|
||
supportsIsFullSet,
|
||
supportsTaskNo,
|
||
} from '@/lib/material-type-options'
|
||
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
|
||
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
|
||
import {
|
||
requiresCategoryAndScene,
|
||
requiresProductCategoryName,
|
||
requiresRenderer,
|
||
} from '@/lib/material-validation'
|
||
|
||
interface EditMaterialModalProps {
|
||
material: {
|
||
id: string
|
||
previewUrl: string
|
||
productModel: string
|
||
reportTitle?: string | null
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string | null
|
||
productCategoryName?: string | null
|
||
materialType: string
|
||
renderer: string
|
||
authorName: string
|
||
userId: string
|
||
taskNo?: string | null
|
||
taskDetailSourceType?: string | null
|
||
taskStatus?: string | null
|
||
taskFetchedAt?: string | null
|
||
isFullSet?: boolean
|
||
taskDetailRequesterUserId?: string | null
|
||
taskDetailRequesterName?: string | null
|
||
}
|
||
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 [reportTitle, setReportTitle] = useState(material.reportTitle || '')
|
||
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 [userId, setUserId] = useState(material.userId)
|
||
const [taskNo, setTaskNo] = useState(material.taskNo || '')
|
||
const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '')
|
||
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '')
|
||
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '')
|
||
const [taskStatus] = useState(material.taskStatus || '')
|
||
const [taskFetchedAt] = useState(material.taskFetchedAt || '')
|
||
const [isFullSet] = useState(Boolean(material.isFullSet))
|
||
const [users, setUsers] = useState<Array<{ id: string; displayName: string; username: string }>>([])
|
||
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 [updating, setUpdating] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const showCategoryScene = requiresCategoryAndScene(materialType)
|
||
const showRenderer = requiresRenderer(materialType)
|
||
const showTaskNo = supportsTaskNo(materialType)
|
||
const showIsFullSet = supportsIsFullSet(materialType)
|
||
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
|
||
const isCertificateReport = isCertificateReportMaterialType(materialType)
|
||
const isFetchedTask = taskStatus === 'fetched'
|
||
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
|
||
const canKeepRequesterBlank =
|
||
material.materialType === '任务-详情' && !material.taskDetailRequesterUserId
|
||
|
||
useEffect(() => {
|
||
void fetchSelectOptions()
|
||
}, [])
|
||
|
||
const fetchSelectOptions = async () => {
|
||
try {
|
||
const [categoriesResponse, scenesResponse, platformsResponse, productCategoriesResponse, usersResponse] = await Promise.all([
|
||
fetch('/api/categories'),
|
||
fetch('/api/scenes'),
|
||
fetch('/api/platforms'),
|
||
fetch('/api/product-categories'),
|
||
fetch('/api/users/list'),
|
||
])
|
||
|
||
if (categoriesResponse.ok) {
|
||
setCategories(await categoriesResponse.json())
|
||
}
|
||
if (scenesResponse.ok) {
|
||
setScenes(await scenesResponse.json())
|
||
}
|
||
if (platformsResponse.ok) {
|
||
setPlatforms(await platformsResponse.json())
|
||
}
|
||
if (productCategoriesResponse.ok) {
|
||
setProductCategories(await productCategoriesResponse.json())
|
||
}
|
||
if (usersResponse.ok) {
|
||
const activeUsers = await usersResponse.json()
|
||
const normalizedUsers = Array.isArray(activeUsers) ? activeUsers : []
|
||
if (
|
||
material.taskDetailRequesterUserId &&
|
||
!normalizedUsers.some((user) => user.id === material.taskDetailRequesterUserId)
|
||
) {
|
||
normalizedUsers.push({
|
||
id: material.taskDetailRequesterUserId,
|
||
username: '已停用',
|
||
displayName: material.taskDetailRequesterName || '原需求运营',
|
||
})
|
||
}
|
||
setUsers(normalizedUsers)
|
||
}
|
||
} catch (fetchError) {
|
||
console.error('加载编辑弹窗数据失败:', fetchError)
|
||
}
|
||
}
|
||
|
||
const handlePreviewChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0]
|
||
if (!file) {
|
||
return
|
||
}
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
setError('请选择图片文件')
|
||
return
|
||
}
|
||
|
||
setPreviewImage(file)
|
||
setPreviewUrl(URL.createObjectURL(file))
|
||
setError('')
|
||
}
|
||
|
||
const handleSubmit = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
setError('')
|
||
|
||
if (!materialType || !authorName) {
|
||
setError('请填写所有必填字段')
|
||
return
|
||
}
|
||
|
||
if (isCertificateReport) {
|
||
if (!productModel.trim() && !reportTitle.trim()) {
|
||
setError('SPU 和 报告名称/检测对象 至少填写一个')
|
||
return
|
||
}
|
||
} else if (!productModel) {
|
||
setError('请填写所有必填字段')
|
||
return
|
||
}
|
||
|
||
if (showCategoryScene && (!categoryName || !sceneName)) {
|
||
setError('请填写店铺和场景')
|
||
return
|
||
}
|
||
|
||
if (requiresProductCategoryName(materialType) && !productCategoryName) {
|
||
setError('标准模型类型必须选择品类')
|
||
return
|
||
}
|
||
|
||
if (showRenderer && !renderer) {
|
||
setError('3D场景类型必须指定渲染器')
|
||
return
|
||
}
|
||
|
||
if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) {
|
||
setError('请选择需求运营人员')
|
||
return
|
||
}
|
||
|
||
if (isFetchedTask) {
|
||
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
}
|
||
|
||
setUpdating(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('id', material.id)
|
||
formData.append('productModel', productModel)
|
||
formData.append('reportTitle', reportTitle)
|
||
formData.append('categoryName', categoryName)
|
||
formData.append('sceneName', sceneName)
|
||
formData.append('platformName', platformName)
|
||
formData.append('productCategoryName', productCategoryName)
|
||
formData.append('materialType', materialType)
|
||
formData.append('renderer', renderer)
|
||
formData.append('authorName', authorName)
|
||
formData.append('userId', userId)
|
||
formData.append('taskNo', showTaskNo ? taskNo : '')
|
||
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
|
||
formData.append(
|
||
'taskDetailRequesterUserId',
|
||
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
|
||
)
|
||
|
||
if (previewImage && allowPreviewReplacement) {
|
||
formData.append('previewImage', previewImage)
|
||
}
|
||
|
||
const response = await fetch('/api/materials/update', {
|
||
method: 'PUT',
|
||
body: formData,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const data = await response.json()
|
||
setError(data.error || '更新失败')
|
||
return
|
||
}
|
||
|
||
onSuccess()
|
||
} catch (submitError) {
|
||
console.error('更新失败:', submitError)
|
||
setError('更新失败,请稍后再试')
|
||
} finally {
|
||
setUpdating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-apple bg-white shadow-apple-lg dark:bg-gray-800">
|
||
<div className="border-b border-gray-200 p-6 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">编辑素材</h2>
|
||
<button onClick={onClose} className="text-2xl leading-none text-gray-400 hover:text-gray-600">
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||
{isFetchedTask && (
|
||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||
这条任务素材已被外部系统获取。
|
||
{taskFetchedAt ? ` 获取时间:${new Date(taskFetchedAt).toLocaleString('zh-CN')}` : ''}
|
||
</div>
|
||
)}
|
||
|
||
{!allowPreviewReplacement && (
|
||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||
当前素材类型的预览以系统生成结果为准,这里不支持手工替换封面。
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-apple border border-red-200 bg-red-50 px-4 py-3 text-red-600">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{allowPreviewReplacement && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">预览图</label>
|
||
<div className="space-y-3">
|
||
{previewUrl ? (
|
||
<div className="overflow-hidden rounded-apple border border-gray-200 bg-gray-50 p-3">
|
||
<img src={previewUrl} alt="预览图" className="mx-auto max-h-72 rounded object-contain" />
|
||
</div>
|
||
) : (
|
||
<div className="rounded-apple border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500">
|
||
当前没有预览图
|
||
</div>
|
||
)}
|
||
<input type="file" accept="image/*" onChange={handlePreviewChange} className="block w-full text-sm" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||
{isCertificateReport ? 'SPU' : '产品型号 *'}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(event) => setProductModel(event.target.value)}
|
||
className="input-apple"
|
||
required={!isCertificateReport}
|
||
/>
|
||
</div>
|
||
|
||
{isCertificateReport && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">报告名称 / 检测对象</label>
|
||
<input
|
||
type="text"
|
||
value={reportTitle}
|
||
onChange={(event) => setReportTitle(event.target.value)}
|
||
className="input-apple"
|
||
placeholder="SPU 和这个字段至少填写一个"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">素材类型 *</label>
|
||
<select value={materialType} onChange={(event) => setMaterialType(event.target.value)} className="input-apple" required>
|
||
{materialTypeOptions.map((type) => (
|
||
<option key={type} value={type}>
|
||
{type}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{showCategoryScene && (
|
||
<>
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">店铺 *</label>
|
||
<select value={categoryName} onChange={(event) => setCategoryName(event.target.value)} className="input-apple" required>
|
||
<option value="">请选择店铺</option>
|
||
{categories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">场景 *</label>
|
||
<select value={sceneName} onChange={(event) => setSceneName(event.target.value)} className="input-apple" required>
|
||
<option value="">请选择场景</option>
|
||
{scenes.map((scene) => (
|
||
<option key={scene.id} value={scene.name}>
|
||
{scene.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">平台分类</label>
|
||
<select value={platformName} onChange={(event) => setPlatformName(event.target.value)} className="input-apple">
|
||
<option value="">不选择</option>
|
||
{platforms.map((platform) => (
|
||
<option key={platform.id} value={platform.name}>
|
||
{platform.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||
品类{requiresProductCategoryName(materialType) ? ' *' : ''}
|
||
</label>
|
||
<select value={productCategoryName} onChange={(event) => setProductCategoryName(event.target.value)} className="input-apple">
|
||
<option value="">不选择</option>
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{showRenderer && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">渲染器 *</label>
|
||
<select value={renderer} onChange={(event) => setRenderer(event.target.value)} className="input-apple" required>
|
||
<option value="">请选择渲染器</option>
|
||
<option value="CR">CR</option>
|
||
<option value="VR">VR</option>
|
||
<option value="其它">其它</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{showTaskNo && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">任务号</label>
|
||
<input
|
||
type="text"
|
||
value={taskNo}
|
||
onChange={(event) => setTaskNo(event.target.value)}
|
||
className="input-apple"
|
||
placeholder="TASK + 12-18位数字,可带多级 -01"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{materialType === '任务-详情' && (
|
||
<>
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">出图方式</label>
|
||
<select
|
||
value={taskDetailSourceType}
|
||
onChange={(event) => setTaskDetailSourceType(event.target.value)}
|
||
className="input-apple"
|
||
>
|
||
<option value="">未设置</option>
|
||
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||
需求运营{canKeepRequesterBlank ? '' : ' *'}
|
||
</label>
|
||
<SearchableUserSelect
|
||
users={users}
|
||
value={taskDetailRequesterUserId}
|
||
onChange={(nextUserId, displayName) => {
|
||
setTaskDetailRequesterUserId(nextUserId)
|
||
setTaskDetailRequesterName(displayName)
|
||
}}
|
||
placeholder="搜索发起本次出图需求的运营人员"
|
||
required={!canKeepRequesterBlank}
|
||
clearable={canKeepRequesterBlank}
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
{canKeepRequesterBlank
|
||
? taskDetailRequesterName
|
||
? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。`
|
||
: '这是一条历史素材,原来没有需求运营数据,可暂时留空。'
|
||
: '记录发起本次出图需求的运营人员,与素材所有者可以不同。'}
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{showIsFullSet && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">整套判定</label>
|
||
<div className="rounded-apple border border-gray-300 px-4 py-3 text-sm text-gray-700">
|
||
<p>
|
||
当前结果:<span className="font-medium">{isFullSet ? '是' : '否'}</span>
|
||
</p>
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
系统会根据是否同时存在主图、SKU 图、详情图自动判定,这里不允许手工修改。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700">作者(素材所有者) *</label>
|
||
<SearchableUserSelect
|
||
users={users}
|
||
value={userId}
|
||
onChange={(nextUserId, displayName) => {
|
||
setUserId(nextUserId)
|
||
setAuthorName(displayName)
|
||
}}
|
||
placeholder="请输入或搜索用户(支持拼音)"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{(showTaskNo || showIsFullSet) && (
|
||
<div className="grid gap-4 rounded-apple border border-gray-200 bg-gray-50 p-4">
|
||
<div>
|
||
<p className="mb-1 text-sm text-gray-500">任务状态</p>
|
||
<p className="text-sm font-medium text-gray-900">{taskStatus || '未记录'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-sm text-gray-500">外部获取时间</p>
|
||
<p className="text-sm font-medium text-gray-900">
|
||
{taskFetchedAt ? new Date(taskFetchedAt).toLocaleString('zh-CN') : '未记录'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
)
|
||
}
|