gongxiangp2/components/EditMaterialModal.tsx
2026-07-24 14:47:27 +08:00

504 lines
20 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 { 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>
)
}