Compare commits

...

4 Commits

Author SHA1 Message Date
chenpei
1ef0c81429 fix: 恢复直属主管组织解析规则 2026-07-31 14:07:17 +08:00
chenpei
39f99e35cd fix: 完善任务详情上传与审批信息展示 2026-07-31 13:53:09 +08:00
chenpei
128201944f fix: 分离视频详情与元数据编辑门禁 2026-07-30 14:51:44 +08:00
chenpei
309e76989c feat: 完善素材搜索与编辑体验 2026-07-30 14:39:18 +08:00
61 changed files with 4351 additions and 1114 deletions

View File

@ -144,6 +144,7 @@ export async function GET(
{ status: 401 }
);
}
const isEditRequest = request.nextUrl.searchParams.get('intent') === 'edit';
let material;
@ -323,6 +324,60 @@ export async function GET(
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
const userIsSuperAdmin = await isSuperAdmin(userId);
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
const canEdit =
(material as any).userId === userId ||
canBypassVideoPreviewGate;
const materialWithUser = material as any;
const displayAuthorName =
materialWithUser.user?.realName ||
materialWithUser.user?.username ||
materialWithUser.authorName;
if (isEditRequest) {
if (!canEdit) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 },
);
}
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
const canShowPreview =
!videoPreviewPolicy.videoPreviewBlocked ||
canBypassVideoPreviewGate;
return NextResponse.json({
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
productCategoryName: materialWithUser.productCategoryName || '',
materialType: materialWithUser.materialType,
renderer: materialWithUser.renderer,
authorName: displayAuthorName,
previewUrl: canShowPreview
? rewriteMediaUrlForClient(materialWithUser.previewUrl, request, 'image')
: null,
taskNo: materialWithUser.taskNo || null,
dingtalkApprovalNo: materialWithUser.dingtalkApprovalNo || null,
taskStatus: materialWithUser.taskStatus || null,
taskFetchedAt: materialWithUser.taskFetchedAt || null,
isFullSet: deriveTaskDetailIsFullSet(
materialWithUser.materialType,
materialWithUser.assets,
Boolean(materialWithUser.isFullSet),
),
taskDetailSourceType: materialWithUser.taskDetailSourceType || null,
taskDetailRequesterUserId: materialWithUser.taskDetailRequesterUserId || null,
taskDetailRequesterName: materialWithUser.taskDetailRequesterName || null,
canEdit: true,
});
}
if (!userIsSuperAdmin) {
const canView = await canUserViewMaterial(
userDepartment,
@ -340,7 +395,6 @@ export async function GET(
}
}
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
return buildVideoPreviewBlockedResponse();
}
@ -376,12 +430,6 @@ export async function GET(
}
}
// 类型断言,确保 user 字段存在
const materialWithUser = material as any;
// 优先使用用户的真实姓名或用户名,如果没有才使用 authorName
const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName;
const formattedAssets = (materialWithUser.assets || []).map((asset: any) =>
formatTaskDetailAsset(asset, displayAuthorName),
);
@ -427,6 +475,7 @@ export async function GET(
),
assets: formattedAssets,
canDownloadSource: canAccessSource,
canEdit,
user: {
username: materialWithUser.user?.username || '',
},
@ -500,7 +549,9 @@ export async function PUT(
const formData = await request.formData();
input = {
productModel: String(formData.get('productModel') || ''),
reportTitle: formData.get('reportTitle') as string | null,
...(formData.has('reportTitle')
? { reportTitle: formData.get('reportTitle') as string | null }
: {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,
@ -508,14 +559,24 @@ export async function PUT(
materialType: String(formData.get('materialType') || ''),
renderer: formData.get('renderer') as string | null,
authorName: String(formData.get('authorName') || ''),
userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') as string | null,
...(formData.has('userId')
? { userId: formData.get('userId') as string | null }
: {}),
...(formData.has('taskNo')
? { taskNo: formData.get('taskNo') as string | null }
: {}),
...(formData.has('dingtalkApprovalNo')
? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null }
: {}),
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null,
...(formData.has('taskDetailSourceType')
? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null }
: {}),
...(formData.has('taskDetailRequesterUserId')
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
: {}),
...(formData.has('isFullSet')
? { isFullSet: formData.get('isFullSet') as string | null }
: {}),
};
const formPreview = formData.get('previewImage');
previewImage = isWebFileLike(formPreview) ? formPreview : null;

View File

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

View File

@ -22,7 +22,9 @@ export async function PUT(request: NextRequest) {
id = String(formData.get('id') || '');
input = {
productModel: String(formData.get('productModel') || ''),
reportTitle: formData.get('reportTitle') as string | null,
...(formData.has('reportTitle')
? { reportTitle: formData.get('reportTitle') as string | null }
: {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,
@ -30,14 +32,24 @@ export async function PUT(request: NextRequest) {
materialType: String(formData.get('materialType') || ''),
renderer: formData.get('renderer') as string | null,
authorName: String(formData.get('authorName') || ''),
userId: formData.get('userId') as string | null,
taskNo: formData.get('taskNo') as string | null,
...(formData.has('userId')
? { userId: formData.get('userId') as string | null }
: {}),
...(formData.has('taskNo')
? { taskNo: formData.get('taskNo') as string | null }
: {}),
...(formData.has('dingtalkApprovalNo')
? { dingtalkApprovalNo: formData.get('dingtalkApprovalNo') as string | null }
: {}),
taskDetailSourceType: formData.get('taskDetailSourceType') as string | null,
taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null,
isFullSet: formData.get('isFullSet') as string | null,
...(formData.has('taskDetailSourceType')
? { taskDetailSourceType: formData.get('taskDetailSourceType') as string | null }
: {}),
...(formData.has('taskDetailRequesterUserId')
? { taskDetailRequesterUserId: formData.get('taskDetailRequesterUserId') as string | null }
: {}),
...(formData.has('isFullSet')
? { isFullSet: formData.get('isFullSet') as string | null }
: {}),
};
const formPreview = formData.get('previewImage');
previewImage = isWebFileLike(formPreview) ? formPreview : null;

View File

@ -2,7 +2,12 @@ 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 { getUploadRootDir } from '@/lib/upload-paths'
import path from 'path'
import { rm, unlink } from 'fs/promises'
@ -22,7 +27,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,24 +59,54 @@ 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 } })
const deletion = await prisma.$transaction(async (tx) => {
// 与 createTaskDetailUploadJob 使用同一把事务锁,防止“检查无任务”后并发入队。
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext('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 NextResponse.json({ success: true, deleted: false, reason: 'already_processed' })
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}`)
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)
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
await deleteUploadSession(uploadId).catch(() => undefined)
}
await prisma.material.delete({ where: { id: materialId } })
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 deleteOwnedUploadSession(uploadId, userId).catch(() => false)
}
return NextResponse.json({
success: true,

View File

@ -8,13 +8,17 @@ 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
@ -55,6 +59,9 @@ export async function POST(request: NextRequest) {
userId: true,
materialType: true,
taskDetailRequesterUserId: true,
taskDetailSourceType: true,
zipFilePath: true,
_count: { select: { assets: true } },
},
})
@ -86,7 +93,64 @@ 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 missingClientPreview = imageAssets.some(
(asset) => !Array.isArray(asset.previewAssets) || asset.previewAssets.length === 0,
)
if (missingClientPreview) {
return NextResponse.json(
{ success: false, error: '客户端预览图缺失,请刷新页面后重新处理图片再提交' },
{ status: 400 },
)
}
const job = await createTaskDetailUploadJob({
materialId: body.materialId,
taskNo: body.taskNo,
dingtalkApprovalNo: body.dingtalkApprovalNo,
@ -100,13 +164,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

@ -11,17 +11,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
// 只获取激活的用户
// 只返回人员选择器实际需要的最小字段,避免向普通登录用户暴露邮箱等信息。
const users = await prisma.user.findMany({
where: {
isActive: true,
status: 'active',
archivedAt: null,
},
select: {
id: true,
username: true,
realName: true,
email: true,
},
orderBy: {
username: 'asc',
@ -33,7 +33,6 @@ export async function GET(request: NextRequest) {
id: user.id,
username: user.username,
displayName: user.realName || user.username,
email: user.email,
}));
return NextResponse.json(userList, { status: 200 });

File diff suppressed because it is too large Load Diff

View File

@ -588,13 +588,12 @@ export default function Home() {
}
if (requestId === materialsRequestIdRef.current) {
const data = await response.json()
// 素材ID验证和previewUrl fallback
// 清理素材 ID,并为预览图补齐统一 fallback
const materialsList = ((data.materials || data) as Material[])
.map((e) => {
// 验证ID
if (!e.id || typeof e.id !== 'string') return null
const trimmedId = e.id.trim()
if (!trimmedId || trimmedId.length < 10 || trimmedId.length > 100) return null
if (!trimmedId) return null
// previewUrl fallback逻辑
const previewUrl = e.previewUrl ||
@ -1346,7 +1345,7 @@ export default function Home() {
<SearchBar
value={searchKeyword}
onChange={handleSearchChange}
placeholder="搜索SPU、店铺、场景类别、作者..."
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
isSearching={isSearchPending}
/>

View File

@ -50,6 +50,7 @@ interface Material {
isLiked?: boolean // 是否已点赞
isFavorited?: boolean // 是否已收藏
taskNo?: string // 产品全案任务号
dingtalkApprovalNo?: string | null // 钉钉审批编号
taskStatus?: string // 产品全案任务状态
taskFetchedAt?: string // 产品全案被获取时间
isFullSet?: boolean
@ -613,6 +614,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,
@ -647,6 +649,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,
@ -680,6 +683,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,
@ -996,6 +1000,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

@ -1,514 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import SearchableUserSelect from '@/components/SearchableUserSelect'
import {
canManuallyReplacePreview,
getSelectableMaterialTypeOptions,
supportsIsFullSet,
supportsTaskNo,
} from '@/lib/material-type-options'
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
import {
requiresCategoryAndScene,
requiresProductCategoryName,
requiresRenderer,
} from '@/lib/material-validation'
interface EditMaterialModalProps {
material: {
id: string
previewUrl: string
productModel: string
reportTitle?: string | null
categoryName: string
sceneName: string
platformName?: string | null
productCategoryName?: string | null
materialType: string
renderer: string
authorName: string
userId: string
taskNo?: string | null
dingtalkApprovalNo?: string | null
taskDetailSourceType?: string | null
taskStatus?: string | null
taskFetchedAt?: string | null
isFullSet?: boolean
taskDetailRequesterUserId?: string | null
taskDetailRequesterName?: string | null
}
onClose: () => void
onSuccess: () => void
}
export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) {
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl || '')
const [productModel, setProductModel] = useState(material.productModel)
const [reportTitle, setReportTitle] = useState(material.reportTitle || '')
const [categoryName, setCategoryName] = useState(material.categoryName)
const [sceneName, setSceneName] = useState(material.sceneName)
const [platformName, setPlatformName] = useState(material.platformName || '')
const [productCategoryName, setProductCategoryName] = useState(material.productCategoryName || '')
const [materialType, setMaterialType] = useState(material.materialType || '3D场景')
const [renderer, setRenderer] = useState(material.renderer || 'CR')
const [authorName, setAuthorName] = useState(material.authorName)
const [userId, setUserId] = useState(material.userId)
const [taskNo, setTaskNo] = useState(material.taskNo || '')
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(material.dingtalkApprovalNo || '')
const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '')
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(material.taskDetailRequesterUserId || '')
const [taskDetailRequesterName, setTaskDetailRequesterName] = useState(material.taskDetailRequesterName || '')
const [taskStatus] = useState(material.taskStatus || '')
const [taskFetchedAt] = useState(material.taskFetchedAt || '')
const [isFullSet] = useState(Boolean(material.isFullSet))
const [users, setUsers] = useState<Array<{ id: string; displayName: string; username: string }>>([])
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
const [updating, setUpdating] = useState(false)
const [error, setError] = useState('')
const showCategoryScene = requiresCategoryAndScene(materialType)
const showRenderer = requiresRenderer(materialType)
const showTaskNo = supportsTaskNo(materialType)
const showIsFullSet = supportsIsFullSet(materialType)
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
const isCertificateReport = isCertificateReportMaterialType(materialType)
const isFetchedTask = taskStatus === 'fetched'
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
const canKeepRequesterBlank =
material.materialType === '任务-详情' && !material.taskDetailRequesterUserId
useEffect(() => {
void fetchSelectOptions()
}, [])
const fetchSelectOptions = async () => {
try {
const [categoriesResponse, scenesResponse, platformsResponse, productCategoriesResponse, usersResponse] = await Promise.all([
fetch('/api/categories'),
fetch('/api/scenes'),
fetch('/api/platforms'),
fetch('/api/product-categories'),
fetch('/api/users/list'),
])
if (categoriesResponse.ok) {
setCategories(await categoriesResponse.json())
}
if (scenesResponse.ok) {
setScenes(await scenesResponse.json())
}
if (platformsResponse.ok) {
setPlatforms(await platformsResponse.json())
}
if (productCategoriesResponse.ok) {
setProductCategories(await productCategoriesResponse.json())
}
if (usersResponse.ok) {
const activeUsers = await usersResponse.json()
const normalizedUsers = Array.isArray(activeUsers) ? activeUsers : []
if (
material.taskDetailRequesterUserId &&
!normalizedUsers.some((user) => user.id === material.taskDetailRequesterUserId)
) {
normalizedUsers.push({
id: material.taskDetailRequesterUserId,
username: '已停用',
displayName: material.taskDetailRequesterName || '原需求运营',
})
}
setUsers(normalizedUsers)
}
} catch (fetchError) {
console.error('加载编辑弹窗数据失败:', fetchError)
}
}
const handlePreviewChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
return
}
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setError('')
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setError('')
if (!materialType || !authorName) {
setError('请填写所有必填字段')
return
}
if (isCertificateReport) {
if (!productModel.trim() && !reportTitle.trim()) {
setError('SPU 和 报告名称/检测对象 至少填写一个')
return
}
} else if (!productModel) {
setError('请填写所有必填字段')
return
}
if (showCategoryScene && (!categoryName || !sceneName)) {
setError('请填写店铺和场景')
return
}
if (requiresProductCategoryName(materialType) && !productCategoryName) {
setError('标准模型类型必须选择品类')
return
}
if (showRenderer && !renderer) {
setError('3D场景类型必须指定渲染器')
return
}
if (materialType === '任务-详情' && !taskDetailRequesterUserId && !canKeepRequesterBlank) {
setError('请选择需求运营人员')
return
}
if (showTaskNo) {
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError)
return
}
}
if (isFetchedTask) {
const confirmed = window.confirm('这条任务素材已经被外部系统获取过,继续保存可能影响对方使用,确认继续吗?')
if (!confirmed) {
return
}
}
setUpdating(true)
try {
const formData = new FormData()
formData.append('id', material.id)
formData.append('productModel', productModel)
formData.append('reportTitle', reportTitle)
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
formData.append('platformName', platformName)
formData.append('productCategoryName', productCategoryName)
formData.append('materialType', materialType)
formData.append('renderer', renderer)
formData.append('authorName', authorName)
formData.append('userId', userId)
formData.append('taskNo', showTaskNo ? taskNo : '')
formData.append('dingtalkApprovalNo', showTaskNo ? dingtalkApprovalNo : '')
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
formData.append(
'taskDetailRequesterUserId',
materialType === '任务-详情' ? taskDetailRequesterUserId : '',
)
if (previewImage && allowPreviewReplacement) {
formData.append('previewImage', previewImage)
}
const response = await fetch('/api/materials/update', {
method: 'PUT',
body: formData,
})
if (!response.ok) {
const data = await response.json()
setError(data.error || '更新失败')
return
}
onSuccess()
} catch (submitError) {
console.error('更新失败:', submitError)
setError('更新失败,请稍后再试')
} finally {
setUpdating(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-apple bg-white shadow-apple-lg dark:bg-gray-800">
<div className="border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">编辑素材</h2>
<button onClick={onClose} className="text-2xl leading-none text-gray-400 hover:text-gray-600">
×
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6 p-6">
{isFetchedTask && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
这条任务素材已被外部系统获取。
{taskFetchedAt ? ` 获取时间:${new Date(taskFetchedAt).toLocaleString('zh-CN')}` : ''}
</div>
)}
{!allowPreviewReplacement && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
当前素材类型的预览以系统生成结果为准,这里不支持手工替换封面。
</div>
)}
{error && (
<div className="rounded-apple border border-red-200 bg-red-50 px-4 py-3 text-red-600">
{error}
</div>
)}
{allowPreviewReplacement && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">预览图</label>
<div className="space-y-3">
{previewUrl ? (
<div className="overflow-hidden rounded-apple border border-gray-200 bg-gray-50 p-3">
<img src={previewUrl} alt="预览图" className="mx-auto max-h-72 rounded object-contain" />
</div>
) : (
<div className="rounded-apple border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500">
当前没有预览图
</div>
)}
<input type="file" accept="image/*" onChange={handlePreviewChange} className="block w-full text-sm" />
</div>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
{isCertificateReport ? 'SPU' : '产品型号 *'}
</label>
<input
type="text"
value={productModel}
onChange={(event) => setProductModel(event.target.value)}
className="input-apple"
required={!isCertificateReport}
/>
</div>
{isCertificateReport && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">报告名称 / 检测对象</label>
<input
type="text"
value={reportTitle}
onChange={(event) => setReportTitle(event.target.value)}
className="input-apple"
placeholder="SPU 和这个字段至少填写一个"
/>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">素材类型 *</label>
<select value={materialType} onChange={(event) => setMaterialType(event.target.value)} className="input-apple" required>
{materialTypeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{showCategoryScene && (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">店铺 *</label>
<select value={categoryName} onChange={(event) => setCategoryName(event.target.value)} className="input-apple" required>
<option value="">请选择店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">场景 *</label>
<select value={sceneName} onChange={(event) => setSceneName(event.target.value)} className="input-apple" required>
<option value="">请选择场景</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
</div>
</>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">平台分类</label>
<select value={platformName} onChange={(event) => setPlatformName(event.target.value)} className="input-apple">
<option value="">不选择</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
品类{requiresProductCategoryName(materialType) ? ' *' : ''}
</label>
<select value={productCategoryName} onChange={(event) => setProductCategoryName(event.target.value)} className="input-apple">
<option value="">不选择</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
{showRenderer && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">渲染器 *</label>
<select value={renderer} onChange={(event) => setRenderer(event.target.value)} className="input-apple" required>
<option value="">请选择渲染器</option>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{showTaskNo && (
<TaskReferenceFields
idPrefix={`edit-material-${material.id}`}
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={updating}
/>
)}
{materialType === '任务-详情' && (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">出图方式</label>
<select
value={taskDetailSourceType}
onChange={(event) => setTaskDetailSourceType(event.target.value)}
className="input-apple"
>
<option value="">未设置</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
需求运营{canKeepRequesterBlank ? '' : ' *'}
</label>
<SearchableUserSelect
users={users}
value={taskDetailRequesterUserId}
onChange={(nextUserId, displayName) => {
setTaskDetailRequesterUserId(nextUserId)
setTaskDetailRequesterName(displayName)
}}
placeholder="搜索发起本次出图需求的运营人员"
required={!canKeepRequesterBlank}
clearable={canKeepRequesterBlank}
/>
<p className="mt-1 text-xs text-gray-500">
{canKeepRequesterBlank
? taskDetailRequesterName
? `原记录为“${taskDetailRequesterName}”,对应账号已不可选;可保持原记录或重新选择。`
: '这是一条历史素材,原来没有需求运营数据,可暂时留空。'
: '记录发起本次出图需求的运营人员,与素材所有者可以不同。'}
</p>
</div>
</>
)}
{showIsFullSet && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">整套判定</label>
<div className="rounded-apple border border-gray-300 px-4 py-3 text-sm text-gray-700">
<p>
当前结果:<span className="font-medium">{isFullSet ? '是' : '否'}</span>
</p>
<p className="mt-2 text-xs text-gray-500">
系统会根据是否同时存在主图、SKU 图、详情图自动判定,这里不允许手工修改。
</p>
</div>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">作者(素材所有者) *</label>
<SearchableUserSelect
users={users}
value={userId}
onChange={(nextUserId, displayName) => {
setUserId(nextUserId)
setAuthorName(displayName)
}}
placeholder="请输入或搜索用户(支持拼音)"
required
/>
</div>
{(showTaskNo || showIsFullSet) && (
<div className="grid gap-4 rounded-apple border border-gray-200 bg-gray-50 p-4">
<div>
<p className="mb-1 text-sm text-gray-500">任务状态</p>
<p className="text-sm font-medium text-gray-900">{taskStatus || '未记录'}</p>
</div>
<div>
<p className="mb-1 text-sm text-gray-500">外部获取时间</p>
<p className="text-sm font-medium text-gray-900">
{taskFetchedAt ? new Date(taskFetchedAt).toLocaleString('zh-CN') : '未记录'}
</p>
</div>
</div>
)}
<div className="flex justify-end space-x-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary" disabled={updating}>
取消
</button>
<button type="submit" className="btn-primary" disabled={updating}>
{updating ? '更新中...' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { pinyin } from 'pinyin-pro'
interface User {
@ -10,6 +10,7 @@ interface User {
}
interface SearchableUserSelectProps {
id?: string
users: User[]
value: string
onChange: (userId: string, displayName: string) => void
@ -18,9 +19,11 @@ interface SearchableUserSelectProps {
className?: string
disabled?: boolean
clearable?: boolean
loading?: boolean
}
export default function SearchableUserSelect({
id,
users,
value,
onChange,
@ -29,50 +32,55 @@ export default function SearchableUserSelect({
className = '',
disabled = false,
clearable = true,
loading = false,
}: SearchableUserSelectProps) {
const [searchTerm, setSearchTerm] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listboxId = useId()
const isDisabled = disabled || loading
// 获取选中用户的显示名称
const selectedUser = users.find(u => u.id === value)
const displayValue = selectedUser ? `${selectedUser.displayName} (${selectedUser.username})` : ''
const selectedUser = useMemo(() => users.find((user) => user.id === value), [users, value])
const displayValue = selectedUser
? selectedUser.displayName === selectedUser.username
? selectedUser.displayName
: `${selectedUser.displayName} (${selectedUser.username})`
: ''
// 拼音搜索匹配函数
const matchesSearch = (user: User, search: string): boolean => {
if (!search) return true
// 人员数量较多时,预先生成一次搜索索引,避免每次按键都重复计算全量拼音。
const indexedUsers = useMemo(() => users.map((user) => {
const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('')
const firstLetters = pinyin(user.displayName, {
pattern: 'first',
toneType: 'none',
type: 'array',
}).join('')
const searchLower = search.toLowerCase()
// 1. 匹配显示名称
if (user.displayName.toLowerCase().includes(searchLower)) {
return true
return {
user,
searchText: [
user.displayName,
user.username,
fullPinyin,
firstLetters,
].join(' ').toLowerCase(),
}
}), [users])
// 2. 匹配用户名
if (user.username.toLowerCase().includes(searchLower)) {
return true
}
const filteredUsers = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) return indexedUsers.map((item) => item.user)
return indexedUsers
.filter((item) => item.searchText.includes(keyword))
.map((item) => item.user)
}, [indexedUsers, searchTerm])
// 3. 匹配拼音全拼
const fullPinyin = pinyin(user.displayName, { toneType: 'none', type: 'array' }).join('').toLowerCase()
if (fullPinyin.includes(searchLower)) {
return true
}
// 4. 匹配拼音首字母
const firstLetters = pinyin(user.displayName, { pattern: 'first', toneType: 'none', type: 'array' }).join('').toLowerCase()
if (firstLetters.includes(searchLower)) {
return true
}
return false
}
// 过滤用户列表
const filteredUsers = users.filter(user => matchesSearch(user, searchTerm))
useEffect(() => {
setHighlightedIndex((current) => Math.min(current, Math.max(filteredUsers.length - 1, 0)))
}, [filteredUsers.length])
// 点击外部关闭下拉框
useEffect(() => {
@ -88,7 +96,7 @@ export default function SearchableUserSelect({
// 键盘导航
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) {
if (isDisabled) {
return
}
@ -132,7 +140,7 @@ export default function SearchableUserSelect({
// 打开下拉框
const handleFocus = () => {
if (disabled) {
if (isDisabled) {
return
}
@ -143,7 +151,7 @@ export default function SearchableUserSelect({
// 清空选择
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled || !clearable) {
if (isDisabled || !clearable) {
return
}
@ -157,26 +165,38 @@ export default function SearchableUserSelect({
<div ref={containerRef} className={`relative ${className}`}>
<div className="relative">
<input
id={id}
ref={inputRef}
type="text"
value={isOpen ? searchTerm : displayValue}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
placeholder={loading ? '正在加载人员...' : placeholder}
required={required}
disabled={disabled}
disabled={isDisabled}
className="input-apple pr-20 disabled:cursor-not-allowed disabled:opacity-60"
autoComplete="off"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen && !isDisabled}
aria-controls={listboxId}
aria-activedescendant={
isOpen && filteredUsers[highlightedIndex]
? `${listboxId}-option-${filteredUsers[highlightedIndex].id}`
: undefined
}
aria-busy={loading}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{value && clearable && !disabled && (
{value && clearable && !isDisabled && (
<button
type="button"
onClick={handleClear}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
className="flex h-9 w-9 items-center justify-center rounded transition-colors hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:hover:bg-gray-600"
title="清空"
aria-label="清空已选人员"
>
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -187,12 +207,13 @@ export default function SearchableUserSelect({
<button
type="button"
onClick={() => {
if (!disabled) {
if (!isDisabled) {
setIsOpen(!isOpen)
}
}}
disabled={disabled}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:cursor-not-allowed"
disabled={isDisabled}
className="flex h-9 w-9 items-center justify-center rounded transition-colors hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed dark:hover:bg-gray-600"
aria-label={isOpen ? '收起人员列表' : '展开人员列表'}
>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
@ -206,8 +227,12 @@ export default function SearchableUserSelect({
</div>
</div>
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{isOpen && !isDisabled && (
<div
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
>
{filteredUsers.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
未找到匹配的用户
@ -216,6 +241,9 @@ export default function SearchableUserSelect({
filteredUsers.map((user, index) => (
<button
key={user.id}
id={`${listboxId}-option-${user.id}`}
role="option"
aria-selected={user.id === value}
type="button"
onClick={() => handleSelect(user)}
className={`w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${

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 className="mt-2 text-sm text-gray-500 dark:text-gray-400">
将文件拖入对应区域,或点击选择文件。
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
</p>
</div>
</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 Message + Buttons(有专用上传器的任务类型不显示按钮,因为它们有自己的按钮) */}
{!usesDedicatedUploader && (
<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="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">
<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>
)}
{/* Buttons(有专用上传器的任务类型不显示,因为它们有自己的按钮) */}
{!usesDedicatedUploader && (
<div className="flex justify-end space-x-3 pt-4">
<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;
@ -118,7 +119,7 @@ export default function ProductPackageDetail({
</div>
)}
<div className="grid grid-cols-[2fr_1fr] gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
{/* 左侧:主图预览区 + 主图小卡片 + 详情图滚动 */}
<div className="space-y-6">
{/* 图片预览区(可显示主图或SKU图) */}
@ -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;
@ -105,7 +106,7 @@ export default function TaskPageDetail({
</div>
)}
<div className="grid grid-cols-[2fr_1fr] gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
{/* 左侧:预览图展示 */}
<div className="space-y-6">
{/* 预览图预览区 */}
@ -194,7 +195,13 @@ 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>

View File

@ -20,6 +20,7 @@ interface ThreeDOriginalDetailProps {
material: {
id: string;
taskNo?: string;
dingtalkApprovalNo?: string | null;
productModel: string;
categoryName: string;
sceneName: string;
@ -107,7 +108,7 @@ export default function ThreeDOriginalDetail({
</div>
)}
<div className="grid grid-cols-[2fr_1fr] gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
{/* 左侧:原图展示 */}
<div className="space-y-6">
{/* 原图预览区 */}
@ -199,7 +200,13 @@ 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>

View File

@ -27,7 +27,7 @@ interface Suggestion {
function SearchBarInner({
value: externalValue,
onChange,
placeholder = '搜索素材:SPU型号、任务号、SKU、店铺、场景、作者...',
placeholder = '搜索素材 ID、SPU、任务号、店铺、场景或作者',
isSearching = false,
}: SearchBarProps) {
// 本地状态用于显示(包括 IME 组合中的拼音)

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,6 +593,7 @@ export default function AIOriginalUploader({
)}
{/* 操作按钮 */}
<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">
@ -595,5 +601,6 @@ export default function AIOriginalUploader({
</button>
</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,11 +15,25 @@ 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';
@ -79,6 +93,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 +133,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 +191,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 +376,22 @@ export default function ProductPackageUploader({
}
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
const { acceptedFiles, errors } = validateImageFiles(validFiles);
const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles);
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,10 +472,14 @@ 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');
@ -393,15 +488,31 @@ export default function ProductPackageUploader({
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 +545,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 +565,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 +605,32 @@ 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;
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 +649,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,169 +657,248 @@ 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 {
const uploadId = await uploadFileWithChunks(asset.file);
uploadedFileIds.push(uploadId);
// 只在上传完成时更新进度
updateProgress();
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') {
try {
const browserPreviews = await withBrowserPreviewSlot(() =>
prepareTaskDetailImagePreview(
asset.file,
group as TaskDetailAssetKind,
(label) => {
if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`);
},
),
);
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,
});
}
} catch (previewError) {
if (cancelledRef.current) throw previewError;
console.error(`[Upload] 浏览器预览处理失败,已停止上传:${asset.file.name}`, previewError);
throw new Error(`${asset.file.name} 的浏览器预览处理失败,请重试或更换桌面版 Chrome/Edge`);
}
}
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,
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)}`);
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,
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);
}
}
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);
setError(message);
if (mountedRef.current) {
const isFailedJob = error instanceof TaskDetailUploadJobError && error.jobStatus === 'failed';
setError(jobAccepted && !isFailedJob ? `文件已上传,后台任务仍在处理:${message}` : message);
setProcessingStep('');
} finally {
setUploading(false);
}
}
} finally {
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 (
@ -754,16 +958,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 +990,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,6 +1005,7 @@ 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"
@ -806,15 +1017,15 @@ export default function ProductPackageUploader({
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 +1067,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,6 +1082,7 @@ 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"
@ -879,15 +1094,15 @@ export default function ProductPackageUploader({
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 +1150,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,6 +1165,7 @@ 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"
@ -990,7 +1209,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 +1226,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 +1272,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 +1296,14 @@ export default function ProductPackageUploader({
)}
{/* 操作按钮 */}
<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}
@ -1092,5 +1315,6 @@ export default function ProductPackageUploader({
</button>
</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,6 +590,7 @@ export default function RealPhotoUploader({
)}
{/* 操作按钮 */}
<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">
@ -592,5 +598,6 @@ export default function RealPhotoUploader({
</button>
</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,6 +645,7 @@ export default function TaskPageUploader({
)}
{/* 操作按钮 */}
<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}
@ -659,5 +665,6 @@ export default function TaskPageUploader({
</button>
</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,6 +593,7 @@ export default function ThreeDOriginalUploader({
)}
{/* 操作按钮 */}
<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">
@ -595,5 +601,6 @@ export default function ThreeDOriginalUploader({
</button>
</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,6 +610,7 @@ export default function VideoScreenshotUploader({
)}
{/* 操作按钮 */}
<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">
@ -612,5 +618,6 @@ export default function VideoScreenshotUploader({
</button>
</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: '' })

View File

@ -38,6 +38,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/')

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

@ -155,6 +155,7 @@ describe('external maixiang-spu-assets route', () => {
deletedAt: null,
materialType: expect.any(Object),
OR: expect.arrayContaining([
{ id: 'GBSFY-AD0297+GBSFY-CZ0102' },
{ productModel: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ reportTitle: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ taskNo: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },

View File

@ -0,0 +1,179 @@
/** @jest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useSession } from 'next-auth/react'
import EditMaterialPage from '@/app/edit/[id]/page'
const mockPush = jest.fn()
const mockBack = jest.fn()
const mockRouter = {
push: mockPush,
back: mockBack,
}
jest.mock('next-auth/react', () => ({
useSession: jest.fn(),
}))
jest.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}))
jest.mock('@/components/Header', () => ({
__esModule: true,
default: () => <a href="/">返回素材库</a>,
}))
jest.mock('@/components/upload/TaskReferenceFields', () => ({
__esModule: true,
default: () => null,
}))
const material = {
id: 'material-1',
userId: 'user-1',
productModel: 'SPU-001',
reportTitle: null,
categoryName: '店铺A',
sceneName: '场景A',
platformName: 'Amazon',
productCategoryName: '',
materialType: '3D场景',
renderer: 'CR',
authorName: '测试作者',
previewUrl: null,
taskNo: null,
dingtalkApprovalNo: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
canEdit: true,
}
function jsonResponse(payload: unknown, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: async () => payload,
} as Response)
}
function referencePayload(url: string) {
if (url.endsWith('/api/categories')) {
return [{ id: 'category-1', name: '店铺A' }]
}
if (url.endsWith('/api/scenes')) {
return [{ id: 'scene-1', name: '场景A' }]
}
if (url.endsWith('/api/platforms')) {
return [{ id: 'platform-1', name: 'Amazon', count: 1 }]
}
if (url.endsWith('/api/product-categories')) {
return []
}
return [{ id: 'user-1', username: 'author', displayName: '测试作者' }]
}
describe('EditMaterialPage', () => {
beforeEach(() => {
jest.clearAllMocks()
;(useSession as jest.Mock).mockReturnValue({
data: { user: { id: 'user-1', role: 'USER' } },
status: 'authenticated',
})
Element.prototype.scrollIntoView = jest.fn()
})
test('素材本体先于可选项显示,选项完成前禁止保存', async () => {
let releaseReferences: (() => void) | undefined
const referenceGate = new Promise<void>((resolve) => {
releaseReferences = resolve
})
global.fetch = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/materials/material-1')) {
return jsonResponse(material)
}
return referenceGate.then(() => jsonResponse(referencePayload(url)))
}) as jest.Mock
render(<EditMaterialPage params={{ id: 'material-1' }} />)
expect(await screen.findByLabelText('产品型号 *')).toHaveValue('SPU-001')
expect(screen.getByText('正在加载店铺、场景和人员选项…')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled()
await act(async () => {
releaseReferences?.()
await referenceGate
})
await waitFor(() => {
expect(screen.queryByText('正在加载店铺、场景和人员选项…')).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()
})
test('部分选项失败时保留当前值、显示重试并禁止保存', async () => {
global.fetch = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/materials/material-1')) {
return jsonResponse(material)
}
if (url.endsWith('/api/categories')) {
return jsonResponse({ error: '店铺读取失败' }, 500)
}
return jsonResponse(referencePayload(url))
}) as jest.Mock
render(<EditMaterialPage params={{ id: 'material-1' }} />)
const categorySelect = await screen.findByLabelText('店铺 *')
await screen.findByText('店铺加载失败,当前值已保留。')
expect(categorySelect).toHaveValue('店铺A')
expect(categorySelect).toBeDisabled()
expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled()
})
test('修改后取消会使用统一弹窗,刷新保护与移动端粘性操作区同时生效', async () => {
const user = userEvent.setup()
global.fetch = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/materials/material-1')) {
return jsonResponse(material)
}
return jsonResponse(referencePayload(url))
}) as jest.Mock
render(<EditMaterialPage params={{ id: 'material-1' }} />)
const productModelInput = await screen.findByLabelText('产品型号 *')
await waitFor(() => {
expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()
})
await user.clear(productModelInput)
await user.type(productModelInput, 'SPU-002')
const beforeUnloadEvent = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(beforeUnloadEvent)
expect(beforeUnloadEvent.defaultPrevented).toBe(true)
const saveButton = screen.getByRole('button', { name: '保存修改' })
expect(saveButton.closest('.sticky')).not.toBeNull()
await user.click(screen.getByRole('button', { name: '取消' }))
expect(await screen.findByText('放弃未保存的修改?')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '继续编辑' }))
expect(mockBack).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: '取消' }))
await user.click(await screen.findByRole('button', { name: '放弃修改' }))
expect(mockBack).toHaveBeenCalledTimes(1)
})
})

View File

@ -166,6 +166,52 @@ describe('material edit routes', () => {
)
})
test.each([
{
name: '/api/materials/[id]',
put: async (formData: FormData) =>
putMaterialById(
new NextRequest('http://localhost/api/materials/material-1', {
method: 'PUT',
body: formData,
}),
{ params: { id: 'material-1' } },
),
},
{
name: '/api/materials/update',
put: async (formData: FormData) => {
formData.append('id', 'material-1')
return putMaterialUpdate(
new NextRequest('http://localhost/api/materials/update', {
method: 'PUT',
body: formData,
}),
)
},
},
])('$name 不应把未提交的可选字段合成为空值', async ({ put }) => {
const formData = new FormData()
formData.append('productModel', 'SPU-001')
formData.append('categoryName', '店铺A')
formData.append('sceneName', '场景A')
formData.append('materialType', '3D场景')
formData.append('renderer', 'CR')
formData.append('authorName', '测试作者')
const response = await put(formData)
expect(response.status).toBe(200)
const call = (updateMaterialWithSharedRules as jest.Mock).mock.calls[0][0]
expect(call.input).not.toHaveProperty('reportTitle')
expect(call.input).not.toHaveProperty('userId')
expect(call.input).not.toHaveProperty('taskNo')
expect(call.input).not.toHaveProperty('dingtalkApprovalNo')
expect(call.input).not.toHaveProperty('taskDetailSourceType')
expect(call.input).not.toHaveProperty('taskDetailRequesterUserId')
expect(call.input).not.toHaveProperty('isFullSet')
})
test.each(['share=1', 'shareId=anything'])(
'GET /api/materials/[id] 不能通过 %s 绕过登录校验',
async (shareParam) => {
@ -250,6 +296,101 @@ describe('material edit routes', () => {
expect(body.taskDetailRequesterUserId).toBe('requester-1')
expect(body.taskDetailRequesterName).toBe('运营甲')
expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1')
expect(body.canEdit).toBe(true)
})
test('视频等待期内,素材所有者仍可读取元数据编辑信息,但不能取得视频预览地址', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
productModel: 'VIDEO-001',
reportTitle: null,
categoryName: '店铺A',
sceneName: '场景A',
platformName: '',
productCategoryName: '',
materialType: '淘系-主图视频',
isAiGenerated: false,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
renderer: '',
authorName: '测试作者',
previewUrl: 'https://s3.jiansun.vip/video-cover.webp',
zipFileName: 'video.mp4',
zipFilePath: 'uploads/video.mp4',
zipFileSize: BigInt(123),
downloadCount: 0,
likeCount: 0,
displayImages: [],
createdAt: new Date(),
videoSourceUrl: 'https://s3.jiansun.vip/video.mp4',
videoPreview540Url: 'https://s3.jiansun.vip/video-540.mp4',
videoPreviewClipUrl: 'https://s3.jiansun.vip/video-clip.mp4',
videoCoverUrl: 'https://s3.jiansun.vip/video-cover.webp',
videoDuration: 20,
videoProcessingStatus: 'completed',
taskNo: null,
dingtalkApprovalNo: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
user: {
id: 'user-1',
username: '测试用户',
realName: '测试用户',
},
assets: [],
})
const response = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1?intent=edit'),
{ params: { id: 'material-1' } },
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.canEdit).toBe(true)
expect(body.productModel).toBe('VIDEO-001')
expect(body.previewUrl).toBeNull()
expect(body).not.toHaveProperty('videoSourceUrl')
expect(body).not.toHaveProperty('videoPreview540Url')
expect(body).not.toHaveProperty('sourceFileUrl')
expect(canUserViewMaterial).not.toHaveBeenCalled()
const detailResponse = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1'),
{ params: { id: 'material-1' } },
)
const detailBody = await detailResponse.json()
expect(detailResponse.status).toBe(403)
expect(detailBody.code).toBe('VIDEO_WAIT_PERIOD')
})
test('非素材所有者不能通过编辑读取意图绕过权限', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-user',
materialType: '3D场景',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
user: {
id: 'other-user',
username: '其他用户',
realName: '其他用户',
},
assets: [],
})
const response = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1?intent=edit'),
{ params: { id: 'material-1' } },
)
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toBe('您没有权限编辑此素材')
expect(canUserViewMaterial).not.toHaveBeenCalled()
})
test('PUT 路由应透传任务详情出图方式字段', async () => {

View File

@ -0,0 +1,42 @@
import { prisma } from '@/lib/prisma'
import { buildMaterialSearchOrConditions } from '@/lib/material-query'
jest.mock('@/lib/prisma', () => ({
prisma: {
materialSku: {
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
},
},
}))
jest.mock('@/lib/department-permission', () => ({
getUserViewableMaterialTypes: jest.fn(),
isSuperAdmin: jest.fn(),
isSystemAdmin: jest.fn(),
}))
describe('material search conditions', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
})
test('完整素材 UUID 应使用精确匹配', async () => {
const materialId = '954554fb-50b4-45c4-9234-9e94b6948953'
const conditions = await buildMaterialSearchOrConditions(materialId)
expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }]))
})
test('非 UUID 的历史素材 ID 也应使用精确匹配', async () => {
const materialId = 'legacy-material-1'
const conditions = await buildMaterialSearchOrConditions(materialId)
expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }]))
})
})

View File

@ -237,6 +237,48 @@ describe('material-update shared rules', () => {
})
})
test('旧编辑调用未提交报告名称时应保留证书原值', async () => {
const existingCertificate = {
...baseMaterial,
materialType: '证书-检测报告',
categoryName: '通用',
sceneName: '通用',
productCategoryName: '板材',
productModel: '',
reportTitle: '原检测报告',
renderer: '',
}
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingCertificate)
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...existingCertificate,
...data,
}))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: '',
categoryName: '',
sceneName: '',
platformName: '',
productCategoryName: '板材',
materialType: '证书-检测报告',
renderer: '',
authorName: '测试作者',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
reportTitle: '原检测报告',
}),
})
})
test('证书-检测报告同时缺少 SPU 和报告名称时应返回 400', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
@ -397,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: '任务-详情',
@ -435,7 +477,7 @@ describe('material-update shared rules', () => {
}),
})
await updateMaterialWithSharedRules({
await expect(updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
@ -451,12 +493,48 @@ describe('material-update shared rules', () => {
userId: 'user-1',
taskDetailSourceType: '',
},
})).resolves.toEqual({
error: '任务-详情必须选择出图方式(实拍、渲染或 AI)',
status: 400,
})
expect(prisma.material.update).toHaveBeenLastCalledWith({
expect(prisma.material.update).toHaveBeenCalledTimes(1)
})
test('旧编辑调用未提交出图方式时应保留任务详情原值', async () => {
const existingTaskDetail = {
...baseMaterial,
materialType: '任务-详情',
taskDetailSourceType: '实拍',
categoryName: '店铺A',
sceneName: '场景A',
}
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingTaskDetail)
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...existingTaskDetail,
...data,
}))
await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'TASK-SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
platformName: '',
productCategoryName: '',
materialType: '任务-详情',
renderer: '',
authorName: '任务作者',
},
})
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailSourceType: null,
taskDetailSourceType: '实拍',
}),
})
})

View File

@ -48,6 +48,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(),
@ -1223,6 +1224,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,
@ -1448,7 +1453,16 @@ describe('performance survey service policies', () => {
])
})
test('直属主管模板遇到人员档案缺少主管唯一值时明确拒绝创建', async () => {
test.each([
['缺少主组织', []],
[
'存在多个主组织',
[
{ sourceDeptId: '1085808623' },
{ sourceDeptId: '1085808624' },
],
],
])('直属主管模板在人员档案缺少主管唯一值且%s时明确拒绝创建', async (_label, primaryMemberships) => {
const target = {
...baseEmployee,
id: 'employee-target',
@ -1470,6 +1484,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, {
@ -1478,33 +1494,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({
@ -1516,14 +1535,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,
@ -1539,6 +1563,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',
@ -1546,13 +1761,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',
@ -1574,7 +1789,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',
@ -1582,7 +1801,7 @@ describe('performance survey service policies', () => {
assessmentMonth: '2026-07',
targetEmployeeIds: [yuanzhe.id],
})).rejects.toEqual(new PerformanceSurveyApiError(
'无法确定元泽的直属主管,请先校准人员档案',
'元泽所属组织内没有可用的M级主管,请先校准人员档案',
409,
))
expect(prisma.$transaction).not.toHaveBeenCalled()
@ -1632,10 +1851,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: [{
@ -1725,10 +1944,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: [{
@ -1812,11 +2031,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,123 @@ 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: '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,54 @@
/** @jest-environment jsdom */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SearchableUserSelect from '@/components/SearchableUserSelect'
describe('SearchableUserSelect', () => {
const users = [
{ id: 'user-1', username: 'moli', displayName: '魔礼红' },
{ id: 'user-2', username: 'wuxie', displayName: '无邪' },
]
test('支持拼音搜索、键盘选择与标准 combobox 语义', async () => {
const onChange = jest.fn()
const user = userEvent.setup()
render(
<SearchableUserSelect
users={users}
value=""
onChange={onChange}
/>,
)
const combobox = screen.getByRole('combobox')
expect(combobox).toHaveAttribute('aria-expanded', 'false')
await user.click(combobox)
await user.type(combobox, 'molihong')
expect(combobox).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('option', { name: /魔礼红/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /无邪/ })).not.toBeInTheDocument()
await user.keyboard('{Enter}')
expect(onChange).toHaveBeenCalledWith('user-1', '魔礼红')
})
test('加载期间保留已选人员并禁用交互', () => {
render(
<SearchableUserSelect
users={users}
value="user-2"
onChange={jest.fn()}
loading
/>,
)
const combobox = screen.getByRole('combobox')
expect(combobox).toBeDisabled()
expect(combobox).toHaveValue('无邪 (wuxie)')
expect(combobox).toHaveAttribute('aria-busy', 'true')
})
})

View File

@ -0,0 +1,89 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/users/list/route'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findMany: jest.fn(),
},
},
}))
describe('GET /api/users/list', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('未登录时返回 401 且不读取人员数据', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await GET(new NextRequest('http://localhost/api/users/list'))
expect(response.status).toBe(401)
expect(prisma.user.findMany).not.toHaveBeenCalled()
})
test('只读取并返回人员选择器需要的最小字段', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1' },
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{
id: 'user-1',
username: 'moli',
realName: '魔礼红',
},
{
id: 'user-2',
username: 'wuxie',
realName: null,
},
])
const response = await GET(new NextRequest('http://localhost/api/users/list'))
const body = await response.json()
expect(response.status).toBe(200)
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
status: 'active',
archivedAt: null,
},
select: {
id: true,
username: true,
realName: true,
},
orderBy: {
username: 'asc',
},
})
expect(body).toEqual([
{
id: 'user-1',
username: 'moli',
displayName: '魔礼红',
},
{
id: 'user-2',
username: 'wuxie',
displayName: 'wuxie',
},
])
expect(body[0]).not.toHaveProperty('email')
expect(body[0]).not.toHaveProperty('realName')
})
})

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并发
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

@ -166,6 +166,7 @@ export async function buildMaterialSearchOrConditions(search: string): Promise<a
const userIds = userMatches.map((item) => item.id)
const orConditions: any[] = [
{ id: keyword },
{ productModel: { contains: keyword, mode: 'insensitive' } },
{ reportTitle: { contains: keyword, mode: 'insensitive' } },
{ taskNo: { contains: keyword, mode: 'insensitive' } },

View File

@ -176,12 +176,16 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
}
const material = editable.material as any
const hasReportTitleField = Object.prototype.hasOwnProperty.call(input, 'reportTitle')
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo')
const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType')
const hasTaskDetailRequesterField = Object.prototype.hasOwnProperty.call(input, 'taskDetailRequesterUserId')
const productModel = normalizeString(input.productModel)
const reportTitle = normalizeReportTitle(input.reportTitle)
const effectiveReportTitle = hasReportTitleField
? reportTitle
: normalizeReportTitle(material.reportTitle)
const materialType = normalizeString(input.materialType)
const authorName = normalizeString(input.authorName)
const shouldUseGeneralCategoryScene = shouldAutoFillGeneral(materialType)
@ -200,7 +204,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
}
if (isCertificateReportMaterialType(materialType)) {
if (!productModel && !reportTitle) {
if (!productModel && !effectiveReportTitle) {
return { error: 'SPU 和 报告名称/检测对象 至少填写一个', status: 400 as const }
}
} else if (!productModel) {
@ -328,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)
@ -341,7 +354,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
const updateData: Record<string, any> = {
previewUrl,
productModel,
reportTitle: isCertificateReportMaterialType(materialType) ? reportTitle : null,
reportTitle: isCertificateReportMaterialType(materialType) ? effectiveReportTitle : null,
categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName),
sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName),
platformName: platformName ?? '',

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

@ -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) {
@ -96,8 +100,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 +129,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 +164,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 +187,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 +292,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,
@ -4835,17 +4836,72 @@ 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: [
accountWhere,
getActivePerformanceSurveyEmployeeProfileWhere(),
{
OR: [
...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []),
@ -4859,7 +4915,64 @@ async function resolveDirectManagerForWorkflow(targetEmployee: any) {
if (managers.length !== 1) {
throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409)
}
return managers[0]
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: [
getActivePerformanceSurveyEmployeeProfileWhere(),
{ id: { not: targetEmployeeId } },
{
OR: [
{ isLeadership: true },
{ levelType: { equals: 'M', mode: 'insensitive' } },
{ positionLevel: { startsWith: 'M', mode: 'insensitive' } },
],
},
{
departmentMemberships: {
some: {
sourceDeptId: primarySourceDeptIds[0],
isPrimary: true,
},
},
},
],
},
take: 2,
})
if (managers.length === 0) {
throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409)
}
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,
})
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,8 +948,18 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
},
})
await cleanupTempFiles(tempFiles, uploadIds)
await assignTaskNoOnComplete(
materialId,
normalizedTaskNo,
normalizedDingtalkApprovalNo,
)
persistentStateCommitted = true
await reportProgress('database', 93)
await cleanupTempFiles(tempFiles, uploadIds)
await reportProgress('cleanup', 97)
try {
await createMaterialLog({
materialId,
userId: operatorUserId,
@ -661,18 +1001,28 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
},
},
})
} 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,442 @@
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
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 tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext('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 tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext('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