590 lines
22 KiB
TypeScript
590 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import Header from '@/components/Header'
|
||
import SearchableUserSelect from '@/components/SearchableUserSelect'
|
||
import {
|
||
canManuallyReplacePreview,
|
||
getSelectableMaterialTypeOptions,
|
||
supportsIsFullSet,
|
||
supportsTaskNo,
|
||
} from '@/lib/material-type-options'
|
||
import {
|
||
requiresCategoryAndScene,
|
||
requiresProductCategoryName,
|
||
requiresRenderer,
|
||
} from '@/lib/material-validation'
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string | null
|
||
productCategoryName?: string | null
|
||
materialType: string
|
||
renderer: string
|
||
authorName: string
|
||
previewUrl?: string | null
|
||
taskNo?: string | null
|
||
taskStatus?: string | null
|
||
taskFetchedAt?: string | null
|
||
isFullSet?: boolean
|
||
taskDetailRequesterUserId?: string | null
|
||
taskDetailRequesterName?: string | null
|
||
}
|
||
|
||
export default function EditMaterialPage({ params }: { params: { id: string } }) {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const [loading, setLoading] = useState(true)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState('')
|
||
|
||
const [material, setMaterial] = useState<Material | null>(null)
|
||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||
const [previewUrl, setPreviewUrl] = useState('')
|
||
const [productModel, setProductModel] = useState('')
|
||
const [categoryName, setCategoryName] = useState('')
|
||
const [sceneName, setSceneName] = useState('')
|
||
const [platformName, setPlatformName] = useState('')
|
||
const [productCategoryName, setProductCategoryName] = useState('')
|
||
const [materialType, setMaterialType] = useState('')
|
||
const [renderer, setRenderer] = useState('')
|
||
const [authorName, setAuthorName] = useState('')
|
||
const [userId, setUserId] = useState('')
|
||
const [taskNo, setTaskNo] = useState('')
|
||
const [taskStatus, setTaskStatus] = useState('')
|
||
const [taskFetchedAt, setTaskFetchedAt] = useState('')
|
||
const [isFullSet, setIsFullSet] = useState(false)
|
||
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('')
|
||
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState('')
|
||
|
||
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 [users, setUsers] = useState<Array<{ id: string; username: string; displayName: string }>>([])
|
||
|
||
const showCategoryScene = requiresCategoryAndScene(materialType)
|
||
const showRenderer = requiresRenderer(materialType)
|
||
const showTaskNo = supportsTaskNo(materialType)
|
||
const showIsFullSet = supportsIsFullSet(materialType)
|
||
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
|
||
const isFetchedTask = taskStatus === 'fetched'
|
||
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
|
||
const canKeepRequesterBlank =
|
||
material?.materialType === '任务-详情' && !material.taskDetailRequesterUserId
|
||
|
||
const loadInitialData = useCallback(async () => {
|
||
try {
|
||
setLoading(true)
|
||
|
||
const [materialResponse, categoriesResponse, scenesResponse, platformsResponse, productCategoriesResponse, usersResponse] =
|
||
await Promise.all([
|
||
fetch(`/api/materials/${params.id}`),
|
||
fetch('/api/categories'),
|
||
fetch('/api/scenes'),
|
||
fetch('/api/platforms'),
|
||
fetch('/api/product-categories'),
|
||
fetch('/api/users/list'),
|
||
])
|
||
|
||
if (!materialResponse.ok) {
|
||
throw new Error('获取素材信息失败')
|
||
}
|
||
|
||
const data = await materialResponse.json()
|
||
const currentUserId = (session?.user as any)?.id
|
||
const currentUserRole = (session?.user as any)?.role
|
||
|
||
let isUserSuperAdmin = false
|
||
try {
|
||
const superAdminCheck = await fetch('/api/admin/check-super-admin')
|
||
if (superAdminCheck.ok) {
|
||
const superAdminData = await superAdminCheck.json()
|
||
isUserSuperAdmin = Boolean(superAdminData.isSuperAdmin)
|
||
}
|
||
} catch (checkError) {
|
||
console.warn('检查超级管理员状态失败:', checkError)
|
||
}
|
||
|
||
if (
|
||
data.userId !== currentUserId &&
|
||
currentUserRole !== 'ADMIN' &&
|
||
currentUserRole !== 'SYSTEM_ADMIN' &&
|
||
!isUserSuperAdmin
|
||
) {
|
||
setError('您没有权限编辑此素材')
|
||
setTimeout(() => router.push('/'), 1500)
|
||
return
|
||
}
|
||
|
||
setMaterial(data)
|
||
setPreviewUrl(data.previewUrl || '')
|
||
setProductModel(data.productModel || '')
|
||
setCategoryName(data.categoryName || '')
|
||
setSceneName(data.sceneName || '')
|
||
setPlatformName(data.platformName || '')
|
||
setProductCategoryName(data.productCategoryName || '')
|
||
setMaterialType(data.materialType || '')
|
||
setRenderer(data.renderer || '')
|
||
setAuthorName(data.authorName || '')
|
||
setUserId(data.userId || '')
|
||
setTaskNo(data.taskNo || '')
|
||
setTaskStatus(data.taskStatus || '')
|
||
setTaskFetchedAt(data.taskFetchedAt || '')
|
||
setIsFullSet(Boolean(data.isFullSet))
|
||
setTaskDetailRequesterUserId(data.taskDetailRequesterUserId || '')
|
||
setTaskDetailRequesterName(data.taskDetailRequesterName || '')
|
||
|
||
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 (
|
||
data.taskDetailRequesterUserId &&
|
||
!normalizedUsers.some((user) => user.id === data.taskDetailRequesterUserId)
|
||
) {
|
||
normalizedUsers.push({
|
||
id: data.taskDetailRequesterUserId,
|
||
username: '已停用',
|
||
displayName: data.taskDetailRequesterName || '原需求运营',
|
||
})
|
||
}
|
||
setUsers(normalizedUsers)
|
||
}
|
||
} catch (loadError: any) {
|
||
console.error('加载编辑页失败:', loadError)
|
||
const { parseApiError } = await import('@/lib/error-handler')
|
||
const { message } = parseApiError(loadError)
|
||
setError(message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [params.id, router, session])
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
|
||
if (status === 'authenticated') {
|
||
void loadInitialData()
|
||
}
|
||
}, [status, router, loadInitialData])
|
||
|
||
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('')
|
||
setSuccess('')
|
||
|
||
if (!productModel || !materialType || !authorName) {
|
||
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
|
||
}
|
||
}
|
||
|
||
setSubmitting(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('productModel', productModel)
|
||
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(
|
||
'taskDetailRequesterUserId',
|
||
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
|
||
)
|
||
|
||
if (previewImage && allowPreviewReplacement) {
|
||
formData.append('previewImage', previewImage)
|
||
}
|
||
|
||
const response = await fetch(`/api/materials/${params.id}`, {
|
||
method: 'PUT',
|
||
body: formData,
|
||
})
|
||
|
||
const data = await response.json()
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '更新失败')
|
||
}
|
||
|
||
setSuccess('素材信息更新成功')
|
||
setTimeout(() => {
|
||
router.push('/')
|
||
}, 1200)
|
||
} catch (submitError: any) {
|
||
console.error('更新失败:', submitError)
|
||
setError(submitError.message || '更新失败,请稍后重试')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (status === 'loading' || loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
<div className="mx-auto max-w-3xl px-4 py-10">
|
||
<div className="text-center text-gray-600 dark:text-gray-400">加载中...</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
<div className="mx-auto max-w-3xl px-4 py-10">
|
||
<div className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
|
||
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">编辑素材信息</h1>
|
||
|
||
{isFetchedTask && (
|
||
<div className="mb-4 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="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||
当前素材类型的预览以系统生成结果为准,这里只允许编辑元数据,不支持手工替换封面。
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-red-600">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div className="mb-4 rounded-lg border border-green-200 bg-green-50 p-4 text-green-600">
|
||
{success}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{allowPreviewReplacement && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
预览图
|
||
</label>
|
||
<div className="space-y-3">
|
||
{previewUrl ? (
|
||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
||
<img src={previewUrl} alt="预览图" className="mx-auto max-h-80 rounded-xl object-contain" />
|
||
</div>
|
||
) : (
|
||
<div className="rounded-2xl border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
||
当前没有预览图
|
||
</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 dark:text-gray-300">
|
||
产品型号 *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(event) => setProductModel(event.target.value)}
|
||
className="input-apple"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
素材类型 *
|
||
</label>
|
||
<select
|
||
value={materialType}
|
||
onChange={(event) => setMaterialType(event.target.value)}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="">请选择素材类型</option>
|
||
{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 dark:text-gray-300">
|
||
店铺 *
|
||
</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 dark:text-gray-300">
|
||
场景 *
|
||
</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 dark:text-gray-300">
|
||
平台分类
|
||
</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 dark:text-gray-300">
|
||
品类{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 dark:text-gray-300">
|
||
渲染器 *
|
||
</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 dark:text-gray-300">
|
||
任务号
|
||
</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 dark:text-gray-300">
|
||
需求运营{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 dark:text-gray-400">
|
||
{canKeepRequesterBlank
|
||
? taskDetailRequesterName
|
||
? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。`
|
||
: '这是一条历史素材,原来没有需求运营数据,可暂时留空。'
|
||
: '记录发起本次出图需求的运营人员,与素材所有者可以不同。'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{showIsFullSet && (
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
整套判定
|
||
</label>
|
||
<div className="rounded-apple border border-gray-300 px-4 py-3 text-sm text-gray-700 dark:border-gray-600 dark:text-gray-200">
|
||
<p>
|
||
当前结果:<span className="font-medium">{isFullSet ? '是' : '否'}</span>
|
||
</p>
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
系统会根据是否同时存在主图、SKU 图、详情图自动判定,这里不允许手工修改。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
作者(素材所有者) *
|
||
</label>
|
||
<SearchableUserSelect
|
||
users={users}
|
||
value={userId}
|
||
onChange={(nextUserId, displayName) => {
|
||
setUserId(nextUserId)
|
||
setAuthorName(displayName)
|
||
}}
|
||
placeholder="请输入或搜索用户(支持拼音)"
|
||
required
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
修改作者会同步变更素材归属人。
|
||
</p>
|
||
</div>
|
||
|
||
{(showTaskNo || showIsFullSet) && (
|
||
<div className="grid gap-4 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
|
||
<div>
|
||
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">任务状态</p>
|
||
<p className="text-sm font-medium text-gray-900 dark:text-white">{taskStatus || '未记录'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">外部获取时间</p>
|
||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{taskFetchedAt ? new Date(taskFetchedAt).toLocaleString('zh-CN') : '未记录'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-4 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => router.back()}
|
||
className="flex-1 rounded-apple border border-gray-300 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||
disabled={submitting}
|
||
>
|
||
取消
|
||
</button>
|
||
<button type="submit" className="btn-primary flex-1" disabled={submitting}>
|
||
{submitting ? '保存中...' : '保存修改'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|