1091 lines
40 KiB
TypeScript
1091 lines
40 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, 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 { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
|
||
import {
|
||
canManuallyReplacePreview,
|
||
getSelectableMaterialTypeOptions,
|
||
supportsIsFullSet,
|
||
supportsTaskNo,
|
||
} from '@/lib/material-type-options'
|
||
import {
|
||
requiresCategoryAndScene,
|
||
requiresProductCategoryName,
|
||
requiresRenderer,
|
||
} from '@/lib/material-validation'
|
||
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
|
||
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
|
||
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
|
||
import { parseFetchError } from '@/lib/error-handler'
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
productModel: string
|
||
reportTitle?: string | null
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string | null
|
||
productCategoryName?: string | null
|
||
materialType: string
|
||
renderer: string
|
||
authorName: string
|
||
previewUrl?: string | null
|
||
taskNo?: string | null
|
||
dingtalkApprovalNo?: string | null
|
||
taskStatus?: string | null
|
||
taskFetchedAt?: string | null
|
||
isFullSet?: boolean
|
||
taskDetailSourceType?: string | null
|
||
taskDetailRequesterUserId?: string | null
|
||
taskDetailRequesterName?: string | null
|
||
canEdit?: boolean
|
||
}
|
||
|
||
type UserOption = {
|
||
id: string
|
||
username: string
|
||
displayName: string
|
||
}
|
||
|
||
type ReferenceKey = 'categories' | 'scenes' | 'platforms' | 'productCategories' | 'users'
|
||
|
||
const REFERENCE_LABELS: Record<ReferenceKey, string> = {
|
||
categories: '店铺',
|
||
scenes: '场景',
|
||
platforms: '平台',
|
||
productCategories: '品类',
|
||
users: '人员',
|
||
}
|
||
|
||
function createFormSignature(values: Record<string, string | null>) {
|
||
return JSON.stringify(values)
|
||
}
|
||
|
||
function mergeUserOptions(primary: UserOption[], fallback: UserOption[]) {
|
||
const merged = new Map<string, UserOption>()
|
||
primary.forEach((user) => merged.set(user.id, user))
|
||
fallback.forEach((user) => {
|
||
if (user.id && !merged.has(user.id)) merged.set(user.id, user)
|
||
})
|
||
return Array.from(merged.values())
|
||
}
|
||
|
||
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 [referenceLoading, setReferenceLoading] = useState(false)
|
||
const [referenceFailures, setReferenceFailures] = useState<ReferenceKey[]>([])
|
||
const [initialFormSignature, setInitialFormSignature] = useState('')
|
||
|
||
const [material, setMaterial] = useState<Material | null>(null)
|
||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||
const [previewUrl, setPreviewUrl] = useState('')
|
||
const [productModel, setProductModel] = useState('')
|
||
const [reportTitle, setReportTitle] = 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 [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('')
|
||
const [taskStatus, setTaskStatus] = useState('')
|
||
const [taskFetchedAt, setTaskFetchedAt] = useState('')
|
||
const [isFullSet, setIsFullSet] = useState(false)
|
||
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('')
|
||
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState('')
|
||
const [taskDetailSourceType, setTaskDetailSourceType] = 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<UserOption[]>([])
|
||
const previewObjectUrlRef = useRef<string | null>(null)
|
||
const bypassUnsavedGuardRef = useRef(false)
|
||
const errorRef = useRef<HTMLDivElement>(null)
|
||
|
||
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
|
||
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
||
const currentFormSignature = useMemo(() => createFormSignature({
|
||
previewImage: previewImage
|
||
? `${previewImage.name}:${previewImage.size}:${previewImage.lastModified}`
|
||
: null,
|
||
productModel,
|
||
reportTitle,
|
||
categoryName,
|
||
sceneName,
|
||
platformName,
|
||
productCategoryName,
|
||
materialType,
|
||
renderer,
|
||
authorName,
|
||
userId,
|
||
taskNo,
|
||
dingtalkApprovalNo,
|
||
taskDetailSourceType,
|
||
taskDetailRequesterUserId,
|
||
}), [
|
||
authorName,
|
||
categoryName,
|
||
dingtalkApprovalNo,
|
||
materialType,
|
||
platformName,
|
||
previewImage,
|
||
productCategoryName,
|
||
productModel,
|
||
renderer,
|
||
reportTitle,
|
||
sceneName,
|
||
taskDetailRequesterUserId,
|
||
taskDetailSourceType,
|
||
taskNo,
|
||
userId,
|
||
])
|
||
const hasUnsavedChanges =
|
||
Boolean(material && initialFormSignature) &&
|
||
currentFormSignature !== initialFormSignature
|
||
const hasReferenceFailure = referenceFailures.length > 0
|
||
|
||
const loadReferenceData = useCallback(async (
|
||
sourceMaterial: Material,
|
||
signal?: AbortSignal,
|
||
) => {
|
||
setReferenceLoading(true)
|
||
setReferenceFailures([])
|
||
|
||
const fallbackUsers: UserOption[] = [
|
||
{
|
||
id: sourceMaterial.userId,
|
||
username: sourceMaterial.authorName || '原素材所有者',
|
||
displayName: sourceMaterial.authorName || '原素材所有者',
|
||
},
|
||
]
|
||
if (sourceMaterial.taskDetailRequesterUserId) {
|
||
fallbackUsers.push({
|
||
id: sourceMaterial.taskDetailRequesterUserId,
|
||
username: '原需求运营',
|
||
displayName: sourceMaterial.taskDetailRequesterName || '原需求运营',
|
||
})
|
||
}
|
||
|
||
const requests: Array<{
|
||
key: ReferenceKey
|
||
url: string
|
||
apply: (payload: any) => void
|
||
}> = [
|
||
{ key: 'categories', url: '/api/categories', apply: setCategories },
|
||
{ key: 'scenes', url: '/api/scenes', apply: setScenes },
|
||
{ key: 'platforms', url: '/api/platforms', apply: setPlatforms },
|
||
{
|
||
key: 'productCategories',
|
||
url: '/api/product-categories',
|
||
apply: setProductCategories,
|
||
},
|
||
{
|
||
key: 'users',
|
||
url: '/api/users/list',
|
||
apply: (payload) => {
|
||
const activeUsers = Array.isArray(payload) ? payload : []
|
||
setUsers(mergeUserOptions(activeUsers, fallbackUsers))
|
||
},
|
||
},
|
||
]
|
||
|
||
const results = await Promise.allSettled(
|
||
requests.map(async ({ url }) => {
|
||
const response = await fetch(url, { signal })
|
||
if (!response.ok) {
|
||
const parsedError = await parseFetchError(response)
|
||
throw new Error(parsedError.message)
|
||
}
|
||
return response.json()
|
||
}),
|
||
)
|
||
|
||
if (signal?.aborted) return
|
||
|
||
const failures: ReferenceKey[] = []
|
||
results.forEach((result, index) => {
|
||
if (result.status === 'fulfilled') {
|
||
requests[index].apply(result.value)
|
||
} else {
|
||
failures.push(requests[index].key)
|
||
console.error(
|
||
`[material-edit] 加载${REFERENCE_LABELS[requests[index].key]}选项失败:`,
|
||
result.reason,
|
||
)
|
||
}
|
||
})
|
||
|
||
setReferenceFailures(failures)
|
||
setReferenceLoading(false)
|
||
}, [])
|
||
|
||
const loadInitialData = useCallback(async (signal: AbortSignal) => {
|
||
try {
|
||
setLoading(true)
|
||
setError('')
|
||
setMaterial(null)
|
||
|
||
const materialResponse = await fetch(`/api/materials/${params.id}?intent=edit`, {
|
||
signal,
|
||
})
|
||
if (!materialResponse.ok) {
|
||
const parsedError = await parseFetchError(materialResponse)
|
||
throw new Error(parsedError.message)
|
||
}
|
||
|
||
const data = await materialResponse.json() as Material
|
||
if (!data.canEdit) {
|
||
setError('您没有权限编辑此素材')
|
||
return
|
||
}
|
||
|
||
setMaterial(data)
|
||
setPreviewUrl(data.previewUrl || '')
|
||
setProductModel(data.productModel || '')
|
||
setReportTitle(data.reportTitle || '')
|
||
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 || '')
|
||
setDingtalkApprovalNo(data.dingtalkApprovalNo || '')
|
||
setTaskStatus(data.taskStatus || '')
|
||
setTaskFetchedAt(data.taskFetchedAt || '')
|
||
setIsFullSet(Boolean(data.isFullSet))
|
||
setTaskDetailRequesterUserId(data.taskDetailRequesterUserId || '')
|
||
setTaskDetailRequesterName(data.taskDetailRequesterName || '')
|
||
setTaskDetailSourceType(data.taskDetailSourceType || '')
|
||
setUsers(mergeUserOptions([], [
|
||
{
|
||
id: data.userId,
|
||
username: data.authorName || '原素材所有者',
|
||
displayName: data.authorName || '原素材所有者',
|
||
},
|
||
...(data.taskDetailRequesterUserId
|
||
? [{
|
||
id: data.taskDetailRequesterUserId,
|
||
username: '原需求运营',
|
||
displayName: data.taskDetailRequesterName || '原需求运营',
|
||
}]
|
||
: []),
|
||
]))
|
||
setInitialFormSignature(createFormSignature({
|
||
previewImage: null,
|
||
productModel: data.productModel || '',
|
||
reportTitle: data.reportTitle || '',
|
||
categoryName: data.categoryName || '',
|
||
sceneName: data.sceneName || '',
|
||
platformName: data.platformName || '',
|
||
productCategoryName: data.productCategoryName || '',
|
||
materialType: data.materialType || '',
|
||
renderer: data.renderer || '',
|
||
authorName: data.authorName || '',
|
||
userId: data.userId || '',
|
||
taskNo: data.taskNo || '',
|
||
dingtalkApprovalNo: data.dingtalkApprovalNo || '',
|
||
taskDetailSourceType: data.taskDetailSourceType || '',
|
||
taskDetailRequesterUserId: data.taskDetailRequesterUserId || '',
|
||
}))
|
||
|
||
void loadReferenceData(data, signal)
|
||
} catch (loadError: any) {
|
||
if (signal.aborted) return
|
||
console.error('加载编辑页失败:', loadError)
|
||
setError(loadError instanceof Error ? loadError.message : '素材信息加载失败')
|
||
} finally {
|
||
if (!signal.aborted) setLoading(false)
|
||
}
|
||
}, [loadReferenceData, params.id])
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
|
||
if (status === 'authenticated') {
|
||
const controller = new AbortController()
|
||
void loadInitialData(controller.signal)
|
||
return () => controller.abort()
|
||
}
|
||
}, [status, router, loadInitialData])
|
||
|
||
useEffect(() => () => {
|
||
if (previewObjectUrlRef.current) {
|
||
URL.revokeObjectURL(previewObjectUrlRef.current)
|
||
previewObjectUrlRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!hasUnsavedChanges) return
|
||
|
||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
if (bypassUnsavedGuardRef.current) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
}, [hasUnsavedChanges])
|
||
|
||
useEffect(() => {
|
||
if (!error) return
|
||
errorRef.current?.focus()
|
||
errorRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
}, [error])
|
||
|
||
const confirmDiscardChanges = useCallback(async () => {
|
||
if (!hasUnsavedChanges) return true
|
||
const { confirmed } = await confirmAction({
|
||
title: '放弃未保存的修改?',
|
||
description: '本次修改不会保留。',
|
||
confirmLabel: '放弃修改',
|
||
cancelLabel: '继续编辑',
|
||
tone: 'danger',
|
||
})
|
||
return confirmed
|
||
}, [confirmAction, hasUnsavedChanges])
|
||
|
||
useEffect(() => {
|
||
if (!hasUnsavedChanges) return
|
||
|
||
const handleAnchorNavigation = (event: MouseEvent) => {
|
||
if (
|
||
event.defaultPrevented ||
|
||
event.button !== 0 ||
|
||
event.metaKey ||
|
||
event.ctrlKey ||
|
||
event.shiftKey ||
|
||
event.altKey
|
||
) {
|
||
return
|
||
}
|
||
|
||
const target = event.target
|
||
if (!(target instanceof Element)) return
|
||
const anchor = target.closest<HTMLAnchorElement>('a[href]')
|
||
if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download')) return
|
||
|
||
const destination = new URL(anchor.href, window.location.href)
|
||
const isSameDocumentHash =
|
||
destination.pathname === window.location.pathname &&
|
||
destination.search === window.location.search &&
|
||
Boolean(destination.hash)
|
||
if (destination.href === window.location.href || isSameDocumentHash) return
|
||
|
||
event.preventDefault()
|
||
void confirmDiscardChanges().then((confirmed) => {
|
||
if (!confirmed) return
|
||
bypassUnsavedGuardRef.current = true
|
||
if (destination.origin === window.location.origin) {
|
||
router.push(`${destination.pathname}${destination.search}${destination.hash}`)
|
||
} else {
|
||
window.location.assign(destination.href)
|
||
}
|
||
})
|
||
}
|
||
|
||
document.addEventListener('click', handleAnchorNavigation, true)
|
||
return () => document.removeEventListener('click', handleAnchorNavigation, true)
|
||
}, [confirmDiscardChanges, hasUnsavedChanges, router])
|
||
|
||
const handlePreviewChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0]
|
||
if (!file) {
|
||
return
|
||
}
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
setError('请选择图片文件')
|
||
return
|
||
}
|
||
|
||
if (previewObjectUrlRef.current) {
|
||
URL.revokeObjectURL(previewObjectUrlRef.current)
|
||
}
|
||
const objectUrl = URL.createObjectURL(file)
|
||
previewObjectUrlRef.current = objectUrl
|
||
setPreviewImage(file)
|
||
setPreviewUrl(objectUrl)
|
||
setError('')
|
||
}
|
||
|
||
const handleCancel = async () => {
|
||
if (!await confirmDiscardChanges()) return
|
||
bypassUnsavedGuardRef.current = true
|
||
router.back()
|
||
}
|
||
|
||
const handleRetryReferenceData = () => {
|
||
if (material) void loadReferenceData(material)
|
||
}
|
||
|
||
const handleSubmit = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
setError('')
|
||
setSuccess('')
|
||
|
||
if (referenceLoading) {
|
||
setError('选项仍在加载,请稍后再保存')
|
||
return
|
||
}
|
||
|
||
if (hasReferenceFailure) {
|
||
setError('部分选项加载失败,请重新加载后再保存')
|
||
return
|
||
}
|
||
|
||
if (!materialType || !authorName) {
|
||
setError('请填写所有必填字段')
|
||
return
|
||
}
|
||
|
||
if (isCertificateReport) {
|
||
if (!productModel.trim() && !reportTitle.trim()) {
|
||
setError('SPU 和报告名称 / 检测对象至少填写一个')
|
||
return
|
||
}
|
||
} else if (!productModel.trim()) {
|
||
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 (showTaskNo) {
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
|
||
if (dingtalkApprovalNoError) {
|
||
setError(dingtalkApprovalNoError)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isFetchedTask) {
|
||
const { confirmed } = await confirmAction({
|
||
title: '保存已被获取的任务素材?',
|
||
description: '外部系统已经读取过这条记录,本次修改可能影响后续使用。',
|
||
confirmLabel: '继续保存',
|
||
})
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
}
|
||
|
||
setSubmitting(true)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
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('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '')
|
||
formData.append(
|
||
'taskDetailSourceType',
|
||
materialType === '任务-详情' ? taskDetailSourceType : '',
|
||
)
|
||
formData.append(
|
||
'taskDetailRequesterUserId',
|
||
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
|
||
)
|
||
|
||
if (previewImage && allowPreviewReplacement) {
|
||
formData.append('previewImage', previewImage)
|
||
}
|
||
|
||
const response = await fetch(`/api/materials/${params.id}`, {
|
||
method: 'PUT',
|
||
body: formData,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const parsedError = await parseFetchError(response)
|
||
throw new Error(parsedError.message)
|
||
}
|
||
|
||
bypassUnsavedGuardRef.current = true
|
||
setInitialFormSignature(currentFormSignature)
|
||
setSuccess('素材信息更新成功')
|
||
setTimeout(() => {
|
||
router.push('/assets/material-library')
|
||
}, 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-6 sm:py-10" aria-busy="true" aria-label="正在加载素材信息">
|
||
<div className="rounded-2xl bg-white p-4 shadow-lg dark:bg-gray-800 sm:p-8">
|
||
<div className="h-8 w-36 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||
<div className="mt-8 space-y-6">
|
||
{[1, 2, 3, 4].map((item) => (
|
||
<div key={item} className="space-y-2">
|
||
<div className="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||
<div className="h-11 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-700/70" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!material) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
<main className="mx-auto max-w-xl px-4 py-10">
|
||
<div
|
||
ref={errorRef}
|
||
role="alert"
|
||
tabIndex={-1}
|
||
className="rounded-2xl bg-white p-6 shadow-lg outline-none dark:bg-gray-800"
|
||
>
|
||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">素材信息加载失败</h1>
|
||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||
{error || '当前无法打开这条素材,请重新加载。'}
|
||
</p>
|
||
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push('/assets/material-library')}
|
||
className="min-h-11 rounded-apple border border-gray-300 px-5 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
返回素材库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => window.location.reload()}
|
||
className="btn-primary min-h-11 px-5"
|
||
>
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</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-6 sm:py-10">
|
||
<div className="rounded-2xl bg-white p-4 shadow-lg dark:bg-gray-800 sm:p-8">
|
||
<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>
|
||
)}
|
||
|
||
{referenceLoading && (
|
||
<div role="status" className="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||
正在加载店铺、场景和人员选项…
|
||
</div>
|
||
)}
|
||
|
||
{hasReferenceFailure && (
|
||
<div role="alert" className="mb-4 flex flex-col gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 sm:flex-row sm:items-center sm:justify-between">
|
||
<span>
|
||
{referenceFailures.map((key) => REFERENCE_LABELS[key]).join('、')}加载失败,当前值已保留。
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleRetryReferenceData}
|
||
className="min-h-10 shrink-0 rounded border border-red-300 px-4 font-medium hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-200"
|
||
>
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div
|
||
ref={errorRef}
|
||
role="alert"
|
||
tabIndex={-1}
|
||
className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-red-600 outline-none"
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div role="status" 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
|
||
htmlFor={`edit-material-${params.id}-preview`}
|
||
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
|
||
id={`edit-material-${params.id}-preview`}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handlePreviewChange}
|
||
className="block w-full text-sm"
|
||
disabled={submitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-product-model`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
{isCertificateReport ? 'SPU' : '产品型号 *'}
|
||
</label>
|
||
<input
|
||
id={`edit-material-${params.id}-product-model`}
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(event) => setProductModel(event.target.value)}
|
||
className="input-apple"
|
||
required={!isCertificateReport}
|
||
/>
|
||
</div>
|
||
|
||
{isCertificateReport && (
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-report-title`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
报告名称 / 检测对象
|
||
</label>
|
||
<input
|
||
id={`edit-material-${params.id}-report-title`}
|
||
type="text"
|
||
value={reportTitle}
|
||
onChange={(event) => setReportTitle(event.target.value)}
|
||
className="input-apple"
|
||
placeholder="SPU 和此字段至少填写一个"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-type`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
素材类型 *
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-type`}
|
||
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
|
||
htmlFor={`edit-material-${params.id}-category`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
店铺 *
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-category`}
|
||
value={categoryName}
|
||
onChange={(event) => setCategoryName(event.target.value)}
|
||
className="input-apple"
|
||
required
|
||
disabled={
|
||
submitting ||
|
||
referenceLoading ||
|
||
referenceFailures.includes('categories')
|
||
}
|
||
>
|
||
<option value="">
|
||
{referenceLoading ? '正在加载店铺...' : '请选择店铺'}
|
||
</option>
|
||
{categoryName && !categories.some((category) => category.name === categoryName) && (
|
||
<option value={categoryName}>{categoryName}</option>
|
||
)}
|
||
{categories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-scene`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
场景 *
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-scene`}
|
||
value={sceneName}
|
||
onChange={(event) => setSceneName(event.target.value)}
|
||
className="input-apple"
|
||
required
|
||
disabled={
|
||
submitting ||
|
||
referenceLoading ||
|
||
referenceFailures.includes('scenes')
|
||
}
|
||
>
|
||
<option value="">
|
||
{referenceLoading ? '正在加载场景...' : '请选择场景'}
|
||
</option>
|
||
{sceneName && !scenes.some((scene) => scene.name === sceneName) && (
|
||
<option value={sceneName}>{sceneName}</option>
|
||
)}
|
||
{scenes.map((scene) => (
|
||
<option key={scene.id} value={scene.name}>
|
||
{scene.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-platform`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
平台分类
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-platform`}
|
||
value={platformName}
|
||
onChange={(event) => setPlatformName(event.target.value)}
|
||
className="input-apple"
|
||
disabled={
|
||
submitting ||
|
||
referenceLoading ||
|
||
referenceFailures.includes('platforms')
|
||
}
|
||
>
|
||
<option value="">{referenceLoading ? '正在加载平台...' : '不选择'}</option>
|
||
{platformName && !platforms.some((platform) => platform.name === platformName) && (
|
||
<option value={platformName}>{platformName}</option>
|
||
)}
|
||
{platforms.map((platform) => (
|
||
<option key={platform.id} value={platform.name}>
|
||
{platform.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-product-category`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
品类{requiresProductCategoryName(materialType) ? ' *' : ''}
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-product-category`}
|
||
value={productCategoryName}
|
||
onChange={(event) => setProductCategoryName(event.target.value)}
|
||
className="input-apple"
|
||
disabled={
|
||
submitting ||
|
||
referenceLoading ||
|
||
referenceFailures.includes('productCategories')
|
||
}
|
||
>
|
||
<option value="">{referenceLoading ? '正在加载品类...' : '不选择'}</option>
|
||
{productCategoryName &&
|
||
!productCategories.some((category) => category.name === productCategoryName) && (
|
||
<option value={productCategoryName}>{productCategoryName}</option>
|
||
)}
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{showRenderer && (
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-renderer`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
渲染器 *
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-renderer`}
|
||
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 && (
|
||
<TaskReferenceFields
|
||
idPrefix={`edit-material-page-${params.id}`}
|
||
taskNo={taskNo}
|
||
onTaskNoChange={setTaskNo}
|
||
dingtalkApprovalNo={dingtalkApprovalNo}
|
||
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
|
||
disabled={submitting}
|
||
/>
|
||
)}
|
||
|
||
{materialType === '任务-详情' && (
|
||
<>
|
||
<div>
|
||
<label
|
||
htmlFor={`edit-material-${params.id}-source-type`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
出图方式
|
||
</label>
|
||
<select
|
||
id={`edit-material-${params.id}-source-type`}
|
||
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
|
||
htmlFor={`edit-material-${params.id}-requester`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
需求运营{canKeepRequesterBlank ? '' : ' *'}
|
||
</label>
|
||
<SearchableUserSelect
|
||
id={`edit-material-${params.id}-requester`}
|
||
users={users}
|
||
value={taskDetailRequesterUserId}
|
||
onChange={(nextUserId, displayName) => {
|
||
setTaskDetailRequesterUserId(nextUserId)
|
||
setTaskDetailRequesterName(displayName)
|
||
}}
|
||
placeholder="搜索发起本次出图需求的运营人员"
|
||
required={!canKeepRequesterBlank}
|
||
clearable={canKeepRequesterBlank}
|
||
loading={referenceLoading}
|
||
disabled={submitting || referenceFailures.includes('users')}
|
||
/>
|
||
<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
|
||
htmlFor={`edit-material-${params.id}-owner`}
|
||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>
|
||
作者(素材所有者) *
|
||
</label>
|
||
<SearchableUserSelect
|
||
id={`edit-material-${params.id}-owner`}
|
||
users={users}
|
||
value={userId}
|
||
onChange={(nextUserId, displayName) => {
|
||
setUserId(nextUserId)
|
||
setAuthorName(displayName)
|
||
}}
|
||
placeholder="请输入或搜索用户(支持拼音)"
|
||
required
|
||
loading={referenceLoading}
|
||
disabled={submitting || referenceFailures.includes('users')}
|
||
/>
|
||
<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="sticky bottom-0 z-20 -mx-4 border-t border-gray-200 bg-white/95 px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/95 sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:pt-4 sm:backdrop-blur-none">
|
||
{hasUnsavedChanges && (
|
||
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500 sm:hidden">
|
||
<span className="h-2 w-2 rounded-full bg-amber-500" aria-hidden="true" />
|
||
未保存
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3 sm:flex sm:gap-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleCancel()}
|
||
className="min-h-11 rounded-apple border border-gray-300 px-4 font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700 sm:flex-1"
|
||
disabled={submitting}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary min-h-11 px-4 sm:flex-1"
|
||
disabled={submitting || referenceLoading || hasReferenceFailure}
|
||
>
|
||
{submitting ? '保存中...' : '保存修改'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{confirmActionDialog}
|
||
</div>
|
||
)
|
||
}
|