Compare commits

...

5 Commits

Author SHA1 Message Date
chenpei
944c15f281 fix: 修复任务详情 Edge 图片预览失败 2026-07-31 17:02:16 +08:00
chenpei
3731aea67c fix: 修复任务详情上传锁错误与空素材状态 2026-07-31 15:41:17 +08:00
chenpei
c3d53bbac7 fix: 支持指定分支的增量部署 2026-07-31 14:19:46 +08:00
chenpei
65922735cd fix: 恢复直属主管组织解析规则 2026-07-31 14:08:31 +08:00
chenpei
b25f6a6236 fix: 完善任务详情上传与审批信息展示 2026-07-31 14:08:31 +08:00
53 changed files with 3462 additions and 411 deletions

View File

@ -24,6 +24,73 @@ import { applyVideoPreviewPolicy } from '@/lib/video-download-policy';
const MAX_PAGE_SIZE = 100; // 单页最大100条记录
const MAX_QUERY_LIMIT = 5000; // comprehensive排序时最大查询5000条
type TaskDetailUploadJobSummary = {
id: string;
materialId: string;
status: string;
stage: string;
progress: number;
errorMessage: string | null;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
};
/**
* 列表页需要知道任务-详情占位素材当前是否仍在后台处理。
* 这里不返回任务的原始 JSON/内部堆栈,只返回可供 UI 展示的状态摘要。
*/
function buildTaskDetailUploadSummary(
material: { materialType?: string | null; taskStatus?: string | null },
job: TaskDetailUploadJobSummary | null,
options: { isPlaceholder?: boolean } = {},
) {
if (!isProductPackageType(material.materialType || '')) {
return {
taskDetailUploadStatus: null,
taskDetailUploadStage: null,
taskDetailUploadProgress: null,
taskDetailUploadError: null,
taskDetailUploadJobId: null,
};
}
const materialStatus = String(material.taskStatus || '').trim() || null;
const jobStatus = String(job?.status || '').trim() || null;
const status = jobStatus || materialStatus || null;
const isFailed = status === 'failed' || materialStatus === 'failed';
const isCompleted = status === 'completed' || materialStatus === 'completed';
// 没有预览图且没有后台任务时,只能确认是“未完成”,不能把历史占位记录误报成失败。
const normalizedStatus = isFailed
? 'failed'
: isCompleted
? 'completed'
: status === 'queued' || status === 'processing' || status === 'uploading'
? status
: options.isPlaceholder
? 'incomplete'
: status;
return {
taskDetailUploadStatus: normalizedStatus,
taskDetailUploadStage: job?.stage || (normalizedStatus === 'uploading' ? 'initializing' : null),
taskDetailUploadProgress:
typeof job?.progress === 'number'
? Math.max(0, Math.min(100, job.progress))
: normalizedStatus === 'completed'
? 100
: normalizedStatus === 'uploading'
? 0
: null,
// 错误消息只保留后台处理的用户可读首行,避免把路径/堆栈暴露到列表响应。
taskDetailUploadError: isFailed
? (job?.errorMessage || '后台处理失败,请重新提交上传').split(/\r?\n/)[0].slice(0, 240)
: null,
taskDetailUploadJobId: job?.id || null,
};
}
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
if (!displayImages) return [];
@ -134,6 +201,7 @@ export async function GET(request: NextRequest) {
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskStatus: true,
user: {
select: {
username: true,
@ -252,7 +320,43 @@ export async function GET(request: NextRequest) {
(fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'),
),
);
let latestUploadJob: TaskDetailUploadJobSummary | null = null;
const uploadJobDelegate = (prisma as any).productPackageUploadJob;
if (isProductPackageType(material.materialType) && typeof uploadJobDelegate?.findFirst === 'function') {
try {
latestUploadJob = await uploadJobDelegate.findFirst({
where: { materialId: material.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
materialId: true,
status: true,
stage: true,
progress: true,
errorMessage: true,
createdAt: true,
startedAt: true,
completedAt: true,
},
}) as TaskDetailUploadJobSummary | null;
} catch (error) {
console.warn('[Materials API] task-detail upload job status unavailable:', error);
}
}
const taskDetailUploadSummary = buildTaskDetailUploadSummary(
material,
latestUploadJob,
{
isPlaceholder:
isProductPackageType(material.materialType) &&
!previewUrl &&
!material.zipFileName &&
(material.assets?.length || 0) === 0,
},
);
const normalizedMaterial = normalizeMaterial({
...material,
authorName: displayAuthorName, // 使用处理后的作者名
@ -266,6 +370,7 @@ export async function GET(request: NextRequest) {
avatar: normalizeUrl(material.user.avatar),
} : null,
assets: formattedAssets,
...taskDetailUploadSummary,
});
return NextResponse.json({ material: normalizedMaterial });
@ -359,6 +464,7 @@ export async function GET(request: NextRequest) {
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskStatus: true,
transcodeLastError: true,
transcodeAttempts: true,
// 包含用户信息(用于显示真实姓名)
@ -464,6 +570,7 @@ export async function GET(request: NextRequest) {
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskStatus: true,
transcodeLastError: true,
transcodeAttempts: true,
user: {
@ -528,6 +635,7 @@ export async function GET(request: NextRequest) {
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskStatus: true,
user: {
select: {
username: true,
@ -598,6 +706,7 @@ export async function GET(request: NextRequest) {
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
taskStatus: true,
user: {
select: {
username: true,
@ -745,6 +854,39 @@ export async function GET(request: NextRequest) {
);
}
}
// 只查询当前页的任务-详情素材,避免把上传任务表做成全表扫描。
// 任务表由独立 migration 管理;若旧环境尚未迁移,状态摘要降级为 taskStatus。
const uploadJobByMaterialId = new Map<string, TaskDetailUploadJobSummary>();
const uploadJobDelegate = (prisma as any).productPackageUploadJob;
if (productPackageMaterialIds.length > 0 && typeof uploadJobDelegate?.findMany === 'function') {
try {
const uploadJobs = await uploadJobDelegate.findMany({
where: { materialId: { in: productPackageMaterialIds } },
orderBy: { createdAt: 'desc' },
select: {
id: true,
materialId: true,
status: true,
stage: true,
progress: true,
errorMessage: true,
createdAt: true,
startedAt: true,
completedAt: true,
},
}) as TaskDetailUploadJobSummary[];
// 同一素材可能有历史重试记录,排序后只保留最新一条。
for (const job of uploadJobs) {
if (!uploadJobByMaterialId.has(job.materialId)) {
uploadJobByMaterialId.set(job.materialId, job);
}
}
} catch (error) {
console.warn('[Materials API] task-detail upload job status unavailable:', error);
}
}
const formattedMaterials = materials.map(material => {
// 处理previewUrl的fallback逻辑
@ -784,6 +926,18 @@ export async function GET(request: NextRequest) {
),
);
const taskDetailUploadSummary = buildTaskDetailUploadSummary(
material,
uploadJobByMaterialId.get(material.id) || null,
{
isPlaceholder:
isProductPackageType(material.materialType) &&
!previewUrl &&
!material.zipFileName &&
(material.assets?.length || 0) === 0,
},
);
const formatted = {
...material,
authorName: displayAuthorName, // 使用处理后的作者名
@ -797,6 +951,7 @@ export async function GET(request: NextRequest) {
avatar: normalizeUrl(material.user.avatar),
} : null,
assets: formattedAssets,
...taskDetailUploadSummary,
};
// 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择
// 注意:不要先调用 normalizeMaterial,因为它会将URL转换为代理URL,导致内网用户无法重写为内网地址

View File

@ -146,6 +146,7 @@ export async function GET(request: NextRequest) {
videoDuration: true,
videoProcessingStatus: true,
taskNo: true,
dingtalkApprovalNo: true,
taskStatus: true,
taskFetchedAt: true,
isFullSet: true,

View File

@ -2,7 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteUploadSession, getUploadSession } from '@/lib/upload-sessions'
import {
deleteOwnedUploadSession,
getOwnedUploadSession,
isSafeUploadId,
} from '@/lib/upload-sessions'
import { acquireTaskDetailUploadLock } from '@/lib/task-detail-upload-jobs'
import { getUploadRootDir } from '@/lib/upload-paths'
import path from 'path'
import { rm, unlink } from 'fs/promises'
@ -22,7 +28,9 @@ export async function POST(req: NextRequest) {
const body = (await req.json()) as AbortRequestBody
const materialId = body.materialId?.trim()
const uploadIds = Array.isArray(body.uploadIds)
? body.uploadIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
? Array.from(new Set(body.uploadIds.filter(
(id): id is string => typeof id === 'string' && isSafeUploadId(id.trim()),
).map((id) => id.trim())))
: []
if (!materialId) {
@ -52,25 +60,55 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, deleted: false, reason: 'not_task_detail' })
}
const assetsCount = await prisma.materialAsset.count({ where: { materialId } })
if (assetsCount > 0 || material.previewUrl || material.zipFilePath) {
return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' })
const deletion = await prisma.$transaction(async (tx) => {
// 与 createTaskDetailUploadJob 使用同一把事务锁,防止“检查无任务”后并发入队。
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
const activeJob = await tx.productPackageUploadJob.findFirst({
where: { materialId, status: { in: ['queued', 'processing'] } },
select: { id: true, status: true },
})
if (activeJob) return { deleted: false as const, reason: 'job_processing' as const, activeJob }
const assetsCount = await tx.materialAsset.count({ where: { materialId } })
if (assetsCount > 0 || material.previewUrl || material.zipFilePath) {
return { deleted: false as const, reason: 'already_processed' as const }
}
await tx.material.delete({ where: { id: materialId } })
return { deleted: true as const, reason: 'deleted' as const }
})
if (!deletion.deleted) {
if (deletion.reason === 'job_processing' && deletion.activeJob) {
return NextResponse.json({
success: true,
deleted: false,
reason: deletion.reason,
jobId: deletion.activeJob.id,
status: deletion.activeJob.status,
})
}
return NextResponse.json({ success: true, deleted: false, reason: deletion.reason })
}
for (const uploadId of uploadIds) {
const uploadSession = await getUploadSession(uploadId)
const uploadSession = await getOwnedUploadSession(uploadId, userId)
if (uploadSession?.fileName) {
const assembledPath = path.join(process.cwd(), 'uploads', 'assembled', `${uploadId}-${uploadSession.fileName}`)
await unlink(assembledPath).catch(() => undefined)
const assembledRoot = path.resolve(getUploadRootDir(), 'assembled')
const assembledName = `${uploadId}-${path.basename(uploadSession.fileName)}`
const assembledPath = path.resolve(assembledRoot, assembledName)
if (assembledPath === assembledRoot || assembledPath.startsWith(`${assembledRoot}${path.sep}`)) {
await unlink(assembledPath).catch(() => undefined)
}
}
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
const tempRoot = path.resolve(getUploadRootDir(), 'temp')
const tempDir = path.resolve(tempRoot, uploadId)
if (!tempDir.startsWith(`${tempRoot}${path.sep}`)) continue
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
await deleteUploadSession(uploadId).catch(() => undefined)
await deleteOwnedUploadSession(uploadId, userId).catch(() => false)
}
await prisma.material.delete({ where: { id: materialId } })
return NextResponse.json({
success: true,
deleted: true,

View File

@ -8,19 +8,24 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
completeProductPackageUpload,
parsePublicProductPackageAssets,
} from '@/lib/product-package-completion'
import {
createTaskDetailUploadJob,
getLatestTaskDetailUploadJobStatus,
} from '@/lib/task-detail-upload-jobs'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
export const maxDuration = 60
interface CompleteRequestBody {
materialId: string
taskNo?: string
dingtalkApprovalNo?: string
noBarcodes?: boolean
allowServerPreviewFallback?: boolean
assets: unknown
}
@ -53,8 +58,11 @@ export async function POST(request: NextRequest) {
select: {
id: true,
userId: true,
materialType: true,
taskDetailRequesterUserId: true,
materialType: true,
taskDetailRequesterUserId: true,
taskDetailSourceType: true,
zipFilePath: true,
_count: { select: { assets: true } },
},
})
@ -86,7 +94,73 @@ export async function POST(request: NextRequest) {
)
}
const result = await completeProductPackageUpload({
if (!normalizeTaskDetailSourceType(material.taskDetailSourceType)) {
return NextResponse.json(
{ success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' },
{ status: 400 },
)
}
// 初始化素材的 zip 路径必须为空。已有路径说明上一次后台处理已经写入了
// 部分持久化结果,禁止再次入队覆盖,避免重复资产和悬空文件。
if (material.zipFilePath || material._count?.assets > 0) {
return NextResponse.json(
{ success: false, error: '该素材已有后台处理结果,请刷新素材状态后再操作' },
{ status: 409 },
)
}
const existingJob = await getLatestTaskDetailUploadJobStatus(
body.materialId,
(session.user as any).id,
)
if (existingJob && ['queued', 'processing', 'completed'].includes(existingJob.status)) {
return NextResponse.json({
success: true,
data: {
jobId: existingJob.jobId,
materialId: existingJob.materialId,
status: existingJob.status,
stage: existingJob.stage,
progress: existingJob.progress,
message: existingJob.status === 'completed'
? '后台处理已完成'
: '文件已接收,正在后台处理',
},
}, { status: 202 })
}
const imageAssets = [
...(assets.main || []),
...(assets.sku || []),
...(assets.detail || []),
]
if (imageAssets.length === 0) {
return NextResponse.json(
{ success: false, error: '请至少上传一张图片' },
{ status: 400 },
)
}
const missingClientPreviewCount = imageAssets.filter(
(asset) => !Array.isArray(asset.previewAssets) || asset.previewAssets.length === 0,
).length
if (missingClientPreviewCount > 0) {
if (body.allowServerPreviewFallback !== true) {
return NextResponse.json(
{ success: false, error: '客户端预览图缺失,请重新处理图片后再提交' },
{ status: 400 },
)
}
// 浏览器预处理属于性能优化,不应成为提交门槛。Edge 的 worker、图片编解码
// 或超大 EXIF 头部出现兼容性问题时,保留原图并交给后台唯一 worker 生成预览;
// 该 worker 仍受像素、文件大小和全局并发限制保护。
console.warn('[Product Package Complete] 客户端预览缺失,转服务器兜底处理', {
materialId: body.materialId,
count: missingClientPreviewCount,
})
}
const job = await createTaskDetailUploadJob({
materialId: body.materialId,
taskNo: body.taskNo,
dingtalkApprovalNo: body.dingtalkApprovalNo,
@ -100,13 +174,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
data: {
materialId: result.materialId,
taskNo: result.taskNo,
dingtalkApprovalNo: result.dingtalkApprovalNo,
isFullSet: result.isFullSet,
message: '上传完成',
jobId: job.jobId,
materialId: job.materialId,
status: job.status,
stage: job.stage,
progress: job.progress,
message: '文件已接收,正在后台处理',
},
})
}, { status: 202 })
} catch (error) {
console.error('[Product Package Complete] Error:', error)
return NextResponse.json(

View File

@ -86,6 +86,13 @@ export async function POST(request: NextRequest) {
);
}
if (!taskDetailSourceType) {
return NextResponse.json(
{ success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' },
{ status: 400 }
);
}
// 如果提供了任务号,验证格式和唯一性
if (taskNo && taskNo.trim()) {
// 验证任务号格式

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
getLatestTaskDetailUploadJobStatus,
getTaskDetailUploadJobStatus,
} from '@/lib/task-detail-upload-jobs'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const operatorUserId = (session?.user as { id?: string } | undefined)?.id
if (!operatorUserId) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
}
const jobId = request.nextUrl.searchParams.get('jobId')?.trim() || ''
const materialId = request.nextUrl.searchParams.get('materialId')?.trim() || ''
if ((!jobId && !materialId) || jobId.length > 100 || materialId.length > 100) {
return NextResponse.json({ success: false, error: '缺少有效的 jobId 或 materialId' }, { status: 400 })
}
const job = jobId
? await getTaskDetailUploadJobStatus(jobId, operatorUserId)
: await getLatestTaskDetailUploadJobStatus(materialId, operatorUserId)
if (!job) {
// 不区分“任务不存在”和“任务属于其他用户”,避免泄露任务状态。
return NextResponse.json({ success: false, error: '任务不存在或无权限查看' }, { status: 404 })
}
return NextResponse.json({ success: true, data: job })
} catch (error) {
console.error('[Product Package Status] Error:', error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '服务器错误' },
{ status: 500 },
)
}
}

View File

@ -108,6 +108,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('')
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState('')
const [taskDetailSourceType, setTaskDetailSourceType] = useState('')
const [taskDetailSourceTypeTouched, setTaskDetailSourceTypeTouched] = useState(false)
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
@ -128,6 +129,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
const canKeepRequesterBlank =
material?.materialType === '任务-详情' && !material.taskDetailRequesterUserId
const canKeepSourceTypeBlank =
material?.materialType === '任务-详情' &&
materialType === '任务-详情' &&
!material.taskDetailSourceType &&
!taskDetailSourceTypeTouched
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const currentFormSignature = useMemo(() => createFormSignature({
previewImage: previewImage
@ -284,6 +290,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setTaskDetailRequesterUserId(data.taskDetailRequesterUserId || '')
setTaskDetailRequesterName(data.taskDetailRequesterName || '')
setTaskDetailSourceType(data.taskDetailSourceType || '')
setTaskDetailSourceTypeTouched(false)
setUsers(mergeUserOptions([], [
{
id: data.userId,
@ -500,6 +507,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
return
}
if (materialType === '任务-详情' && !taskDetailSourceType && !canKeepSourceTypeBlank) {
setError('请选择出图方式')
return
}
if (showTaskNo) {
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
@ -951,15 +963,23 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
htmlFor={`edit-material-${params.id}-source-type`}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
出图方式
出图方式 {!canKeepSourceTypeBlank && <span className="text-red-500">*</span>}
</label>
<select
id={`edit-material-${params.id}-source-type`}
value={taskDetailSourceType}
onChange={(event) => setTaskDetailSourceType(event.target.value)}
onChange={(event) => {
setTaskDetailSourceType(event.target.value)
setTaskDetailSourceTypeTouched(true)
}}
required={!canKeepSourceTypeBlank}
aria-required={!canKeepSourceTypeBlank}
aria-invalid={Boolean(error && !taskDetailSourceType && !canKeepSourceTypeBlank)}
className="input-apple"
>
<option value="">未设置</option>
<option value="">
{canKeepSourceTypeBlank ? '未设置(历史记录)' : '请选择出图方式'}
</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}

View File

@ -56,6 +56,7 @@ interface Material {
isFavorited?: boolean // 是否已收藏
capabilities?: MaterialDetailCapabilities
taskNo?: string // 产品全案任务号
dingtalkApprovalNo?: string | null // 钉钉审批编号
taskStatus?: string // 产品全案任务状态
taskFetchedAt?: string // 产品全案被获取时间
isFullSet?: boolean
@ -729,6 +730,7 @@ export default function SharePage() {
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
@ -762,6 +764,7 @@ export default function SharePage() {
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
@ -794,6 +797,7 @@ export default function SharePage() {
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
@ -1109,6 +1113,22 @@ export default function SharePage() {
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.authorName}</p>
</div>
{material.materialType?.startsWith('任务-') && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{material.taskNo || '未填写'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">钉钉审批编号</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{material.dingtalkApprovalNo || '未填写'}
</p>
</div>
</div>
)}
{isCertificateReportMaterialType(material.materialType) ? (
<>
<div>

View File

@ -63,6 +63,7 @@ import CertificateReportUploader from '@/components/upload/CertificateReportUplo
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from '@/components/upload/drag-drop-utils'
@ -187,6 +188,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const previewInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const modalTitleRef = useRef<HTMLHeadingElement>(null)
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null)
const displayImageDropZoneRef = useRef<HTMLDivElement>(null) // 详情模板展示图片拖拽区域
const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const xhrRef = useRef<XMLHttpRequest | null>(null)
@ -200,6 +203,29 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
(displayImage?.size || 0) +
modelImages.reduce((sum, file) => sum + file.size, 0)
useEffect(() => {
previouslyFocusedElementRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusTimer = window.setTimeout(() => modalTitleRef.current?.focus(), 0)
return () => {
window.clearTimeout(focusTimer)
previouslyFocusedElementRef.current?.focus()
}
}, [])
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
if (!uploading) onClose()
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [onClose, uploading])
// 加载当前用户可见的素材类型
useEffect(() => {
const loadViewableTypes = async () => {
@ -331,8 +357,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}
// 处理模特类型的多图上传(压缩 + 切片)
const handleModelImagesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
const processModelImages = async (files: File[]) => {
if (uploading) {
setError('文件正在上传,请等待完成后再添加图片')
return
}
await handleModelImagesInput(
files,
(processedFiles, urls) => {
@ -344,6 +373,15 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
)
}
const handleModelImagesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
await processModelImages(Array.from(e.target.files || []))
e.currentTarget.value = ''
}
const handleModelImagesDrop = async (files: File[]) => {
await processModelImages(files)
}
// 删除模特图片
const removeModelImage = (index: number) => {
const newImages = modelImages.filter((_, i) => i !== index)
@ -680,8 +718,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// 买家秀批量图片选择处理
const handleBatchBuyerShowImageSelect = (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => {
const files = Array.from(e.target.files || [])
const handleBatchBuyerShowImageSelect = (source: FileList | File[], batchIndex: number) => {
const files = Array.from(source)
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
@ -697,14 +735,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
newFiles[batchIndex].images = [...newFiles[batchIndex].images, ...imageFiles]
setBatchBuyerShowFiles(newFiles)
setError('')
if (e.target) {
e.target.value = ''
}
}
// 小红书批量图片选择处理
const handleBatchXiaohongshuImageSelect = (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => {
const files = Array.from(e.target.files || [])
const handleBatchXiaohongshuImageSelect = (source: FileList | File[], batchIndex: number) => {
const files = Array.from(source)
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
@ -720,9 +755,6 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
newFiles[batchIndex].images = [...newFiles[batchIndex].images, ...imageFiles]
setBatchXiaohongshuFiles(newFiles)
setError('')
if (e.target) {
e.target.value = ''
}
}
// 添加新的买家秀批次
@ -1756,32 +1788,32 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 sm:p-4" role="presentation">
<div
className="bg-white dark:bg-gray-800 rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg"
className="flex h-full max-h-[calc(100dvh-1rem)] w-full max-w-4xl flex-col overflow-hidden rounded-lg bg-white shadow-apple-lg dark:bg-gray-800 sm:max-h-[90vh] sm:rounded-apple"
role="dialog"
aria-modal="true"
aria-labelledby="upload-modal-title"
>
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="shrink-0 border-b border-gray-200 p-4 dark:border-gray-700 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">上传素材</h2>
<h2 ref={modalTitleRef} tabIndex={-1} id="upload-modal-title" className="text-xl font-bold text-gray-900 outline-none dark:text-white sm:text-2xl">上传素材</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
className="rounded-full px-2 text-2xl leading-none text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:hover:bg-gray-700 dark:hover:text-gray-300"
aria-label="关闭上传弹窗"
type="button"
>
×
</button>
</div>
<div className="space-y-1 mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
💡 提示:可拖拽文件到对应的虚线框内上传
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
</p>
</div>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
将文件拖入对应区域,或点击选择文件。
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<form onSubmit={handleSubmit} className="min-h-0 flex-1 space-y-6 overflow-y-auto p-4 sm:p-6">
{/* 素材类型 - 第一栏,让用户先选择类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@ -2091,6 +2123,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
<ModelImagesUpload
modelImageUrls={modelImageUrls}
onModelImagesChange={handleModelImagesChange}
onModelImagesDrop={handleModelImagesDrop}
onRemoveModelImage={removeModelImage}
/>
)}
@ -2311,6 +2344,37 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
/>
)}
{/* 已有批量文件时仍保留追加入口,避免只能删空后重新选择 */}
{!usesDedicatedUploader &&
!isTaskOtherMaterial &&
((materialType === '音乐' && batchMusicFiles.length > 0) ||
(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) ||
(materialType === '3D场景' && batch3DFiles.length > 0) ||
(materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0)) && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-apple border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-600 dark:bg-gray-800/50">
<input
ref={batchFileInputRef}
type="file"
onChange={handleBatchFileSelect}
accept={getMainFileAccept(materialType)}
multiple
className="hidden"
disabled={uploading}
/>
<button
type="button"
disabled={uploading}
onClick={() => batchFileInputRef.current?.click()}
className="btn-secondary text-sm"
>
继续添加文件
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
可继续追加,已选文件不会被替换
</span>
</div>
)}
{/* 单文件上传区域(非批量类型或批量列表为空时显示) */}
{!(
(materialType === '音乐' && batchMusicFiles.length > 0) ||
@ -2322,6 +2386,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
) && materialType !== '标准模型' && (
<div
ref={dropZoneRef}
role="group"
tabIndex={uploading ? -1 : 0}
aria-label="选择素材文件"
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-6 text-center transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
@ -2334,6 +2401,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => zipInputRef.current?.click())}
>
{zipFile ? (
<div className="space-y-2">
@ -2433,16 +2501,19 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple">
{error}
</div>
)}
{/* Buttons(有专用上传器的任务类型不显示,因为它们有自己的按钮) */}
{/* Error Message + Buttons(有专用上传器的任务类型不显示按钮,因为它们有自己的按钮) */}
{!usesDedicatedUploader && (
<div className="flex justify-end space-x-3 pt-4">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80 sm:-mx-6 sm:-mb-6 sm:px-6">
{error && (
<div
className="mb-3 rounded-apple border border-red-200 bg-red-50 px-4 py-3 text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
role="alert"
aria-live="assertive"
>
{error}
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleCancelUpload}
@ -2458,6 +2529,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
{uploading ? (isProcessing ? '处理中...' : `上传中 ${uploadProgress}%`) : '上传'}
</button>
</div>
</div>
)}
</form>
</div>

View File

@ -21,6 +21,7 @@ interface ProductPackageDetailProps {
material: {
id: string;
taskNo: string;
dingtalkApprovalNo?: string | null;
productModel: string;
categoryName: string;
sceneName?: string;
@ -234,7 +235,13 @@ export default function ProductPackageDetail({
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo}</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo || '未填写'}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">钉钉审批编号</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{material.dingtalkApprovalNo || '未填写'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>

View File

@ -20,6 +20,7 @@ interface TaskPageDetailProps {
material: {
id: string;
taskNo?: string;
dingtalkApprovalNo?: string | null;
productModel: string;
categoryName: string;
sceneName: string;
@ -194,8 +195,14 @@ export default function TaskPageDetail({
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo}</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo || '未填写'}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">钉钉审批编号</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{material.dingtalkApprovalNo || '未填写'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.productModel}</p>

View File

@ -20,6 +20,7 @@ interface ThreeDOriginalDetailProps {
material: {
id: string;
taskNo?: string;
dingtalkApprovalNo?: string | null;
productModel: string;
categoryName: string;
sceneName: string;
@ -199,8 +200,14 @@ export default function ThreeDOriginalDetail({
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo}</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo || '未填写'}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">钉钉审批编号</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{material.dingtalkApprovalNo || '未填写'}
</p>
</div>
{!isTaskOther && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>

View File

@ -119,4 +119,43 @@ describe('MaterialCard', () => {
expect(screen.getByText('未设置')).toBeInTheDocument()
})
test('任务-详情后台处理中不显示普通空预览提示', () => {
render(
<MaterialCard
{...defaultProps}
material={{
...baseMaterial,
materialType: '任务-详情',
previewUrl: '',
taskDetailUploadStatus: 'processing',
taskDetailUploadStage: 'previews',
taskDetailUploadProgress: 55,
}}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent('后台处理中')
expect(screen.getByRole('status')).toHaveTextContent('图片已收到,后台正在生成预览图和压缩包')
expect(screen.queryByText('暂无预览图')).not.toBeInTheDocument()
})
test('任务-详情失败时显示可读的恢复提示', () => {
render(
<MaterialCard
{...defaultProps}
material={{
...baseMaterial,
materialType: '任务-详情',
previewUrl: '',
taskDetailUploadStatus: 'failed',
taskDetailUploadError: '上传文件校验失败',
}}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent('上传失败')
expect(screen.getByRole('status')).toHaveTextContent('上传文件校验失败')
expect(screen.queryByText('暂无预览图')).not.toBeInTheDocument()
})
})

View File

@ -25,6 +25,12 @@ export interface Material {
materialType: string
isAiGenerated?: boolean
taskDetailSourceType?: string | null
taskStatus?: string | null
taskDetailUploadStatus?: string | null
taskDetailUploadStage?: string | null
taskDetailUploadProgress?: number | null
taskDetailUploadError?: string | null
taskDetailUploadJobId?: string | null
renderer?: string
authorName: string
previewUrl: string
@ -137,6 +143,34 @@ const getTaskDetailSourceBadgeClassName = (value?: string | null): string => {
return 'bg-slate-100 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300'
}
const getTaskDetailUploadStatusLabel = (value?: string | null): string => {
switch (String(value || '').trim()) {
case 'queued':
return '等待后台处理'
case 'processing':
case 'uploading':
return '后台处理中'
case 'failed':
return '上传失败'
case 'incomplete':
return '上传未完成'
default:
return ''
}
}
const getTaskDetailUploadStatusClassName = (value?: string | null): string => {
if (value === 'failed') {
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}
if (value === 'incomplete') {
return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
}
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
}
export default function MaterialCard({
material,
session,
@ -220,8 +254,32 @@ export default function MaterialCard({
}
const isTaskDetailMaterial = material.materialType === '任务-详情'
const taskDetailUploadStatus = isTaskDetailMaterial
? (material.taskDetailUploadStatus || material.taskStatus || null)
: null
const taskDetailUploadStatusLabel = getTaskDetailUploadStatusLabel(taskDetailUploadStatus)
const isTaskDetailUploadPending = ['queued', 'processing', 'uploading'].includes(taskDetailUploadStatus || '')
const isTaskDetailUploadFailed = taskDetailUploadStatus === 'failed'
const isTaskDetailUploadIncomplete = taskDetailUploadStatus === 'incomplete'
const shouldShowTaskDetailUploadState = Boolean(
isTaskDetailMaterial &&
!material.previewUrl &&
(isTaskDetailUploadPending || isTaskDetailUploadFailed || isTaskDetailUploadIncomplete),
)
const taskDetailUploadMessage = isTaskDetailUploadFailed
? (material.taskDetailUploadError || '后台处理失败,请重新提交上传')
: isTaskDetailUploadIncomplete
? '未检测到可用的上传结果,请重新提交上传'
: material.taskDetailUploadStage === 'initializing'
? '正在创建后台任务…'
: '图片已收到,后台正在生成预览图和压缩包'
const handleMaterialClick = () => {
if (shouldShowTaskDetailUploadState) {
showToast(taskDetailUploadMessage, { variant: isTaskDetailUploadFailed ? 'error' : 'success' })
return
}
if (isPreviewWaiting) {
showToast(previewUnavailableMessage, { variant: 'error' })
return
@ -547,6 +605,8 @@ export default function MaterialCard({
<span className="text-gray-500 dark:text-gray-400 text-sm">图片加载失败</span>
</div>
</>
) : shouldShowTaskDetailUploadState ? (
<div className="h-full w-full bg-gray-200 dark:bg-gray-700" aria-hidden="true" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 audio-placeholder">
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
@ -586,6 +646,34 @@ export default function MaterialCard({
</div>
</div>
)}
{shouldShowTaskDetailUploadState && (
<div
className={`absolute inset-0 z-40 flex items-center justify-center px-5 text-center ${
isTaskDetailUploadFailed
? 'bg-red-950/85'
: isTaskDetailUploadIncomplete
? 'bg-amber-950/85'
: 'bg-slate-950/80'
}`}
role="status"
aria-live="polite"
>
<div className="max-w-[260px] text-white">
<p className="text-base font-semibold">{taskDetailUploadStatusLabel}</p>
<p className="mt-2 text-xs leading-5 text-white/80">{taskDetailUploadMessage}</p>
{isTaskDetailUploadPending && (
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-white transition-[width] duration-300"
style={{
width: `${Math.max(8, Math.min(95, material.taskDetailUploadProgress ?? 15))}%`,
}}
/>
</div>
)}
</div>
</div>
)}
</div>
{/* Info */}
@ -612,6 +700,11 @@ export default function MaterialCard({
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}
</span>
)}
{shouldShowTaskDetailUploadState && (
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getTaskDetailUploadStatusClassName(taskDetailUploadStatus)}`}>
{taskDetailUploadStatusLabel}
</span>
)}
{!isTaskDetailMaterial && material.isAiGenerated && (
<span className="inline-flex items-center px-2 py-1 bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300 rounded text-xs font-medium">
AI生成

View File

@ -5,6 +5,7 @@ import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
@ -506,7 +507,10 @@ export default function AIOriginalUploader({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择原图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -517,6 +521,7 @@ export default function AIOriginalUploader({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
ref={mainInputRef}
@ -535,13 +540,13 @@ export default function AIOriginalUploader({
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-apple"
className="h-32 w-full rounded-apple bg-gray-50 object-contain dark:bg-gray-800"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 disabled:opacity-50"
>
×
</button>
@ -569,7 +574,7 @@ export default function AIOriginalUploader({
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div role="alert" aria-live="assertive" className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
@ -588,12 +593,14 @@ export default function AIOriginalUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useMemo, useRef, useState } from 'react'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, detectFileKindFromName } from '@/lib/certificate-report'
import { handleDropZoneKeyDown, hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
interface CategoryItem {
id: string
@ -35,6 +36,7 @@ export default function CertificateReportUploader({
const [uploadProgress, setUploadProgress] = useState(0)
const [processingStep, setProcessingStep] = useState('')
const [error, setError] = useState('')
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const totalSizeText = useMemo(() => {
@ -225,7 +227,7 @@ export default function CertificateReportUploader({
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
<div role="alert" aria-live="assertive" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
)}
@ -290,7 +292,30 @@ export default function CertificateReportUploader({
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
报告文件 *
</label>
<div className="rounded-apple border-2 border-dashed border-gray-300 p-6 text-center dark:border-gray-600">
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-label="选择报告文件"
aria-disabled={uploading}
className={`rounded-apple border-2 border-dashed p-6 text-center transition-colors dark:border-gray-600 ${
isDragging ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300'
}`}
onDragEnter={(event) => {
prepareFileDrag(event, uploading)
if (!uploading) setIsDragging(true)
}}
onDragLeave={(event) => {
prepareFileDrag(event, uploading)
if (hasDragLeftDropZone(event)) setIsDragging(false)
}}
onDragOver={(event) => prepareFileDrag(event, uploading)}
onDrop={(event) => {
prepareFileDrag(event, uploading)
setIsDragging(false)
if (!uploading) handleFileSelect(event.dataTransfer.files)
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => fileInputRef.current?.click())}
>
<input
ref={fileInputRef}
type="file"
@ -358,7 +383,7 @@ export default function CertificateReportUploader({
</div>
)}
<div className="flex items-center justify-end gap-3">
<div className="sticky bottom-0 -mx-4 -mb-4 flex items-center justify-end gap-3 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80 sm:-mx-6 sm:-mb-6 sm:px-6">
<button type="button" onClick={onCancel} className="btn-secondary" disabled={uploading}>
取消
</button>

View File

@ -132,7 +132,7 @@ export default function DetailImageSorter({
return (
<div
className="grid grid-cols-3 gap-4"
className="grid grid-cols-1 gap-4 lg:grid-cols-3"
onDragOver={(e) => {
if (draggedIndex !== null) {
e.preventDefault();
@ -149,11 +149,11 @@ export default function DetailImageSorter({
}}
>
{/* 左侧:实时预览(占2/3宽度) */}
<div className="col-span-2 border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<div className="border rounded-lg bg-gray-50 p-4 dark:bg-gray-800 lg:col-span-2">
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">预览效果</h4>
<div
ref={previewRef}
className="max-h-[600px] overflow-y-auto"
className="max-h-[min(600px,60vh)] overflow-y-auto"
>
{sortedImages.map((img, index) => (
<div key={img.id} className="relative">
@ -172,9 +172,9 @@ export default function DetailImageSorter({
</div>
{/* 右侧:可拖拽缩略图(单列,占1/3宽度) */}
<div className="col-span-1 border rounded-lg p-4 dark:border-gray-700">
<div className="border rounded-lg p-4 dark:border-gray-700 lg:col-span-1">
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">拖拽排序</h4>
<div className="space-y-2 max-h-[600px] overflow-y-auto">
<div className="max-h-[min(600px,60vh)] space-y-2 overflow-y-auto">
{sortedImages.map((img, index) => (
<div
key={img.id}
@ -185,6 +185,15 @@ export default function DetailImageSorter({
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
onClick={() => scrollToImage(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToImage(index);
}
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={`查看详情图 ${index + 1}`}
className={`
relative border-2 rounded-lg overflow-hidden
${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-move'}
@ -197,7 +206,7 @@ export default function DetailImageSorter({
<img
src={img.preview}
alt={`缩略图 ${index + 1}`}
className="w-full h-32 object-cover"
className="h-32 w-full bg-gray-50 object-contain dark:bg-gray-800"
/>
{/* 序号 */}

View File

@ -1,6 +1,8 @@
'use client'
import { useRef } from 'react'
import Image from 'next/image'
import { handleDropZoneKeyDown } from './drag-drop-utils'
interface DisplayImageUploadProps {
displayImageUrl: string
@ -27,6 +29,7 @@ export default function DisplayImageUpload({
onDisplayImageDrop,
materialType = '',
}: DisplayImageUploadProps) {
const displayImageInputRef = useRef<HTMLInputElement>(null)
// 根据素材类型显示不同的标签和提示
const isBrandVI = materialType === '品牌VI灵感'
const labelText = isBrandVI ? '展示图片(可选)' : '详情页展示图片 *'
@ -41,6 +44,9 @@ export default function DisplayImageUpload({
</label>
<div
ref={displayImageDropZoneRef}
role="group"
tabIndex={disabled ? -1 : 0}
aria-label="选择展示图片文件"
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : ''
@ -53,6 +59,7 @@ export default function DisplayImageUpload({
onDragLeave={onDisplayImageDragLeave}
onDragOver={onDisplayImageDragOver}
onDrop={onDisplayImageDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => displayImageInputRef.current?.click())}
>
<div className="flex items-center space-x-4">
{displayImageUrl ? (
@ -62,7 +69,7 @@ export default function DisplayImageUpload({
alt="Display"
fill
unoptimized
className="object-cover"
className="bg-gray-50 object-contain dark:bg-gray-800"
/>
</div>
) : (
@ -75,15 +82,15 @@ export default function DisplayImageUpload({
<div className="flex-1">
<input
type="file"
ref={displayImageInputRef}
onChange={onDisplayImageChange}
accept="image/*"
className="hidden"
id="displayImageInput"
disabled={disabled}
/>
<button
type="button"
onClick={() => document.getElementById('displayImageInput')?.click()}
onClick={() => displayImageInputRef.current?.click()}
className="btn-secondary"
disabled={disabled}
>

View File

@ -1,24 +1,55 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { handleDropZoneKeyDown, hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
interface ModelImagesUploadProps {
modelImageUrls: string[]
onModelImagesChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onModelImagesDrop: (files: File[]) => void
onRemoveModelImage: (index: number) => void
}
export default function ModelImagesUpload({
modelImageUrls,
onModelImagesChange,
onModelImagesDrop,
onRemoveModelImage,
}: ModelImagesUploadProps) {
const [isDragging, setIsDragging] = useState(false)
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
模特展示图片 * (可上传多张)
</label>
<div className="border-2 border-dashed rounded-apple p-4 border-gray-300 dark:border-gray-600">
<div
role="group"
tabIndex={0}
aria-label="上传模特展示图片"
className={`border-2 border-dashed rounded-apple p-4 transition-colors border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
isDragging ? 'border-blue-500 bg-blue-50/70 dark:border-blue-400 dark:bg-blue-900/20' : ''
}`}
onDragEnter={(event) => {
prepareFileDrag(event)
setIsDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
if (hasDragLeftDropZone(event)) setIsDragging(false)
}}
onDragOver={prepareFileDrag}
onDrop={(event) => {
prepareFileDrag(event)
setIsDragging(false)
onModelImagesDrop(Array.from(event.dataTransfer.files || []))
}}
onKeyDown={(event) =>
handleDropZoneKeyDown(event, () => document.getElementById('modelImagesInput')?.click())
}
>
<input
type="file"
onChange={onModelImagesChange}
@ -27,7 +58,7 @@ export default function ModelImagesUpload({
className="hidden"
id="modelImagesInput"
/>
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{modelImageUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
@ -36,7 +67,7 @@ export default function ModelImagesUpload({
alt={`模特图 ${index + 1}`}
fill
unoptimized
className="object-cover"
className="bg-gray-50 object-contain dark:bg-gray-800"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
@ -47,7 +78,7 @@ export default function ModelImagesUpload({
<button
type="button"
onClick={() => onRemoveModelImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
>
×
</button>
@ -65,7 +96,7 @@ export default function ModelImagesUpload({
</div>
<div className="mt-3 space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
可一次选择多张图片上传
可一次选择多张图片,也可以直接拖拽到这里
</p>
<p className="text-xs text-blue-600 dark:text-blue-400">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割

View File

@ -2,6 +2,7 @@
import Image from 'next/image'
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
import { handleDropZoneKeyDown } from './drag-drop-utils'
interface PreviewImageUploadProps {
materialType: string
@ -42,6 +43,9 @@ export default function PreviewImageUpload({
</label>
<div
ref={previewDropZoneRef}
role="group"
tabIndex={disabled ? -1 : 0}
aria-label="选择预览图片文件"
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : ''
@ -54,6 +58,7 @@ export default function PreviewImageUpload({
onDragLeave={onPreviewDragLeave}
onDragOver={onPreviewDragOver}
onDrop={onPreviewDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => previewInputRef.current?.click())}
>
<input
ref={previewInputRef}
@ -64,7 +69,7 @@ export default function PreviewImageUpload({
className="hidden"
disabled={disabled}
/>
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{previewUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
@ -73,7 +78,7 @@ export default function PreviewImageUpload({
alt={`预览图 ${index + 1}`}
fill
unoptimized
className="object-cover"
className="bg-gray-50 object-contain dark:bg-gray-800"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
@ -85,7 +90,7 @@ export default function PreviewImageUpload({
type="button"
onClick={() => onRemovePreviewImage(index)}
disabled={disabled}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
>
×
</button>

View File

@ -97,13 +97,14 @@ describe('ProductPackageUploader', () => {
expect(screen.queryByText('empty.psd')).not.toBeInTheDocument()
})
test('应提供任务详情出图方式选择项,且默认未设置', async () => {
test('应提供任务详情出图方式选择项,且默认为必填', async () => {
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement
expect(select.value).toBe('')
expect(screen.getByRole('option', { name: '未设置' })).toBeInTheDocument()
expect(select).toBeRequired()
expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument()
@ -116,7 +117,7 @@ describe('ProductPackageUploader', () => {
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(input)
await user.type(input, '运营甲')
await user.click(screen.getByRole('button', { name: /运营甲/ }))
await user.click(screen.getByRole('option', { name: /运营甲/ }))
expect(input).toHaveValue('运营甲 (operator-a)')
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()

View File

@ -15,15 +15,33 @@ import {
} from '@/lib/task-detail-source-type';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import {
TASK_DETAIL_UPLOAD_LIMITS,
validateTaskDetailUploadFiles,
} from '@/lib/task-detail-upload-policy';
import {
prepareTaskDetailImagePreview,
type TaskDetailAssetKind,
type TaskDetailBrowserPreview,
} from '@/lib/task-detail-image-browser';
import {
pollTaskDetailUploadJob,
TaskDetailUploadJobError,
} from '@/lib/task-detail-upload-status';
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_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar、.7z';
// 浏览器预处理 worker 的输入格式白名单。不要使用 image/*,它还会放行
// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
function splitEmptyFiles(files: FileList | File[] | null): {
validFiles: File[];
@ -79,6 +97,35 @@ interface AssetFile {
barcodes?: string[];
}
type UploadGroup = 'main' | 'sku' | 'detail' | 'psd';
type UploadedAsset = {
fileName: string;
fileSize: number;
uploadId: string;
sortOrder: number;
previewAssets?: Array<{
uploadId: string;
fileName: string;
fileSize: number;
width: number;
height: number;
kind: 'preview' | 'slice';
sourceName: string;
sourceIndex: number;
sliceIndex: number;
sliceCount: number;
}>;
previewUploadId?: string;
previewFileName?: string;
previewFileSize?: number;
};
type UploadedTaskResult = {
group: UploadGroup;
asset: UploadedAsset;
};
function parseBarcodeInput(input: string): string[] {
return Array.from(
new Set(
@ -90,6 +137,16 @@ function parseBarcodeInput(input: string): string[] {
);
}
function revokeAssetPreviews(groups: Array<AssetFile[]>) {
const urls = new Set(
groups
.flat()
.map((item) => item.preview)
.filter((preview): preview is string => Boolean(preview) && preview.startsWith('blob:')),
);
urls.forEach((preview) => URL.revokeObjectURL(preview));
}
export default function ProductPackageUploader({
onSuccess,
onCancel,
@ -138,6 +195,33 @@ export default function ProductPackageUploader({
const skuInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const psdInputRef = useRef<HTMLInputElement>(null);
const mountedRef = useRef(true);
const cancelledRef = useRef(false);
const activeXhrsRef = useRef<Set<XMLHttpRequest>>(new Set());
const activeXhrSetsRef = useRef<Map<string, Set<XMLHttpRequest>>>(new Map());
const jobPollAbortRef = useRef<AbortController | null>(null);
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const jobAcceptedRef = useRef(false);
const assetGroupsRef = useRef<AssetFile[][]>([]);
useEffect(() => {
assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles];
}, [mainImages, skuImages, detailImages, psdFiles]);
useEffect(() => {
const activeXhrSets = activeXhrSetsRef.current;
return () => {
mountedRef.current = false;
cancelledRef.current = true;
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSets.clear();
jobPollAbortRef.current?.abort();
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
revokeAssetPreviews(assetGroupsRef.current);
};
}, []);
useEffect(() => {
let active = true;
@ -296,11 +380,24 @@ export default function ProductPackageUploader({
}
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
const { acceptedFiles, errors } = validateImageFiles(validFiles);
const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles, {
allowedExtensions: TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS,
});
const oversizedFiles = formatAcceptedFiles.filter(
(file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const acceptedFiles = formatAcceptedFiles.filter(
(file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const validationErrors = [...errors];
if (emptyFileNames.length > 0) {
validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`);
}
if (oversizedFiles.length > 0) {
validationErrors.push(
`以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')}`,
);
}
setError(validationErrors.join(';'));
setter((prev) => {
@ -381,27 +478,47 @@ export default function ProductPackageUploader({
}
};
// 使用分片上传到本地服务器(和任务-3D原图一样)
// 使用分片上传到本地服务器(和任务-3D原图一样)。
// 图片预处理和网络上传是两条不同的资源链路:预处理保持单张,网络分片
// 允许两个并发,避免 300M 公网链路被单个请求的往返等待限制吞吐。
const uploadFileWithChunks = async (
file: File,
onProgress?: (progress: number) => void
onProgress?: (progress: number) => void,
isCancelled?: () => boolean,
requestKey?: string,
): Promise<string> => {
const { chunkUpload } = await import('@/lib/chunk-upload');
const result = await chunkUpload(file, {
materialType: '任务-详情',
fileName: file.name,
}, {
// chunkSize使用默认配置:内网20MB,公网10MB
skipComplete: true, // 跳过complete,由后续统一处理
onProgress: (progress) => {
onProgress?.(progress);
skipComplete: true,
chunkConcurrency: 2,
isCancelled,
onProgress: (progress) => onProgress?.(progress),
onActiveXhrChange: (xhrs) => {
if (requestKey) {
activeXhrSetsRef.current.set(requestKey, xhrs);
const merged = new Set<XMLHttpRequest>();
activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr)));
activeXhrsRef.current = merged;
}
},
});
if (requestKey) {
activeXhrSetsRef.current.delete(requestKey);
const merged = new Set<XMLHttpRequest>();
activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr)));
activeXhrsRef.current = merged;
}
return result.uploadId;
};
// 网络传输允许两个文件同时进行;浏览器预处理在提交函数内另有单槽位,
// 不会同时解码两张大图。服务器端真正的 Sharp/ZIP 处理仍由后台 worker 全局串行。
const MAX_FILE_CONCURRENCY = 2;
const runWithConcurrency = async <TInput, TOutput>(
@ -434,13 +551,16 @@ export default function ProductPackageUploader({
return;
}
// 验证必填字段(任务号改为可选,不提示)
if (!taskDetailSourceType) {
setError('请选择出图方式');
return;
}
if (!productModel || !categoryName) {
setError('请填写SPU型号和店铺');
return;
}
// 如果提供了任务号,验证格式
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return;
@ -451,31 +571,32 @@ export default function ProductPackageUploader({
return;
}
// 主图改为非必选,只要至少有一张图片即可
if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) {
setError('请至少上传一张图片(主图、SKU图或详情图)');
return;
}
const uploadPolicyError = validateTaskDetailUploadFiles({
main: mainImages.map((item) => item.file),
sku: skuImages.map((item) => item.file),
detail: detailImages.map((item) => item.file),
psd: psdFiles.map((item) => item.file),
});
if (uploadPolicyError) {
setError(uploadPolicyError);
return;
}
let normalizedSkuImages: AssetFile[];
try {
normalizedSkuImages = skuImages.map((image, index) => {
const skuCode = String(image.skuCode || '').trim();
if (!skuCode) {
throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`);
}
if (!skuCode) throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`);
const barcodes = parseBarcodeInput(image.barcodeInput || '');
if (!noBarcodes && barcodes.length === 0) {
throw new Error(`请为 SKU 图 ${image.file.name} 至少填写 1 个产品条码`);
}
return {
...image,
skuCode,
barcodeInput: barcodes.join('\n'),
barcodes,
};
return { ...image, skuCode, barcodeInput: barcodes.join('\n'), barcodes };
});
} catch (validationError) {
setError(validationError instanceof Error ? validationError.message : 'SKU 条码信息校验失败');
@ -490,22 +611,33 @@ export default function ProductPackageUploader({
setError(`产品条码 ${barcode} 不能同时绑定两张 SKU 图`);
return;
}
barcodeOwners.set(barcode, image.id);
}
}
setUploading(true);
setUploadProgress(0);
progressRef.current = 0;
setProcessingStep('');
setError('');
cancelledRef.current = false;
jobAcceptedRef.current = false;
jobPollAbortRef.current?.abort();
let createdMaterialId: string | null = null;
const uploadedFileIds: string[] = [];
let completed = false;
let jobAccepted = false;
let serverPreviewFallbackUsed = false;
const setProgress = (nextProgress: number) => {
if (!mountedRef.current) return;
const next = Math.max(progressRef.current, Math.min(100, Math.round(nextProgress)));
progressRef.current = next;
setUploadProgress(next);
};
try {
// 1. 初始化上传
setProcessingStep('初始化上传...');
const initResponse = await fetch('/api/materials/upload/product-package/init', {
method: 'POST',
@ -524,7 +656,7 @@ export default function ProductPackageUploader({
});
if (!initResponse.ok) {
const errorData = await initResponse.json();
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
@ -532,171 +664,260 @@ export default function ProductPackageUploader({
const materialId = initData.materialId;
createdMaterialId = materialId;
// 2. 上传所有文件(并行上传以提高速度)
const totalFiles =
mainImages.length + skuImages.length + detailImages.length + psdFiles.length;
let uploadedCount = 0;
const uploadTasks: Array<{
group: UploadGroup;
asset: AssetFile;
sortOrder: number;
skuCode?: string;
barcodes?: string[];
}> = [
...mainImages.map((asset, sortOrder) => ({ group: 'main' as const, asset, sortOrder })),
...normalizedSkuImages.map((asset, sortOrder) => ({
group: 'sku' as const,
asset,
sortOrder,
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
})),
...detailImages.map((asset, sortOrder) => ({ group: 'detail' as const, asset, sortOrder })),
...psdFiles.map((asset, sortOrder) => ({ group: 'psd' as const, asset, sortOrder })),
];
// 进度更新函数(线程安全)
const updateProgress = () => {
uploadedCount++;
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100));
let totalWorkBytes = uploadTasks.reduce((sum, task) => sum + task.asset.file.size, 0);
let completedWorkBytes = 0;
const progressByRequest = new Map<string, number>();
const reportFileProgress = (requestKey: string, fileSize: number, progress: number) => {
const uploadedBytes = Math.min(fileSize, Math.max(0, fileSize * progress / 100));
const previousBytes = progressByRequest.get(requestKey) || 0;
if (uploadedBytes <= previousBytes) return;
progressByRequest.set(requestKey, uploadedBytes);
completedWorkBytes += uploadedBytes - previousBytes;
if (totalWorkBytes > 0) setProgress(completedWorkBytes / totalWorkBytes * 90);
};
// 并行上传资产(使用分片上传)
const uploadAssets = async (
assets: AssetFile[],
assetType: string
): Promise<Array<{ fileName: string; fileSize: number; uploadId: string; sortOrder: number }>> => {
if (assets.length === 0) return [];
const fileConcurrency = MAX_FILE_CONCURRENCY;
return runWithConcurrency(assets, MAX_FILE_CONCURRENCY, async (asset, index) => {
let browserPreviewBusy = false;
const browserPreviewWaiters: Array<() => void> = [];
const withBrowserPreviewSlot = async <T,>(worker: () => Promise<T>): Promise<T> => {
if (browserPreviewBusy) {
await new Promise<void>((resolve) => browserPreviewWaiters.push(resolve));
}
browserPreviewBusy = true;
try {
return await worker();
} finally {
browserPreviewBusy = false;
browserPreviewWaiters.shift()?.();
}
};
const uploadTask = async (task: typeof uploadTasks[number], taskIndex: number): Promise<UploadedTaskResult> => {
if (cancelledRef.current) throw new Error('上传已取消');
const { group, asset } = task;
const requestPrefix = `${taskIndex}-${asset.id}`;
let previewAssets: NonNullable<UploadedAsset['previewAssets']> = [];
if (group !== 'psd') {
let browserPreviews: TaskDetailBrowserPreview[] = [];
try {
const uploadId = await uploadFileWithChunks(asset.file);
uploadedFileIds.push(uploadId);
// 只在上传完成时更新进度
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId,
sortOrder: index,
};
} catch (error) {
console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error);
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
browserPreviews = await withBrowserPreviewSlot(() =>
prepareTaskDetailImagePreview(
asset.file,
group as TaskDetailAssetKind,
(label) => {
if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`);
},
),
);
} catch (previewError) {
if (cancelledRef.current) throw previewError;
const reason = previewError instanceof Error ? previewError.message : String(previewError);
console.warn(`[Upload] 浏览器预览处理失败,继续上传原图并交由服务器生成预览:${asset.file.name}`, reason);
serverPreviewFallbackUsed = true;
if (mountedRef.current) {
setProcessingStep(`${asset.file.name}:浏览器预览不可用,继续上传原图,服务器将自动生成预览`);
}
}
});
// 预览生成失败只影响优化链路,不应阻断原图上传。完成接口会在缺少
// 客户端预览时进入受限的服务器兜底处理;网络上传错误仍然直接抛出,
// 由外层回滚本次上传,避免把网络错误伪装成浏览器兼容问题。
for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) {
const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex];
totalWorkBytes += preview.file.size;
const previewKey = `${requestPrefix}-preview-${previewIndex}`;
const previewUploadId = await uploadFileWithChunks(
preview.file,
(progress) => reportFileProgress(previewKey, preview.file.size, progress),
() => cancelledRef.current,
previewKey,
);
uploadedFileIds.push(previewUploadId);
previewAssets.push({
uploadId: previewUploadId,
fileName: preview.file.name,
fileSize: preview.file.size,
width: preview.width,
height: preview.height,
kind: preview.kind,
sourceName: preview.sourceName,
sourceIndex: preview.sourceIndex,
sliceIndex: preview.sliceIndex,
sliceCount: preview.sliceCount,
});
}
}
const originalUploadId = await uploadFileWithChunks(
asset.file,
(progress) => reportFileProgress(`${requestPrefix}-original`, asset.file.size, progress),
() => cancelledRef.current,
`${requestPrefix}-original`,
);
uploadedFileIds.push(originalUploadId);
return {
group,
asset: {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: originalUploadId,
sortOrder: task.sortOrder,
...(previewAssets.length > 0
? {
previewAssets,
previewUploadId: previewAssets[0].uploadId,
previewFileName: previewAssets[0].fileName,
previewFileSize: previewAssets[0].fileSize,
}
: {}),
...(group === 'sku' ? { skuCode: task.skuCode, barcodes: task.barcodes || [] } : {}),
},
};
};
// 并行上传PSD文件(使用分片上传)
const uploadPsdFiles = async (): Promise<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
if (psdFiles.length === 0) return [];
setProcessingStep('浏览器预处理并上传素材中...');
const uploadedResults = await runWithConcurrency(uploadTasks, fileConcurrency, uploadTask);
if (cancelledRef.current) throw new Error('上传已取消');
return runWithConcurrency(psdFiles, MAX_FILE_CONCURRENCY, async (psdFileItem, index) => {
try {
const psdFile = psdFileItem.file;
const uploadId = await uploadFileWithChunks(psdFile);
uploadedFileIds.push(uploadId);
// 只在上传完成时更新进度
updateProgress();
return {
fileName: psdFile.name,
fileSize: psdFile.size,
uploadId,
};
} catch (error) {
console.error(`[Upload] Failed to upload PSD file ${index}:`, error);
throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
}
});
};
const sortAssets = (group: UploadGroup) => uploadedResults
.filter((result) => result.group === group)
.sort((left, right) => left.asset.sortOrder - right.asset.sortOrder)
.map((result) => result.asset);
const mainAssets = sortAssets('main');
const skuAssets = sortAssets('sku');
const detailAssets = sortAssets('detail');
const psdAssets = sortAssets('psd');
// 顺序上传不同资产类型,避免一次性触发过多并发请求导致会话不稳定
setProcessingStep('上传主图中...');
const mainAssets = await uploadAssets(mainImages, 'main');
setProcessingStep('上传SKU图中...');
const uploadedSkuAssets = await uploadAssets(normalizedSkuImages, 'sku');
const skuAssets = uploadedSkuAssets.map((asset, index) => ({
...asset,
skuCode: normalizedSkuImages[index].skuCode,
barcodes: normalizedSkuImages[index].barcodes || [],
}));
setProcessingStep('上传详情图中...');
const detailAssets = await uploadAssets(detailImages, 'detail');
setProcessingStep('上传PSD中...');
const psdAssetsArray = await uploadPsdFiles();
// PSD资产格式:支持数组(complete接口支持数组格式)
const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined;
// 3. 完成上传(传递uploadId,后端会处理文件)
setProcessingStep('打包文件中...');
setUploadProgress(95);
setProcessingStep('已上传,正在提交后台处理任务...');
setProgress(90);
const completeResponse = await fetch('/api/materials/upload/product-package/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
taskNo, // 传递taskNo,上传完成后写入数据库
taskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
noBarcodes,
allowServerPreviewFallback: serverPreviewFallbackUsed,
assets: {
main: mainAssets,
sku: skuAssets,
detail: detailAssets,
psd: psdAssets, // 数组格式(complete接口支持)
psd: psdAssets.length > 0 ? psdAssets : undefined,
},
}),
});
const completePayload = await completeResponse.json().catch(() => ({}));
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
const errorMessage = errorData.error || '完成上传失败';
console.error('[Upload] Complete failed:', errorMessage, errorData);
const errorMessage = completePayload.error || '完成上传失败';
console.error('[Upload] Complete failed:', errorMessage, completePayload);
throw new Error(errorMessage);
}
setProcessingStep('生成预览图中...');
const jobId = completePayload?.data?.jobId;
if (!jobId) {
throw new Error('后台处理任务创建失败,请重试');
}
jobAccepted = true;
jobAcceptedRef.current = true;
if (jobId) {
const pollController = new AbortController();
jobPollAbortRef.current = pollController;
await pollTaskDetailUploadJob(jobId, {
signal: pollController.signal,
onStatus: (status) => {
if (!mountedRef.current) return;
const backendProgress = Number.isFinite(status.progress) ? Math.max(0, Math.min(100, status.progress || 0)) : 0;
setProgress(90 + backendProgress * 0.1);
setProcessingStep(status.stage || (status.status === 'queued' ? '后台排队处理中...' : '后台处理中...'));
},
});
}
// 短暂延迟以显示处理完成状态
await new Promise(resolve => setTimeout(resolve, 300));
setUploadProgress(100);
setProgress(100);
setProcessingStep('完成!');
completed = true;
// 清理预览URL
const cleanup = () => {
mainImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
skuImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
detailImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
psdFiles.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
};
cleanup();
setTimeout(() => {
revokeAssetPreviews([mainImages, skuImages, detailImages, psdFiles]);
completionTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return;
setProcessingStep('');
onSuccess();
}, 500);
} catch (error: unknown) {
console.error('上传失败:', error);
if (createdMaterialId && !completed) {
if (createdMaterialId && !completed && !jobAccepted) {
try {
await fetch('/api/materials/upload/product-package/abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId: createdMaterialId,
uploadIds: uploadedFileIds,
}),
body: JSON.stringify({ materialId: createdMaterialId, uploadIds: uploadedFileIds }),
});
} catch (abortError) {
console.error('[Upload] 失败回滚未完成素材失败:', abortError);
}
}
const { parseApiError } = await import('@/lib/error-handler');
const { message } = parseApiError(error);
setError(message);
setProcessingStep('');
if (cancelledRef.current || (error instanceof Error && error.message === '上传已取消')) {
if (mountedRef.current) {
setError(jobAccepted || jobAcceptedRef.current
? '已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态'
: '已取消上传');
setProcessingStep('');
}
} else {
const { parseApiError } = await import('@/lib/error-handler');
const { message } = parseApiError(error);
if (mountedRef.current) {
const isFailedJob = error instanceof TaskDetailUploadJobError && error.jobStatus === 'failed';
setError(jobAccepted && !isFailedJob ? `文件已上传,后台任务仍在处理:${message}` : message);
setProcessingStep('');
}
}
} finally {
setUploading(false);
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
jobPollAbortRef.current = null;
if (mountedRef.current) setUploading(false);
}
};
const handleCancelUpload = () => {
if (!uploading) {
onCancel();
return;
}
cancelledRef.current = true;
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
jobPollAbortRef.current?.abort();
setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...');
};
return (
<div className="space-y-6">
<TaskReferenceFields
@ -754,16 +975,19 @@ export default function ProductPackageUploader({
<div>
<label htmlFor="task-detail-source-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
出图方式 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
出图方式 <span className="text-red-500">*</span>
</label>
<select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
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"
>
<option value="">未设置</option>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
@ -783,7 +1007,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择主图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -795,26 +1022,27 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
id="main-images-input"
ref={mainInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setMainImages)}
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{mainImages.map((img) => (
<div key={img.id} className="relative group">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<img src={img.preview} alt="" className="w-full h-full object-cover" />
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setMainImages)}
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
@ -856,7 +1084,10 @@ export default function ProductPackageUploader({
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 SKU 图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -868,26 +1099,27 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())}
>
<input
id="sku-images-input"
ref={skuInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setSkuImages)}
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{skuImages.map((img) => (
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
<div className="relative w-full h-20 rounded-apple overflow-hidden border border-gray-200 dark:border-gray-600">
<img src={img.preview} alt="" className="w-full h-full object-cover" />
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setSkuImages)}
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
@ -935,7 +1167,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择详情图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -947,12 +1182,13 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => detailInputRef.current?.click())}
>
<input
id="detail-images-input"
ref={detailInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setDetailImages)}
className="hidden"
@ -968,7 +1204,7 @@ export default function ProductPackageUploader({
>
{detailImages.length > 0 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs text-gray-400">JPG / PNG / WebP,支持拖拽</span>
<span className="text-xs text-gray-400">JPG / PNG / GIF / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}
@ -990,7 +1226,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 PSD 源文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -1004,6 +1243,7 @@ export default function ProductPackageUploader({
onDrop={(e) => {
handleDrop(e, setDetailImages, setIsPsdDragging, 'psd');
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => psdInputRef.current?.click())}
>
<input
id="psd-files-input"
@ -1049,7 +1289,7 @@ export default function ProductPackageUploader({
{/* 错误提示 */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
<div role="alert" aria-live="assertive" className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
@ -1073,14 +1313,14 @@ export default function ProductPackageUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
onClick={uploading ? handleCancelUpload : onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
{uploading ? '取消上传' : '取消'}
</button>
<button
onClick={handleSubmit}
@ -1090,7 +1330,8 @@ export default function ProductPackageUploader({
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
@ -503,7 +504,10 @@ export default function RealPhotoUploader({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择原图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -514,6 +518,7 @@ export default function RealPhotoUploader({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
ref={mainInputRef}
@ -532,13 +537,13 @@ export default function RealPhotoUploader({
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-apple"
className="h-32 w-full rounded-apple bg-gray-50 object-contain dark:bg-gray-800"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 disabled:opacity-50"
>
×
</button>
@ -566,7 +571,7 @@ export default function RealPhotoUploader({
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div role="alert" aria-live="assertive" className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
@ -585,12 +590,14 @@ export default function RealPhotoUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -5,8 +5,10 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { formatBytes } from '@/components/upload/upload-utils'
import { handleDropZoneKeyDown, hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
/**
* 音乐/视频/品牌VI 批量文件项
@ -163,6 +165,8 @@ interface Batch3DListProps {
}
export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage, onDelete }: Batch3DListProps) {
const [draggingPreviewIndex, setDraggingPreviewIndex] = useState<number | null>(null)
if (files.length === 0) return null
return (
@ -192,15 +196,42 @@ export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage,
if (file) {
onUpdatePreviewImage(index, file)
}
e.currentTarget.value = ''
}}
className="hidden"
id={`3d-preview-${index}`}
/>
<label
htmlFor={`3d-preview-${index}`}
className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer"
role="group"
tabIndex={0}
aria-label={`为第 ${index + 1} 个 3D 文件选择预览图`}
className={`rounded border border-dashed px-2 py-1 text-xs cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
draggingPreviewIndex === index
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/20 dark:text-blue-300'
: 'border-transparent text-blue-600 dark:text-blue-400'
}`}
onDragEnter={(event) => {
prepareFileDrag(event)
setDraggingPreviewIndex(index)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
if (hasDragLeftDropZone(event) && draggingPreviewIndex === index) setDraggingPreviewIndex(null)
}}
onDragOver={prepareFileDrag}
onDrop={(event) => {
prepareFileDrag(event)
setDraggingPreviewIndex(null)
const file = event.dataTransfer.files?.[0]
if (file) onUpdatePreviewImage(index, file)
}}
onKeyDown={(event) =>
handleDropZoneKeyDown(event, () => document.getElementById(`3d-preview-${index}`)?.click())
}
>
{item.previewImage ? '已选预览图' : '选择预览图'}
{item.previewImage ? '已选预览图' : '拖拽或选择预览图'}
</label>
<button
type="button"
@ -264,7 +295,7 @@ export function BatchBrandVIList({ files, onUpdateProductModel, onDelete }: Batc
interface BatchBuyerShowUIProps {
batches: BatchImageGroup[]
onUpdateProductModel: (batchIndex: number, value: string) => void
onImageSelect: (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => void
onImageSelect: (files: FileList | File[], batchIndex: number) => void
onRemoveImage: (batchIndex: number, imageIndex: number) => void
onDeleteBatch: (batchIndex: number) => void
onAddBatch: () => void
@ -278,10 +309,41 @@ export function BatchBuyerShowUI({
onDeleteBatch,
onAddBatch,
}: BatchBuyerShowUIProps) {
const [draggingBatchIndex, setDraggingBatchIndex] = useState<number | null>(null)
const imageUrls = useBatchImagePreviewUrls(batches)
return (
<div className="mb-4 space-y-4">
{batches.map((batch, batchIndex) => (
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
<div
key={batchIndex}
role="group"
tabIndex={0}
aria-label={`买家秀批次 ${batchIndex + 1}`}
className={`rounded-apple border p-4 transition-colors bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
draggingBatchIndex === batchIndex
? 'border-blue-500 bg-blue-50/70 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
onDragEnter={(event) => {
prepareFileDrag(event)
setDraggingBatchIndex(batchIndex)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
if (hasDragLeftDropZone(event) && draggingBatchIndex === batchIndex) setDraggingBatchIndex(null)
}}
onDragOver={prepareFileDrag}
onDrop={(event) => {
prepareFileDrag(event)
setDraggingBatchIndex(null)
onImageSelect(event.dataTransfer.files, batchIndex)
}}
onKeyDown={(event) =>
handleDropZoneKeyDown(event, () => document.getElementById(`buyer-show-images-${batchIndex}`)?.click())
}
>
<div className="flex items-center space-x-3 mb-3">
<input
type="text"
@ -294,7 +356,10 @@ export function BatchBuyerShowUI({
type="file"
accept="image/*"
multiple
onChange={(e) => onImageSelect(e, batchIndex)}
onChange={(e) => {
onImageSelect(e.target.files || [], batchIndex)
e.currentTarget.value = ''
}}
className="hidden"
id={`buyer-show-images-${batchIndex}`}
/>
@ -318,7 +383,7 @@ export function BatchBuyerShowUI({
<div key={imgIndex} className="relative group">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={URL.createObjectURL(img)}
src={imageUrls[batchIndex]?.[imgIndex] || ''}
alt={`图片 ${imgIndex + 1}`}
fill
unoptimized
@ -328,7 +393,7 @@ export function BatchBuyerShowUI({
<button
type="button"
onClick={() => onRemoveImage(batchIndex, imgIndex)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity text-xs"
>
×
</button>
@ -359,7 +424,7 @@ export function BatchBuyerShowUI({
interface BatchXiaohongshuUIProps {
batches: BatchImageGroup[]
onUpdateProductModel: (batchIndex: number, value: string) => void
onImageSelect: (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => void
onImageSelect: (files: FileList | File[], batchIndex: number) => void
onRemoveImage: (batchIndex: number, imageIndex: number) => void
onDeleteBatch: (batchIndex: number) => void
onAddBatch: () => void
@ -373,10 +438,41 @@ export function BatchXiaohongshuUI({
onDeleteBatch,
onAddBatch,
}: BatchXiaohongshuUIProps) {
const [draggingBatchIndex, setDraggingBatchIndex] = useState<number | null>(null)
const imageUrls = useBatchImagePreviewUrls(batches)
return (
<div className="mb-4 space-y-4">
{batches.map((batch, batchIndex) => (
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
<div
key={batchIndex}
role="group"
tabIndex={0}
aria-label={`小红书批次 ${batchIndex + 1}`}
className={`rounded-apple border p-4 transition-colors bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
draggingBatchIndex === batchIndex
? 'border-blue-500 bg-blue-50/70 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
onDragEnter={(event) => {
prepareFileDrag(event)
setDraggingBatchIndex(batchIndex)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
if (hasDragLeftDropZone(event) && draggingBatchIndex === batchIndex) setDraggingBatchIndex(null)
}}
onDragOver={prepareFileDrag}
onDrop={(event) => {
prepareFileDrag(event)
setDraggingBatchIndex(null)
onImageSelect(event.dataTransfer.files, batchIndex)
}}
onKeyDown={(event) =>
handleDropZoneKeyDown(event, () => document.getElementById(`xiaohongshu-images-${batchIndex}`)?.click())
}
>
<div className="flex items-center space-x-3 mb-3">
<input
type="text"
@ -389,7 +485,10 @@ export function BatchXiaohongshuUI({
type="file"
accept="image/*"
multiple
onChange={(e) => onImageSelect(e, batchIndex)}
onChange={(e) => {
onImageSelect(e.target.files || [], batchIndex)
e.currentTarget.value = ''
}}
className="hidden"
id={`xiaohongshu-images-${batchIndex}`}
/>
@ -413,7 +512,7 @@ export function BatchXiaohongshuUI({
<div key={imgIndex} className="relative group">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={URL.createObjectURL(img)}
src={imageUrls[batchIndex]?.[imgIndex] || ''}
alt={`图片 ${imgIndex + 1}`}
fill
unoptimized
@ -423,7 +522,7 @@ export function BatchXiaohongshuUI({
<button
type="button"
onClick={() => onRemoveImage(batchIndex, imgIndex)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity text-xs"
>
×
</button>
@ -443,3 +542,18 @@ export function BatchXiaohongshuUI({
</div>
)
}
function useBatchImagePreviewUrls(batches: BatchImageGroup[]): string[][] {
const imageUrls = useMemo(
() => batches.map((batch) => batch.images.map((file) => URL.createObjectURL(file))),
[batches],
)
useEffect(() => {
return () => {
imageUrls.flat().forEach((url) => URL.revokeObjectURL(url))
}
}, [imageUrls])
return imageUrls
}

View File

@ -5,6 +5,7 @@ import Image from 'next/image';
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
@ -533,7 +534,10 @@ export default function TaskPageUploader({
预览图 *
</label>
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择页面预览图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -563,6 +567,7 @@ export default function TaskPageUploader({
}
handlePreviewSelect(e.dataTransfer.files);
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => previewInputRef.current?.click())}
>
<input
ref={previewInputRef}
@ -573,18 +578,18 @@ export default function TaskPageUploader({
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<img src={img.preview} alt={`预览图 ${index + 1}`} className="w-full h-full object-cover" />
<img src={img.preview} alt={`预览图 ${index + 1}`} className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs rounded px-1">
{index + 1}
</div>
</div>
<button
onClick={() => removePreviewImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
@ -616,7 +621,7 @@ export default function TaskPageUploader({
{/* 错误提示 */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
<div role="alert" aria-live="assertive" className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
@ -640,7 +645,8 @@ export default function TaskPageUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="btn-secondary"
@ -657,7 +663,8 @@ export default function TaskPageUploader({
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
@ -506,7 +507,10 @@ export default function ThreeDOriginalUploader({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择原图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -517,6 +521,7 @@ export default function ThreeDOriginalUploader({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
ref={mainInputRef}
@ -535,13 +540,13 @@ export default function ThreeDOriginalUploader({
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-apple"
className="h-32 w-full rounded-apple bg-gray-50 object-contain dark:bg-gray-800"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 disabled:opacity-50"
>
×
</button>
@ -569,7 +574,7 @@ export default function ThreeDOriginalUploader({
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div role="alert" aria-live="assertive" className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
@ -588,12 +593,14 @@ export default function ThreeDOriginalUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
@ -521,7 +522,10 @@ export default function VideoScreenshotUploader({
视频截图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择原图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -532,6 +536,7 @@ export default function VideoScreenshotUploader({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => screenshotInputRef.current?.click())}
>
<input
ref={screenshotInputRef}
@ -550,13 +555,13 @@ export default function VideoScreenshotUploader({
alt={`截图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-apple"
className="h-32 w-full rounded-apple bg-gray-50 object-contain dark:bg-gray-800"
/>
<button
type="button"
onClick={() => removeScreenshot(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 disabled:opacity-50"
>
×
</button>
@ -584,7 +589,7 @@ export default function VideoScreenshotUploader({
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div role="alert" aria-live="assertive" className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
@ -605,12 +610,14 @@ export default function VideoScreenshotUploader({
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import {
hasAllowedFileExtension,
hasDragLeftDropZone,
handleDropZoneKeyDown,
isSupportedImageFile,
prepareFileDrag,
validateImageFiles,
@ -69,6 +70,35 @@ describe('drag-drop-utils', () => {
expect(event.dataTransfer.dropEffect).toBe('none')
})
test('上传区获得焦点时按 Enter 或空格应打开文件选择器', () => {
const dropZone = document.createElement('div')
const openFilePicker = jest.fn()
handleDropZoneKeyDown({
key: 'Enter',
preventDefault: jest.fn(),
currentTarget: dropZone,
target: dropZone,
} as unknown as React.KeyboardEvent<HTMLElement>, openFilePicker)
expect(openFilePicker).toHaveBeenCalledTimes(1)
})
test('上传区内的按钮按键不应再次打开文件选择器', () => {
const dropZone = document.createElement('div')
const child = document.createElement('button')
const openFilePicker = jest.fn()
handleDropZoneKeyDown({
key: ' ',
preventDefault: jest.fn(),
currentTarget: dropZone,
target: child,
} as unknown as React.KeyboardEvent<HTMLElement>, openFilePicker)
expect(openFilePicker).not.toHaveBeenCalled()
})
test('Windows 未提供 MIME 时应按常见图片扩展名识别', () => {
const file = new File(['image'], 'preview.PNG', { type: '' })
@ -87,6 +117,19 @@ describe('drag-drop-utils', () => {
expect(result.errors.join(';')).toContain('large.jpg')
})
test('业务链路可按浏览器处理能力限制图片格式', () => {
const jpg = new File(['image'], '1.jpg', { type: 'image/jpeg' })
const heic = new File(['image'], 'phone.heic', { type: 'image/heic' })
const result = validateImageFiles([jpg, heic], {
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
})
expect(result.acceptedFiles).toEqual([jpg])
expect(result.errors.join(';')).toContain('phone.heic')
expect(result.errors.join(';')).toContain('JPG、JPEG、PNG、GIF、WEBP')
})
test('图片数量限制应考虑已经选择的图片', () => {
const files = [
new File(['1'], '1.png', { type: 'image/png' }),

View File

@ -14,6 +14,11 @@ export interface ImageFileValidationOptions {
maxBytes?: number
maxCount?: number
existingCount?: number
/**
* 可选的业务格式白名单。浏览器的 image/* MIME 范围比实际处理能力更宽,
* 需要在特定上传链路中按扩展名提前拦截不兼容格式。
*/
allowedExtensions?: readonly string[]
}
export interface ImageFileValidationResult {
@ -38,6 +43,19 @@ export function prepareFileDrag(event: React.DragEvent<Element>, disabled = fals
event.dataTransfer.dropEffect = disabled ? 'none' : 'copy'
}
/**
* 让拖拽上传区域也能通过键盘打开文件选择器,避免只能用鼠标点击。
*/
export function handleDropZoneKeyDown(
event: React.KeyboardEvent<HTMLElement>,
openFilePicker: () => void,
): void {
if (event.target !== event.currentTarget) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openFilePicker()
}
export function isSupportedImageFile(file: File): boolean {
if (file.type) {
return file.type.startsWith('image/')
@ -60,13 +78,16 @@ export function validateImageFiles(
files: FileList | File[],
options: ImageFileValidationOptions = {},
): ImageFileValidationResult {
const { maxBytes, maxCount, existingCount = 0 } = options
const { maxBytes, maxCount, existingCount = 0, allowedExtensions } = options
const unsupportedNames: string[] = []
const oversizedNames: string[] = []
const validFiles: File[] = []
for (const file of Array.from(files)) {
if (!isSupportedImageFile(file)) {
const isValidImage = allowedExtensions
? hasAllowedFileExtension(file, allowedExtensions)
: isSupportedImageFile(file)
if (!isValidImage) {
unsupportedNames.push(file.name)
continue
}
@ -87,7 +108,10 @@ export function validateImageFiles(
const errors: string[] = []
if (unsupportedNames.length > 0) {
errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}`)
const supportedLabel = allowedExtensions?.length
? `,请上传 ${allowedExtensions.map((extension) => extension.replace(/^\./, '').toUpperCase()).join('、')} 格式`
: ''
errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}${supportedLabel}`)
}
if (oversizedNames.length > 0 && maxBytes !== undefined) {
const maxMegabytes = Math.round(maxBytes / 1024 / 1024)

View File

@ -19,5 +19,30 @@ export async function register() {
} catch (error) {
console.error('[Instrumentation] 启动清理任务失败:', error)
}
const recoveryGlobal = globalThis as typeof globalThis & {
__taskDetailUploadRecoveryStarted?: boolean
}
if (!recoveryGlobal.__taskDetailUploadRecoveryStarted) {
recoveryGlobal.__taskDetailUploadRecoveryStarted = true
// 先注册周期任务,再做首次恢复。数据库启动瞬时不可用时,后续周期仍会重试,
// 不会因为一次失败让 processing/queued 任务永久无人处理。
setInterval(() => {
void import('./lib/task-detail-upload-jobs')
.then(({ recoverTaskDetailUploadJobs }) => recoverTaskDetailUploadJobs())
.catch((error) => {
console.error('[Instrumentation] 任务-详情后台队列周期恢复失败:', error)
})
}, 60_000)
}
try {
const { recoverTaskDetailUploadJobs } = await import('./lib/task-detail-upload-jobs')
await recoverTaskDetailUploadJobs()
console.log('[Instrumentation] 任务-详情后台队列恢复检查完成')
} catch (error) {
// 迁移未执行或数据库暂不可用时只记录;周期恢复已提前注册,会继续重试。
console.error('[Instrumentation] 任务-详情后台队列恢复失败:', error)
}
}
}

View File

@ -439,7 +439,7 @@ describe('material-update shared rules', () => {
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('任务-详情编辑时应支持保存和清空出图方式', async () => {
test('任务-详情编辑时应支持保存,但不允许清空出图方式', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '任务-详情',
@ -477,7 +477,7 @@ describe('material-update shared rules', () => {
}),
})
await updateMaterialWithSharedRules({
await expect(updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
@ -493,14 +493,12 @@ describe('material-update shared rules', () => {
userId: 'user-1',
taskDetailSourceType: '',
},
})).resolves.toEqual({
error: '任务-详情必须选择出图方式(实拍、渲染或 AI)',
status: 400,
})
expect(prisma.material.update).toHaveBeenLastCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailSourceType: null,
}),
})
expect(prisma.material.update).toHaveBeenCalledTimes(1)
})
test('旧编辑调用未提交出图方式时应保留任务详情原值', async () => {

View File

@ -96,6 +96,51 @@ describe('materials share security', () => {
)
})
test('公开分享响应应包含钉钉审批编号', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
previewUrl: '',
productModel: 'SPU-001',
reportTitle: null,
categoryName: '旗舰店',
sceneName: '客厅',
platformName: '',
productCategoryName: '',
materialType: '图片',
isAiGenerated: false,
taskDetailSourceType: null,
authorName: '张三',
zipFileName: '',
zipFilePath: '',
zipFileSize: BigInt(0),
downloadCount: 0,
likeCount: 0,
displayImages: [],
createdAt: new Date('2026-07-31T00:00:00.000Z'),
userId: 'user-1',
videoSourceUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
videoProcessingStatus: null,
taskNo: 'TASK202607310001',
dingtalkApprovalNo: '202508211445000157693',
taskStatus: 'completed',
taskFetchedAt: null,
isFullSet: false,
})
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-1'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.taskNo).toBe('TASK202607310001')
expect(body.dingtalkApprovalNo).toBe('202508211445000157693')
})
test('鉴权下载响应禁止共享缓存', () => {
const routeSource = fs.readFileSync(
path.join(process.cwd(), 'app/api/materials/download/route.ts'),

View File

@ -80,6 +80,7 @@ jest.mock('@/lib/performance-survey-position-config', () => {
jest.mock('@/lib/performance-survey-report-sync', () => ({
ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(),
ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(),
getActivePerformanceSurveyEmployeeProfileWhere: jest.fn(() => ({ employmentStatus: { not: 'inactive' } })),
getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({ id: { not: '__hidden__' } })),
previewPerformanceSurveyEmployeeSync: jest.fn(),
syncPerformanceSurveyEmployees: jest.fn(),
@ -1352,6 +1353,10 @@ describe('performance survey service policies', () => {
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([creator, target])
.mockResolvedValueOnce([creator])
;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{
id: baseUser.id,
dingtalkUserId: baseUser.dingtalkUserId,
}])
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
id: baseUser.id,
dingtalkUserId: baseUser.dingtalkUserId,
@ -1577,7 +1582,16 @@ describe('performance survey service policies', () => {
])
})
test('直属主管模板遇到人员档案缺少主管唯一值时明确拒绝创建', async () => {
test.each([
['缺少主组织', []],
[
'存在多个主组织',
[
{ sourceDeptId: '1085808623' },
{ sourceDeptId: '1085808624' },
],
],
])('直属主管模板在人员档案缺少主管唯一值且%s时明确拒绝创建', async (_label, primaryMemberships) => {
const target = {
...baseEmployee,
id: 'employee-target',
@ -1599,6 +1613,8 @@ describe('performance survey service policies', () => {
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock)
.mockResolvedValueOnce(primaryMemberships)
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([target])
await expect(createPerformanceSurvey(session, {
@ -1607,33 +1623,36 @@ describe('performance survey service policies', () => {
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法确定缺主管人员的直属主管,请先校准人员档案',
'无法确定缺主管人员所属的主组织,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('直属主管模板不按同部门 M 级人员猜测主管', async () => {
test('直属主管唯一值缺失时按主组织唯一 M 级人员解析主管', async () => {
const target = {
...baseEmployee,
id: 'employee-yijiang',
displayName: '忆江',
departmentName: '综合支持部',
departmentPath: '集团A/品牌中心/综合支持部',
dingtalkUserId: 'ding-yijiang',
id: 'employee-xinyu',
displayName: '新雨',
departmentName: '品牌创意A部',
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部',
dingtalkUserId: '1766367441840704',
levelType: 'P',
positionLevel: 'P4',
positionLevel: 'P1',
managerDingtalkUserId: null,
managerJobNumber: null,
}
const uniqueManager = {
...baseEmployee,
id: 'employee-wuxie',
displayName: '无邪',
id: 'employee-boyan',
displayName: '伯言',
departmentName: target.departmentName,
departmentPath: target.departmentPath,
dingtalkUserId: 'ding-wuxie',
dingtalkUserId: '09061617282237',
levelType: 'M',
positionLevel: 'M1',
positionLevel: 'M2',
isLeadership: true,
sourcePayload: { materialUserId: 'user-boyan' },
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
@ -1645,14 +1664,19 @@ describe('performance survey service policies', () => {
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock)
.mockResolvedValueOnce([{ sourceDeptId: '1085808623' }])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([uniqueManager])
.mockResolvedValueOnce([uniqueManager])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-wuxie',
;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{
id: 'user-boyan',
dingtalkUserId: uniqueManager.dingtalkUserId,
}])
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
id: 'user-boyan',
dingtalkUserId: uniqueManager.dingtalkUserId,
})
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'work_cooperation',
creatorEmployeeId: target.id,
@ -1668,6 +1692,197 @@ describe('performance survey service policies', () => {
}],
}))
await createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})
expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith({
where: {
employeeProfileId: target.id,
isPrimary: true,
},
select: { sourceDeptId: true },
take: 2,
})
const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0]
expect(managerQuery.where.AND).toContainEqual({
departmentMemberships: {
some: {
sourceDeptId: '1085808623',
isPrimary: true,
},
},
})
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
AND: [
{
archivedAt: null,
status: 'active',
isActive: true,
},
{ id: 'user-boyan' },
],
},
select: {
id: true,
dingtalkUserId: true,
},
take: 2,
})
const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data
expect(assignmentRows).toEqual([
expect.objectContaining({
evaluatorEmployeeId: uniqueManager.id,
evaluatorDingtalkUserId: uniqueManager.dingtalkUserId,
evaluatorName: uniqueManager.displayName,
reviewerSource: 'direct_manager',
}),
])
})
test.each([
['没有有效账户', [], '被考核人的直属主管尚无可用账户,请先校准人员档案'],
[
'账户钉钉身份与档案不一致',
[{ id: 'user-manager', dingtalkUserId: 'ding-account-manager' }],
'被考核人的直属主管钉钉身份不一致,请先校准人员档案',
],
])('主管唯一值已配置但%s时不回退组织推断', async (_label, activeAccounts, expectedMessage) => {
const target = {
...baseEmployee,
id: 'employee-target',
displayName: '被考核人',
dingtalkUserId: 'ding-target',
managerDingtalkUserId: 'ding-declared-manager',
}
const manager = {
...baseEmployee,
id: 'employee-manager',
displayName: '档案直属主管',
dingtalkUserId: 'ding-declared-manager',
sourcePayload: { materialUserId: 'user-manager' },
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'work_cooperation',
name: '工作配合度满意度调研',
shortName: '工作配合度',
surveyType: 'person_period',
weight: 0.2,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([manager])
;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce(activeAccounts)
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(expectedMessage, 409))
expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test.each([
['没有M级主管', [], '新雨所属组织内没有可用的M级主管,请先校准人员档案'],
[
'存在多名M级人员',
[
{ ...baseEmployee, id: 'employee-manager-1', displayName: '主管甲', levelType: 'M', positionLevel: 'M1' },
{ ...baseEmployee, id: 'employee-manager-2', displayName: '主管乙', levelType: 'M', positionLevel: 'M2' },
],
'新雨所属组织内存在多名M级人员,请明确配置直属主管',
],
])('直属主管唯一值缺失且主组织%s时拒绝创建', async (_label, managers, expectedMessage) => {
const target = {
...baseEmployee,
id: 'employee-xinyu',
displayName: '新雨',
departmentName: '品牌创意A部',
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部',
dingtalkUserId: '1766367441840704',
levelType: 'P',
positionLevel: 'P1',
managerDingtalkUserId: null,
managerJobNumber: null,
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'work_cooperation',
name: '工作配合度满意度调研',
shortName: '工作配合度',
surveyType: 'person_period',
weight: 0.2,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock)
.mockResolvedValueOnce([{ sourceDeptId: '1085808623' }])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce(managers)
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(expectedMessage, 409))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('直属主管唯一值缺失且主组织唯一 M 级人员无可用账户时拒绝创建', async () => {
const target = {
...baseEmployee,
id: 'employee-xinyu',
displayName: '新雨',
departmentName: '品牌创意A部',
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意A部',
dingtalkUserId: '1766367441840704',
levelType: 'P',
positionLevel: 'P1',
managerDingtalkUserId: null,
managerJobNumber: null,
}
const uniqueManager = {
...baseEmployee,
id: 'employee-boyan',
displayName: '伯言',
departmentName: target.departmentName,
departmentPath: target.departmentPath,
dingtalkUserId: '09061617282237',
levelType: 'M',
positionLevel: 'M2',
isLeadership: true,
sourcePayload: { materialUserId: 'user-boyan' },
}
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target)
;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({
code: 'work_cooperation',
name: '工作配合度满意度调研',
shortName: '工作配合度',
surveyType: 'person_period',
weight: 0.2,
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock)
.mockResolvedValueOnce([{ sourceDeptId: '1085808623' }])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([uniqueManager])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
positionGroupCode: 'brand_designer',
@ -1675,13 +1890,13 @@ describe('performance survey service policies', () => {
targetEmployeeIds: [target.id],
openNow: false,
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法确定忆江的直属主管,请先校准人员档案',
'新雨的直属主管尚无可用账户,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('人员姓名不再触发固定评分人规则,缺主管仍按档案错误处理', async () => {
test('人员姓名不触发固定评分人规则,缺主管时仍按组织规则处理', async () => {
const yuanzhe = {
...baseEmployee,
id: 'employee-yuanzhe',
@ -1703,7 +1918,11 @@ describe('performance survey service policies', () => {
questions: [],
scoreBands: [],
})
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([yuanzhe])
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock)
.mockResolvedValueOnce([{ sourceDeptId: '1085808623' }])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock)
.mockResolvedValueOnce([yuanzhe])
.mockResolvedValueOnce([])
await expect(createPerformanceSurvey(session, {
templateCode: 'work_cooperation',
@ -1711,7 +1930,7 @@ describe('performance survey service policies', () => {
assessmentMonth: '2026-07',
targetEmployeeIds: [yuanzhe.id],
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法确定元泽的直属主管,请先校准人员档案',
'元泽所属组织内没有可用的M级主管,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
@ -1761,10 +1980,10 @@ describe('performance survey service policies', () => {
.mockResolvedValueOnce([manager])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockReset()
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe)
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ id: 'user-1', dingtalkUserId: 'ding-yuanzhe' },
{ id: 'user-manager', dingtalkUserId: 'ding-manager' },
])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-manager',
dingtalkUserId: 'ding-manager',
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'visual_brand_tone',
targets: [{
@ -1854,10 +2073,10 @@ describe('performance survey service policies', () => {
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([manager])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe)
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ id: 'user-target', dingtalkUserId },
{ id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' },
])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-manager',
dingtalkUserId: 'ding-declared-manager',
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'visual_brand_tone',
targets: [{
@ -1941,11 +2160,10 @@ describe('performance survey service policies', () => {
.mockResolvedValueOnce([target])
.mockResolvedValueOnce([guanshi])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe)
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ id: 'user-target', dingtalkUserId: 'ding-photographer' },
{ id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' },
{ id: 'user-guanshi', dingtalkUserId: '17642061233583361' },
])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
id: 'user-guanshi',
dingtalkUserId: '17642061233583361',
}])
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({
templateCode: 'visual_brand_tone',
targets: [{

View File

@ -6,6 +6,10 @@ import { POST } from '@/app/api/materials/upload/product-package/complete/route'
import {
completeProductPackageUpload,
} from '@/lib/product-package-completion'
import {
createTaskDetailUploadJob,
getLatestTaskDetailUploadJobStatus,
} from '@/lib/task-detail-upload-jobs'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
@ -23,6 +27,10 @@ jest.mock('@/lib/product-package-completion', () => {
completeProductPackageUpload: jest.fn(),
}
})
jest.mock('@/lib/task-detail-upload-jobs', () => ({
createTaskDetailUploadJob: jest.fn(),
getLatestTaskDetailUploadJobStatus: jest.fn().mockResolvedValue(null),
}))
describe('product-package complete public route security', () => {
beforeEach(() => {
jest.clearAllMocks()
@ -32,6 +40,13 @@ describe('product-package complete public route security', () => {
taskNo: null,
isFullSet: false,
})
;(createTaskDetailUploadJob as jest.Mock).mockResolvedValue({
jobId: 'job-1',
materialId: 'material-1',
status: 'queued',
stage: 'queued',
progress: 0,
})
})
test.each([
@ -92,4 +107,185 @@ describe('product-package complete public route security', () => {
expect(body.error).toContain('缺少需求运营人员')
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
test('缺少出图方式的新流程素材不能完成上传', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: null,
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: { main: [] },
}),
} as any)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('出图方式')
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
test('合法任务详情上传会快速入队并返回 jobId', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: '实拍',
zipFilePath: '',
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: {
main: [{
uploadId: 'upload-main',
fileName: 'main.png',
fileSize: 10,
previewAssets: [{
uploadId: 'upload-preview',
fileName: 'main-preview.webp',
fileSize: 5,
}],
}],
},
}),
} as any)
const body = await response.json()
expect(response.status).toBe(202)
expect(body.data.jobId).toBe('job-1')
expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({
materialId: 'material-1',
operatorUserId: 'user-1',
}))
expect(completeProductPackageUpload).not.toHaveBeenCalled()
})
test('客户端预览失败时仍可带原图入队,由后台生成预览', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: '实拍',
zipFilePath: '',
_count: { assets: 0 },
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
allowServerPreviewFallback: true,
assets: {
main: [{
uploadId: 'upload-main',
fileName: 'main.jpg',
fileSize: 10,
}],
},
}),
} as any)
const body = await response.json()
expect(response.status).toBe(202)
expect(body.data.jobId).toBe('job-1')
expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({
materialId: 'material-1',
assets: expect.objectContaining({
main: [expect.objectContaining({ uploadId: 'upload-main' })],
}),
}))
})
test('缺少客户端预览且未声明兜底时仍拒绝完成请求', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: '实拍',
zipFilePath: '',
_count: { assets: 0 },
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: {
main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }],
},
}),
} as any)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('客户端预览图缺失')
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
})
test('已有部分持久化结果的素材不能重复入队', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: '实拍',
zipFilePath: 'materials/product-package/material-1/all.zip',
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: {
main: [{
uploadId: 'upload-main',
fileName: 'main.png',
fileSize: 10,
previewAssets: [{ uploadId: 'upload-preview', fileName: 'main.webp', fileSize: 5 }],
}],
},
}),
} as any)
expect(response.status).toBe(409)
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
})
test('重复提交同一素材时返回已有后台任务,避免丢失 202 响应后重复上传', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailRequesterUserId: 'requester-1',
taskDetailSourceType: '实拍',
zipFilePath: '',
_count: { assets: 0 },
})
;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({
jobId: 'job-existing',
materialId: 'material-1',
status: 'processing',
stage: 'zips',
progress: 80,
})
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: { main: [] },
}),
} as any)
const body = await response.json()
expect(response.status).toBe(202)
expect(body.data.jobId).toBe('job-existing')
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
})
})

View File

@ -20,6 +20,11 @@ jest.mock('@/lib/prisma', () => ({
findUnique: jest.fn(),
update: jest.fn(),
},
materialAsset: {
count: jest.fn(),
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
}))
@ -101,6 +106,12 @@ describe('product-package-completion', () => {
},
})
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: (tx: any) => unknown) => callback({
materialAsset: { deleteMany: prisma.materialAsset.deleteMany },
material: { update: prisma.material.update },
}))
global.fetch = jest.fn(async () =>
new Response(Buffer.from('image-binary'), {
status: 200,
@ -165,6 +176,41 @@ describe('product-package-completion', () => {
expect(createMaterialLog).toHaveBeenCalled()
})
test('任务详情直接完成时缺少出图方式应拒绝处理', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: null,
productModel: 'SPU-001',
categoryName: '旗舰店',
previewUrl: '',
user: {
realName: '张三',
username: 'zhangsan',
},
})
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [
{
fileUrl: 'https://img.example/main.png',
fileName: 'main.png',
fileSize: 10,
sortOrder: 0,
},
],
},
}),
).rejects.toThrow('任务-详情必须选择出图方式')
expect(createZipFromFiles).not.toHaveBeenCalled()
})
test('SKU 图缺少 skuCode 时应直接报错', async () => {
await expect(
completeProductPackageUpload({
@ -347,4 +393,37 @@ describe('product-package-completion', () => {
},
})).rejects.toThrow('不属于当前用户')
})
test('数据库资产已保存但完成标记失败时会补偿并清理文件', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: false,
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(assignTaskNoOnComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在'))
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: 'TASK202601030184',
dingtalkApprovalNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
},
})).rejects.toThrow('任务号已存在')
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { materialId: 'material-1' } })
expect(prisma.$transaction).toHaveBeenCalled()
})
})

View File

@ -53,7 +53,7 @@ describe('product-package init route', () => {
})
})
test('未填写出图方式时也应允许初始化任务详情上传', async () => {
test('未填写出图方式时应拒绝初始化任务详情上传', async () => {
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
method: 'POST',
body: JSON.stringify({
@ -70,14 +70,10 @@ describe('product-package init route', () => {
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(prisma.material.create).toHaveBeenCalledWith({
data: expect.objectContaining({
materialType: '任务-详情',
taskDetailSourceType: null,
}),
})
expect(response.status).toBe(400)
expect(body.success).toBe(false)
expect(body.error).toContain('出图方式')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('非法出图方式应返回 400', async () => {

View File

@ -0,0 +1,57 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/materials/upload/product-package/status/route'
import {
getLatestTaskDetailUploadJobStatus,
getTaskDetailUploadJobStatus,
} from '@/lib/task-detail-upload-jobs'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/task-detail-upload-jobs', () => ({
getLatestTaskDetailUploadJobStatus: jest.fn(),
getTaskDetailUploadJobStatus: jest.fn(),
}))
function requestWithQuery(query: string) {
const url = new URL(`http://localhost/api/materials/upload/product-package/status${query}`)
return { nextUrl: { searchParams: url.searchParams } } as any
}
describe('product-package status route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
})
test('未登录拒绝查询', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await GET(requestWithQuery('?jobId=job-1'))
expect(response.status).toBe(401)
})
test('按 jobId 查询时保持用户隔离', async () => {
;(getTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue(null)
const response = await GET(requestWithQuery('?jobId=job-1'))
expect(response.status).toBe(404)
expect(getTaskDetailUploadJobStatus).toHaveBeenCalledWith('job-1', 'user-1')
})
test('关闭上传页后可按 materialId 找到最新任务', async () => {
;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({
jobId: 'job-1',
materialId: 'material-1',
status: 'processing',
stage: 'zips',
progress: 80,
})
const response = await GET(requestWithQuery('?materialId=material-1'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.jobId).toBe('job-1')
expect(getLatestTaskDetailUploadJobStatus).toHaveBeenCalledWith('material-1', 'user-1')
expect(getTaskDetailUploadJobStatus).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,32 @@
/** @jest-environment node */
import type { Prisma } from '@prisma/client'
import { acquireTaskDetailUploadLock } from '@/lib/task-detail-upload-jobs'
jest.mock('@/lib/prisma', () => ({
prisma: {},
}))
jest.mock('@/lib/product-package-completion', () => ({
completeProductPackageUpload: jest.fn(),
}))
describe('task-detail upload advisory lock', () => {
test('只返回可由 Prisma 反序列化的整数列,不直接读取 void 返回值', async () => {
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
} as unknown as Prisma.TransactionClient
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
expect(tx.$queryRaw).toHaveBeenCalledTimes(1)
const [template, lockName] = (tx.$queryRaw as jest.Mock).mock.calls[0]
const sql = Array.from(template as TemplateStringsArray).join('?')
expect(sql).toContain('SELECT 1 AS locked')
expect(sql).toContain('FROM (')
expect(sql).toContain('pg_advisory_xact_lock(hashtext(?)')
expect(sql.trim()).not.toMatch(/^SELECT\s+pg_advisory_xact_lock/i)
expect(lockName).toBe('product-package-upload-create')
})
})

View File

@ -20,6 +20,7 @@ interface ChunkUploadOptions {
isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 暴露所有活跃的XHR请求集合(用于批量取消)
requestHeaders?: Record<string, string> // 供钉钉等独立登录态透传认证头
chunkConcurrency?: number // 单次文件上传的并发块数;不传时继续按网络环境自动选择
}
interface ChunkUploadState {
@ -541,13 +542,15 @@ export async function chunkUpload(
}
// 动态并发上传配置(根据网络环境自动调整)
let defaultConcurrency = 2 // 默认2并发
try {
defaultConcurrency = await getOptimalConcurrency()
} catch (error) {
console.warn('[ChunkUpload] 获取最优并发数失败,使用默认值(2):', error)
let defaultConcurrency = options.chunkConcurrency ?? 2 // 默认2并发
if (options.chunkConcurrency === undefined) {
try {
defaultConcurrency = await getOptimalConcurrency()
} catch (error) {
console.warn('[ChunkUpload] 获取最优并发数失败,使用默认值(2):', error)
}
}
const CONCURRENT_CHUNKS = defaultConcurrency
const CONCURRENT_CHUNKS = Math.max(1, Math.min(8, Math.floor(Number(defaultConcurrency) || 1)))
// 创建待上传chunk队列
const pendingChunks: number[] = []

View File

@ -332,6 +332,15 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
nextTaskDetailRequesterName = requester.realName || requester.username
}
// 新建或已有出图方式的任务-详情不能清空;未提交该字段时保留原值,历史上从未填写过的记录继续兼容。
const currentTaskDetailSourceType = normalizeTaskDetailSourceType(material.taskDetailSourceType)
const canPreserveSourceType =
material.materialType === '任务-详情' &&
(!hasTaskDetailSourceTypeField || !currentTaskDetailSourceType)
if (materialType === '任务-详情' && !taskDetailSourceType && !canPreserveSourceType) {
return { error: '任务-详情必须选择出图方式(实拍、渲染或 AI)', status: 400 as const }
}
let previewUrl = material.previewUrl
try {
previewUrl = await resolvePreviewUrl(previewImage, materialType, material.previewUrl)

View File

@ -46,7 +46,7 @@ function assertPerformanceSurveyBrowserImageSupport() {
function runImageWorker(
files: File[],
profile: 'performance_survey' | 'work_review',
profile: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail',
): Promise<WorkerProcessedImage[]> {
assertPerformanceSurveyBrowserImageSupport()
@ -88,7 +88,7 @@ export async function preparePerformanceSurveyImagesInBrowser(
files: File[],
onProgress?: (label: string) => void,
options?: {
profile?: 'performance_survey' | 'work_review'
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
},
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
if (files.length === 0) throw new Error('请至少选择 1 张图片')

View File

@ -7,6 +7,9 @@ export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = {
sliceHeight: 1_500,
maxSlicesPerCase: 60,
headerReadBytes: 256 * 1024,
// 少数 JPEG 的 EXIF/ICC 段会超过首段读取范围;扩大到 4MB 仍远小于解码整图的成本,
// 用于在浏览器解码前继续拦截超大像素图片。
extendedHeaderReadBytes: 4 * 1024 * 1024,
} as const
export type PerformanceSurveySourceImageDimensions = {

View File

@ -11,7 +11,7 @@ export {}
type WorkerRequest = {
id: string
files: File[]
profile?: 'performance_survey' | 'work_review'
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
}
type WorkerProcessedImage = {
@ -35,6 +35,10 @@ const MAX_ORDERED_SLICE_COUNT = 60
const PREVIEW_QUALITY = 0.68
let outputWidthLimit = 1_600
let sliceQuality = 0.76
let previewWidth = PREVIEW_WIDTH
let previewHeight = PREVIEW_HEIGHT
let previewQuality = PREVIEW_QUALITY
let previewFit: 'cover' | 'inside' = 'cover'
const workerScope = self as unknown as DedicatedWorkerGlobalScope
function removeFileExtension(fileName: string) {
@ -50,15 +54,37 @@ function getCanvasContext(canvas: OffscreenCanvas) {
async function decodeImage(file: File) {
const header = new Uint8Array(await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer())
assertPerformanceSurveyBrowserSourceImageDimensions(
readPerformanceSurveySourceImageDimensions(header),
file.name,
)
let headerDimensions = readPerformanceSurveySourceImageDimensions(header)
if (!headerDimensions && file.size > header.byteLength) {
// JPEG 的 EXIF/ICC 段可能超过 256KB。再读取有限的扩展头部,优先在解码前
// 拦截超大像素图片;只有仍无法识别时才交给浏览器实际解码。
const extendedHeader = new Uint8Array(
await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(),
)
headerDimensions = readPerformanceSurveySourceImageDimensions(extendedHeader)
}
// 常见图片会在文件头直接提供尺寸,先做保护性校验,避免超大图片进入浏览器解码。
// JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码,
// 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。
if (headerDimensions) {
assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name)
}
let bitmap: ImageBitmap
try {
return await createImageBitmap(file)
bitmap = await createImageBitmap(file)
} catch {
throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`)
}
try {
assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: bitmap.width, height: bitmap.height },
file.name,
)
return bitmap
} catch (error) {
bitmap.close()
throw error
}
}
async function renderSlice(
@ -96,8 +122,18 @@ async function renderSlice(
async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight: number) {
const bitmap = await createImageBitmap(firstSlice)
try {
const targetWidth = Math.min(PREVIEW_WIDTH, sliceWidth)
const targetHeight = Math.min(PREVIEW_HEIGHT, sliceHeight)
const targetWidth = Math.min(previewWidth, sliceWidth)
const targetHeight = Math.min(previewHeight, sliceHeight)
if (previewFit === 'inside') {
const scale = Math.min(targetWidth / bitmap.width, targetHeight / bitmap.height, 1)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = getCanvasContext(canvas)
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: previewQuality })
return { blob, width, height }
}
const coverScale = Math.max(targetWidth / bitmap.width, targetHeight / bitmap.height)
const sourceWidth = Math.min(bitmap.width, targetWidth / coverScale)
const sourceHeight = Math.min(bitmap.height, targetHeight / coverScale)
@ -115,14 +151,14 @@ async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight:
targetWidth,
targetHeight,
)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: PREVIEW_QUALITY })
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: previewQuality })
return { blob, width: targetWidth, height: targetHeight }
} finally {
bitmap.close()
}
}
async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]> {
async function processSingleOriginal(file: File, includePreview = true): Promise<WorkerProcessedImage[]> {
const bitmap = await decodeImage(file)
try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
@ -150,6 +186,7 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
})
}
if (!includePreview) return slices
const preview = await renderPreview(slices[0].blob, slices[0].width, slices[0].height)
return [{
...preview,
@ -172,6 +209,37 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
}
}
async function renderPreviewFromBitmap(bitmap: ImageBitmap) {
const scale = Math.min(previewWidth / bitmap.width, previewHeight / bitmap.height, 1)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = getCanvasContext(canvas)
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: previewQuality })
return { blob, width, height }
}
async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]> {
const bitmap = await decodeImage(file)
try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const preview = await renderPreviewFromBitmap(bitmap)
return [{
...preview,
fileName: `${removeFileExtension(file.name)}-preview.webp`,
mimeType: 'image/webp',
kind: 'preview',
sourceName: file.name,
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}]
} finally {
bitmap.close()
}
}
async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage[]> {
if (files.length > MAX_ORDERED_SLICE_COUNT) {
throw new Error(`单个案例最多上传 ${MAX_ORDERED_SLICE_COUNT} 张有序切片`)
@ -246,11 +314,18 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片')
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
const isTaskDetail = profile === 'task_detail' || profile === 'task_detail_preview' || profile === 'task_detail_detail'
previewWidth = isTaskDetail ? 800 : PREVIEW_WIDTH
previewHeight = isTaskDetail ? 1_200 : PREVIEW_HEIGHT
previewQuality = isTaskDetail ? 0.74 : PREVIEW_QUALITY
previewFit = isTaskDetail ? 'inside' : 'cover'
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
}
const images = files.length === 1
? await processSingleOriginal(files[0])
? profile === 'task_detail_preview'
? await processSinglePreview(files[0])
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
: await processOrderedSlices(files)
workerScope.postMessage({ id, ok: true, images })
} catch (error) {

View File

@ -64,6 +64,7 @@ import {
import {
ensurePerformanceSurveyDailyEmployeeSyncStarted,
ensurePerformanceSurveyEmployeeProfileForUser,
getActivePerformanceSurveyEmployeeProfileWhere,
getPerformanceSurveyAccountProfileWhere,
previewPerformanceSurveyEmployeeSync,
syncPerformanceSurveyEmployees,
@ -4861,31 +4862,143 @@ async function resolveEmployeeEvaluator(employee: any) {
}
}
async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager: any) {
const sourcePayload = manager?.sourcePayload && typeof manager.sourcePayload === 'object'
? manager.sourcePayload as Record<string, unknown>
: {}
const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId)
const managerDingtalkIdentity = clean(manager?.dingtalkUserId)
const managerEmail = clean(manager?.dingEmail)
const accountIdentityWhere = linkedUserId
? { id: linkedUserId }
: managerDingtalkIdentity
? { dingtalkUserId: managerDingtalkIdentity }
: managerEmail
? { email: { equals: managerEmail, mode: 'insensitive' as const } }
: null
if (!accountIdentityWhere) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409)
}
const activeAccounts = await prisma.user.findMany({
where: {
AND: [
{
archivedAt: null,
status: 'active',
isActive: true,
},
accountIdentityWhere,
],
},
select: {
id: true,
dingtalkUserId: true,
},
take: 2,
})
if (activeAccounts.length !== 1) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管尚无可用账户,请先校准人员档案`, 409)
}
const accountDingtalkUserId = clean(activeAccounts[0].dingtalkUserId)
if (
managerDingtalkIdentity
&& managerDingtalkIdentity !== accountDingtalkUserId
) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管钉钉身份不一致,请先校准人员档案`, 409)
}
const resolvedDingtalkUserId = managerDingtalkIdentity || accountDingtalkUserId
if (!resolvedDingtalkUserId) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少钉钉唯一值,请先校准人员档案`, 409)
}
return {
...manager,
dingtalkUserId: resolvedDingtalkUserId,
}
}
async function resolveDirectManagerForWorkflow(targetEmployee: any) {
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId)
const managerJobNumber = clean(targetEmployee?.managerJobNumber)
if (!managerDingtalkUserId && !managerJobNumber) {
throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}的直属主管,请先校准人员档案`, 409)
// 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。
if (managerDingtalkUserId || managerJobNumber) {
const managers = await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
getActivePerformanceSurveyEmployeeProfileWhere(),
{
OR: [
...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []),
...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []),
],
},
],
},
take: 2,
})
if (managers.length !== 1) {
throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409)
}
return resolveAvailableDirectManagerProfile(targetEmployee, managers[0])
}
// 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。
const targetEmployeeId = clean(targetEmployee?.id)
if (!targetEmployeeId) {
throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409)
}
const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({
where: {
employeeProfileId: targetEmployeeId,
isPrimary: true,
},
select: { sourceDeptId: true },
take: 2,
})
const primarySourceDeptIds = Array.from(new Set(
primaryMemberships
.map((membership) => clean(membership.sourceDeptId))
.filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)),
))
if (primarySourceDeptIds.length !== 1) {
throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409)
}
const managers = await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
accountWhere,
getActivePerformanceSurveyEmployeeProfileWhere(),
{ id: { not: targetEmployeeId } },
{
OR: [
...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []),
...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []),
{ isLeadership: true },
{ levelType: { equals: 'M', mode: 'insensitive' } },
{ positionLevel: { startsWith: 'M', mode: 'insensitive' } },
],
},
{
departmentMemberships: {
some: {
sourceDeptId: primarySourceDeptIds[0],
isPrimary: true,
},
},
},
],
},
take: 2,
})
if (managers.length !== 1) {
throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409)
if (managers.length === 0) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409)
}
return managers[0]
if (managers.length > 1) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409)
}
return resolveAvailableDirectManagerProfile(targetEmployee, managers[0])
}
async function resolveConfiguredWorkflowEvaluators(input: {

View File

@ -22,6 +22,7 @@ import {
} from '@/lib/external-auto-listing'
import path from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
@ -36,6 +37,7 @@ export interface ProductPackageImageInput {
sortOrder?: number
skuCode?: string
barcodes?: string[]
previewAssets?: ProductPackagePreviewInput[]
}
export interface ProductPackageSourceInput {
@ -46,6 +48,19 @@ export interface ProductPackageSourceInput {
fileSize: number
}
export interface ProductPackagePreviewInput {
uploadId: string
fileName: string
fileSize: number
width?: number
height?: number
kind?: string
sourceName?: string
sourceIndex?: number
sliceIndex?: number
sliceCount?: number
}
export interface ProductPackageAssetsInput {
main?: ProductPackageImageInput[]
sku?: ProductPackageImageInput[]
@ -63,9 +78,14 @@ export interface CompleteProductPackageUploadInput {
allowSkuWithoutBarcodes?: boolean
zipTimeoutMs?: number
allZipTimeoutMs?: number
zipCompressionLevel?: number
preserveMaterialOnError?: boolean
onProgress?: (update: { stage: string; progress: number; message?: string }) => Promise<void> | void
}
}
type ProductPackageReadableAsset = ProductPackageImageInput | ProductPackageSourceInput | ProductPackagePreviewInput
type PublicProductPackageAsset = Record<string, unknown>
function parsePublicAssetGroup(
@ -120,6 +140,65 @@ function parsePublicAssetGroup(
(barcode): barcode is string => typeof barcode === 'string',
)
}
const rawPreviewAssets = Array.isArray(asset.previewAssets) ? asset.previewAssets : []
const previewAssets = rawPreviewAssets.map((rawPreviewAsset, previewIndex) => {
if (!rawPreviewAsset || typeof rawPreviewAsset !== 'object' || Array.isArray(rawPreviewAsset)) {
throw new Error(`${group} 第 ${index + 1} 个预览图 ${previewIndex + 1} 格式错误`)
}
const preview = rawPreviewAsset as PublicProductPackageAsset
if (
Object.prototype.hasOwnProperty.call(preview, 'localFilePath') ||
Object.prototype.hasOwnProperty.call(preview, 'fileUrl')
) {
throw new Error('公开上传接口只允许使用预览 uploadId')
}
const previewUploadId = typeof preview.uploadId === 'string' ? preview.uploadId.trim() : ''
const previewFileName = typeof preview.fileName === 'string' ? preview.fileName.trim() : ''
const previewFileSize = Number(preview.fileSize)
if (!previewUploadId || !previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) {
throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`)
}
if (!/\.webp$/i.test(previewFileName)) {
throw new Error(`${group} 第 ${index + 1} 个预览图必须是 WebP 格式`)
}
const parsedPreview: ProductPackagePreviewInput = {
uploadId: previewUploadId,
fileName: previewFileName,
fileSize: previewFileSize,
}
for (const field of ['width', 'height', 'sourceIndex', 'sliceIndex', 'sliceCount'] as const) {
if (Number.isInteger(preview[field]) && Number(preview[field]) >= 0) {
parsedPreview[field] = Number(preview[field])
}
}
for (const field of ['kind', 'sourceName'] as const) {
if (typeof preview[field] === 'string' && preview[field].trim()) {
parsedPreview[field] = preview[field].trim()
}
}
return parsedPreview
})
const previewUploadId = typeof asset.previewUploadId === 'string' ? asset.previewUploadId.trim() : ''
if (previewAssets.length > 0) {
imageAsset.previewAssets = previewAssets
} else if (previewUploadId) {
const previewFileName = typeof asset.previewFileName === 'string'
? asset.previewFileName.trim()
: `${fileName.replace(/\.[^.]+$/, '')}-preview.webp`
const previewFileSize = Number(asset.previewFileSize)
if (!previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) {
throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`)
}
imageAsset.previewAssets = [{
uploadId: previewUploadId,
fileName: previewFileName,
fileSize: previewFileSize,
}]
}
}
return parsedAsset
@ -144,17 +223,105 @@ async function assertOwnedProductPackageUploadSessions(
assets: ProductPackageAssetsInput,
operatorUserId: string,
) {
const uploadIds = Array.from(new Set(
[assets.main, assets.sku, assets.detail, assets.psd]
.flatMap((group) => group || [])
.map((asset) => String(asset.uploadId || '').trim())
.filter(Boolean),
))
const imageAssets = [assets.main || [], assets.sku || [], assets.detail || []].flat()
const allAssets = [...imageAssets, ...(assets.psd || [])]
const seenUploadIds = new Set<string>()
const maxImageBytes = 100 * 1024 * 1024
const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES)
const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0
? configuredMaxPreviewBytes
: 10 * 1024 * 1024
const maxTotalSourceBytes = 1024 * 1024 * 1024
const maxTotalPreviewBytes = 512 * 1024 * 1024
const maxTotalPreviewCount = 1000
let totalSourceBytes = 0
let totalPreviewBytes = 0
let totalPreviewCount = 0
for (const uploadId of uploadIds) {
if (!(await getOwnedUploadSession(uploadId, operatorUserId))) {
for (const asset of allAssets) {
const uploadId = String(asset.uploadId || '').trim()
if (!uploadId) {
// 内部历史导入仍可传受信任的本地路径/已签名地址;公开 HTTP 路由在
// parsePublicProductPackageAssets 阶段已经拒绝这两类来源。
if (!('fileUrl' in asset && asset.fileUrl) && !('localFilePath' in asset && asset.localFilePath)) {
throw new Error(`资产缺少 uploadId: ${asset.fileName}`)
}
if (imageAssets.includes(asset) && asset.fileSize > maxImageBytes) {
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
}
totalSourceBytes += Math.max(0, asset.fileSize)
continue
}
if (seenUploadIds.has(uploadId)) {
throw new Error(`上传会话重复使用: ${asset.fileName}`)
}
const session = await getOwnedUploadSession(uploadId, operatorUserId)
if (!session) {
throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`)
}
seenUploadIds.add(uploadId)
if (session.fileSize !== asset.fileSize) {
throw new Error(`文件大小校验失败,请重新上传: ${asset.fileName}`)
}
if (imageAssets.includes(asset) && session.fileSize > maxImageBytes) {
throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`)
}
totalSourceBytes += session.fileSize
}
for (const asset of imageAssets) {
const previews = asset.previewAssets || []
if (previews.length > 60) {
throw new Error(`图片 ${asset.fileName} 的预览切片超过 60 张限制`)
}
const slicePreviews = previews.filter((preview) => preview.kind === 'slice')
if (slicePreviews.length > 0) {
const expectedCount = slicePreviews[0].sliceCount
if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) {
throw new Error(`图片 ${asset.fileName} 的预览切片数量校验失败`)
}
const indices = slicePreviews.map((preview) => preview.sliceIndex).sort((a, b) => (a ?? 0) - (b ?? 0))
if (indices.some((value, index) => value !== index)) {
throw new Error(`图片 ${asset.fileName} 的预览切片顺序校验失败`)
}
}
for (const preview of previews) {
if (seenUploadIds.has(preview.uploadId)) {
throw new Error(`客户端预览图上传会话重复使用: ${preview.fileName}`)
}
seenUploadIds.add(preview.uploadId)
totalPreviewCount += 1
totalPreviewBytes += preview.fileSize
if (totalPreviewCount > maxTotalPreviewCount) {
throw new Error('本次客户端预览图数量过多,请分批上传')
}
if (preview.fileSize <= 0 || preview.fileSize > maxPreviewBytes) {
throw new Error(`客户端预览图 ${preview.fileName} 超出大小限制`)
}
if (preview.kind !== 'preview' && preview.kind !== 'slice') {
throw new Error(`客户端预览图 ${preview.fileName} 类型无效`)
}
if (preview.width !== undefined && (preview.width < 1 || preview.width > 16000)) {
throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`)
}
if (preview.height !== undefined && (preview.height < 1 || preview.height > 16000)) {
throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`)
}
const previewSession = await getOwnedUploadSession(preview.uploadId, operatorUserId)
if (!previewSession || previewSession.fileSize !== preview.fileSize) {
throw new Error(`预览图上传会话校验失败: ${preview.fileName}`)
}
totalSourceBytes += previewSession.fileSize
if (!seenUploadIds.has(preview.uploadId)) seenUploadIds.add(preview.uploadId)
}
}
if (totalSourceBytes > maxTotalSourceBytes) {
throw new Error('本次上传素材总大小超过 1GB,请分批提交')
}
if (totalPreviewBytes > maxTotalPreviewBytes) {
throw new Error('本次客户端预览图总大小超过 512MB,请分批提交')
}
}
@ -248,14 +415,16 @@ async function downloadToTempFile(prefix: string, asset: ProductPackageImageInpu
}
async function readAssetBuffer(
asset: ProductPackageImageInput | ProductPackageSourceInput,
asset: ProductPackageReadableAsset,
type: 'main' | 'sku' | 'detail' | 'psd',
assembledFiles?: Map<string, string>,
tempFiles?: string[],
) {
if (asset.localFilePath) {
if ('localFilePath' in asset && asset.localFilePath) {
return readFile(asset.localFilePath)
}
if (asset.fileUrl) {
if ('fileUrl' in asset && asset.fileUrl) {
const response = await fetch(asset.fileUrl)
if (!response.ok) {
throw new Error(`下载${type}预览原图失败: ${asset.fileName}`)
@ -264,10 +433,15 @@ async function readAssetBuffer(
}
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId)
const cachedPath = assembledFiles?.get(asset.uploadId)
const filePath = cachedPath || await getUploadedFile(asset.uploadId)
if (!filePath) {
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
}
assembledFiles?.set(asset.uploadId, filePath)
if (tempFiles && !tempFiles.includes(filePath)) {
tempFiles.push(filePath)
}
return readFile(filePath)
}
@ -279,6 +453,7 @@ async function collectAssetFiles(
prefix: string,
tempFiles: string[],
uploadIds: string[],
assembledFiles?: Map<string, string>,
) {
const files: Array<{ path: string; name: string; size: number }> = []
@ -288,17 +463,23 @@ async function collectAssetFiles(
for (const asset of assets) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId)
const cachedPath = assembledFiles?.get(asset.uploadId)
const filePath = cachedPath || await getUploadedFile(asset.uploadId)
if (!filePath) {
throw new Error(`文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`)
}
assembledFiles?.set(asset.uploadId, filePath)
files.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
})
tempFiles.push(filePath)
uploadIds.push(asset.uploadId)
if (!tempFiles.includes(filePath)) {
tempFiles.push(filePath)
}
if (!uploadIds.includes(asset.uploadId)) {
uploadIds.push(asset.uploadId)
}
continue
}
@ -323,27 +504,102 @@ async function generatePreviewsFromAssets(
assets: ProductPackageImageInput[] | undefined,
type: 'main' | 'sku' | 'detail',
uploadedPreviewUrls: string[],
assembledFiles?: Map<string, string>,
tempFiles?: string[],
uploadIds?: string[],
) {
if (!assets || assets.length === 0) {
return []
}
const previews: GeneratedProductPackagePreviewAsset[] = []
const { compressImage, sliceImage } = await import('@/lib/image-processor')
const sharp = await import('sharp')
let compressImage: typeof import('@/lib/image-processor').compressImage | undefined
let sliceImage: typeof import('@/lib/image-processor').sliceImage | undefined
let sharp: { default: typeof import('sharp') } | undefined
const getServerPreviewTools = async () => {
if (!compressImage || !sliceImage || !sharp) {
const imageProcessor = await import('@/lib/image-processor')
compressImage = imageProcessor.compressImage
sliceImage = imageProcessor.sliceImage
sharp = await import('sharp') as unknown as { default: typeof import('sharp') }
}
return { compressImage, sliceImage, sharp }
}
const safePreviewFileName = (fileName: string, index: number, sliceIndex: number) => {
const safeName = fileName.replace(/[\/\\:*?"<>|\0\r\n]/g, '_').trim()
return safeName || `${type}-preview-${index}-${sliceIndex}.webp`
}
for (let index = 0; index < assets.length; index += 1) {
const asset = assets[index]
if (asset.previewAssets && asset.previewAssets.length > 0) {
for (let sliceIndex = 0; sliceIndex < asset.previewAssets.length; sliceIndex += 1) {
const previewAsset = asset.previewAssets[sliceIndex]
if (uploadIds && !uploadIds.includes(previewAsset.uploadId)) {
uploadIds.push(previewAsset.uploadId)
}
const buffer = await readAssetBuffer(previewAsset, type, assembledFiles, tempFiles)
const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES)
const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0
? configuredMaxPreviewBytes
: 10 * 1024 * 1024
if (buffer.length > maxPreviewBytes) {
throw new Error(`客户端预览图 ${previewAsset.fileName} 不能超过 ${Math.ceil(maxPreviewBytes / 1024 / 1024)}MB`)
}
if (!/\.webp$/i.test(previewAsset.fileName)) {
throw new Error(`客户端预览图 ${previewAsset.fileName} 必须是 WebP 格式`)
}
if (
buffer.length < 12
|| buffer.toString('ascii', 0, 4) !== 'RIFF'
|| buffer.toString('ascii', 8, 12) !== 'WEBP'
) {
throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`)
}
const fileName = safePreviewFileName(previewAsset.fileName, index, sliceIndex)
const previewUrl = await uploadPublicDisplayToS3(
buffer,
buildTaskDetailPublicDisplayPath(materialId, type, `client-${Date.now()}-${index}-${sliceIndex}-${fileName}`),
'image/webp',
)
previews.push({
fileUrl: previewUrl,
fileName,
sortOrder: previews.length,
...(type === 'sku'
? {
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
}
: {}),
})
uploadedPreviewUrls.push(previewUrl)
}
continue
}
const previewTools = await getServerPreviewTools()
let buffer: Buffer
buffer = await readAssetBuffer(asset, type)
buffer = await readAssetBuffer(asset, type, assembledFiles, tempFiles)
const metadata = await sharp.default(buffer).metadata()
if (buffer.length > 200 * 1024 * 1024) {
throw new Error(`预览源图片 ${asset.fileName} 不能超过 200MB`)
}
const metadata = await previewTools.sharp!.default(buffer, {
limitInputPixels: 60_000_000,
sequentialRead: true,
failOn: 'error',
}).metadata()
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
const buffers = shouldSlice
? await sliceImage(buffer, 1500, 200)
? await previewTools.sliceImage!(buffer, 1500, 200)
: [
await compressImage(buffer, {
await previewTools.compressImage!(buffer, {
maxWidth: 800,
maxHeight: 1200,
maxSizeKB: 200,
@ -383,6 +639,7 @@ async function movePsdFilesToPermanentDir(
materialId: string,
assets: ProductPackageSourceInput[] | undefined,
tempFiles: string[],
assembledFiles?: Map<string, string>,
) {
const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = []
@ -436,10 +693,12 @@ async function movePsdFilesToPermanentDir(
continue
}
const tempFilePath = await getUploadedFile(psd.uploadId)
const cachedPath = assembledFiles?.get(psd.uploadId)
const tempFilePath = cachedPath || await getUploadedFile(psd.uploadId)
if (!tempFilePath) {
throw new Error(`PSD 文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`)
}
assembledFiles?.set(psd.uploadId, tempFilePath)
const timestamp = Date.now()
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_')
@ -470,8 +729,33 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
const { materialId, taskNo, dingtalkApprovalNo, assets, operatorUserId, options } = input
const tempFiles: string[] = []
const uploadIds: string[] = []
const assembledFiles = new Map<string, string>()
const uploadedPreviewUrls: string[] = []
const createdZipPaths: string[] = []
let persistentStateCommitted = false
let databaseSaveCompleted = false
let originalMaterialState: {
previewUrl: string
zipFilePath: string
zipFileName: string
zipFileSize: bigint
taskNo: string | null
dingtalkApprovalNo: string | null
taskStatus: string | null
isFullSet: boolean
} | null = null
let canCompensateFreshMaterial = false
const startedAt = Date.now()
const reportProgress = async (stage: string, progress: number, message?: string) => {
console.log(
`[ProductPackage] material=${materialId} stage=${stage} progress=${progress} elapsedMs=${Date.now() - startedAt}`,
)
try {
await options?.onProgress?.({ stage, progress, message })
} catch (error) {
console.warn('[ProductPackage] progress update skipped:', error)
}
}
const normalizedAssets = {
main: assets.main || [],
sku: normalizeSkuPreviewAssets(assets.sku, options),
@ -482,12 +766,19 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
const zipTimeoutMs = options?.zipTimeoutMs && options.zipTimeoutMs > 0 ? options.zipTimeoutMs : 30000
const allZipTimeoutMs = options?.allZipTimeoutMs && options.allZipTimeoutMs > 0 ? options.allZipTimeoutMs : 60000
const configuredCompressionLevel = Number(
options?.zipCompressionLevel ?? process.env.TASK_DETAIL_ZIP_COMPRESSION_LEVEL ?? 1,
)
const zipCompressionLevel = Number.isFinite(configuredCompressionLevel)
? Math.max(0, Math.min(9, Math.round(configuredCompressionLevel)))
: 1
if (!materialId || !assets) {
throw new Error('缺少必填参数')
}
await assertOwnedProductPackageUploadSessions(assets, operatorUserId)
await reportProgress('validating', 5)
const totalImages =
normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length
@ -506,6 +797,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
}
try {
await reportProgress('validating', 10)
const material: any = await prisma.material.findUnique({
where: { id: materialId },
include: {
@ -522,18 +814,52 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
throw new Error('素材不存在')
}
originalMaterialState = {
previewUrl: material.previewUrl || '',
zipFilePath: material.zipFilePath || '',
zipFileName: material.zipFileName || '',
zipFileSize: typeof material.zipFileSize === 'bigint' ? material.zipFileSize : BigInt(material.zipFileSize || 0),
taskNo: material.taskNo || null,
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
taskStatus: material.taskStatus || null,
isFullSet: Boolean(material.isFullSet),
}
// 初始化接口创建的素材没有任何持久化文件。只有这种“新素材”允许自动补偿,
// 避免重试旧素材时误删原有资产。
let originalAssetCount = 0
if (originalMaterialState.taskStatus === 'uploading') {
originalAssetCount = await prisma.materialAsset.count({ where: { materialId } })
}
canCompensateFreshMaterial = Boolean(
!originalMaterialState.previewUrl &&
!originalMaterialState.zipFilePath &&
!originalMaterialState.taskNo &&
(originalMaterialState.taskStatus === null ||
(originalMaterialState.taskStatus === 'uploading' && originalAssetCount === 0)),
)
if (
material.materialType === '任务-详情' &&
!normalizeTaskDetailSourceType(material.taskDetailSourceType)
) {
throw new Error('任务-详情必须选择出图方式(实拍、渲染或 AI)')
}
const authorName = material.user?.realName || material.user?.username || '未知作者'
const categoryName = material.categoryName || '未知店铺'
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds)
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds)
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds)
await reportProgress('assembling', 15)
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds, assembledFiles)
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds, assembledFiles)
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds, assembledFiles)
await reportProgress('assembling', 30)
const previewAssets = {
main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls),
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls),
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls),
main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds),
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds),
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds),
}
await reportProgress('previews', 55)
const zipInfos: {
main?: { filePath: string; fileName: string; fileSize: number }
@ -554,6 +880,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
authorName,
categoryName,
zipTimeoutMs,
zipCompressionLevel,
)
createdZipPaths.push(zipInfos.main.filePath)
zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName })
@ -569,6 +896,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
authorName,
categoryName,
zipTimeoutMs,
zipCompressionLevel,
)
createdZipPaths.push(zipInfos.sku.filePath)
zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName })
@ -584,6 +912,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
authorName,
categoryName,
zipTimeoutMs,
zipCompressionLevel,
)
createdZipPaths.push(zipInfos.detail.filePath)
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName })
@ -595,21 +924,22 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
material.productModel || 'product',
'任务-详情',
allZipTimeoutMs,
zipCompressionLevel,
)
createdZipPaths.push(zipInfos.all.filePath)
await reportProgress('zips', 80)
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles)
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles, assembledFiles)
createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath))
await reportProgress('database', 85)
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
databaseSaveCompleted = true
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
await assignTaskNoOnComplete(
materialId,
normalizedTaskNo,
normalizedDingtalkApprovalNo,
)
// 先写入非完成标记的数据,最后由 assignTaskNoOnComplete 写入
// taskStatus=completed,作为后台任务可恢复的提交标记。
await prisma.material.update({
where: { id: materialId },
data: {
@ -618,13 +948,23 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
},
})
await cleanupTempFiles(tempFiles, uploadIds)
await createMaterialLog({
await assignTaskNoOnComplete(
materialId,
userId: operatorUserId,
action: 'UPLOAD',
changes: {
normalizedTaskNo,
normalizedDingtalkApprovalNo,
)
persistentStateCommitted = true
await reportProgress('database', 93)
await cleanupTempFiles(tempFiles, uploadIds)
await reportProgress('cleanup', 97)
try {
await createMaterialLog({
materialId,
userId: operatorUserId,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-详情',
@ -659,20 +999,30 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
all: zipInfos.all.fileSize,
},
},
},
})
},
})
} catch (logError) {
// 主数据已经提交,日志失败不能再回滚 ZIP/S3,避免产生“数据库完成但文件被删”的坏素材。
console.error('[ProductPackage] material log write failed after commit:', logError)
}
if (derivedIsFullSet) {
void queueTaskDetailFullSetNotification(materialId)
void queueTaskDetailFullSetNotification(materialId).catch((notificationError) => {
console.error('[ProductPackage] full-set notification failed:', notificationError)
})
}
if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
void queueTaskDetailBarcodeImagesUpdatedNotification(
materialId,
saveResult.createdSkuPreviewAssets.map((asset) => asset.id),
)
).catch((notificationError) => {
console.error('[ProductPackage] barcode notification failed:', notificationError)
})
}
await reportProgress('completed', 100)
return {
materialId,
taskNo: normalizedTaskNo,
@ -680,7 +1030,47 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
isFullSet: derivedIsFullSet,
}
} catch (error) {
await cleanupOnError(uploadedPreviewUrls, createdZipPaths, tempFiles, materialId)
if (persistentStateCommitted) {
console.error('[ProductPackage] post-commit error; skip destructive cleanup:', error)
throw error
}
let databaseCompensated = !databaseSaveCompleted
if (databaseSaveCompleted && canCompensateFreshMaterial && originalMaterialState) {
try {
await prisma.$transaction(async (tx) => {
await tx.materialAsset.deleteMany({ where: { materialId } })
await tx.material.update({
where: { id: materialId },
data: {
previewUrl: originalMaterialState!.previewUrl,
zipFilePath: originalMaterialState!.zipFilePath,
zipFileName: originalMaterialState!.zipFileName,
zipFileSize: originalMaterialState!.zipFileSize,
taskNo: originalMaterialState!.taskNo,
dingtalkApprovalNo: originalMaterialState!.dingtalkApprovalNo,
taskStatus: originalMaterialState!.taskStatus,
isFullSet: originalMaterialState!.isFullSet,
},
})
})
databaseCompensated = true
console.warn(`[ProductPackage] compensated partial database save material=${materialId}`)
} catch (compensationError) {
// 数据库补偿失败时保留已写入的文件,避免数据库仍指向已被删除的 ZIP/S3。
databaseCompensated = false
console.error('[ProductPackage] database compensation failed; keep committed files:', compensationError)
}
}
await cleanupOnError(
databaseCompensated ? uploadedPreviewUrls : [],
databaseCompensated ? createdZipPaths : [],
tempFiles,
materialId,
uploadIds,
!options?.preserveMaterialOnError,
)
throw error
}
}

View File

@ -0,0 +1,38 @@
import {
preparePerformanceSurveyImagesInBrowser,
type PerformanceSurveyBrowserProcessedImage,
} from '@/lib/performance-survey-image-browser'
export type TaskDetailAssetKind = 'main' | 'sku' | 'detail'
export type TaskDetailBrowserPreview = PerformanceSurveyBrowserProcessedImage & {
sourceIndex: number
}
/**
* 在用户浏览器中逐张生成任务-详情预览。
*
* 这里故意一次只传一张源图,避免把整批原图和 Blob 同时留在内存里。
* 详情长图只上传切片;普通主图/SKU图和短详情图上传一张预览。
*/
export async function prepareTaskDetailImagePreview(
file: File,
assetKind: TaskDetailAssetKind,
onProgress?: (label: string) => void,
): Promise<TaskDetailBrowserPreview[]> {
const processed = await preparePerformanceSurveyImagesInBrowser(
[file],
onProgress,
{ profile: assetKind === 'detail' ? 'task_detail_detail' : 'task_detail_preview' },
)
if (assetKind === 'detail' && processed.some((item) => item.sliceCount > 1)) {
return processed.filter((item) => item.kind === 'slice')
}
const preview = processed.filter((item) => item.kind === 'preview')
if (preview.length > 0) return preview
// 极旧浏览器/worker 兼容:没有 preview 时至少保留第一张切片,不能因预览缺失阻断原图上传。
return processed.filter((item) => item.kind === 'slice').slice(0, 1)
}

View File

@ -0,0 +1,462 @@
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
completeProductPackageUpload,
type ProductPackageAssetsInput,
type CompleteProductPackageUploadInput,
} from '@/lib/product-package-completion'
export const TASK_DETAIL_UPLOAD_JOB_STATUS = {
QUEUED: 'queued',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export const TASK_DETAIL_UPLOAD_JOB_STAGE = {
QUEUED: 'queued',
VALIDATING: 'validating',
ASSEMBLING: 'assembling',
PREVIEWS: 'previews',
ZIPS: 'zips',
DATABASE: 'database',
CLEANUP: 'cleanup',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export type TaskDetailUploadJobStatus =
(typeof TASK_DETAIL_UPLOAD_JOB_STATUS)[keyof typeof TASK_DETAIL_UPLOAD_JOB_STATUS]
export interface CreateTaskDetailUploadJobInput {
materialId: string
taskNo?: string | null
dingtalkApprovalNo?: string | null
operatorUserId: string
assets: ProductPackageAssetsInput
options?: CompleteProductPackageUploadInput['options']
}
export interface TaskDetailUploadJobStatusView {
jobId: string
materialId: string
status: TaskDetailUploadJobStatus | string
stage: string
progress: number
error: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
}
const MAX_CONCURRENT_JOBS = 1
// 有心跳的正常任务不会触发此门禁;进程被杀后最多等待 30 分钟即可变成
// failed,而不是把素材永久卡在 processing。超大任务可通过环境变量放宽。
const DEFAULT_STALE_JOB_MS = 30 * 60 * 1000
const RECOVERY_BATCH_SIZE = 20
let ensureTablePromise: Promise<void> | null = null
const pendingJobIds = new Set<string>()
let activeJobCount = 0
let pumpRunning = false
/**
* 在当前事务内获取任务-详情上传的 advisory lock。
*
* pg_advisory_xact_lock() 本身返回 PostgreSQL 的 void 类型。直接用
* Prisma $queryRaw 读取它会触发 `Failed to deserialize column of type 'void'`,
* 因此把函数放进子查询,只返回一个普通整数列给 Prisma;锁仍会在当前
* 事务结束时自动释放。
*/
export async function acquireTaskDetailUploadLock(
tx: Prisma.TransactionClient,
lockName: string,
) {
await tx.$queryRaw`
SELECT 1 AS locked
FROM (
SELECT pg_advisory_xact_lock(hashtext(${lockName}))
) AS lock_result
`
}
function getStaleJobMs() {
const value = Number(process.env.TASK_DETAIL_UPLOAD_STALE_MS)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_STALE_JOB_MS
}
function toJsonValue(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
}
function toIso(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
function toStatusView(job: {
id: string
materialId: string
status: string
stage: string
progress: number
errorMessage: string | null
createdAt: Date
startedAt: Date | null
completedAt: Date | null
}): TaskDetailUploadJobStatusView {
return {
jobId: job.id,
materialId: job.materialId,
status: job.status,
stage: job.stage,
progress: Math.max(0, Math.min(100, job.progress)),
error: job.errorMessage || null,
createdAt: job.createdAt.toISOString(),
startedAt: toIso(job.startedAt),
completedAt: toIso(job.completedAt),
}
}
/**
* 任务表由 reviewed Prisma migration 创建。请求路径不执行 DDL,避免滚动发布时
* 多个容器同时抢迁移锁;若迁移未执行,调用方会收到明确的数据库错误并阻止入队。
*/
export async function ensureProductPackageUploadJobTable() {
if (!ensureTablePromise) {
ensureTablePromise = Promise.resolve()
}
return ensureTablePromise
}
async function updateJobProgress(
jobId: string,
update: { stage: string; progress: number; message?: string },
) {
const progress = Math.max(0, Math.min(99, Math.round(update.progress)))
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: {
stage: update.stage,
progress,
...(update.message ? { errorMessage: update.message } : {}),
},
})
}
async function claimTaskDetailUploadJob(jobId: string) {
return prisma.$transaction(async (tx) => {
// 用事务级 advisory lock 串行化所有容器的“领取任务”动作,保证全局最多一个
// 任务进入 Sharp/ZIP 重处理,而不是只在单个 Node 进程内限流。
await acquireTaskDetailUploadLock(tx, 'product-package-upload-worker')
const activeCount = await tx.productPackageUploadJob.count({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
})
if (activeCount > 0) return false
const claimed = await tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.VALIDATING,
progress: 5,
startedAt: new Date(),
completedAt: null,
errorMessage: null,
},
})
return claimed.count === 1
})
}
async function reconcileCommittedTaskDetailUploadJob(jobId: string) {
const job = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: {
id: true,
status: true,
materialId: true,
material: { select: { zipFilePath: true, taskStatus: true } },
},
})
if (!job || (job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED && job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING)) {
return !job
}
const assetCount = await prisma.materialAsset.count({ where: { materialId: job.materialId } })
if (job.material.taskStatus !== 'completed' || !job.material.zipFilePath || assetCount === 0) return false
// 主资产事务已提交但容器可能在 job.completed 写入前被杀掉:不要拿已清理的
// upload session 重跑,直接把持久化任务补记为完成。
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: { in: [TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING] } },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: new Date(),
errorMessage: null,
},
})
return true
}
async function processOneTaskDetailUploadJob(jobId: string) {
if (await reconcileCommittedTaskDetailUploadJob(jobId)) return
const claimed = await claimTaskDetailUploadJob(jobId)
if (!claimed) {
const pendingJob = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: { status: true },
})
if (pendingJob?.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) {
setTimeout(() => triggerTaskDetailUploadJob(jobId), 5_000)
}
return
}
const job = await prisma.productPackageUploadJob.findUnique({ where: { id: jobId } })
if (!job) {
return
}
const heartbeatTimer = setInterval(() => {
void prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: { updatedAt: new Date() },
}).catch((heartbeatError) => {
console.warn(`[TaskDetailUploadJob] heartbeat failed job=${jobId}:`, heartbeatError)
})
}, 30_000)
try {
try {
await completeProductPackageUpload({
materialId: job.materialId,
taskNo: job.taskNo,
dingtalkApprovalNo: job.dingtalkApprovalNo,
operatorUserId: job.operatorUserId,
assets: job.assets as ProductPackageAssetsInput,
options: {
...(job.options && typeof job.options === 'object' ? job.options as Record<string, unknown> : {}),
preserveMaterialOnError: true,
onProgress: (update) => updateJobProgress(jobId, update),
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '任务-详情上传处理失败'
console.error(`[TaskDetailUploadJob] failed job=${jobId}:`, error)
try {
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED,
errorMessage: errorMessage.slice(0, 2000),
completedAt: new Date(),
},
})
} catch (jobFailureRecordError) {
console.error(`[TaskDetailUploadJob] failed record write failed job=${jobId}:`, jobFailureRecordError)
}
await prisma.material.updateMany({
where: { id: job.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
}).catch((statusError) => {
console.error(`[TaskDetailUploadJob] failed to mark material status job=${jobId}:`, statusError)
})
return
}
try {
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: new Date(),
errorMessage: null,
},
})
} catch (completionRecordError) {
// 素材处理已经成功时,不能把 job 反写成 failed。保留 processing,交给
// reconcile/周期恢复在数据库恢复后补记 completed。
console.error(`[TaskDetailUploadJob] completed record write failed job=${jobId}:`, completionRecordError)
try {
await reconcileCommittedTaskDetailUploadJob(jobId)
} catch (reconcileError) {
console.error(`[TaskDetailUploadJob] completed record reconcile failed job=${jobId}:`, reconcileError)
}
}
} finally {
clearInterval(heartbeatTimer)
}
}
async function pumpTaskDetailUploadJobs() {
if (pumpRunning) {
return
}
pumpRunning = true
try {
while (activeJobCount < MAX_CONCURRENT_JOBS && pendingJobIds.size > 0) {
const nextJobId = pendingJobIds.values().next().value as string | undefined
if (!nextJobId) {
break
}
pendingJobIds.delete(nextJobId)
activeJobCount += 1
void processOneTaskDetailUploadJob(nextJobId)
.catch((error) => {
console.error(`[TaskDetailUploadJob] worker crashed job=${nextJobId}:`, error)
})
.finally(() => {
activeJobCount -= 1
void pumpTaskDetailUploadJobs()
})
}
} finally {
pumpRunning = false
}
}
export function triggerTaskDetailUploadJob(jobId: string) {
pendingJobIds.add(jobId)
void pumpTaskDetailUploadJobs().catch((error) => {
console.error('[TaskDetailUploadJob] queue pump failed:', error)
})
}
export async function createTaskDetailUploadJob(input: CreateTaskDetailUploadJobInput) {
await ensureProductPackageUploadJobTable()
const job = await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
const existing = await tx.productPackageUploadJob.findFirst({
where: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
status: {
in: [
TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
],
},
},
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
const created = await tx.productPackageUploadJob.create({
data: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
taskNo: input.taskNo?.trim() || null,
dingtalkApprovalNo: input.dingtalkApprovalNo?.trim() || null,
assets: toJsonValue(input.assets),
options: input.options ? toJsonValue(input.options) : undefined,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED,
progress: 0,
errorMessage: null,
},
})
// 素材列表/详情页可以据此识别“正在后台处理”的空素材,用户关闭上传页后
// 仍然能知道它不是一个普通的空卡片。完成函数会把状态推进到 completed。
await tx.material.update({
where: { id: input.materialId },
data: { taskStatus: 'uploading' },
})
return created
})
triggerTaskDetailUploadJob(job.id)
return toStatusView(job)
}
export async function getTaskDetailUploadJobStatus(jobId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
id: jobId,
operatorUserId,
},
})
return job ? toStatusView(job) : null
}
export async function getLatestTaskDetailUploadJobStatus(materialId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
materialId,
operatorUserId,
},
orderBy: { createdAt: 'desc' },
})
return job ? toStatusView(job) : null
}
/**
* 服务重启后恢复未完成队列。processing 会在心跳停止并超过 stale 窗口后标记失败,
* 不直接重跑可能已经写入部分资产的非幂等处理链路。
*/
export async function recoverTaskDetailUploadJobs() {
await ensureProductPackageUploadJobTable()
const staleBefore = new Date(Date.now() - getStaleJobMs())
const staleJobs = await prisma.productPackageUploadJob.findMany({
where: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
updatedAt: { lt: staleBefore },
},
select: { id: true, materialId: true },
})
// 处理链路会写入 ZIP、预览图和资产记录,无法安全地把中断中的任务直接
// 重跑,否则可能重复资产或覆盖仍被数据库引用的文件。只有确认完成标记已
// 落库的任务才补记 completed,其余任务标记 failed,交给用户重新提交。
for (const staleJob of staleJobs) {
if (await reconcileCommittedTaskDetailUploadJob(staleJob.id)) continue
await prisma.productPackageUploadJob.updateMany({
where: { id: staleJob.id, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED,
errorMessage: '后台处理进程中断,请重新提交上传',
completedAt: new Date(),
},
})
await prisma.material.updateMany({
where: { id: staleJob.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
}).catch((statusError) => {
console.error(`[TaskDetailUploadJob] failed to mark stale material=${staleJob.materialId}:`, statusError)
})
}
const queuedJobs = await prisma.productPackageUploadJob.findMany({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED },
orderBy: { createdAt: 'asc' },
take: RECOVERY_BATCH_SIZE,
select: { id: true },
})
for (const job of queuedJobs) {
triggerTaskDetailUploadJob(job.id)
}
if (queuedJobs.length > 0) {
console.log(`[TaskDetailUploadJob] recovered ${queuedJobs.length} queued job(s)`)
}
return queuedJobs.length
}

View File

@ -0,0 +1,47 @@
export const TASK_DETAIL_UPLOAD_LIMITS = {
/** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */
maxSingleImageBytes: 100 * 1024 * 1024,
/** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。 */
maxImagesPerGroup: 60,
maxTotalImageCount: 120,
/** 原图总大小保护上限,给大批量上传留出空间,同时避免误操作打爆客户端和服务器。 */
maxTotalSourceBytes: 1024 * 1024 * 1024,
} as const
export type TaskDetailUploadFileGroups = {
main: File[]
sku: File[]
detail: File[]
psd?: File[]
}
export function validateTaskDetailUploadFiles(groups: TaskDetailUploadFileGroups): string | null {
const imageGroups: Array<[string, File[]]> = [
['主图', groups.main],
['SKU 图', groups.sku],
['详情图', groups.detail],
]
for (const [label, files] of imageGroups) {
if (files.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) {
return `${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`
}
const oversized = files.find((file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes)
if (oversized) {
return `${label}「${oversized.name}」超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制,请先压缩或拆分`
}
}
const imageFiles = imageGroups.flatMap(([, files]) => files)
if (imageFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) {
return `本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批提交`
}
const allFiles = [...imageFiles, ...(groups.psd || [])]
const totalBytes = allFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0)
if (totalBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
return `本次素材总大小超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes / 1024 / 1024 / 1024)}GB,请分批提交`
}
return null
}

View File

@ -0,0 +1,70 @@
export type TaskDetailUploadJobStatus = {
jobId: string
materialId?: string
status: 'queued' | 'processing' | 'completed' | 'failed'
stage?: string
progress?: number
error?: string
taskNo?: string
}
export class TaskDetailUploadJobError extends Error {
readonly jobStatus?: TaskDetailUploadJobStatus['status']
constructor(message: string, jobStatus?: TaskDetailUploadJobStatus['status']) {
super(message)
this.name = 'TaskDetailUploadJobError'
this.jobStatus = jobStatus
}
}
type PollOptions = {
signal?: AbortSignal
timeoutMs?: number
onStatus?: (status: TaskDetailUploadJobStatus) => void
}
function wait(delayMs: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('上传已取消', 'AbortError'))
return
}
const timer = setTimeout(resolve, delayMs)
signal?.addEventListener('abort', () => {
clearTimeout(timer)
reject(new DOMException('上传已取消', 'AbortError'))
}, { once: true })
})
}
export async function pollTaskDetailUploadJob(
jobId: string,
options: PollOptions = {},
): Promise<TaskDetailUploadJobStatus> {
const deadline = Date.now() + (options.timeoutMs ?? 30 * 60 * 1000)
let delayMs = 800
while (Date.now() < deadline) {
const response = await fetch(
`/api/materials/upload/product-package/status?jobId=${encodeURIComponent(jobId)}`,
{ signal: options.signal },
)
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.error || '查询后台处理状态失败')
}
const status = (payload?.data || payload) as TaskDetailUploadJobStatus
options.onStatus?.(status)
if (status.status === 'completed') return status
if (status.status === 'failed') {
throw new TaskDetailUploadJobError(status.error || '后台处理失败', status.status)
}
await wait(delayMs, options.signal)
delayMs = Math.min(3_000, Math.round(delayMs * 1.25))
}
throw new Error('后台处理超时,请稍后到素材详情查看处理结果')
}

View File

@ -120,6 +120,7 @@ export async function createZipFromFiles(
authorName?: string, // 作者名(上传人姓名)
categoryName?: string, // 店铺名
timeoutMs: number = 30000,
compressionLevel: number = 9,
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
if (!files || files.length === 0) {
throw new Error('没有文件需要打包');
@ -193,7 +194,7 @@ export async function createZipFromFiles(
// 创建输出流
output = createWriteStream(zipFilePath);
archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
zlib: { level: Math.max(0, Math.min(9, Math.round(compressionLevel))) },
});
// 监听完成事件
@ -278,6 +279,7 @@ export async function createZipFromZips(
productModel: string,
materialType: string,
timeoutMs: number = 60000,
compressionLevel: number = 9,
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
if (zipFiles.length === 0) {
throw new Error('没有文件需要打包');
@ -339,7 +341,7 @@ export async function createZipFromZips(
try {
output = createWriteStream(zipFilePath);
archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
zlib: { level: Math.max(0, Math.min(9, Math.round(compressionLevel))) },
});
output.on('close', async () => {
@ -557,7 +559,9 @@ export async function cleanupOnError(
uploadedPreviewUrls: string[],
zipFilePaths: string | string[] | null,
tempFiles: string[],
materialId: string | null
materialId: string | null,
uploadIds: string[] = [],
deleteMaterial = true,
): Promise<void> {
console.log('[Cleanup] Starting error cleanup...');
@ -606,8 +610,13 @@ export async function cleanupOnError(
}
}
// 失败时也要清掉分片目录和 Redis 会话,避免重复上传/失败重试不断堆积。
if (uploadIds.length > 0) {
await cleanupTempFiles([], uploadIds)
}
// 删除素材记录
if (materialId) {
if (materialId && deleteMaterial) {
try {
await prisma.material.delete({
where: { id: materialId },

View File

@ -0,0 +1,36 @@
CREATE TABLE "product_package_upload_jobs" (
"id" TEXT NOT NULL,
"material_id" TEXT NOT NULL,
"operator_user_id" TEXT NOT NULL,
"task_no" TEXT,
"dingtalk_approval_no" TEXT,
"assets" JSONB NOT NULL,
"options" JSONB,
"status" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "product_package_upload_jobs_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "product_package_upload_jobs_material_id_idx"
ON "product_package_upload_jobs"("material_id");
CREATE INDEX "product_package_upload_jobs_operator_user_id_created_at_idx"
ON "product_package_upload_jobs"("operator_user_id", "created_at");
CREATE INDEX "product_package_upload_jobs_status_created_at_idx"
ON "product_package_upload_jobs"("status", "created_at");
ALTER TABLE "product_package_upload_jobs"
ADD CONSTRAINT "product_package_upload_jobs_material_id_fkey"
FOREIGN KEY ("material_id") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "product_package_upload_jobs"
ADD CONSTRAINT "product_package_upload_jobs_operator_user_id_fkey"
FOREIGN KEY ("operator_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -51,6 +51,7 @@ model User {
materialTransfersAsOperator MaterialTransfer[] @relation("MaterialTransferOperator")
materials Material[] @relation("MaterialOwner")
taskDetailRequestedMaterials Material[] @relation("TaskDetailRequester")
productPackageUploadJobs ProductPackageUploadJob[] @relation("ProductPackageUploadJobOperator")
departmentRef Department? @relation("UserDepartment", fields: [departmentId], references: [id])
transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id])
@ -114,6 +115,7 @@ model Material {
skus MaterialSku[]
transfers MaterialTransfer[]
transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress")
productPackageUploadJobs ProductPackageUploadJob[]
user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade)
taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull)
@ -140,6 +142,32 @@ model Material {
@@map("materials")
}
model ProductPackageUploadJob {
id String @id @default(uuid())
materialId String @map("material_id")
operatorUserId String @map("operator_user_id")
taskNo String? @map("task_no")
dingtalkApprovalNo String? @map("dingtalk_approval_no")
assets Json
options Json?
status String
stage String
progress Int @default(0)
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
updatedAt DateTime @updatedAt @map("updated_at")
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
operator User @relation("ProductPackageUploadJobOperator", fields: [operatorUserId], references: [id], onDelete: Cascade)
@@index([materialId])
@@index([operatorUserId, createdAt])
@@index([status, createdAt])
@@map("product_package_upload_jobs")
}
model MaterialLike {
id String @id @default(uuid())
materialId String

View File

@ -422,7 +422,10 @@ fi
cd "$REMOTE_BUILD_DIR"
git remote set-url origin "$REPO_URL"
git_cmd fetch --prune origin "$BRANCH"
# 传入分支名时显式写入 remote-tracking ref。仅传分支名的 fetch
# 在部分已有 worktree 上只更新 FETCH_HEAD,后续 origin/$BRANCH
# 会不存在,导致 checkout 回退到 pathspec 错误。
git_cmd fetch --prune origin "$BRANCH" "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
TARGET_REF="origin/$BRANCH"
if [ -n "$COMMIT_INPUT" ]; then