diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index de63b76..c4efc59 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -184,6 +184,7 @@ export async function GET( videoDuration: true, videoProcessingStatus: true, taskNo: true, + dingtalkApprovalNo: true, taskStatus: true, taskFetchedAt: true, isFullSet: true, @@ -255,6 +256,7 @@ export async function GET( videoDuration: true, videoProcessingStatus: true, taskNo: true, + dingtalkApprovalNo: true, taskStatus: true, taskFetchedAt: true, isFullSet: true, @@ -415,6 +417,7 @@ export async function GET( videoDuration: materialWithUser.videoDuration || null, videoProcessingStatus: materialWithUser.videoProcessingStatus || null, taskNo: materialWithUser.taskNo || null, + dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null, taskStatus: materialWithUser.taskStatus || null, taskFetchedAt: materialWithUser.taskFetchedAt || null, isFullSet: deriveTaskDetailIsFullSet( @@ -507,6 +510,9 @@ export async function PUT( authorName: String(formData.get('authorName') || ''), userId: formData.get('userId') as string | null, 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, @@ -545,6 +551,7 @@ export async function PUT( renderer: result.material.renderer, authorName: result.material.authorName, taskNo: result.material.taskNo, + dingtalkApprovalNo: (result.material as any).dingtalkApprovalNo || null, taskDetailSourceType: (result.material as any).taskDetailSourceType || null, taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null, taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null, diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index de4f904..ec0cd7b 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -32,6 +32,9 @@ export async function PUT(request: NextRequest) { authorName: String(formData.get('authorName') || ''), userId: formData.get('userId') as string | null, 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, diff --git a/app/api/materials/upload/3d-original/complete/route.ts b/app/api/materials/upload/3d-original/complete/route.ts index 594de64..0369d36 100644 --- a/app/api/materials/upload/3d-original/complete/route.ts +++ b/app/api/materials/upload/3d-original/complete/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets, taskNo } = body; + const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body; materialId = matId; @@ -123,7 +123,7 @@ export async function POST(request: NextRequest) { await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); // 步骤4.5: 仅在素材成功完成后写入任务号 - await assignTaskNoOnComplete(materialId!, taskNo); + await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo); // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/3d-original/init/route.ts b/app/api/materials/upload/3d-original/init/route.ts index dc87839..247b3b2 100644 --- a/app/api/materials/upload/3d-original/init/route.ts +++ b/app/api/materials/upload/3d-original/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; // POST - 初始化3D原图素材记录 export async function POST(request: NextRequest) { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const { taskNo, dingtalkApprovalNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; // 验证必填字段 if (!productModel || !categoryName || !sceneName) { @@ -54,6 +55,11 @@ export async function POST(request: NextRequest) { } } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json({ error: dingtalkApprovalNoError }, { status: 400 }); + } + // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, diff --git a/app/api/materials/upload/ai-original/complete/route.ts b/app/api/materials/upload/ai-original/complete/route.ts index bfcbae3..d2e2d5a 100644 --- a/app/api/materials/upload/ai-original/complete/route.ts +++ b/app/api/materials/upload/ai-original/complete/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets, taskNo } = body; + const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body; materialId = matId; @@ -123,7 +123,7 @@ export async function POST(request: NextRequest) { await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); // 步骤4.5: 仅在素材成功完成后写入任务号 - await assignTaskNoOnComplete(materialId!, taskNo); + await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo); // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/ai-original/init/route.ts b/app/api/materials/upload/ai-original/init/route.ts index 410bc9b..8b182a2 100644 --- a/app/api/materials/upload/ai-original/init/route.ts +++ b/app/api/materials/upload/ai-original/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; // POST - 初始化AI原图素材记录 export async function POST(request: NextRequest) { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const { taskNo, dingtalkApprovalNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; // 验证必填字段 if (!productModel || !categoryName || !sceneName) { @@ -58,6 +59,11 @@ export async function POST(request: NextRequest) { } } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json({ error: dingtalkApprovalNoError }, { status: 400 }); + } + // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index 71128bd..0c60e10 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -19,6 +19,7 @@ export const maxDuration = 10800 interface CompleteRequestBody { materialId: string taskNo?: string + dingtalkApprovalNo?: string noBarcodes?: boolean assets: unknown } @@ -88,6 +89,7 @@ export async function POST(request: NextRequest) { const result = await completeProductPackageUpload({ materialId: body.materialId, taskNo: body.taskNo, + dingtalkApprovalNo: body.dingtalkApprovalNo, assets, operatorUserId: (session.user as any).id, options: { @@ -100,6 +102,7 @@ export async function POST(request: NextRequest) { data: { materialId: result.materialId, taskNo: result.taskNo, + dingtalkApprovalNo: result.dingtalkApprovalNo, isFullSet: result.isFullSet, message: '上传完成', }, diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index 2d2982e..09dbe8d 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -9,6 +9,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } from '@/lib/product-package'; import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; export async function POST(request: NextRequest) { try { @@ -24,6 +25,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { taskNo, + dingtalkApprovalNo, productModel, categoryName, sceneName, @@ -33,6 +35,14 @@ export async function POST(request: NextRequest) { const rawTaskDetailSourceType = body?.taskDetailSourceType; const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType); const taskDetailRequesterUserId = String(body?.taskDetailRequesterUserId || '').trim(); + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + + if (dingtalkApprovalNoError) { + return NextResponse.json( + { success: false, error: dingtalkApprovalNoError }, + { status: 400 } + ); + } if (rawTaskDetailSourceType != null && String(rawTaskDetailSourceType).trim() && !taskDetailSourceType) { return NextResponse.json( diff --git a/app/api/materials/upload/real-photo/complete/route.ts b/app/api/materials/upload/real-photo/complete/route.ts index ff88b1c..81c0ea3 100644 --- a/app/api/materials/upload/real-photo/complete/route.ts +++ b/app/api/materials/upload/real-photo/complete/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets, taskNo } = body; + const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body; materialId = matId; @@ -123,7 +123,7 @@ export async function POST(request: NextRequest) { await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); // 步骤4.5: 仅在素材成功完成后写入任务号 - await assignTaskNoOnComplete(materialId!, taskNo); + await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo); // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/real-photo/init/route.ts b/app/api/materials/upload/real-photo/init/route.ts index dd5c1f6..83bdad7 100644 --- a/app/api/materials/upload/real-photo/init/route.ts +++ b/app/api/materials/upload/real-photo/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; // POST - 初始化实拍原图素材记录 export async function POST(request: NextRequest) { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const { taskNo, dingtalkApprovalNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; // 验证必填字段 if (!productModel || !categoryName || !sceneName) { @@ -58,6 +59,11 @@ export async function POST(request: NextRequest) { } } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json({ error: dingtalkApprovalNoError }, { status: 400 }); + } + // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index a861cd3..4cd1ad0 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -28,6 +28,10 @@ import { resolveOwnedMergedUpload, type OwnedMergedUpload, } from '@/lib/upload-sessions'; +import { + getDingtalkApprovalNoError, + normalizeDingtalkApprovalNo, +} from '@/lib/dingtalk-approval-no'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -234,6 +238,7 @@ export async function POST(request: NextRequest) { } const isTaskOtherMaterial = materialType === '任务-其他'; + const isTaskMaterial = materialType.startsWith('任务-'); const rendererParam = formData.get('renderer') as string | null; // 统一处理renderer:3D场景必须有renderer,其他类型可选 const renderer = requiresRenderer(materialType) @@ -241,6 +246,10 @@ export async function POST(request: NextRequest) { : (rendererParam || ''); const rawTaskNo = (formData.get('taskNo') as string) || ''; const taskNo = isTaskOtherMaterial ? rawTaskNo.trim().toUpperCase() : rawTaskNo; + const rawDingtalkApprovalNo = (formData.get('dingtalkApprovalNo') as string) || ''; + const dingtalkApprovalNo = isTaskMaterial + ? normalizeDingtalkApprovalNo(rawDingtalkApprovalNo) + : null; const productModel = productModelInput || (isTaskOtherMaterial ? taskNo : ''); const shouldAutoFillGeneralCategoryScene = AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型'; @@ -298,6 +307,21 @@ export async function POST(request: NextRequest) { ); } + if (!isTaskMaterial && rawDingtalkApprovalNo.trim()) { + return NextResponse.json( + { error: '钉钉审批编号仅适用于“任务-”素材' }, + { status: 400 } + ); + } + + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json( + { error: dingtalkApprovalNoError }, + { status: 400 } + ); + } + // 任务号验证(可选,如果提供则验证格式和唯一性) if (taskNo && taskNo.trim()) { const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = await import('@/lib/product-package'); @@ -831,31 +855,10 @@ export async function POST(request: NextRequest) { } } - // 如果提供了taskNo,上传完成后写入taskNo(所有素材类型统一处理) - if (taskNo && taskNo.trim()) { - try { - // 双重验证:再次检查taskNo唯一性(防止并发问题) - const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); - if (validateTaskNo(taskNo)) { - const exists = await isTaskNoExists(taskNo); - if (!exists) { - // 写入taskNo和taskStatus - await prisma.material.update({ - where: { id: material.id }, - data: { - taskNo: taskNo, - taskStatus: 'completed', - } as any, - }); - console.log('[Upload] TaskNo written to database:', taskNo); - } else { - console.warn('[Upload] TaskNo already exists, skipping write:', taskNo); - } - } - } catch (taskNoError) { - console.error('[Upload] Failed to write taskNo:', taskNoError); - // taskNo写入失败不影响上传流程,只记录错误 - } + // 文件成功落盘后再写入任务关联字段,避免半成品占用任务号。 + if (isTaskMaterial || taskNo.trim()) { + const { assignTaskNoOnComplete } = await import('@/lib/product-package'); + await assignTaskNoOnComplete(material.id, taskNo, dingtalkApprovalNo); } // 记录上传日志 @@ -884,6 +887,10 @@ export async function POST(request: NextRequest) { oldValue: null, newValue: taskNo || null, }, + dingtalkApprovalNo: { + oldValue: null, + newValue: dingtalkApprovalNo, + }, }, }); @@ -1010,16 +1017,15 @@ export async function POST(request: NextRequest) { console.log(`[Upload] Cleaned up ${cleanupResult.deletedCount} files, ${cleanupResult.failedCount} failed`); } - // 如果素材记录已创建但没有taskNo,删除记录(避免残留) + // 只回滚尚未完成的素材;任务号本身是可选字段,不能据此判断上传是否完成。 if (materialId) { try { const material = await prisma.material.findUnique({ where: { id: materialId }, - select: { taskNo: true }, + select: { taskStatus: true }, }); - // 如果没有taskNo,说明上传未完成,删除记录 - if (material && !material.taskNo) { + if (material && material.taskStatus !== 'completed') { // 先删除关联记录 const materialType = (error as any)?.materialType || '未知'; await deleteMaterialRelatedRecords(materialId, materialType); diff --git a/app/api/materials/upload/task-page/complete/route.ts b/app/api/materials/upload/task-page/complete/route.ts index a388c86..e8c9332 100644 --- a/app/api/materials/upload/task-page/complete/route.ts +++ b/app/api/materials/upload/task-page/complete/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets, taskNo } = body; + const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body; materialId = matId; @@ -123,7 +123,7 @@ export async function POST(request: NextRequest) { await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); // 步骤4.5: 仅在素材成功完成后写入任务号 - await assignTaskNoOnComplete(materialId!, taskNo); + await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo); // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/task-page/init/route.ts b/app/api/materials/upload/task-page/init/route.ts index b66f7dd..e2fc1cb 100644 --- a/app/api/materials/upload/task-page/init/route.ts +++ b/app/api/materials/upload/task-page/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; // POST - 初始化任务-页面素材记录 export async function POST(request: NextRequest) { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const { taskNo, dingtalkApprovalNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; // 验证必填字段 if (!productModel || !categoryName || !sceneName) { @@ -58,6 +59,11 @@ export async function POST(request: NextRequest) { } } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json({ error: dingtalkApprovalNoError }, { status: 400 }); + } + // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, diff --git a/app/api/materials/upload/video-screenshot/complete/route.ts b/app/api/materials/upload/video-screenshot/complete/route.ts index 04507e9..3a84e13 100644 --- a/app/api/materials/upload/video-screenshot/complete/route.ts +++ b/app/api/materials/upload/video-screenshot/complete/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets, taskNo } = body; + const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body; materialId = matId; @@ -123,7 +123,7 @@ export async function POST(request: NextRequest) { await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); // 步骤4.5: 仅在素材成功完成后写入任务号 - await assignTaskNoOnComplete(materialId!, taskNo); + await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo); // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/video-screenshot/init/route.ts b/app/api/materials/upload/video-screenshot/init/route.ts index e1e2047..23a3175 100644 --- a/app/api/materials/upload/video-screenshot/init/route.ts +++ b/app/api/materials/upload/video-screenshot/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; // POST - 初始化视频截图素材记录 export async function POST(request: NextRequest) { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const { taskNo, dingtalkApprovalNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; // 验证必填字段 if (!productModel || !categoryName || !sceneName) { @@ -58,6 +59,11 @@ export async function POST(request: NextRequest) { } } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + return NextResponse.json({ error: dingtalkApprovalNoError }, { status: 400 }); + } + // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 1cc2c8c..4124e53 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' import SearchableUserSelect from '@/components/SearchableUserSelect' +import TaskReferenceFields from '@/components/upload/TaskReferenceFields' import { canManuallyReplacePreview, getSelectableMaterialTypeOptions, @@ -16,6 +17,7 @@ import { requiresProductCategoryName, requiresRenderer, } from '@/lib/material-validation' +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no' interface Material { id: string @@ -30,6 +32,7 @@ interface Material { authorName: string previewUrl?: string | null taskNo?: string | null + dingtalkApprovalNo?: string | null taskStatus?: string | null taskFetchedAt?: string | null isFullSet?: boolean @@ -58,6 +61,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) 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) @@ -136,6 +140,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setAuthorName(data.authorName || '') setUserId(data.userId || '') setTaskNo(data.taskNo || '') + setDingtalkApprovalNo(data.dingtalkApprovalNo || '') setTaskStatus(data.taskStatus || '') setTaskFetchedAt(data.taskFetchedAt || '') setIsFullSet(Boolean(data.isFullSet)) @@ -236,6 +241,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) return } + if (showTaskNo) { + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo) + if (dingtalkApprovalNoError) { + setError(dingtalkApprovalNoError) + return + } + } + if (isFetchedTask) { const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') if (!confirmed) { @@ -257,6 +270,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) formData.append('authorName', authorName) formData.append('userId', userId) formData.append('taskNo', showTaskNo ? taskNo : '') + formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '') formData.append( 'taskDetailRequesterUserId', materialType === '任务-详情' ? taskDetailRequesterUserId : '', @@ -478,18 +492,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) )} {showTaskNo && ( -
- - setTaskNo(event.target.value)} - className="input-apple" - placeholder="TASK + 12-18位数字,可带多级 -01" - /> -
+ )} {materialType === '任务-详情' && ( diff --git a/components/EditMaterialModal.tsx b/components/EditMaterialModal.tsx index 2ef7d6e..065d7c7 100644 --- a/components/EditMaterialModal.tsx +++ b/components/EditMaterialModal.tsx @@ -10,6 +10,8 @@ import { } 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, @@ -31,6 +33,7 @@ interface EditMaterialModalProps { authorName: string userId: string taskNo?: string | null + dingtalkApprovalNo?: string | null taskDetailSourceType?: string | null taskStatus?: string | null taskFetchedAt?: string | null @@ -56,6 +59,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit 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 || '') @@ -182,6 +186,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit return } + if (showTaskNo) { + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo) + if (dingtalkApprovalNoError) { + setError(dingtalkApprovalNoError) + return + } + } + if (isFetchedTask) { const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') if (!confirmed) { @@ -205,6 +217,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit 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', @@ -389,16 +402,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit )} {showTaskNo && ( -
- - setTaskNo(event.target.value)} - className="input-apple" - placeholder="TASK + 12-18位数字,可带多级 -01" - /> -
+ )} {materialType === '任务-详情' && ( diff --git a/components/UploadModal.task-other.test.tsx b/components/UploadModal.task-other.test.tsx index c6d3796..6fffce1 100644 --- a/components/UploadModal.task-other.test.tsx +++ b/components/UploadModal.task-other.test.tsx @@ -51,7 +51,7 @@ describe('UploadModal - 任务-其他', () => { }) as any }) - test('任务-其他应使用公共上传面板形式,只保留任务号 + 预览图片 + 素材文件', async () => { + test('任务-其他应使用公共上传面板形式,任务号与钉钉审批编号并列显示', async () => { const user = userEvent.setup() render() @@ -63,6 +63,7 @@ describe('UploadModal - 任务-其他', () => { expect(screen.getByLabelText('任务号 *')).toBeInTheDocument() }) + expect(screen.getByLabelText('钉钉审批编号')).toBeInTheDocument() expect(screen.getByText('预览图片 *')).toBeInTheDocument() expect(screen.getByText('素材文件 *')).toBeInTheDocument() expect(screen.getByText('支持格式:压缩包(.zip / .rar / .7z)')).toBeInTheDocument() diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index 9070e89..3b995bc 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -60,11 +60,13 @@ import ProductPackageUploader from '@/components/upload/ProductPackageUploader' import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader' import TaskPageUploader from '@/components/upload/TaskPageUploader' import CertificateReportUploader from '@/components/upload/CertificateReportUploader' +import TaskReferenceFields from '@/components/upload/TaskReferenceFields' import { hasDragLeftDropZone, prepareFileDrag, validateImageFiles, } from '@/components/upload/drag-drop-utils' +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no' interface UploadModalProps { onClose: () => void @@ -155,6 +157,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 任务号(所有素材类型支持) const [taskNo, setTaskNo] = useState('') + const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('') // SKU号(视频类素材支持) const [sku, setSku] = useState('') @@ -178,6 +181,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const [retryCount, setRetryCount] = useState(0) const isTaskOtherMaterial = materialType === '任务-其他' + const isTaskMaterial = materialType.startsWith('任务-') const usesDedicatedUploader = SPECIAL_UPLOAD_TYPES.includes(materialType as typeof SPECIAL_UPLOAD_TYPES[number]) const previewInputRef = useRef(null) @@ -239,6 +243,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setProductModel('') setIsBatchMode(false) setTaskNo('') // 切换素材类型时清空任务号 + setDingtalkApprovalNo('') setSku('') // 切换素材类型时清空SKU号 }, [materialType]) @@ -1504,6 +1509,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)') return } + + if (isTaskMaterial) { + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo) + if (dingtalkApprovalNoError) { + setError(dingtalkApprovalNoError) + return + } + } // 任务号为空时仅提醒,不阻止上传(已在UI中显示提醒) @@ -1613,6 +1626,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { productCategoryName, renderer: resolvedRenderer, taskNo: resolvedTaskNo, + dingtalkApprovalNo: isTaskMaterial ? dingtalkApprovalNo.trim() : '', }) // 分片后主文件已在服务器,添加路径信息 @@ -1793,23 +1807,36 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { - {/* 任务号输入框(有专用上传器的任务类型不显示,因为它们有自己的任务号输入) */} - {!usesDedicatedUploader && ( + {/* 专用上传器会在自己的表单内渲染任务关联字段。 */} + {!usesDedicatedUploader && isTaskMaterial && ( + + )} + + {!usesDedicatedUploader && !isTaskMaterial && (
-
)} diff --git a/components/upload/AIOriginalUploader.tsx b/components/upload/AIOriginalUploader.tsx index 7d57f97..a53ce41 100644 --- a/components/upload/AIOriginalUploader.tsx +++ b/components/upload/AIOriginalUploader.tsx @@ -8,6 +8,8 @@ import { prepareFileDrag, validateImageFiles, } from './drag-drop-utils'; +import TaskReferenceFields from './TaskReferenceFields'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; interface AssetFile { file: File; @@ -43,6 +45,7 @@ export default function AIOriginalUploader({ }: AIOriginalUploaderProps) { const [taskNo, setTaskNo] = useState(''); + const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(''); const [productModel, setProductModel] = useState(''); const [categoryName, setCategoryName] = useState(''); const [sceneName, setSceneName] = useState(''); @@ -203,6 +206,11 @@ export default function AIOriginalUploader({ setError('任务号格式错误'); return; } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + setError(dingtalkApprovalNoError); + return; + } if (!productModel.trim()) { setError('请输入产品型号(SPU)'); return; @@ -230,6 +238,7 @@ export default function AIOriginalUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskNo: taskNo || undefined, + dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, productModel, categoryName, sceneName, @@ -287,6 +296,7 @@ export default function AIOriginalUploader({ body: JSON.stringify({ materialId, taskNo: taskNo || undefined, + dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, assets: { preview: [], // 不传预览图,后端自动生成 main: mainAssets.map((m, index) => ({ @@ -330,24 +340,14 @@ export default function AIOriginalUploader({ return (
- {/* 任务号字段 */} -
- - setTaskNo(e.target.value)} - placeholder="TASK + 12-18位数字,如 TASK202601030184" - className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none" - disabled={uploading} - /> - {taskNo && !validateTaskNo(taskNo) && ( -

任务号格式错误

- )} -
+ {/* SPU型号 */}
diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index e929c6b..5c8d296 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -18,6 +18,8 @@ import { prepareFileDrag, validateImageFiles, } from './drag-drop-utils'; +import TaskReferenceFields from './TaskReferenceFields'; +import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; @@ -100,6 +102,7 @@ export default function ProductPackageUploader({ }: ProductPackageUploaderProps) { // 基本信息 const [taskNo, setTaskNo] = useState(''); + const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(''); const [productModel, setProductModel] = useState(''); const [categoryName, setCategoryName] = useState(''); const [sceneName, setSceneName] = useState(''); @@ -442,6 +445,11 @@ export default function ProductPackageUploader({ setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)'); return; } + const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); + if (dingtalkApprovalNoError) { + setError(dingtalkApprovalNoError); + return; + } // 主图改为非必选,只要至少有一张图片即可 if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) { @@ -504,6 +512,7 @@ export default function ProductPackageUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskNo, + dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, productModel, categoryName, sceneName, @@ -613,6 +622,7 @@ export default function ProductPackageUploader({ body: JSON.stringify({ materialId, taskNo, // 传递taskNo,上传完成后写入数据库 + dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, noBarcodes, assets: { main: mainAssets, @@ -689,24 +699,14 @@ export default function ProductPackageUploader({ return (
- {/* 任务号字段(任务-详情独有) */} -
- - setTaskNo(e.target.value)} - placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01" - className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none" - disabled={uploading} - /> - {taskNo && !validateTaskNo(taskNo) && ( -

任务号格式错误

- )} -
+