diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index c4efc59..3c25640 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -341,6 +341,9 @@ export async function GET( } const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin; + const canEdit = + (material as any).userId === userId || + canBypassVideoPreviewGate; if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) { return buildVideoPreviewBlockedResponse(); } @@ -427,6 +430,7 @@ export async function GET( ), assets: formattedAssets, canDownloadSource: canAccessSource, + canEdit, user: { username: materialWithUser.user?.username || '', }, @@ -500,7 +504,9 @@ export async function PUT( const formData = await request.formData(); input = { productModel: String(formData.get('productModel') || ''), - reportTitle: formData.get('reportTitle') as string | null, + ...(formData.has('reportTitle') + ? { reportTitle: formData.get('reportTitle') as string | null } + : {}), categoryName: formData.get('categoryName') as string | null, sceneName: formData.get('sceneName') as string | null, platformName: formData.get('platformName') as string | null, @@ -508,14 +514,24 @@ export async function PUT( materialType: String(formData.get('materialType') || ''), renderer: formData.get('renderer') as string | null, authorName: String(formData.get('authorName') || ''), - userId: formData.get('userId') as string | null, - taskNo: formData.get('taskNo') as string | null, + ...(formData.has('userId') + ? { userId: formData.get('userId') as string | null } + : {}), + ...(formData.has('taskNo') + ? { taskNo: formData.get('taskNo') as string | null } + : {}), ...(formData.has('dingtalkApprovalNo') ? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null } : {}), - taskDetailSourceType: formData.get('taskDetailSourceType') as string | null, - taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null, - isFullSet: formData.get('isFullSet') as string | null, + ...(formData.has('taskDetailSourceType') + ? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null } + : {}), + ...(formData.has('taskDetailRequesterUserId') + ? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null } + : {}), + ...(formData.has('isFullSet') + ? { isFullSet: formData.get('isFullSet') as string | null } + : {}), }; const formPreview = formData.get('previewImage'); previewImage = isWebFileLike(formPreview) ? formPreview : null; diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index ec0cd7b..fb2519c 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -22,7 +22,9 @@ export async function PUT(request: NextRequest) { id = String(formData.get('id') || ''); input = { productModel: String(formData.get('productModel') || ''), - reportTitle: formData.get('reportTitle') as string | null, + ...(formData.has('reportTitle') + ? { reportTitle: formData.get('reportTitle') as string | null } + : {}), categoryName: formData.get('categoryName') as string | null, sceneName: formData.get('sceneName') as string | null, platformName: formData.get('platformName') as string | null, @@ -30,14 +32,24 @@ export async function PUT(request: NextRequest) { materialType: String(formData.get('materialType') || ''), renderer: formData.get('renderer') as string | null, authorName: String(formData.get('authorName') || ''), - userId: formData.get('userId') as string | null, - taskNo: formData.get('taskNo') as string | null, + ...(formData.has('userId') + ? { userId: formData.get('userId') as string | null } + : {}), + ...(formData.has('taskNo') + ? { taskNo: formData.get('taskNo') as string | null } + : {}), ...(formData.has('dingtalkApprovalNo') ? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null } : {}), - taskDetailSourceType: formData.get('taskDetailSourceType') as string | null, - taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null, - isFullSet: formData.get('isFullSet') as string | null, + ...(formData.has('taskDetailSourceType') + ? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null } + : {}), + ...(formData.has('taskDetailRequesterUserId') + ? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null } + : {}), + ...(formData.has('isFullSet') + ? { isFullSet: formData.get('isFullSet') as string | null } + : {}), }; const formPreview = formData.get('previewImage'); previewImage = isWebFileLike(formPreview) ? formPreview : null; diff --git a/app/api/users/list/route.ts b/app/api/users/list/route.ts index 9dfe20e..a61e635 100644 --- a/app/api/users/list/route.ts +++ b/app/api/users/list/route.ts @@ -11,17 +11,17 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - // 只获取激活的用户 + // 只返回人员选择器实际需要的最小字段,避免向普通登录用户暴露邮箱等信息。 const users = await prisma.user.findMany({ where: { isActive: true, status: 'active', + archivedAt: null, }, select: { id: true, username: true, realName: true, - email: true, }, orderBy: { username: 'asc', @@ -33,7 +33,6 @@ export async function GET(request: NextRequest) { id: user.id, username: user.username, displayName: user.realName || user.username, - email: user.email, })); return NextResponse.json(userList, { status: 200 }); diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 4124e53..412fed7 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +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, @@ -17,12 +18,16 @@ import { 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 @@ -36,8 +41,39 @@ interface Material { 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 = { + categories: '店铺', + scenes: '场景', + platforms: '平台', + productCategories: '品类', + users: '人员', +} + +function createFormSignature(values: Record) { + return JSON.stringify(values) +} + +function mergeUserOptions(primary: UserOption[], fallback: UserOption[]) { + const merged = new Map() + 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 } }) { @@ -47,11 +83,15 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') const [success, setSuccess] = useState('') + const [referenceLoading, setReferenceLoading] = useState(false) + const [referenceFailures, setReferenceFailures] = useState([]) + const [initialFormSignature, setInitialFormSignature] = useState('') const [material, setMaterial] = useState(null) const [previewImage, setPreviewImage] = useState(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('') @@ -67,70 +107,167 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [isFullSet, setIsFullSet] = useState(false) const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('') const [taskDetailRequesterName, setTaskDetailRequesterName] = useState('') + const [taskDetailSourceType, setTaskDetailSourceType] = useState('') const [categories, setCategories] = useState>([]) const [scenes, setScenes] = useState>([]) const [platforms, setPlatforms] = useState>([]) const [productCategories, setProductCategories] = useState>([]) - const [users, setUsers] = useState>([]) + const [users, setUsers] = useState([]) + const previewObjectUrlRef = useRef(null) + const bypassUnsavedGuardRef = useRef(false) + const errorRef = useRef(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 loadInitialData = useCallback(async () => { + 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, 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'), - ]) - + const materialResponse = await fetch(`/api/materials/${params.id}`, { + signal, + }) if (!materialResponse.ok) { - throw new Error('获取素材信息失败') + const parsedError = await parseFetchError(materialResponse) + throw new Error(parsedError.message) } - 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 - ) { + const data = await materialResponse.json() as Material + if (!data.canEdit) { setError('您没有权限编辑此素材') - setTimeout(() => router.push('/'), 1500) return } setMaterial(data) setPreviewUrl(data.previewUrl || '') setProductModel(data.productModel || '') + setReportTitle(data.reportTitle || '') setCategoryName(data.categoryName || '') setSceneName(data.sceneName || '') setPlatformName(data.platformName || '') @@ -146,43 +283,48 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) 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 || '', + })) - 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) - } + void loadReferenceData(data, signal) } catch (loadError: any) { + if (signal.aborted) return console.error('加载编辑页失败:', loadError) - const { parseApiError } = await import('@/lib/error-handler') - const { message } = parseApiError(loadError) - setError(message) + setError(loadError instanceof Error ? loadError.message : '素材信息加载失败') } finally { - setLoading(false) + if (!signal.aborted) setLoading(false) } - }, [params.id, router, session]) + }, [loadReferenceData, params.id]) useEffect(() => { if (status === 'unauthenticated') { @@ -191,10 +333,92 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) } if (status === 'authenticated') { - void loadInitialData() + 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('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) => { const file = event.target.files?.[0] if (!file) { @@ -206,21 +430,56 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) return } + if (previewObjectUrlRef.current) { + URL.revokeObjectURL(previewObjectUrlRef.current) + } + const objectUrl = URL.createObjectURL(file) + previewObjectUrlRef.current = objectUrl setPreviewImage(file) - setPreviewUrl(URL.createObjectURL(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 (!productModel || !materialType || !authorName) { + 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 @@ -250,7 +509,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) } if (isFetchedTask) { - const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') + const { confirmed } = await confirmAction({ + title: '保存已被获取的任务素材?', + description: '外部系统已经读取过这条记录,本次修改可能影响后续使用。', + confirmLabel: '继续保存', + }) if (!confirmed) { return } @@ -261,6 +524,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) 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) @@ -271,6 +535,10 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) formData.append('userId', userId) formData.append('taskNo', showTaskNo ? taskNo : '') formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '') + formData.append( + 'taskDetailSourceType', + materialType === '任务-详情' ? taskDetailSourceType : '', + ) formData.append( 'taskDetailRequesterUserId', materialType === '任务-详情' ? taskDetailRequesterUserId : '', @@ -285,11 +553,13 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) body: formData, }) - const data = await response.json() if (!response.ok) { - throw new Error(data.error || '更新失败') + const parsedError = await parseFetchError(response) + throw new Error(parsedError.message) } + bypassUnsavedGuardRef.current = true + setInitialFormSignature(currentFormSignature) setSuccess('素材信息更新成功') setTimeout(() => { router.push('/') @@ -306,18 +576,65 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) return (
-
-
加载中...
+
+
+
+
+ {[1, 2, 3, 4].map((item) => ( +
+
+
+
+ ))} +
+
) } + if (!material) { + return ( +
+
+
+
+

素材信息加载失败

+

+ {error || '当前无法打开这条素材,请重新加载。'} +

+
+ + +
+
+
+
+ ) + } + return (
-
-
+
+

编辑素材信息

{isFetchedTask && ( @@ -334,14 +651,40 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
)} + {referenceLoading && ( +
+ 正在加载店铺、场景和人员选项… +
+ )} + + {hasReferenceFailure && ( +
+ + {referenceFailures.map((key) => REFERENCE_LABELS[key]).join('、')}加载失败,当前值已保留。 + + +
+ )} + {error && ( -
+
{error}
)} {success && ( -
+
{success}
)} @@ -349,7 +692,10 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
{allowPreviewReplacement && (
-
)}
-
+ {isCertificateReport && ( +
+ + setReportTitle(event.target.value)} + className="input-apple" + placeholder="SPU 和此字段至少填写一个" + /> +
+ )} +
-
-
-
+ +
+ + { + setTaskDetailRequesterUserId(nextUserId) + setTaskDetailRequesterName(displayName) + }} + placeholder="搜索发起本次出图需求的运营人员" + required={!canKeepRequesterBlank} + clearable={canKeepRequesterBlank} + loading={referenceLoading} + disabled={submitting || referenceFailures.includes('users')} + /> +

+ {canKeepRequesterBlank + ? taskDetailRequesterName + ? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。` + : '这是一条历史素材,原来没有需求运营数据,可暂时留空。' + : '记录发起本次出图需求的运营人员,与素材所有者可以不同。'} +

+
+ )} {showIsFullSet && ( @@ -545,10 +1017,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) )}
-
)} -
- - +
+ {hasUnsavedChanges && ( +
+
+ )} +
+ + +
+ {confirmActionDialog}
) } diff --git a/app/page.tsx b/app/page.tsx index 3c4110f..608b062 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -588,13 +588,12 @@ export default function Home() { } if (requestId === materialsRequestIdRef.current) { const data = await response.json() - // 素材ID验证和previewUrl fallback + // 清理素材 ID,并为预览图补齐统一 fallback const materialsList = ((data.materials || data) as Material[]) .map((e) => { - // 验证ID if (!e.id || typeof e.id !== 'string') return null const trimmedId = e.id.trim() - if (!trimmedId || trimmedId.length < 10 || trimmedId.length > 100) return null + if (!trimmedId) return null // previewUrl fallback逻辑 const previewUrl = e.previewUrl || @@ -1346,7 +1345,7 @@ export default function Home() { diff --git a/components/EditMaterialModal.tsx b/components/EditMaterialModal.tsx deleted file mode 100644 index 065d7c7..0000000 --- a/components/EditMaterialModal.tsx +++ /dev/null @@ -1,514 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import SearchableUserSelect from '@/components/SearchableUserSelect' -import { - canManuallyReplacePreview, - getSelectableMaterialTypeOptions, - supportsIsFullSet, - supportsTaskNo, -} from '@/lib/material-type-options' -import { isCertificateReportMaterialType } from '@/lib/certificate-report' -import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type' -import TaskReferenceFields from '@/components/upload/TaskReferenceFields' -import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no' -import { - requiresCategoryAndScene, - requiresProductCategoryName, - requiresRenderer, -} from '@/lib/material-validation' - -interface EditMaterialModalProps { - material: { - id: string - previewUrl: string - productModel: string - reportTitle?: string | null - categoryName: string - sceneName: string - platformName?: string | null - productCategoryName?: string | null - materialType: string - renderer: string - authorName: string - userId: string - taskNo?: string | null - dingtalkApprovalNo?: string | null - taskDetailSourceType?: string | null - taskStatus?: string | null - taskFetchedAt?: string | null - isFullSet?: boolean - taskDetailRequesterUserId?: string | null - taskDetailRequesterName?: string | null - } - onClose: () => void - onSuccess: () => void -} - -export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) { - const [previewImage, setPreviewImage] = useState(null) - const [previewUrl, setPreviewUrl] = useState(material.previewUrl || '') - const [productModel, setProductModel] = useState(material.productModel) - const [reportTitle, setReportTitle] = useState(material.reportTitle || '') - const [categoryName, setCategoryName] = useState(material.categoryName) - const [sceneName, setSceneName] = useState(material.sceneName) - const [platformName, setPlatformName] = useState(material.platformName || '') - const [productCategoryName, setProductCategoryName] = useState(material.productCategoryName || '') - const [materialType, setMaterialType] = useState(material.materialType || '3D场景') - const [renderer, setRenderer] = useState(material.renderer || 'CR') - const [authorName, setAuthorName] = useState(material.authorName) - const [userId, setUserId] = useState(material.userId) - const [taskNo, setTaskNo] = useState(material.taskNo || '') - const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(material.dingtalkApprovalNo || '') - const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '') - const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '') - const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '') - const [taskStatus] = useState(material.taskStatus || '') - const [taskFetchedAt] = useState(material.taskFetchedAt || '') - const [isFullSet] = useState(Boolean(material.isFullSet)) - const [users, setUsers] = useState>([]) - const [categories, setCategories] = useState>([]) - const [scenes, setScenes] = useState>([]) - const [platforms, setPlatforms] = useState>([]) - const [productCategories, setProductCategories] = useState>([]) - const [updating, setUpdating] = useState(false) - const [error, setError] = useState('') - - const showCategoryScene = requiresCategoryAndScene(materialType) - const showRenderer = requiresRenderer(materialType) - const showTaskNo = supportsTaskNo(materialType) - const showIsFullSet = supportsIsFullSet(materialType) - const allowPreviewReplacement = canManuallyReplacePreview(materialType) - const isCertificateReport = isCertificateReportMaterialType(materialType) - const isFetchedTask = taskStatus === 'fetched' - const materialTypeOptions = getSelectableMaterialTypeOptions(materialType) - const canKeepRequesterBlank = - material.materialType === '任务-详情' && !material.taskDetailRequesterUserId - - useEffect(() => { - void fetchSelectOptions() - }, []) - - const fetchSelectOptions = async () => { - try { - const [categoriesResponse, scenesResponse, platformsResponse, productCategoriesResponse, usersResponse] = await Promise.all([ - fetch('/api/categories'), - fetch('/api/scenes'), - fetch('/api/platforms'), - fetch('/api/product-categories'), - fetch('/api/users/list'), - ]) - - if (categoriesResponse.ok) { - setCategories(await categoriesResponse.json()) - } - if (scenesResponse.ok) { - setScenes(await scenesResponse.json()) - } - if (platformsResponse.ok) { - setPlatforms(await platformsResponse.json()) - } - if (productCategoriesResponse.ok) { - setProductCategories(await productCategoriesResponse.json()) - } - if (usersResponse.ok) { - const activeUsers = await usersResponse.json() - const normalizedUsers = Array.isArray(activeUsers) ? activeUsers : [] - if ( - material.taskDetailRequesterUserId && - !normalizedUsers.some((user) => user.id === material.taskDetailRequesterUserId) - ) { - normalizedUsers.push({ - id: material.taskDetailRequesterUserId, - username: '已停用', - displayName: material.taskDetailRequesterName || '原需求运营', - }) - } - setUsers(normalizedUsers) - } - } catch (fetchError) { - console.error('加载编辑弹窗数据失败:', fetchError) - } - } - - const handlePreviewChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] - if (!file) { - return - } - - if (!file.type.startsWith('image/')) { - setError('请选择图片文件') - return - } - - setPreviewImage(file) - setPreviewUrl(URL.createObjectURL(file)) - setError('') - } - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault() - setError('') - - if (!materialType || !authorName) { - setError('请填写所有必填字段') - return - } - - if (isCertificateReport) { - if (!productModel.trim() && !reportTitle.trim()) { - setError('SPU 和 报告名称/检测对象 至少填写一个') - return - } - } else if (!productModel) { - setError('请填写所有必填字段') - return - } - - if (showCategoryScene && (!categoryName || !sceneName)) { - setError('请填写店铺和场景') - return - } - - if (requiresProductCategoryName(materialType) && !productCategoryName) { - setError('标准模型类型必须选择品类') - return - } - - if (showRenderer && !renderer) { - setError('3D场景类型必须指定渲染器') - return - } - - if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) { - setError('请选择需求运营人员') - return - } - - if (showTaskNo) { - const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo) - if (dingtalkApprovalNoError) { - setError(dingtalkApprovalNoError) - return - } - } - - if (isFetchedTask) { - const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') - if (!confirmed) { - return - } - } - - setUpdating(true) - - try { - const formData = new FormData() - formData.append('id', material.id) - formData.append('productModel', productModel) - formData.append('reportTitle', reportTitle) - formData.append('categoryName', categoryName) - formData.append('sceneName', sceneName) - formData.append('platformName', platformName) - formData.append('productCategoryName', productCategoryName) - formData.append('materialType', materialType) - formData.append('renderer', renderer) - formData.append('authorName', authorName) - formData.append('userId', userId) - formData.append('taskNo', showTaskNo ? taskNo : '') - formData.append('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/update', { - method: 'PUT', - body: formData, - }) - - if (!response.ok) { - const data = await response.json() - setError(data.error || '更新失败') - return - } - - onSuccess() - } catch (submitError) { - console.error('更新失败:', submitError) - setError('更新失败,请稍后再试') - } finally { - setUpdating(false) - } - } - - return ( -
-
-
-
-

编辑素材

- -
-
- -
- {isFetchedTask && ( -
- 这条任务素材已被外部系统获取。 - {taskFetchedAt ? ` 获取时间:${new Date(taskFetchedAt).toLocaleString('zh-CN')}` : ''} -
- )} - - {!allowPreviewReplacement && ( -
- 当前素材类型的预览以系统生成结果为准,这里不支持手工替换封面。 -
- )} - - {error && ( -
- {error} -
- )} - - {allowPreviewReplacement && ( -
- -
- {previewUrl ? ( -
- 预览图 -
- ) : ( -
- 当前没有预览图 -
- )} - -
-
- )} - -
- - setProductModel(event.target.value)} - className="input-apple" - required={!isCertificateReport} - /> -
- - {isCertificateReport && ( -
- - setReportTitle(event.target.value)} - className="input-apple" - placeholder="SPU 和这个字段至少填写一个" - /> -
- )} - -
- - -
- - {showCategoryScene && ( - <> -
- - -
- -
- - -
- - )} - -
- - -
- -
- - -
- - {showRenderer && ( -
- - -
- )} - - {showTaskNo && ( - - )} - - {materialType === '任务-详情' && ( - <> -
- - -
- -
- - { - setTaskDetailRequesterUserId(nextUserId) - setTaskDetailRequesterName(displayName) - }} - placeholder="搜索发起本次出图需求的运营人员" - required={!canKeepRequesterBlank} - clearable={canKeepRequesterBlank} - /> -

- {canKeepRequesterBlank - ? taskDetailRequesterName - ? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。` - : '这是一条历史素材,原来没有需求运营数据,可暂时留空。' - : '记录发起本次出图需求的运营人员,与素材所有者可以不同。'} -

-
- - )} - - {showIsFullSet && ( -
- -
-

- 当前结果:{isFullSet ? '是' : '否'} -

-

- 系统会根据是否同时存在主图、SKU 图、详情图自动判定,这里不允许手工修改。 -

-
-
- )} - -
- - { - setUserId(nextUserId) - setAuthorName(displayName) - }} - placeholder="请输入或搜索用户(支持拼音)" - required - /> -
- - {(showTaskNo || showIsFullSet) && ( -
-
-

任务状态

-

{taskStatus || '未记录'}

-
-
-

外部获取时间

-

- {taskFetchedAt ? new Date(taskFetchedAt).toLocaleString('zh-CN') : '未记录'} -

-
-
- )} - -
- - -
- -
-
- ) -} diff --git a/components/SearchableUserSelect.tsx b/components/SearchableUserSelect.tsx index 97e9c61..ae92cda 100644 --- a/components/SearchableUserSelect.tsx +++ b/components/SearchableUserSelect.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useRef, useEffect } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { pinyin } from 'pinyin-pro' interface User { @@ -10,6 +10,7 @@ interface User { } interface SearchableUserSelectProps { + id?: string users: User[] value: string onChange: (userId: string, displayName: string) => void @@ -18,9 +19,11 @@ interface SearchableUserSelectProps { className?: string disabled?: boolean clearable?: boolean + loading?: boolean } export default function SearchableUserSelect({ + id, users, value, onChange, @@ -29,50 +32,55 @@ export default function SearchableUserSelect({ className = '', disabled = false, clearable = true, + loading = false, }: SearchableUserSelectProps) { const [searchTerm, setSearchTerm] = useState('') const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) const containerRef = useRef(null) const inputRef = useRef(null) + const listboxId = useId() + const isDisabled = disabled || loading // 获取选中用户的显示名称 - const selectedUser = users.find(u => u.id === value) - const displayValue = selectedUser ? `${selectedUser.displayName} (${selectedUser.username})` : '' + const selectedUser = useMemo(() => users.find((user) => user.id === value), [users, value]) + const displayValue = selectedUser + ? selectedUser.displayName === selectedUser.username + ? selectedUser.displayName + : `${selectedUser.displayName} (${selectedUser.username})` + : '' - // 拼音搜索匹配函数 - const matchesSearch = (user: User, search: string): boolean => { - if (!search) return true + // 人员数量较多时,预先生成一次搜索索引,避免每次按键都重复计算全量拼音。 + const indexedUsers = useMemo(() => users.map((user) => { + const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('') + const firstLetters = pinyin(user.displayName, { + pattern: 'first', + toneType: 'none', + type: 'array', + }).join('') - const searchLower = search.toLowerCase() - - // 1. 匹配显示名称 - if (user.displayName.toLowerCase().includes(searchLower)) { - return true + return { + user, + searchText: [ + user.displayName, + user.username, + fullPinyin, + firstLetters, + ].join(' ').toLowerCase(), } + }), [users]) - // 2. 匹配用户名 - if (user.username.toLowerCase().includes(searchLower)) { - return true - } + const filteredUsers = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase() + if (!keyword) return indexedUsers.map((item) => item.user) + return indexedUsers + .filter((item) => item.searchText.includes(keyword)) + .map((item) => item.user) + }, [indexedUsers, searchTerm]) - // 3. 匹配拼音全拼 - const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('').toLowerCase() - if (fullPinyin.includes(searchLower)) { - return true - } - - // 4. 匹配拼音首字母 - const firstLetters = pinyin(user.displayName, { pattern: 'first', toneType: 'none', type: 'array' }).join('').toLowerCase() - if (firstLetters.includes(searchLower)) { - return true - } - - return false - } - - // 过滤用户列表 - const filteredUsers = users.filter(user => matchesSearch(user, searchTerm)) + useEffect(() => { + setHighlightedIndex((current) => Math.min(current, Math.max(filteredUsers.length - 1, 0))) + }, [filteredUsers.length]) // 点击外部关闭下拉框 useEffect(() => { @@ -88,7 +96,7 @@ export default function SearchableUserSelect({ // 键盘导航 const handleKeyDown = (e: React.KeyboardEvent) => { - if (disabled) { + if (isDisabled) { return } @@ -132,7 +140,7 @@ export default function SearchableUserSelect({ // 打开下拉框 const handleFocus = () => { - if (disabled) { + if (isDisabled) { return } @@ -143,7 +151,7 @@ export default function SearchableUserSelect({ // 清空选择 const handleClear = (e: React.MouseEvent) => { e.stopPropagation() - if (disabled || !clearable) { + if (isDisabled || !clearable) { return } @@ -157,26 +165,38 @@ export default function SearchableUserSelect({
setSearchTerm(e.target.value)} onFocus={handleFocus} onKeyDown={handleKeyDown} - placeholder={placeholder} + placeholder={loading ? '正在加载人员...' : placeholder} required={required} - disabled={disabled} + disabled={isDisabled} className="input-apple pr-20 disabled:cursor-not-allowed disabled:opacity-60" autoComplete="off" + role="combobox" + aria-autocomplete="list" + aria-expanded={isOpen && !isDisabled} + aria-controls={listboxId} + aria-activedescendant={ + isOpen && filteredUsers[highlightedIndex] + ? `${listboxId}-option-${filteredUsers[highlightedIndex].id}` + : undefined + } + aria-busy={loading} />
- {value && clearable && !disabled && ( + {value && clearable && !isDisabled && (
- {isOpen && !disabled && ( -
+ {isOpen && !isDisabled && ( +
{filteredUsers.length === 0 ? (
未找到匹配的用户 @@ -216,6 +241,9 @@ export default function SearchableUserSelect({ filteredUsers.map((user, index) => (