feat: 为任务素材增加钉钉审批编号

This commit is contained in:
chenpei 2026-07-29 11:57:35 +08:00
parent b19a5fff58
commit 362a468d67
40 changed files with 733 additions and 196 deletions

View File

@ -184,6 +184,7 @@ export async function GET(
videoDuration: true, videoDuration: true,
videoProcessingStatus: true, videoProcessingStatus: true,
taskNo: true, taskNo: true,
dingtalkApprovalNo: true,
taskStatus: true, taskStatus: true,
taskFetchedAt: true, taskFetchedAt: true,
isFullSet: true, isFullSet: true,
@ -255,6 +256,7 @@ export async function GET(
videoDuration: true, videoDuration: true,
videoProcessingStatus: true, videoProcessingStatus: true,
taskNo: true, taskNo: true,
dingtalkApprovalNo: true,
taskStatus: true, taskStatus: true,
taskFetchedAt: true, taskFetchedAt: true,
isFullSet: true, isFullSet: true,
@ -415,6 +417,7 @@ export async function GET(
videoDuration: materialWithUser.videoDuration || null, videoDuration: materialWithUser.videoDuration || null,
videoProcessingStatus: materialWithUser.videoProcessingStatus || null, videoProcessingStatus: materialWithUser.videoProcessingStatus || null,
taskNo: materialWithUser.taskNo || null, taskNo: materialWithUser.taskNo || null,
dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null,
taskStatus: materialWithUser.taskStatus || null, taskStatus: materialWithUser.taskStatus || null,
taskFetchedAt: materialWithUser.taskFetchedAt || null, taskFetchedAt: materialWithUser.taskFetchedAt || null,
isFullSet: deriveTaskDetailIsFullSet( isFullSet: deriveTaskDetailIsFullSet(
@ -507,6 +510,9 @@ export async function PUT(
authorName: String(formData.get('authorName') || ''), authorName: String(formData.get('authorName') || ''),
userId: formData.get('userId') as string | null, userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') 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, taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null, taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null, isFullSet: formData.get('isFullSet') as string | null,
@ -545,6 +551,7 @@ export async function PUT(
renderer: result.material.renderer, renderer: result.material.renderer,
authorName: result.material.authorName, authorName: result.material.authorName,
taskNo: result.material.taskNo, taskNo: result.material.taskNo,
dingtalkApprovalNo: (result.material as any).dingtalkApprovalNo || null,
taskDetailSourceType: (result.material as any).taskDetailSourceType || null, taskDetailSourceType: (result.material as any).taskDetailSourceType || null,
taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null, taskDetailRequesterUserId: (result.material as any).taskDetailRequesterUserId || null,
taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null, taskDetailRequesterName: (result.material as any).taskDetailRequesterName || null,

View File

@ -32,6 +32,9 @@ export async function PUT(request: NextRequest) {
authorName: String(formData.get('authorName') || ''), authorName: String(formData.get('authorName') || ''),
userId: formData.get('userId') as string | null, userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') 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, taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null, taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null, isFullSet: formData.get('isFullSet') as string | null,

View File

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); const body = await request.json();
const { materialId: matId, assets, taskNo } = body; const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body;
materialId = matId; materialId = matId;
@ -123,7 +123,7 @@ export async function POST(request: NextRequest) {
await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); await saveAssets(materialId!, uploadedPreviewUrls, zipInfo);
// 步骤4.5: 仅在素材成功完成后写入任务号 // 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo); await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
// 步骤5: 清理临时文件 // 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds); await cleanupTempFiles(tempFiles, uploadIds);

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
// POST - 初始化3D原图素材记录 // POST - 初始化3D原图素材记录
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); 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) { 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({ const user = await prisma.user.findUnique({
where: { id: (session.user as any).id }, where: { id: (session.user as any).id },

View File

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); const body = await request.json();
const { materialId: matId, assets, taskNo } = body; const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body;
materialId = matId; materialId = matId;
@ -123,7 +123,7 @@ export async function POST(request: NextRequest) {
await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); await saveAssets(materialId!, uploadedPreviewUrls, zipInfo);
// 步骤4.5: 仅在素材成功完成后写入任务号 // 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo); await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
// 步骤5: 清理临时文件 // 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds); await cleanupTempFiles(tempFiles, uploadIds);

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
// POST - 初始化AI原图素材记录 // POST - 初始化AI原图素材记录
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); 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) { 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({ const user = await prisma.user.findUnique({
where: { id: (session.user as any).id }, where: { id: (session.user as any).id },

View File

@ -19,6 +19,7 @@ export const maxDuration = 10800
interface CompleteRequestBody { interface CompleteRequestBody {
materialId: string materialId: string
taskNo?: string taskNo?: string
dingtalkApprovalNo?: string
noBarcodes?: boolean noBarcodes?: boolean
assets: unknown assets: unknown
} }
@ -88,6 +89,7 @@ export async function POST(request: NextRequest) {
const result = await completeProductPackageUpload({ const result = await completeProductPackageUpload({
materialId: body.materialId, materialId: body.materialId,
taskNo: body.taskNo, taskNo: body.taskNo,
dingtalkApprovalNo: body.dingtalkApprovalNo,
assets, assets,
operatorUserId: (session.user as any).id, operatorUserId: (session.user as any).id,
options: { options: {
@ -100,6 +102,7 @@ export async function POST(request: NextRequest) {
data: { data: {
materialId: result.materialId, materialId: result.materialId,
taskNo: result.taskNo, taskNo: result.taskNo,
dingtalkApprovalNo: result.dingtalkApprovalNo,
isFullSet: result.isFullSet, isFullSet: result.isFullSet,
message: '上传完成', message: '上传完成',
}, },

View File

@ -9,6 +9,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } from '@/lib/product-package'; import { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } from '@/lib/product-package';
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'; import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@ -24,6 +25,7 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { const {
taskNo, taskNo,
dingtalkApprovalNo,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -33,6 +35,14 @@ export async function POST(request: NextRequest) {
const rawTaskDetailSourceType = body?.taskDetailSourceType; const rawTaskDetailSourceType = body?.taskDetailSourceType;
const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType); const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType);
const taskDetailRequesterUserId = String(body?.taskDetailRequesterUserId || '').trim(); 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) { if (rawTaskDetailSourceType != null && String(rawTaskDetailSourceType).trim() && !taskDetailSourceType) {
return NextResponse.json( return NextResponse.json(

View File

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); const body = await request.json();
const { materialId: matId, assets, taskNo } = body; const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body;
materialId = matId; materialId = matId;
@ -123,7 +123,7 @@ export async function POST(request: NextRequest) {
await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); await saveAssets(materialId!, uploadedPreviewUrls, zipInfo);
// 步骤4.5: 仅在素材成功完成后写入任务号 // 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo); await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
// 步骤5: 清理临时文件 // 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds); await cleanupTempFiles(tempFiles, uploadIds);

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
// POST - 初始化实拍原图素材记录 // POST - 初始化实拍原图素材记录
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); 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) { 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({ const user = await prisma.user.findUnique({
where: { id: (session.user as any).id }, where: { id: (session.user as any).id },

View File

@ -28,6 +28,10 @@ import {
resolveOwnedMergedUpload, resolveOwnedMergedUpload,
type OwnedMergedUpload, type OwnedMergedUpload,
} from '@/lib/upload-sessions'; } from '@/lib/upload-sessions';
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from '@/lib/dingtalk-approval-no';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -234,6 +238,7 @@ export async function POST(request: NextRequest) {
} }
const isTaskOtherMaterial = materialType === '任务-其他'; const isTaskOtherMaterial = materialType === '任务-其他';
const isTaskMaterial = materialType.startsWith('任务-');
const rendererParam = formData.get('renderer') as string | null; const rendererParam = formData.get('renderer') as string | null;
// 统一处理renderer:3D场景必须有renderer,其他类型可选 // 统一处理renderer:3D场景必须有renderer,其他类型可选
const renderer = requiresRenderer(materialType) const renderer = requiresRenderer(materialType)
@ -241,6 +246,10 @@ export async function POST(request: NextRequest) {
: (rendererParam || ''); : (rendererParam || '');
const rawTaskNo = (formData.get('taskNo') as string) || ''; const rawTaskNo = (formData.get('taskNo') as string) || '';
const taskNo = isTaskOtherMaterial ? rawTaskNo.trim().toUpperCase() : rawTaskNo; 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 productModel = productModelInput || (isTaskOtherMaterial ? taskNo : '');
const shouldAutoFillGeneralCategoryScene = const shouldAutoFillGeneralCategoryScene =
AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型'; 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()) { if (taskNo && taskNo.trim()) {
const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = await import('@/lib/product-package'); 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()) { if (isTaskMaterial || taskNo.trim()) {
try { const { assignTaskNoOnComplete } = await import('@/lib/product-package');
// 双重验证:再次检查taskNo唯一性(防止并发问题) await assignTaskNoOnComplete(material.id, taskNo, dingtalkApprovalNo);
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写入失败不影响上传流程,只记录错误
}
} }
// 记录上传日志 // 记录上传日志
@ -884,6 +887,10 @@ export async function POST(request: NextRequest) {
oldValue: null, oldValue: null,
newValue: taskNo || 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`); console.log(`[Upload] Cleaned up ${cleanupResult.deletedCount} files, ${cleanupResult.failedCount} failed`);
} }
// 如果素材记录已创建但没有taskNo,删除记录(避免残留) // 只回滚尚未完成的素材;任务号本身是可选字段,不能据此判断上传是否完成。
if (materialId) { if (materialId) {
try { try {
const material = await prisma.material.findUnique({ const material = await prisma.material.findUnique({
where: { id: materialId }, where: { id: materialId },
select: { taskNo: true }, select: { taskStatus: true },
}); });
// 如果没有taskNo,说明上传未完成,删除记录 if (material && material.taskStatus !== 'completed') {
if (material && !material.taskNo) {
// 先删除关联记录 // 先删除关联记录
const materialType = (error as any)?.materialType || '未知'; const materialType = (error as any)?.materialType || '未知';
await deleteMaterialRelatedRecords(materialId, materialType); await deleteMaterialRelatedRecords(materialId, materialType);

View File

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); const body = await request.json();
const { materialId: matId, assets, taskNo } = body; const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body;
materialId = matId; materialId = matId;
@ -123,7 +123,7 @@ export async function POST(request: NextRequest) {
await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); await saveAssets(materialId!, uploadedPreviewUrls, zipInfo);
// 步骤4.5: 仅在素材成功完成后写入任务号 // 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo); await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
// 步骤5: 清理临时文件 // 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds); await cleanupTempFiles(tempFiles, uploadIds);

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
// POST - 初始化任务-页面素材记录 // POST - 初始化任务-页面素材记录
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); 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) { 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({ const user = await prisma.user.findUnique({
where: { id: (session.user as any).id }, where: { id: (session.user as any).id },

View File

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); const body = await request.json();
const { materialId: matId, assets, taskNo } = body; const { materialId: matId, assets, taskNo, dingtalkApprovalNo } = body;
materialId = matId; materialId = matId;
@ -123,7 +123,7 @@ export async function POST(request: NextRequest) {
await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); await saveAssets(materialId!, uploadedPreviewUrls, zipInfo);
// 步骤4.5: 仅在素材成功完成后写入任务号 // 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo); await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
// 步骤5: 清理临时文件 // 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds); await cleanupTempFiles(tempFiles, uploadIds);

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
// POST - 初始化视频截图素材记录 // POST - 初始化视频截图素材记录
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json(); 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) { 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({ const user = await prisma.user.findUnique({
where: { id: (session.user as any).id }, where: { id: (session.user as any).id },

View File

@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Header from '@/components/Header' import Header from '@/components/Header'
import SearchableUserSelect from '@/components/SearchableUserSelect' import SearchableUserSelect from '@/components/SearchableUserSelect'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import { import {
canManuallyReplacePreview, canManuallyReplacePreview,
getSelectableMaterialTypeOptions, getSelectableMaterialTypeOptions,
@ -16,6 +17,7 @@ import {
requiresProductCategoryName, requiresProductCategoryName,
requiresRenderer, requiresRenderer,
} from '@/lib/material-validation' } from '@/lib/material-validation'
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
interface Material { interface Material {
id: string id: string
@ -30,6 +32,7 @@ interface Material {
authorName: string authorName: string
previewUrl?: string | null previewUrl?: string | null
taskNo?: string | null taskNo?: string | null
dingtalkApprovalNo?: string | null
taskStatus?: string | null taskStatus?: string | null
taskFetchedAt?: string | null taskFetchedAt?: string | null
isFullSet?: boolean isFullSet?: boolean
@ -58,6 +61,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [authorName, setAuthorName] = useState('') const [authorName, setAuthorName] = useState('')
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [taskNo, setTaskNo] = useState('') const [taskNo, setTaskNo] = useState('')
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('')
const [taskStatus, setTaskStatus] = useState('') const [taskStatus, setTaskStatus] = useState('')
const [taskFetchedAt, setTaskFetchedAt] = useState('') const [taskFetchedAt, setTaskFetchedAt] = useState('')
const [isFullSet, setIsFullSet] = useState(false) const [isFullSet, setIsFullSet] = useState(false)
@ -136,6 +140,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setAuthorName(data.authorName || '') setAuthorName(data.authorName || '')
setUserId(data.userId || '') setUserId(data.userId || '')
setTaskNo(data.taskNo || '') setTaskNo(data.taskNo || '')
setDingtalkApprovalNo(data.dingtalkApprovalNo || '')
setTaskStatus(data.taskStatus || '') setTaskStatus(data.taskStatus || '')
setTaskFetchedAt(data.taskFetchedAt || '') setTaskFetchedAt(data.taskFetchedAt || '')
setIsFullSet(Boolean(data.isFullSet)) setIsFullSet(Boolean(data.isFullSet))
@ -236,6 +241,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
return return
} }
if (showTaskNo) {
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError)
return
}
}
if (isFetchedTask) { if (isFetchedTask) {
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
if (!confirmed) { if (!confirmed) {
@ -257,6 +270,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
formData.append('authorName', authorName) formData.append('authorName', authorName)
formData.append('userId', userId) formData.append('userId', userId)
formData.append('taskNo', showTaskNo ? taskNo : '') formData.append('taskNo', showTaskNo ? taskNo : '')
formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '')
formData.append( formData.append(
'taskDetailRequesterUserId', 'taskDetailRequesterUserId',
materialType === '任务-详情' ? taskDetailRequesterUserId : '', materialType === '任务-详情' ? taskDetailRequesterUserId : '',
@ -478,18 +492,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
)} )}
{showTaskNo && ( {showTaskNo && (
<div> <TaskReferenceFields
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> idPrefix={`edit-material-page-${params.id}`}
任务号 taskNo={taskNo}
</label> onTaskNoChange={setTaskNo}
<input dingtalkApprovalNo={dingtalkApprovalNo}
type="text" onDingtalkApprovalNoChange={setDingtalkApprovalNo}
value={taskNo} disabled={submitting}
onChange={(event) => setTaskNo(event.target.value)} />
className="input-apple"
placeholder="TASK + 12-18位数字,可带多级 -01"
/>
</div>
)} )}
{materialType === '任务-详情' && ( {materialType === '任务-详情' && (

View File

@ -10,6 +10,8 @@ import {
} from '@/lib/material-type-options' } from '@/lib/material-type-options'
import { isCertificateReportMaterialType } from '@/lib/certificate-report' import { isCertificateReportMaterialType } from '@/lib/certificate-report'
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type' 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 { import {
requiresCategoryAndScene, requiresCategoryAndScene,
requiresProductCategoryName, requiresProductCategoryName,
@ -31,6 +33,7 @@ interface EditMaterialModalProps {
authorName: string authorName: string
userId: string userId: string
taskNo?: string | null taskNo?: string | null
dingtalkApprovalNo?: string | null
taskDetailSourceType?: string | null taskDetailSourceType?: string | null
taskStatus?: string | null taskStatus?: string | null
taskFetchedAt?: string | null taskFetchedAt?: string | null
@ -56,6 +59,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
const [authorName, setAuthorName] = useState(material.authorName) const [authorName, setAuthorName] = useState(material.authorName)
const [userId, setUserId] = useState(material.userId) const [userId, setUserId] = useState(material.userId)
const [taskNo, setTaskNo] = useState(material.taskNo || '') const [taskNo, setTaskNo] = useState(material.taskNo || '')
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(material.dingtalkApprovalNo || '')
const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '') const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '')
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '') const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '')
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '') const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '')
@ -182,6 +186,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
return return
} }
if (showTaskNo) {
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError)
return
}
}
if (isFetchedTask) { if (isFetchedTask) {
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?') const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
if (!confirmed) { if (!confirmed) {
@ -205,6 +217,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
formData.append('authorName', authorName) formData.append('authorName', authorName)
formData.append('userId', userId) formData.append('userId', userId)
formData.append('taskNo', showTaskNo ? taskNo : '') formData.append('taskNo', showTaskNo ? taskNo : '')
formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '')
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '') formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
formData.append( formData.append(
'taskDetailRequesterUserId', 'taskDetailRequesterUserId',
@ -389,16 +402,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
)} )}
{showTaskNo && ( {showTaskNo && (
<div> <TaskReferenceFields
<label className="mb-2 block text-sm font-medium text-gray-700">任务号</label> idPrefix={`edit-material-${material.id}`}
<input taskNo={taskNo}
type="text" onTaskNoChange={setTaskNo}
value={taskNo} dingtalkApprovalNo={dingtalkApprovalNo}
onChange={(event) => setTaskNo(event.target.value)} onDingtalkApprovalNoChange={setDingtalkApprovalNo}
className="input-apple" disabled={updating}
placeholder="TASK + 12-18位数字,可带多级 -01" />
/>
</div>
)} )}
{materialType === '任务-详情' && ( {materialType === '任务-详情' && (

View File

@ -51,7 +51,7 @@ describe('UploadModal - 任务-其他', () => {
}) as any }) as any
}) })
test('任务-其他应使用公共上传面板形式,只保留任务号 + 预览图片 + 素材文件', async () => { test('任务-其他应使用公共上传面板形式,任务号与钉钉审批编号并列显示', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />) render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
@ -63,6 +63,7 @@ describe('UploadModal - 任务-其他', () => {
expect(screen.getByLabelText('任务号 *')).toBeInTheDocument() expect(screen.getByLabelText('任务号 *')).toBeInTheDocument()
}) })
expect(screen.getByLabelText('钉钉审批编号')).toBeInTheDocument()
expect(screen.getByText('预览图片 *')).toBeInTheDocument() expect(screen.getByText('预览图片 *')).toBeInTheDocument()
expect(screen.getByText('素材文件 *')).toBeInTheDocument() expect(screen.getByText('素材文件 *')).toBeInTheDocument()
expect(screen.getByText('支持格式:压缩包(.zip / .rar / .7z)')).toBeInTheDocument() expect(screen.getByText('支持格式:压缩包(.zip / .rar / .7z)')).toBeInTheDocument()

View File

@ -60,11 +60,13 @@ import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader' import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader'
import TaskPageUploader from '@/components/upload/TaskPageUploader' import TaskPageUploader from '@/components/upload/TaskPageUploader'
import CertificateReportUploader from '@/components/upload/CertificateReportUploader' import CertificateReportUploader from '@/components/upload/CertificateReportUploader'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import { import {
hasDragLeftDropZone, hasDragLeftDropZone,
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from '@/components/upload/drag-drop-utils' } from '@/components/upload/drag-drop-utils'
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
interface UploadModalProps { interface UploadModalProps {
onClose: () => void onClose: () => void
@ -155,6 +157,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// 任务号(所有素材类型支持) // 任务号(所有素材类型支持)
const [taskNo, setTaskNo] = useState('') const [taskNo, setTaskNo] = useState('')
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('')
// SKU号(视频类素材支持) // SKU号(视频类素材支持)
const [sku, setSku] = useState('') const [sku, setSku] = useState('')
@ -178,6 +181,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const [retryCount, setRetryCount] = useState(0) const [retryCount, setRetryCount] = useState(0)
const isTaskOtherMaterial = materialType === '任务-其他' const isTaskOtherMaterial = materialType === '任务-其他'
const isTaskMaterial = materialType.startsWith('任务-')
const usesDedicatedUploader = SPECIAL_UPLOAD_TYPES.includes(materialType as typeof SPECIAL_UPLOAD_TYPES[number]) const usesDedicatedUploader = SPECIAL_UPLOAD_TYPES.includes(materialType as typeof SPECIAL_UPLOAD_TYPES[number])
const previewInputRef = useRef<HTMLInputElement>(null) const previewInputRef = useRef<HTMLInputElement>(null)
@ -239,6 +243,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
setProductModel('') setProductModel('')
setIsBatchMode(false) setIsBatchMode(false)
setTaskNo('') // 切换素材类型时清空任务号 setTaskNo('') // 切换素材类型时清空任务号
setDingtalkApprovalNo('')
setSku('') // 切换素材类型时清空SKU号 setSku('') // 切换素材类型时清空SKU号
}, [materialType]) }, [materialType])
@ -1504,6 +1509,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)') setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)')
return return
} }
if (isTaskMaterial) {
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError)
return
}
}
// 任务号为空时仅提醒,不阻止上传(已在UI中显示提醒) // 任务号为空时仅提醒,不阻止上传(已在UI中显示提醒)
@ -1613,6 +1626,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
productCategoryName, productCategoryName,
renderer: resolvedRenderer, renderer: resolvedRenderer,
taskNo: resolvedTaskNo, taskNo: resolvedTaskNo,
dingtalkApprovalNo: isTaskMaterial ? dingtalkApprovalNo.trim() : '',
}) })
// 分片后主文件已在服务器,添加路径信息 // 分片后主文件已在服务器,添加路径信息
@ -1793,23 +1807,36 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</select> </select>
</div> </div>
{/* 任务号输入框(有专用上传器的任务类型不显示,因为它们有自己的任务号输入) */} {/* 专用上传器会在自己的表单内渲染任务关联字段。 */}
{!usesDedicatedUploader && ( {!usesDedicatedUploader && isTaskMaterial && (
<TaskReferenceFields
idPrefix="generic-task-upload"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
taskNoRequired={isTaskOtherMaterial}
disabled={uploading}
/>
)}
{!usesDedicatedUploader && !isTaskMaterial && (
<div> <div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label htmlFor="taskNo" className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{isTaskOtherMaterial ? '任务号 *' : <>任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span></>} 任务号
</label> </label>
<input <input
id="taskNo" id="taskNo"
type="text" type="text"
value={taskNo} value={taskNo}
onChange={(e) => setTaskNo(e.target.value)} onChange={(event) => setTaskNo(event.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01" placeholder="例如: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" className="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 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-gray-400 dark:focus:ring-gray-600"
disabled={uploading} disabled={uploading}
aria-invalid={Boolean(taskNo && !validateTaskNo(taskNo.trim().toUpperCase()))}
/> />
{taskNo && !validateTaskNo(taskNo.trim().toUpperCase()) && ( {taskNo && !validateTaskNo(taskNo.trim().toUpperCase()) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p> <p className="mt-1 text-sm text-red-600 dark:text-red-400">任务号格式错误</p>
)} )}
</div> </div>
)} )}

View File

@ -8,6 +8,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile { interface AssetFile {
file: File; file: File;
@ -43,6 +45,7 @@ export default function AIOriginalUploader({
}: AIOriginalUploaderProps) { }: AIOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -203,6 +206,11 @@ export default function AIOriginalUploader({
setError('任务号格式错误'); setError('任务号格式错误');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
if (!productModel.trim()) { if (!productModel.trim()) {
setError('请输入产品型号(SPU)'); setError('请输入产品型号(SPU)');
return; return;
@ -230,6 +238,7 @@ export default function AIOriginalUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -287,6 +296,7 @@ export default function AIOriginalUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: { assets: {
preview: [], // 不传预览图,后端自动生成 preview: [], // 不传预览图,后端自动生成
main: mainAssets.map((m, index) => ({ main: mainAssets.map((m, index) => ({
@ -330,24 +340,14 @@ export default function AIOriginalUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段 */} <TaskReferenceFields
<div> idPrefix="ai-original"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */} {/* SPU型号 */}
<div> <div>

View File

@ -18,6 +18,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } 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_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const;
const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z';
@ -100,6 +102,7 @@ export default function ProductPackageUploader({
}: ProductPackageUploaderProps) { }: ProductPackageUploaderProps) {
// 基本信息 // 基本信息
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -442,6 +445,11 @@ export default function ProductPackageUploader({
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)'); setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
// 主图改为非必选,只要至少有一张图片即可 // 主图改为非必选,只要至少有一张图片即可
if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) { if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) {
@ -504,6 +512,7 @@ export default function ProductPackageUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo, taskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -613,6 +622,7 @@ export default function ProductPackageUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo, // 传递taskNo,上传完成后写入数据库 taskNo, // 传递taskNo,上传完成后写入数据库
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
noBarcodes, noBarcodes,
assets: { assets: {
main: mainAssets, main: mainAssets,
@ -689,24 +699,14 @@ export default function ProductPackageUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段(任务-详情独有) */} <TaskReferenceFields
<div> idPrefix="product-package"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">

View File

@ -8,6 +8,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile { interface AssetFile {
file: File; file: File;
@ -43,6 +45,7 @@ export default function RealPhotoUploader({
}: RealPhotoUploaderProps) { }: RealPhotoUploaderProps) {
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -203,6 +206,11 @@ export default function RealPhotoUploader({
setError('任务号格式错误'); setError('任务号格式错误');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
if (!productModel.trim()) { if (!productModel.trim()) {
setError('请输入产品型号(SPU)'); setError('请输入产品型号(SPU)');
return; return;
@ -230,6 +238,7 @@ export default function RealPhotoUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -286,6 +295,7 @@ export default function RealPhotoUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: { assets: {
preview: [], preview: [],
main: mainAssets.map((m, index) => ({ main: mainAssets.map((m, index) => ({
@ -327,24 +337,14 @@ export default function RealPhotoUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段 */} <TaskReferenceFields
<div> idPrefix="real-photo"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */} {/* SPU型号 */}
<div> <div>

View File

@ -8,6 +8,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile { interface AssetFile {
file: File; file: File;
@ -43,6 +45,7 @@ export default function TaskPageUploader({
}: TaskPageUploaderProps) { }: TaskPageUploaderProps) {
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -181,6 +184,11 @@ export default function TaskPageUploader({
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)'); setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
// 验证至少有一张预览图 // 验证至少有一张预览图
if (previewImages.length === 0) { if (previewImages.length === 0) {
@ -201,6 +209,7 @@ export default function TaskPageUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo, taskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -262,6 +271,7 @@ export default function TaskPageUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: { assets: {
main: previewAssets.map((m, index) => ({ main: previewAssets.map((m, index) => ({
uploadId: m.uploadId, uploadId: m.uploadId,
@ -313,24 +323,14 @@ export default function TaskPageUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段 */} <TaskReferenceFields
<div> idPrefix="task-page"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */} {/* SPU型号 */}
<div> <div>

View File

@ -0,0 +1,58 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
describe('TaskReferenceFields', () => {
test('任务号与钉钉审批编号使用响应式双列布局', () => {
const { container } = render(
<TaskReferenceFields
idPrefix="task-reference"
taskNo=""
onTaskNoChange={jest.fn()}
dingtalkApprovalNo=""
onDingtalkApprovalNoChange={jest.fn()}
/>,
)
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(
<TaskReferenceFields
idPrefix="task-reference"
taskNo=""
onTaskNoChange={onTaskNoChange}
dingtalkApprovalNo=""
onDingtalkApprovalNoChange={jest.fn()}
/>,
)
fireEvent.change(screen.getByLabelText('任务号'), {
target: { value: 'task202601030184' },
})
expect(onTaskNoChange).toHaveBeenCalledWith('TASK202601030184')
})
test('非法审批编号应显示紧邻输入框的错误状态', () => {
render(
<TaskReferenceFields
idPrefix="task-reference"
taskNo=""
onTaskNoChange={jest.fn()}
dingtalkApprovalNo="20250230123"
onDingtalkApprovalNoChange={jest.fn()}
/>,
)
const input = screen.getByLabelText('钉钉审批编号')
fireEvent.blur(input)
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByText('钉钉审批编号前8位不是有效日期')).toBeInTheDocument()
})
})

View File

@ -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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="min-w-0">
<label
htmlFor={taskNoId}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
任务号{taskNoRequired ? ' *' : ''}
</label>
<input
id={taskNoId}
type="text"
value={taskNo}
onChange={(event) => 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 && (
<p id={taskNoErrorId} className="mt-1 text-sm text-red-600 dark:text-red-400">
{visibleTaskNoError}
</p>
)}
</div>
<div className="min-w-0">
<label
htmlFor={approvalNoId}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
钉钉审批编号
</label>
<input
id={approvalNoId}
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={DINGTALK_APPROVAL_NO_MAX_LENGTH}
value={dingtalkApprovalNo}
onChange={(event) => onDingtalkApprovalNoChange(event.target.value)}
onBlur={() => setApprovalNoTouched(true)}
placeholder="例如:202508211445000157693"
className={inputClassName}
disabled={disabled}
aria-invalid={Boolean(visibleApprovalNoError)}
aria-describedby={visibleApprovalNoError ? approvalNoErrorId : undefined}
/>
{visibleApprovalNoError && (
<p id={approvalNoErrorId} className="mt-1 text-sm text-red-600 dark:text-red-400">
{visibleApprovalNoError}
</p>
)}
</div>
</div>
)
}

View File

@ -8,6 +8,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile { interface AssetFile {
file: File; file: File;
@ -43,6 +45,7 @@ export default function ThreeDOriginalUploader({
}: ThreeDOriginalUploaderProps) { }: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -203,6 +206,11 @@ export default function ThreeDOriginalUploader({
setError('任务号格式错误'); setError('任务号格式错误');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
if (!productModel.trim()) { if (!productModel.trim()) {
setError('请输入产品型号(SPU)'); setError('请输入产品型号(SPU)');
return; return;
@ -230,6 +238,7 @@ export default function ThreeDOriginalUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -287,6 +296,7 @@ export default function ThreeDOriginalUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: { assets: {
preview: [], // 不传预览图,后端自动生成 preview: [], // 不传预览图,后端自动生成
main: mainAssets.map((m, index) => ({ main: mainAssets.map((m, index) => ({
@ -330,24 +340,14 @@ export default function ThreeDOriginalUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段 */} <TaskReferenceFields
<div> idPrefix="three-d-original"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */} {/* SPU型号 */}
<div> <div>

View File

@ -8,6 +8,8 @@ import {
prepareFileDrag, prepareFileDrag,
validateImageFiles, validateImageFiles,
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile { interface AssetFile {
file: File; file: File;
@ -43,6 +45,7 @@ export default function VideoScreenshotUploader({
}: VideoScreenshotUploaderProps) { }: VideoScreenshotUploaderProps) {
const [taskNo, setTaskNo] = useState(''); const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState(''); const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState(''); const [sceneName, setSceneName] = useState('');
@ -204,6 +207,11 @@ export default function VideoScreenshotUploader({
setError('任务号格式错误'); setError('任务号格式错误');
return; return;
} }
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
if (!productModel.trim()) { if (!productModel.trim()) {
setError('请输入产品型号(SPU)'); setError('请输入产品型号(SPU)');
return; return;
@ -234,6 +242,7 @@ export default function VideoScreenshotUploader({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel, productModel,
categoryName, categoryName,
sceneName, sceneName,
@ -293,6 +302,7 @@ export default function VideoScreenshotUploader({
body: JSON.stringify({ body: JSON.stringify({
materialId, materialId,
taskNo: taskNo || undefined, taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: { assets: {
preview: [], // 不传预览图,后端自动生成 preview: [], // 不传预览图,后端自动生成
main: screenshotAssets.map((m, index) => ({ main: screenshotAssets.map((m, index) => ({
@ -345,24 +355,14 @@ export default function VideoScreenshotUploader({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 任务号字段 */} <TaskReferenceFields
<div> idPrefix="video-screenshot"
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> taskNo={taskNo}
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span> onTaskNoChange={setTaskNo}
</label> dingtalkApprovalNo={dingtalkApprovalNo}
<input onDingtalkApprovalNoChange={setDingtalkApprovalNo}
id="taskNo" disabled={uploading}
type="text" />
value={taskNo}
onChange={(e) => 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) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */} {/* SPU型号 */}
<div> <div>

View File

@ -23,6 +23,7 @@ export function buildFormData(params: {
productCategoryName: string productCategoryName: string
renderer: string renderer: string
taskNo?: string taskNo?: string
dingtalkApprovalNo?: string
}): FormData { }): FormData {
const { const {
previewImages, previewImages,
@ -37,6 +38,7 @@ export function buildFormData(params: {
productCategoryName, productCategoryName,
renderer, renderer,
taskNo, taskNo,
dingtalkApprovalNo,
} = params } = params
const formData = new FormData() const formData = new FormData()
@ -72,6 +74,9 @@ export function buildFormData(params: {
if (taskNo) { if (taskNo) {
formData.append('taskNo', taskNo) formData.append('taskNo', taskNo)
} }
if (dingtalkApprovalNo) {
formData.append('dingtalkApprovalNo', dingtalkApprovalNo)
}
// 统一处理renderer字段:根据素材类型判断是否需要传递 // 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, renderer) const rendererInfo = shouldPassRenderer(materialType, renderer)

View File

@ -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}位`,
)
})
})

View File

@ -63,6 +63,7 @@ describe('material-update shared rules', () => {
renderer: 'CR', renderer: 'CR',
authorName: '旧作者', authorName: '旧作者',
taskNo: 'TASK202601141713525624', taskNo: 'TASK202601141713525624',
dingtalkApprovalNo: null,
taskDetailRequesterUserId: null, taskDetailRequesterUserId: null,
taskDetailRequesterName: null, taskDetailRequesterName: null,
isFullSet: true, isFullSet: true,
@ -684,6 +685,7 @@ describe('material-update shared rules', () => {
authorName: '任务作者', authorName: '任务作者',
userId: 'user-1', userId: 'user-1',
taskNo: 'task202603310001', taskNo: 'task202603310001',
dingtalkApprovalNo: '202508211445000157693',
}, },
}) })
@ -695,11 +697,65 @@ describe('material-update shared rules', () => {
categoryName: '通用', categoryName: '通用',
sceneName: '通用', sceneName: '通用',
taskNo: 'TASK202603310001', taskNo: 'TASK202603310001',
dingtalkApprovalNo: '202508211445000157693',
materialType: '任务-其他', 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 () => { test('任务-详情编辑时应按已有资产自动回写 isFullSet', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial, ...baseMaterial,

View File

@ -7,6 +7,7 @@ import { compressImage, sanitizeFileName } from '@/lib/image-processor'
import { createMaterialLog } from '@/lib/material-log' import { createMaterialLog } from '@/lib/material-log'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { assignTaskNoOnComplete } from '@/lib/product-package'
jest.mock('next-auth', () => ({ jest.mock('next-auth', () => ({
getServerSession: jest.fn(), getServerSession: jest.fn(),
@ -83,6 +84,7 @@ jest.mock('@/lib/public-display', () => ({
jest.mock('@/lib/product-package', () => ({ jest.mock('@/lib/product-package', () => ({
validateTaskNo: jest.fn(() => true), validateTaskNo: jest.fn(() => true),
isTaskNoExists: jest.fn().mockResolvedValue(false), isTaskNoExists: jest.fn().mockResolvedValue(false),
assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({ purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({
deletedMaterials: 0, deletedMaterials: 0,
deletedFiles: 0, deletedFiles: 0,
@ -143,6 +145,7 @@ describe('materials upload route - 任务-其他', () => {
const formData = new FormData() const formData = new FormData()
formData.append('materialType', '任务-其他') formData.append('materialType', '任务-其他')
formData.append('taskNo', 'TASK202603310001') formData.append('taskNo', 'TASK202603310001')
formData.append('dingtalkApprovalNo', '202508211445000157693')
formData.append('productModel', '') formData.append('productModel', '')
formData.append('categoryName', '') formData.append('categoryName', '')
formData.append('sceneName', '') formData.append('sceneName', '')
@ -193,6 +196,18 @@ describe('materials upload route - 任务-其他', () => {
expect(prisma.material.create).not.toHaveBeenCalled() 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 () => { test('正常上传时应自动补通用店铺和场景,并保留原始压缩包文件名', async () => {
const formData = buildTaskOtherFormData() const formData = buildTaskOtherFormData()
@ -226,14 +241,10 @@ describe('materials upload route - 任务-其他', () => {
]), ]),
}), }),
) )
expect(prisma.material.update).toHaveBeenCalledWith( expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
expect.objectContaining({ 'material-task-other-1',
where: { id: 'material-task-other-1' }, 'TASK202603310001',
data: expect.objectContaining({ '202508211445000157693',
taskNo: 'TASK202603310001',
taskStatus: 'completed',
}),
}),
) )
expect(createMaterialLog).toHaveBeenCalledWith( expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

View File

@ -12,6 +12,7 @@ import {
import { createMaterialLog } from '@/lib/material-log' import { createMaterialLog } from '@/lib/material-log'
import { createZipFromFiles } from '@/lib/task-material-processor' import { createZipFromFiles } from '@/lib/task-material-processor'
import { getOwnedUploadSession } from '@/lib/upload-sessions' import { getOwnedUploadSession } from '@/lib/upload-sessions'
import { assignTaskNoOnComplete } from '@/lib/product-package'
jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/prisma', () => ({
prisma: { prisma: {
@ -114,6 +115,7 @@ describe('product-package-completion', () => {
const result = await completeProductPackageUpload({ const result = await completeProductPackageUpload({
materialId: 'material-1', materialId: 'material-1',
taskNo: null, taskNo: null,
dingtalkApprovalNo: '202508211445000157693',
operatorUserId: 'user-1', operatorUserId: 'user-1',
assets: { assets: {
main: [ main: [
@ -148,8 +150,14 @@ describe('product-package-completion', () => {
expect(result).toEqual({ expect(result).toEqual({
materialId: 'material-1', materialId: 'material-1',
taskNo: null, taskNo: null,
dingtalkApprovalNo: '202508211445000157693',
isFullSet: true, isFullSet: true,
}) })
expect(assignTaskNoOnComplete).toHaveBeenCalledWith(
'material-1',
null,
'202508211445000157693',
)
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1') expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [ expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [
'sku-preview-1', 'sku-preview-1',
@ -231,6 +239,7 @@ describe('product-package-completion', () => {
).resolves.toEqual({ ).resolves.toEqual({
materialId: 'material-1', materialId: 'material-1',
taskNo: null, taskNo: null,
dingtalkApprovalNo: null,
isFullSet: true, isFullSet: true,
}) })
}) })

View File

@ -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
}

View File

@ -169,6 +169,7 @@ export async function buildMaterialSearchOrConditions(search: string): Promise<a
{ productModel: { contains: keyword, mode: 'insensitive' } }, { productModel: { contains: keyword, mode: 'insensitive' } },
{ reportTitle: { contains: keyword, mode: 'insensitive' } }, { reportTitle: { contains: keyword, mode: 'insensitive' } },
{ taskNo: { contains: keyword, mode: 'insensitive' } }, { taskNo: { contains: keyword, mode: 'insensitive' } },
{ dingtalkApprovalNo: { contains: keyword, mode: 'insensitive' } },
{ categoryName: { contains: keyword, mode: 'insensitive' } }, { categoryName: { contains: keyword, mode: 'insensitive' } },
{ sceneName: { contains: keyword, mode: 'insensitive' } }, { sceneName: { contains: keyword, mode: 'insensitive' } },
{ authorName: { contains: keyword, mode: 'insensitive' } }, { authorName: { contains: keyword, mode: 'insensitive' } },

View File

@ -1,4 +1,4 @@
import { is3DOriginalType, isProductPackageType } from './material-type-mapper' import { isProductPackageType, mapMaterialType } from './material-type-mapper'
import { import {
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE, DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
DOUYIN_QIANCHUAN_VIDEO_TYPE, DOUYIN_QIANCHUAN_VIDEO_TYPE,
@ -57,7 +57,7 @@ export function getSelectableMaterialTypeOptions(currentType?: string | null): s
} }
export function isTaskMaterialType(materialType: string): boolean { export function isTaskMaterialType(materialType: string): boolean {
return isProductPackageType(materialType) || is3DOriginalType(materialType) return mapMaterialType(materialType).startsWith('任务-')
} }
export function supportsTaskNo(materialType: string): boolean { export function supportsTaskNo(materialType: string): boolean {

View File

@ -23,6 +23,10 @@ import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing' import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report' import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from '@/lib/dingtalk-approval-no'
export interface MaterialUpdateInput { export interface MaterialUpdateInput {
productModel: string productModel: string
@ -36,6 +40,7 @@ export interface MaterialUpdateInput {
authorName: string authorName: string
userId?: string | null userId?: string | null
taskNo?: string | null taskNo?: string | null
dingtalkApprovalNo?: string | null
taskDetailSourceType?: string | null taskDetailSourceType?: string | null
taskDetailRequesterUserId?: string | null taskDetailRequesterUserId?: string | null
// 兼容旧调用方传参,实际保存时会按资产自动计算 // 兼容旧调用方传参,实际保存时会按资产自动计算
@ -172,6 +177,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
const material = editable.material as any const material = editable.material as any
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo') const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo')
const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType') const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType')
const hasTaskDetailRequesterField = Object.prototype.hasOwnProperty.call(input, 'taskDetailRequesterUserId') const hasTaskDetailRequesterField = Object.prototype.hasOwnProperty.call(input, 'taskDetailRequesterUserId')
const productModel = normalizeString(input.productModel) const productModel = normalizeString(input.productModel)
@ -184,6 +190,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
const platformName = normalizeOptionalString(input.platformName) const platformName = normalizeOptionalString(input.platformName)
const productCategoryName = normalizeOptionalString(input.productCategoryName) const productCategoryName = normalizeOptionalString(input.productCategoryName)
const taskNo = normalizeTaskNo(input.taskNo) const taskNo = normalizeTaskNo(input.taskNo)
const dingtalkApprovalNo = normalizeDingtalkApprovalNo(input.dingtalkApprovalNo)
const rawTaskDetailSourceType = input.taskDetailSourceType const rawTaskDetailSourceType = input.taskDetailSourceType
const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType) const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType)
const requestedTaskDetailRequesterUserId = normalizeOptionalString(input.taskDetailRequesterUserId) const requestedTaskDetailRequesterUserId = normalizeOptionalString(input.taskDetailRequesterUserId)
@ -219,6 +226,21 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
} }
} }
if (dingtalkApprovalNo && !materialType.startsWith('任务-')) {
return {
error: '钉钉审批编号仅适用于“任务-”素材',
status: 400 as const,
}
}
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
return {
error: dingtalkApprovalNoError,
status: 400 as const,
}
}
if ( if (
hasTaskDetailSourceTypeField && hasTaskDetailSourceTypeField &&
rawTaskDetailSourceType != null && rawTaskDetailSourceType != null &&
@ -330,6 +352,9 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
? normalizeOptionalString(input.renderer) || material.renderer || getDefaultRenderer(materialType) ? normalizeOptionalString(input.renderer) || material.renderer || getDefaultRenderer(materialType)
: material.renderer, : material.renderer,
taskNo: hasTaskNoField ? taskNo : material.taskNo, taskNo: hasTaskNoField ? taskNo : material.taskNo,
dingtalkApprovalNo: materialType.startsWith('任务-')
? (hasDingtalkApprovalNoField ? dingtalkApprovalNo : material.dingtalkApprovalNo)
: null,
taskDetailSourceType: taskDetailSourceType:
materialType === '任务-详情' materialType === '任务-详情'
? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null) ? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null)

View File

@ -22,6 +22,10 @@ import {
} from '@/lib/external-auto-listing' } from '@/lib/external-auto-listing'
import path from 'path' import path from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises' import { mkdir, readFile, writeFile } from 'fs/promises'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from '@/lib/dingtalk-approval-no'
export interface ProductPackageImageInput { export interface ProductPackageImageInput {
uploadId?: string uploadId?: string
@ -52,6 +56,7 @@ export interface ProductPackageAssetsInput {
export interface CompleteProductPackageUploadInput { export interface CompleteProductPackageUploadInput {
materialId: string materialId: string
taskNo?: string | null taskNo?: string | null
dingtalkApprovalNo?: string | null
operatorUserId: string operatorUserId: string
assets: ProductPackageAssetsInput assets: ProductPackageAssetsInput
options?: { options?: {
@ -462,7 +467,7 @@ async function movePsdFilesToPermanentDir(
} }
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) { export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
const { materialId, taskNo, assets, operatorUserId, options } = input const { materialId, taskNo, dingtalkApprovalNo, assets, operatorUserId, options } = input
const tempFiles: string[] = [] const tempFiles: string[] = []
const uploadIds: string[] = [] const uploadIds: string[] = []
const uploadedPreviewUrls: string[] = [] const uploadedPreviewUrls: string[] = []
@ -494,6 +499,11 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) { if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) {
throw new Error('任务号格式错误') throw new Error('任务号格式错误')
} }
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo)
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo)
if (dingtalkApprovalNoError) {
throw new Error(dingtalkApprovalNoError)
}
try { try {
const material: any = await prisma.material.findUnique({ const material: any = await prisma.material.findUnique({
@ -595,7 +605,11 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
await assignTaskNoOnComplete(materialId, normalizedTaskNo) await assignTaskNoOnComplete(
materialId,
normalizedTaskNo,
normalizedDingtalkApprovalNo,
)
await prisma.material.update({ await prisma.material.update({
where: { id: materialId }, where: { id: materialId },
data: { data: {
@ -619,6 +633,10 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
oldValue: null, oldValue: null,
newValue: normalizedTaskNo, newValue: normalizedTaskNo,
}, },
dingtalkApprovalNo: {
oldValue: null,
newValue: normalizedDingtalkApprovalNo,
},
isFullSet: { isFullSet: {
oldValue: false, oldValue: false,
newValue: derivedIsFullSet, newValue: derivedIsFullSet,
@ -658,6 +676,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
return { return {
materialId, materialId,
taskNo: normalizedTaskNo, taskNo: normalizedTaskNo,
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
isFullSet: derivedIsFullSet, isFullSet: derivedIsFullSet,
} }
} catch (error) { } catch (error) {

View File

@ -4,6 +4,10 @@
import { prisma } from './prisma'; import { prisma } from './prisma';
import path from 'path'; import path from 'path';
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from './dingtalk-approval-no';
// 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复) // 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例: // 示例:
@ -318,21 +322,32 @@ export async function purgeDeletedMaterialsByTaskNo(taskNo: string): Promise<{
} }
/** /**
* 在素材成功完成后再写入任务号,避免失败中的半成品占用任务号 * 在素材成功完成后再写入任务关联字段,避免失败中的半成品占用任务号
*/ */
export async function assignTaskNoOnComplete( export async function assignTaskNoOnComplete(
materialId: string, materialId: string,
taskNo?: string | null, taskNo?: string | null,
dingtalkApprovalNo?: string | null,
): Promise<{ ): Promise<{
deletedMaterials: number; deletedMaterials: number;
deletedFiles: number; deletedFiles: number;
failedFiles: number; failedFiles: number;
} | null> { } | null> {
const normalizedTaskNo = taskNo?.trim(); const normalizedTaskNo = taskNo?.trim();
const hasDingtalkApprovalNo = dingtalkApprovalNo !== undefined;
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo);
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo);
if (dingtalkApprovalNoError) {
throw new Error(dingtalkApprovalNoError);
}
if (!normalizedTaskNo) { if (!normalizedTaskNo) {
await prisma.material.update({ await prisma.material.update({
where: { id: materialId }, where: { id: materialId },
data: { data: {
...(hasDingtalkApprovalNo
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
: {}),
taskStatus: TASK_STATUS.COMPLETED, taskStatus: TASK_STATUS.COMPLETED,
} as any, } as any,
}); });
@ -368,6 +383,9 @@ export async function assignTaskNoOnComplete(
where: { id: materialId }, where: { id: materialId },
data: { data: {
taskNo: normalizedTaskNo, taskNo: normalizedTaskNo,
...(hasDingtalkApprovalNo
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
: {}),
taskStatus: TASK_STATUS.COMPLETED, taskStatus: TASK_STATUS.COMPLETED,
} as any, } as any,
}); });

View File

@ -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");

View File

@ -98,6 +98,7 @@ model Material {
transferredBy String? transferredBy String?
transferNote String? transferNote String?
taskNo String? @map("task_no") taskNo String? @map("task_no")
dingtalkApprovalNo String? @map("dingtalk_approval_no")
taskStatus String? @map("task_status") taskStatus String? @map("task_status")
taskFetchedAt DateTime? @map("task_fetched_at") taskFetchedAt DateTime? @map("task_fetched_at")
isFullSet Boolean @default(false) @map("is_full_set") isFullSet Boolean @default(false) @map("is_full_set")
@ -130,6 +131,7 @@ model Material {
@@index([originalOwnerId]) @@index([originalOwnerId])
@@index([transferredAt]) @@index([transferredAt])
@@index([taskNo]) @@index([taskNo])
@@index([dingtalkApprovalNo])
@@index([taskStatus]) @@index([taskStatus])
@@index([isFullSet]) @@index([isFullSet])
@@index([productModel]) @@index([productModel])