2026-08-19 12:08:38 +08:00

1091 lines
40 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, 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>
)
}