106 lines
4.7 KiB
TypeScript
106 lines
4.7 KiB
TypeScript
import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy'
|
||
|
||
export const TASK_DETAIL_UPLOAD_LIMITS = {
|
||
/** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */
|
||
maxSingleImageBytes: 100 * 1024 * 1024,
|
||
/** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。生产存量素材 SKU 图最多 220 张,上限需覆盖真实业务。 */
|
||
maxImagesPerGroup: 300,
|
||
maxTotalImageCount: 600,
|
||
/** PSD/PSB/TIFF 等源文件不是图片计数的一部分,单独限制,防止小文件堆积绕过图片上限。 */
|
||
maxPsdFileCount: 20,
|
||
/** 原图与源文件的总条目数上限。 */
|
||
maxTotalSourceFileCount: 700,
|
||
/** 仅图片原图总大小。PSD/压缩包不算进这项,避免把源文件误卡在 1GB。 */
|
||
maxTotalSourceBytes: 1024 * 1024 * 1024,
|
||
/** 单个 PSD/压缩包与分片上传单文件上限对齐。 */
|
||
maxSinglePsdBytes: MAX_LOGICAL_UPLOAD_BYTES,
|
||
/** 一次提交的源文件总量:大约够主图/SKU/详情各一份大源文件。 */
|
||
maxTotalPsdBytes: 3 * MAX_LOGICAL_UPLOAD_BYTES,
|
||
maxPreviewCountPerImage: 60,
|
||
maxTotalPreviewCount: 2_000,
|
||
maxSinglePreviewBytes: 10 * 1024 * 1024,
|
||
maxTotalPreviewBytes: 512 * 1024 * 1024,
|
||
/** 普通预览为 800x1200,详情切片最大 1600x1500。 */
|
||
maxPreviewWidth: 800,
|
||
maxPreviewHeight: 1_200,
|
||
maxPreviewPixels: 800 * 1_200,
|
||
maxSliceWidth: 1_600,
|
||
maxSliceHeight: 1_500,
|
||
maxSlicePixels: 1_600 * 1_500,
|
||
maxPreviewAnimationFrames: 1,
|
||
/** 单个用户最多排队 3 个,防止一个账号长期占满唯一图片 worker。 */
|
||
maxQueuedJobsPerUser: 3,
|
||
/** 全站最多等待 20 个任务;processing 任务不计入等待容量。 */
|
||
maxQueuedJobsGlobal: 20,
|
||
/** 合法最大提交:140 个源文件 + 1000 个客户端预览。 */
|
||
maxTotalUploadSessionCount: 1_140,
|
||
} 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 psdFiles = groups.psd || []
|
||
if (psdFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) {
|
||
return `PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交`
|
||
}
|
||
|
||
const allFiles = [...imageFiles, ...psdFiles]
|
||
if (allFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) {
|
||
return `本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交`
|
||
}
|
||
const totalImageBytes = imageFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0)
|
||
if (totalImageBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
|
||
return `本次图片总大小超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes)},请分批提交`
|
||
}
|
||
const oversizedPsd = psdFiles.find((file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes)
|
||
if (oversizedPsd) {
|
||
return `源文件「${oversizedPsd.name}」超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes)},请先拆分或压缩`
|
||
}
|
||
const totalPsdBytes = psdFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0)
|
||
if (totalPsdBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes) {
|
||
return `本次源文件总大小超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes)},请分批提交`
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export function formatTaskDetailByteLimit(bytes: number): string {
|
||
const gib = 1024 * 1024 * 1024
|
||
const mib = 1024 * 1024
|
||
if (bytes >= gib) {
|
||
const value = bytes / gib
|
||
return Number.isInteger(value) ? `${value}GB` : `${value.toFixed(1)}GB`
|
||
}
|
||
if (bytes >= mib) {
|
||
const value = bytes / mib
|
||
return Number.isInteger(value) ? `${value}MB` : `${value.toFixed(1)}MB`
|
||
}
|
||
return `${bytes}B`
|
||
}
|