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) && (
-
任务号格式错误
- )}
-
+
diff --git a/components/upload/RealPhotoUploader.tsx b/components/upload/RealPhotoUploader.tsx
index 59a4716..aa363f1 100644
--- a/components/upload/RealPhotoUploader.tsx
+++ b/components/upload/RealPhotoUploader.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 RealPhotoUploader({
}: RealPhotoUploaderProps) {
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 RealPhotoUploader({
setError('任务号格式错误');
return;
}
+ const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
+ if (dingtalkApprovalNoError) {
+ setError(dingtalkApprovalNoError);
+ return;
+ }
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
@@ -230,6 +238,7 @@ export default function RealPhotoUploader({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel,
categoryName,
sceneName,
@@ -286,6 +295,7 @@ export default function RealPhotoUploader({
body: JSON.stringify({
materialId,
taskNo: taskNo || undefined,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: {
preview: [],
main: mainAssets.map((m, index) => ({
@@ -327,24 +337,14 @@ export default function RealPhotoUploader({
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/TaskPageUploader.tsx b/components/upload/TaskPageUploader.tsx
index 0e9a5f8..751a6fe 100644
--- a/components/upload/TaskPageUploader.tsx
+++ b/components/upload/TaskPageUploader.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 TaskPageUploader({
}: TaskPageUploaderProps) {
const [taskNo, setTaskNo] = useState('');
+ const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
@@ -181,6 +184,11 @@ export default function TaskPageUploader({
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return;
}
+ const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
+ if (dingtalkApprovalNoError) {
+ setError(dingtalkApprovalNoError);
+ return;
+ }
// 验证至少有一张预览图
if (previewImages.length === 0) {
@@ -201,6 +209,7 @@ export default function TaskPageUploader({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel,
categoryName,
sceneName,
@@ -262,6 +271,7 @@ export default function TaskPageUploader({
body: JSON.stringify({
materialId,
taskNo: taskNo || undefined,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: {
main: previewAssets.map((m, index) => ({
uploadId: m.uploadId,
@@ -313,24 +323,14 @@ export default function TaskPageUploader({
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) && (
-
任务号格式错误
- )}
-
+
{/* SPU型号 */}
diff --git a/components/upload/TaskReferenceFields.test.tsx b/components/upload/TaskReferenceFields.test.tsx
new file mode 100644
index 0000000..19b35e0
--- /dev/null
+++ b/components/upload/TaskReferenceFields.test.tsx
@@ -0,0 +1,58 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+
+import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
+
+describe('TaskReferenceFields', () => {
+ test('任务号与钉钉审批编号使用响应式双列布局', () => {
+ const { container } = render(
+
,
+ )
+
+ expect(container.firstChild).toHaveClass('grid-cols-1', 'md:grid-cols-2')
+ expect(screen.getByLabelText('任务号')).toBeInTheDocument()
+ expect(screen.getByLabelText('钉钉审批编号')).toBeInTheDocument()
+ })
+
+ test('任务号输入会统一转为大写', () => {
+ const onTaskNoChange = jest.fn()
+
+ render(
+
,
+ )
+
+ fireEvent.change(screen.getByLabelText('任务号'), {
+ target: { value: 'task202601030184' },
+ })
+
+ expect(onTaskNoChange).toHaveBeenCalledWith('TASK202601030184')
+ })
+
+ test('非法审批编号应显示紧邻输入框的错误状态', () => {
+ render(
+
,
+ )
+
+ const input = screen.getByLabelText('钉钉审批编号')
+ fireEvent.blur(input)
+ expect(input).toHaveAttribute('aria-invalid', 'true')
+ expect(screen.getByText('钉钉审批编号前8位不是有效日期')).toBeInTheDocument()
+ })
+})
diff --git a/components/upload/TaskReferenceFields.tsx b/components/upload/TaskReferenceFields.tsx
new file mode 100644
index 0000000..e1ca22d
--- /dev/null
+++ b/components/upload/TaskReferenceFields.tsx
@@ -0,0 +1,112 @@
+'use client'
+
+import { useState } from 'react'
+
+import {
+ DINGTALK_APPROVAL_NO_MAX_LENGTH,
+ getDingtalkApprovalNoError,
+} from '@/lib/dingtalk-approval-no'
+
+interface TaskReferenceFieldsProps {
+ idPrefix: string
+ taskNo: string
+ onTaskNoChange: (value: string) => void
+ dingtalkApprovalNo: string
+ onDingtalkApprovalNoChange: (value: string) => void
+ taskNoRequired?: boolean
+ disabled?: boolean
+}
+
+function getTaskNoError(value: string): string | null {
+ const normalized = value.trim().toUpperCase()
+ if (!normalized) {
+ return null
+ }
+
+ return /^TASK\d{12,18}(-\d{2})*$/.test(normalized)
+ ? null
+ : '任务号格式错误'
+}
+
+export default function TaskReferenceFields({
+ idPrefix,
+ taskNo,
+ onTaskNoChange,
+ dingtalkApprovalNo,
+ onDingtalkApprovalNoChange,
+ taskNoRequired = false,
+ disabled = false,
+}: TaskReferenceFieldsProps) {
+ const [taskNoTouched, setTaskNoTouched] = useState(false)
+ const [approvalNoTouched, setApprovalNoTouched] = useState(false)
+ const taskNoId = `${idPrefix}-task-no`
+ const taskNoErrorId = `${taskNoId}-error`
+ const approvalNoId = `${idPrefix}-dingtalk-approval-no`
+ const approvalNoErrorId = `${approvalNoId}-error`
+ const taskNoError = getTaskNoError(taskNo)
+ const approvalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
+ const visibleTaskNoError = taskNoTouched ? taskNoError : null
+ const visibleApprovalNoError = approvalNoTouched ? approvalNoError : null
+ const inputClassName =
+ 'w-full rounded-apple border border-gray-300 px-4 py-2 outline-none focus:border-gray-900 focus:ring-2 focus:ring-gray-200 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-gray-400 dark:focus:ring-gray-600'
+
+ return (
+
+
+
+ 任务号{taskNoRequired ? ' *' : ''}
+
+
onTaskNoChange(event.target.value.toUpperCase())}
+ onBlur={() => setTaskNoTouched(true)}
+ placeholder="例如:TASK202601030184"
+ className={inputClassName}
+ disabled={disabled}
+ required={taskNoRequired}
+ aria-invalid={Boolean(visibleTaskNoError)}
+ aria-describedby={visibleTaskNoError ? taskNoErrorId : undefined}
+ />
+ {visibleTaskNoError && (
+
+ {visibleTaskNoError}
+
+ )}
+
+
+
+
+ 钉钉审批编号
+
+
onDingtalkApprovalNoChange(event.target.value)}
+ onBlur={() => setApprovalNoTouched(true)}
+ placeholder="例如:202508211445000157693"
+ className={inputClassName}
+ disabled={disabled}
+ aria-invalid={Boolean(visibleApprovalNoError)}
+ aria-describedby={visibleApprovalNoError ? approvalNoErrorId : undefined}
+ />
+ {visibleApprovalNoError && (
+
+ {visibleApprovalNoError}
+
+ )}
+
+
+ )
+}
diff --git a/components/upload/ThreeDOriginalUploader.tsx b/components/upload/ThreeDOriginalUploader.tsx
index c7e1bec..0b00938 100644
--- a/components/upload/ThreeDOriginalUploader.tsx
+++ b/components/upload/ThreeDOriginalUploader.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 ThreeDOriginalUploader({
}: ThreeDOriginalUploaderProps) {
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 ThreeDOriginalUploader({
setError('任务号格式错误');
return;
}
+ const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
+ if (dingtalkApprovalNoError) {
+ setError(dingtalkApprovalNoError);
+ return;
+ }
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
@@ -230,6 +238,7 @@ export default function ThreeDOriginalUploader({
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 ThreeDOriginalUploader({
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 ThreeDOriginalUploader({
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/VideoScreenshotUploader.tsx b/components/upload/VideoScreenshotUploader.tsx
index b772235..9811404 100644
--- a/components/upload/VideoScreenshotUploader.tsx
+++ b/components/upload/VideoScreenshotUploader.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 VideoScreenshotUploader({
}: VideoScreenshotUploaderProps) {
const [taskNo, setTaskNo] = useState('');
+ const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
@@ -204,6 +207,11 @@ export default function VideoScreenshotUploader({
setError('任务号格式错误');
return;
}
+ const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
+ if (dingtalkApprovalNoError) {
+ setError(dingtalkApprovalNoError);
+ return;
+ }
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
@@ -234,6 +242,7 @@ export default function VideoScreenshotUploader({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel,
categoryName,
sceneName,
@@ -293,6 +302,7 @@ export default function VideoScreenshotUploader({
body: JSON.stringify({
materialId,
taskNo: taskNo || undefined,
+ dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: {
preview: [], // 不传预览图,后端自动生成
main: screenshotAssets.map((m, index) => ({
@@ -345,24 +355,14 @@ export default function VideoScreenshotUploader({
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/file-upload-handlers.ts b/components/upload/file-upload-handlers.ts
index e2e6e60..4273ca6 100644
--- a/components/upload/file-upload-handlers.ts
+++ b/components/upload/file-upload-handlers.ts
@@ -23,6 +23,7 @@ export function buildFormData(params: {
productCategoryName: string
renderer: string
taskNo?: string
+ dingtalkApprovalNo?: string
}): FormData {
const {
previewImages,
@@ -37,6 +38,7 @@ export function buildFormData(params: {
productCategoryName,
renderer,
taskNo,
+ dingtalkApprovalNo,
} = params
const formData = new FormData()
@@ -72,6 +74,9 @@ export function buildFormData(params: {
if (taskNo) {
formData.append('taskNo', taskNo)
}
+ if (dingtalkApprovalNo) {
+ formData.append('dingtalkApprovalNo', dingtalkApprovalNo)
+ }
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, renderer)
diff --git a/lib/__tests__/dingtalk-approval-no.test.ts b/lib/__tests__/dingtalk-approval-no.test.ts
new file mode 100644
index 0000000..738c1f9
--- /dev/null
+++ b/lib/__tests__/dingtalk-approval-no.test.ts
@@ -0,0 +1,39 @@
+import {
+ DINGTALK_APPROVAL_NO_MAX_LENGTH,
+ getDingtalkApprovalNoError,
+ normalizeDingtalkApprovalNo,
+ validateDingtalkApprovalNo,
+} from '@/lib/dingtalk-approval-no'
+
+describe('dingtalk approval number', () => {
+ test('允许不填写', () => {
+ expect(normalizeDingtalkApprovalNo(' ')).toBeNull()
+ expect(validateDingtalkApprovalNo('')).toBe(true)
+ expect(validateDingtalkApprovalNo(null)).toBe(true)
+ })
+
+ test('接受日期前缀加任意长度数字后缀', () => {
+ expect(validateDingtalkApprovalNo('202508211445000157693')).toBe(true)
+ expect(validateDingtalkApprovalNo('202402291')).toBe(true)
+ expect(validateDingtalkApprovalNo(' 20250821123 ')).toBe(true)
+ })
+
+ test('拒绝非数字内容和缺少后续编号', () => {
+ expect(getDingtalkApprovalNoError('20250821A1')).toBe('钉钉审批编号只能输入数字')
+ expect(getDingtalkApprovalNoError('20250821')).toBe('钉钉审批编号需包含8位日期和后续编号')
+ })
+
+ test('拒绝无效日期前缀', () => {
+ expect(getDingtalkApprovalNoError('20250230123')).toBe('钉钉审批编号前8位不是有效日期')
+ expect(getDingtalkApprovalNoError('20230229123')).toBe('钉钉审批编号前8位不是有效日期')
+ expect(getDingtalkApprovalNoError('20251301123')).toBe('钉钉审批编号前8位不是有效日期')
+ })
+
+ test('限制异常超长输入', () => {
+ const value = `20250821${'1'.repeat(DINGTALK_APPROVAL_NO_MAX_LENGTH - 7)}`
+ expect(value).toHaveLength(DINGTALK_APPROVAL_NO_MAX_LENGTH + 1)
+ expect(getDingtalkApprovalNoError(value)).toBe(
+ `钉钉审批编号不能超过${DINGTALK_APPROVAL_NO_MAX_LENGTH}位`,
+ )
+ })
+})
diff --git a/lib/__tests__/material-update.test.ts b/lib/__tests__/material-update.test.ts
index 291d7d2..9dff4e6 100644
--- a/lib/__tests__/material-update.test.ts
+++ b/lib/__tests__/material-update.test.ts
@@ -63,6 +63,7 @@ describe('material-update shared rules', () => {
renderer: 'CR',
authorName: '旧作者',
taskNo: 'TASK202601141713525624',
+ dingtalkApprovalNo: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
isFullSet: true,
@@ -684,6 +685,7 @@ describe('material-update shared rules', () => {
authorName: '任务作者',
userId: 'user-1',
taskNo: 'task202603310001',
+ dingtalkApprovalNo: '202508211445000157693',
},
})
@@ -695,11 +697,65 @@ describe('material-update shared rules', () => {
categoryName: '通用',
sceneName: '通用',
taskNo: 'TASK202603310001',
+ dingtalkApprovalNo: '202508211445000157693',
materialType: '任务-其他',
}),
})
})
+ test('任务素材编辑时应拒绝无效的钉钉审批编号', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
+ ...baseMaterial,
+ materialType: '任务-页面',
+ })
+
+ const result = await updateMaterialWithSharedRules({
+ materialId: 'material-1',
+ operatorUserId: 'user-1',
+ operatorRole: 'USER',
+ input: {
+ productModel: 'SPU-001',
+ categoryName: '店铺A',
+ sceneName: '场景A',
+ materialType: '任务-页面',
+ renderer: '',
+ authorName: '任务作者',
+ dingtalkApprovalNo: '20250230123',
+ },
+ })
+
+ expect(result).toEqual({
+ error: '钉钉审批编号前8位不是有效日期',
+ status: 400,
+ })
+ expect(prisma.material.update).not.toHaveBeenCalled()
+ })
+
+ test('非任务素材不能写入钉钉审批编号', async () => {
+ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
+
+ const result = await updateMaterialWithSharedRules({
+ materialId: 'material-1',
+ operatorUserId: 'user-1',
+ operatorRole: 'USER',
+ input: {
+ productModel: 'SPU-001',
+ categoryName: '店铺A',
+ sceneName: '场景A',
+ materialType: '3D场景',
+ renderer: 'CR',
+ authorName: '作者',
+ dingtalkApprovalNo: '202508211445000157693',
+ },
+ })
+
+ expect(result).toEqual({
+ error: '钉钉审批编号仅适用于“任务-”素材',
+ status: 400,
+ })
+ expect(prisma.material.update).not.toHaveBeenCalled()
+ })
+
test('任务-详情编辑时应按已有资产自动回写 isFullSet', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
diff --git a/lib/__tests__/materials-upload-task-other-route.test.ts b/lib/__tests__/materials-upload-task-other-route.test.ts
index a9b1ec3..dd38903 100644
--- a/lib/__tests__/materials-upload-task-other-route.test.ts
+++ b/lib/__tests__/materials-upload-task-other-route.test.ts
@@ -7,6 +7,7 @@ import { compressImage, sanitizeFileName } from '@/lib/image-processor'
import { createMaterialLog } from '@/lib/material-log'
import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
+import { assignTaskNoOnComplete } from '@/lib/product-package'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -83,6 +84,7 @@ jest.mock('@/lib/public-display', () => ({
jest.mock('@/lib/product-package', () => ({
validateTaskNo: jest.fn(() => true),
isTaskNoExists: jest.fn().mockResolvedValue(false),
+ assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({
deletedMaterials: 0,
deletedFiles: 0,
@@ -143,6 +145,7 @@ describe('materials upload route - 任务-其他', () => {
const formData = new FormData()
formData.append('materialType', '任务-其他')
formData.append('taskNo', 'TASK202603310001')
+ formData.append('dingtalkApprovalNo', '202508211445000157693')
formData.append('productModel', '')
formData.append('categoryName', '')
formData.append('sceneName', '')
@@ -193,6 +196,18 @@ describe('materials upload route - 任务-其他', () => {
expect(prisma.material.create).not.toHaveBeenCalled()
})
+ test('钉钉审批编号日期无效时应在创建素材前返回失败', async () => {
+ const formData = buildTaskOtherFormData()
+ formData.set('dingtalkApprovalNo', '20250230123')
+
+ const response = await POST(createRequest(formData))
+ const body = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(body.error).toBe('钉钉审批编号前8位不是有效日期')
+ expect(prisma.material.create).not.toHaveBeenCalled()
+ })
+
test('正常上传时应自动补通用店铺和场景,并保留原始压缩包文件名', async () => {
const formData = buildTaskOtherFormData()
@@ -226,14 +241,10 @@ describe('materials upload route - 任务-其他', () => {
]),
}),
)
- expect(prisma.material.update).toHaveBeenCalledWith(
- expect.objectContaining({
- where: { id: 'material-task-other-1' },
- data: expect.objectContaining({
- taskNo: 'TASK202603310001',
- taskStatus: 'completed',
- }),
- }),
+ expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
+ 'material-task-other-1',
+ 'TASK202603310001',
+ '202508211445000157693',
)
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts
index 809f89e..e7c5dd6 100644
--- a/lib/__tests__/product-package-completion.test.ts
+++ b/lib/__tests__/product-package-completion.test.ts
@@ -12,6 +12,7 @@ import {
import { createMaterialLog } from '@/lib/material-log'
import { createZipFromFiles } from '@/lib/task-material-processor'
import { getOwnedUploadSession } from '@/lib/upload-sessions'
+import { assignTaskNoOnComplete } from '@/lib/product-package'
jest.mock('@/lib/prisma', () => ({
prisma: {
@@ -114,6 +115,7 @@ describe('product-package-completion', () => {
const result = await completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
+ dingtalkApprovalNo: '202508211445000157693',
operatorUserId: 'user-1',
assets: {
main: [
@@ -148,8 +150,14 @@ describe('product-package-completion', () => {
expect(result).toEqual({
materialId: 'material-1',
taskNo: null,
+ dingtalkApprovalNo: '202508211445000157693',
isFullSet: true,
})
+ expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
+ 'material-1',
+ null,
+ '202508211445000157693',
+ )
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [
'sku-preview-1',
@@ -231,6 +239,7 @@ describe('product-package-completion', () => {
).resolves.toEqual({
materialId: 'material-1',
taskNo: null,
+ dingtalkApprovalNo: null,
isFullSet: true,
})
})
diff --git a/lib/dingtalk-approval-no.ts b/lib/dingtalk-approval-no.ts
new file mode 100644
index 0000000..209c905
--- /dev/null
+++ b/lib/dingtalk-approval-no.ts
@@ -0,0 +1,69 @@
+export const DINGTALK_APPROVAL_NO_MAX_LENGTH = 64
+
+function isLeapYear(year: number): boolean {
+ return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0)
+}
+
+function getDaysInMonth(year: number, month: number): number {
+ if (month === 2) {
+ return isLeapYear(year) ? 29 : 28
+ }
+
+ return [4, 6, 9, 11].includes(month) ? 30 : 31
+}
+
+function hasValidDatePrefix(value: string): boolean {
+ const year = Number.parseInt(value.slice(0, 4), 10)
+ const month = Number.parseInt(value.slice(4, 6), 10)
+ const day = Number.parseInt(value.slice(6, 8), 10)
+
+ if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1) {
+ return false
+ }
+
+ return day <= getDaysInMonth(year, month)
+}
+
+export function normalizeDingtalkApprovalNo(
+ value: string | null | undefined,
+): string | null {
+ if (typeof value !== 'string') {
+ return null
+ }
+
+ const normalized = value.trim()
+ return normalized || null
+}
+
+export function getDingtalkApprovalNoError(
+ value: string | null | undefined,
+): string | null {
+ const normalized = normalizeDingtalkApprovalNo(value)
+ if (!normalized) {
+ return null
+ }
+
+ if (!/^\d+$/.test(normalized)) {
+ return '钉钉审批编号只能输入数字'
+ }
+
+ if (normalized.length < 9) {
+ return '钉钉审批编号需包含8位日期和后续编号'
+ }
+
+ if (normalized.length > DINGTALK_APPROVAL_NO_MAX_LENGTH) {
+ return `钉钉审批编号不能超过${DINGTALK_APPROVAL_NO_MAX_LENGTH}位`
+ }
+
+ if (!hasValidDatePrefix(normalized)) {
+ return '钉钉审批编号前8位不是有效日期'
+ }
+
+ return null
+}
+
+export function validateDingtalkApprovalNo(
+ value: string | null | undefined,
+): boolean {
+ return getDingtalkApprovalNoError(value) === null
+}
diff --git a/lib/material-query.ts b/lib/material-query.ts
index 00639a2..015d470 100644
--- a/lib/material-query.ts
+++ b/lib/material-query.ts
@@ -169,6 +169,7 @@ export async function buildMaterialSearchOrConditions(search: string): Promise
{
const normalizedTaskNo = taskNo?.trim();
+ const hasDingtalkApprovalNo = dingtalkApprovalNo !== undefined;
+ const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo);
+ const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo);
+ if (dingtalkApprovalNoError) {
+ throw new Error(dingtalkApprovalNoError);
+ }
+
if (!normalizedTaskNo) {
await prisma.material.update({
where: { id: materialId },
data: {
+ ...(hasDingtalkApprovalNo
+ ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
+ : {}),
taskStatus: TASK_STATUS.COMPLETED,
} as any,
});
@@ -368,6 +383,9 @@ export async function assignTaskNoOnComplete(
where: { id: materialId },
data: {
taskNo: normalizedTaskNo,
+ ...(hasDingtalkApprovalNo
+ ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
+ : {}),
taskStatus: TASK_STATUS.COMPLETED,
} as any,
});
diff --git a/prisma/migrations/20260729_add_material_dingtalk_approval_no/migration.sql b/prisma/migrations/20260729_add_material_dingtalk_approval_no/migration.sql
new file mode 100644
index 0000000..80201f2
--- /dev/null
+++ b/prisma/migrations/20260729_add_material_dingtalk_approval_no/migration.sql
@@ -0,0 +1,5 @@
+ALTER TABLE "materials"
+ADD COLUMN "dingtalk_approval_no" TEXT;
+
+CREATE INDEX "materials_dingtalk_approval_no_idx"
+ON "materials"("dingtalk_approval_no");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e6ae35f..6124963 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -98,6 +98,7 @@ model Material {
transferredBy String?
transferNote String?
taskNo String? @map("task_no")
+ dingtalkApprovalNo String? @map("dingtalk_approval_no")
taskStatus String? @map("task_status")
taskFetchedAt DateTime? @map("task_fetched_at")
isFullSet Boolean @default(false) @map("is_full_set")
@@ -130,6 +131,7 @@ model Material {
@@index([originalOwnerId])
@@index([transferredAt])
@@index([taskNo])
+ @@index([dingtalkApprovalNo])
@@index([taskStatus])
@@index([isFullSet])
@@index([productModel])