2026-07-24 14:47:27 +08:00

590 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 { 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>
)
}