diff --git a/app/api/materials/[id]/assets/batch/route.ts b/app/api/materials/[id]/assets/batch/route.ts new file mode 100644 index 0000000..27c4b09 --- /dev/null +++ b/app/api/materials/[id]/assets/batch/route.ts @@ -0,0 +1,102 @@ +/** + * 任务-详情批量单图编辑接口 + * + * POST body: { operations: Array< + * { kind: 'delete'; assetId } | + * { kind: 'rename'; assetId; newFileName } | + * { kind: 'replace'; assetId; uploadId } + * > } + * + * 前端把用户的一批编辑操作攒在本地,点「应用」后一次性提交; + * 服务端一次解压、一次重建分组 ZIP 与 all.zip、一次事务提交。 + * 限制与权限口径与单图编辑一致(canManageMaterial)。 + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { + TaskDetailAssetEditError, + batchEditTaskDetailImages, + type BatchTaskDetailImageEditOperation, +} from '@/lib/task-detail-asset-edit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + +type RouteParams = { params: { id: string } } + +function getSessionUser(session: unknown): { id: string; role?: string | null } | null { + const user = (session as { user?: { id?: string; role?: string } } | null)?.user + if (!user?.id) { + return null + } + return { id: user.id, role: user.role ?? null } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const operator = getSessionUser(session) + if (!operator) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const body = await request.json().catch(() => null) + const rawOperations = Array.isArray((body as { operations?: unknown } | null)?.operations) + ? ((body as { operations: unknown[] }).operations) + : [] + + const operations: BatchTaskDetailImageEditOperation[] = [] + for (const raw of rawOperations) { + const item = raw as Record + const kind = String(item?.kind || '') + const assetId = String(item?.assetId || '').trim() + if (!assetId) { + return NextResponse.json({ success: false, error: '操作缺少图片标识' }, { status: 400 }) + } + if (kind === 'delete') { + operations.push({ kind: 'delete', assetId }) + } else if (kind === 'rename') { + const newFileName = String(item?.newFileName || '').trim() + if (!newFileName) { + return NextResponse.json({ success: false, error: '缺少新文件名' }, { status: 400 }) + } + operations.push({ kind: 'rename', assetId, newFileName }) + } else if (kind === 'replace') { + const uploadId = String(item?.uploadId || '').trim() + if (!uploadId) { + return NextResponse.json({ success: false, error: '缺少替换文件的上传会话,请重新选择文件' }, { status: 400 }) + } + operations.push({ kind: 'replace', assetId, uploadId }) + } else { + return NextResponse.json({ success: false, error: `不支持的操作类型:${kind}` }, { status: 400 }) + } + } + if (operations.length === 0) { + return NextResponse.json({ success: false, error: '批量操作不能为空' }, { status: 400 }) + } + + const result = await batchEditTaskDetailImages({ + materialId: params.id, + operatorUserId: operator.id, + operatorRole: operator.role, + operations, + }) + + return NextResponse.json({ success: true, data: result }) + } catch (error) { + if (error instanceof TaskDetailAssetEditError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.statusCode }, + ) + } + console.error('[materials/asset-batch-edit] error:', error) + return NextResponse.json( + { success: false, error: '批量编辑失败,请稍后重试' }, + { status: 500 }, + ) + } +} diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 107c41a..d569217 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -327,6 +327,16 @@ export default function SharePage() { error: string | null saving: boolean } | null>(null) + // 批量编辑攒批清单:用户的一组删除/改名/替换操作先攒在本地,点「应用」一次性提交 + const [taskDetailPendingOps, setTaskDetailPendingOps] = useState>([]) + const [taskDetailApplying, setTaskDetailApplying] = useState(false) useGSAP(() => { if (loading || !material || typeof window.matchMedia !== 'function') return @@ -961,19 +971,13 @@ export default function SharePage() { throw new Error('上传未完成,请重试') } - showToast('图片已上传,正在重新打包素材…', { variant: 'success' }) - const response = await fetch(`/api/materials/${material.id}/assets/${targetAssetId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadId }), + queueTaskDetailPendingOp({ + assetId: targetAssetId, + kind: 'replace', + label: `替换为 ${file.name}`, + uploadId, }) - const data = await response.json().catch(() => null) - if (!response.ok || !data?.success) { - throw new Error(data?.error || '替换失败,请稍后重试') - } - - showToast('图片已替换,预览和下载包已同步更新', { variant: 'success' }) - await fetchMaterial() + showToast('替换已加入待应用清单,全部操作完后点「应用变更」', { variant: 'success' }) } catch (error) { showToast(error instanceof Error ? error.message : '替换失败,请稍后重试', { variant: 'error' }) } finally { @@ -1015,57 +1019,69 @@ export default function SharePage() { return } - setRenameDialogState((prev) => prev ? { ...prev, saving: true, error: null } : prev) - try { - const response = await fetch( - `/api/materials/${material.id}/assets/${renameDialogState.assetId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newFileName: nextBaseName + renameDialogState.extension }), - }, - ) - const data = await response.json().catch(() => null) - if (!response.ok || !data?.success) { - throw new Error(data?.error || '重命名失败,请稍后重试') - } - showToast('文件名已更新', { variant: 'success' }) - setRenameDialogState(null) - await fetchMaterial() - } catch (error) { - setRenameDialogState((prev) => prev - ? { ...prev, saving: false, error: error instanceof Error ? error.message : '重命名失败' } - : prev) - } + queueTaskDetailPendingOp({ + assetId: renameDialogState.assetId, + kind: 'rename', + label: `重命名为 ${nextBaseName}${renameDialogState.extension}`, + newFileName: nextBaseName + renameDialogState.extension, + }) + showToast('重命名已加入待应用清单', { variant: 'success' }) + setRenameDialogState(null) } const handleTaskDetailDeleteRequest = async (asset: { id: string; fileName?: string }) => { if (!material) return - await confirmAction({ - title: '删除这张图片?', - description: `「${asset.fileName || '未命名图片'}」将从素材和下载包中移除。`, - confirmLabel: '删除图片', - loadingLabel: '正在删除…', - tone: 'danger', - action: async () => { - setTaskDetailEditingAssetId(asset.id) - try { - const response = await fetch(`/api/materials/${material.id}/assets/${asset.id}`, { - method: 'DELETE', - }) - const data = await response.json().catch(() => null) - if (!response.ok || !data?.success) { - throw new Error(data?.error || '删除失败,请稍后重试') - } - showToast('图片已删除,下载包已同步更新', { variant: 'success' }) - await fetchMaterial() - } catch (error) { - showToast(error instanceof Error ? error.message : '删除失败,请稍后重试', { variant: 'error' }) - } finally { - setTaskDetailEditingAssetId(null) - } - }, + // 删除先进待应用清单(可撤销),与其他操作一起批量提交 + queueTaskDetailPendingOp({ + assetId: asset.id, + kind: 'delete', + label: `删除 ${asset.fileName || '未命名图片'}`, }) + showToast('删除已加入待应用清单,撤销请点清单中的 ×', { variant: 'success' }) + } + + // 同一张图的新操作覆盖旧操作;清单按加入顺序提交 + const queueTaskDetailPendingOp = (op: { + assetId: string + kind: 'delete' | 'rename' | 'replace' + label: string + uploadId?: string + newFileName?: string + }) => { + setTaskDetailPendingOps((prev) => [ + ...prev.filter((item) => item.assetId !== op.assetId), + { ...op, key: `${op.assetId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }, + ]) + } + + const applyTaskDetailPendingOps = async () => { + if (!material || taskDetailPendingOps.length === 0 || taskDetailApplying) return + setTaskDetailApplying(true) + try { + const response = await fetch(`/api/materials/${material.id}/assets/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operations: taskDetailPendingOps.map((op) => ({ + kind: op.kind, + assetId: op.assetId, + ...(op.kind === 'replace' ? { uploadId: op.uploadId } : {}), + ...(op.kind === 'rename' ? { newFileName: op.newFileName } : {}), + })), + }), + }) + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || '应用变更失败,请稍后重试') + } + showToast(`已应用 ${taskDetailPendingOps.length} 项变更,下载包已同步更新`, { variant: 'success' }) + setTaskDetailPendingOps([]) + await fetchMaterial() + } catch (error) { + showToast(error instanceof Error ? error.message : '应用变更失败,请稍后重试', { variant: 'error' }) + } finally { + setTaskDetailApplying(false) + } } if (loading) { @@ -1153,6 +1169,58 @@ export default function SharePage() { {/* 任务-详情类型:使用专用详情组件 */} {mappedMaterialType === '任务-详情' ? (
+ {Boolean(material.canManageTaskDetailAssets) && taskDetailPendingOps.length > 0 && ( +
+
+

+ 待应用变更 {taskDetailPendingOps.length} 项 +

+
+ + +
+
+
    + {taskDetailPendingOps.map((op) => ( +
  • + + + {op.kind === 'delete' ? '删除' : op.kind === 'rename' ? '改名' : '替换'} + + {op.label} + + +
  • + ))} +
+
+ )} op.assetId)} onRequestReplace={handleTaskDetailReplaceRequest} onRequestRename={openTaskDetailRenameDialog} onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)} diff --git a/components/detail/ProductPackageDetail.tsx b/components/detail/ProductPackageDetail.tsx index 130627d..78b1ff4 100644 --- a/components/detail/ProductPackageDetail.tsx +++ b/components/detail/ProductPackageDetail.tsx @@ -51,6 +51,8 @@ interface ProductPackageDetailProps { onRequestReplace?: (asset: Asset) => void; onRequestRename?: (asset: Asset) => void; onRequestDelete?: (asset: Asset) => void; + /** 已加入待应用清单的资产 id,缩略图上显示待应用标记 */ + pendingAssetIds?: string[]; } function AssetEditMenu({ @@ -147,6 +149,7 @@ export default function ProductPackageDetail({ onRequestReplace, onRequestRename, onRequestDelete, + pendingAssetIds = [], }: ProductPackageDetailProps) { // 当前预览的图片类型和索引 const [previewType, setPreviewType] = useState<'main' | 'sku'>('main'); @@ -316,6 +319,9 @@ export default function ProductPackageDetail({ {editingAssetId === img.id && ( )} + {pendingAssetIds.includes(img.id) && ( + 待应用 + )} )}
@@ -355,6 +361,9 @@ export default function ProductPackageDetail({ 正在重新打包,请稍候… )} + {pendingAssetIds.includes(img.id) && ( + 待应用 + )} )} @@ -451,6 +460,9 @@ export default function ProductPackageDetail({ {editingAssetId === img.id && ( )} + {pendingAssetIds.includes(img.id) && ( + 待应用 + )} )} diff --git a/lib/__tests__/task-detail-asset-batch-route.test.ts b/lib/__tests__/task-detail-asset-batch-route.test.ts new file mode 100644 index 0000000..6b478bb --- /dev/null +++ b/lib/__tests__/task-detail-asset-batch-route.test.ts @@ -0,0 +1,128 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' + +import { POST } from '@/app/api/materials/[id]/assets/batch/route' +import { + TaskDetailAssetEditError, + batchEditTaskDetailImages, +} from '@/lib/task-detail-asset-edit' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/task-detail-asset-edit', () => ({ + TaskDetailAssetEditError: class TaskDetailAssetEditError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.statusCode = statusCode + } + }, + batchEditTaskDetailImages: jest.fn(), +})) + +const mockedBatch = batchEditTaskDetailImages as jest.Mock + +function requestWith(body: unknown) { + return { json: async () => body } as any +} + +describe('任务-详情批量单图编辑路由', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('未登录返回 401,不触发批量逻辑', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + const response = await POST( + requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), + { params: { id: 'm1' } }, + ) + expect(response.status).toBe(401) + expect(mockedBatch).not.toHaveBeenCalled() + }) + + test('operations 为空数组返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) + const response = await POST(requestWith({ operations: [] }), { params: { id: 'm1' } }) + expect(response.status).toBe(400) + expect(mockedBatch).not.toHaveBeenCalled() + }) + + test('不支持的操作类型返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + const response = await POST( + requestWith({ operations: [{ kind: 'magic', assetId: 'a1' }] }), + { params: { id: 'm1' } }, + ) + expect(response.status).toBe(400) + expect(mockedBatch).not.toHaveBeenCalled() + }) + + test('replace 缺少 uploadId 返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + const response = await POST( + requestWith({ operations: [{ kind: 'replace', assetId: 'a1' }] }), + { params: { id: 'm1' } }, + ) + expect(response.status).toBe(400) + }) + + test('合法混合操作透传给批量引擎并返回结果', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) + mockedBatch.mockResolvedValue({ success: true, appliedCount: 3, entryCount: 7 }) + + const response = await POST( + requestWith({ + operations: [ + { kind: 'delete', assetId: 'a1' }, + { kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' }, + { kind: 'replace', assetId: 'a3', uploadId: 'up-1' }, + ], + }), + { params: { id: 'm1' } }, + ) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.data.appliedCount).toBe(3) + expect(mockedBatch).toHaveBeenCalledWith({ + materialId: 'm1', + operatorUserId: 'u1', + operatorRole: 'ADMIN', + operations: [ + { kind: 'delete', assetId: 'a1' }, + { kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' }, + { kind: 'replace', assetId: 'a3', uploadId: 'up-1' }, + ], + }) + }) + + test('业务错误透传状态码与文案', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + mockedBatch.mockRejectedValue( + new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400), + ) + + const response = await POST( + requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), + { params: { id: 'm1' } }, + ) + expect(response.status).toBe(400) + const payload = await response.json() + expect(payload.error).toContain('同一分组') + }) + + test('未知异常返回 500 且不泄漏内部信息', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + mockedBatch.mockRejectedValue(new Error('internal detail')) + + const response = await POST( + requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), + { params: { id: 'm1' } }, + ) + expect(response.status).toBe(500) + const payload = await response.json() + expect(payload.error).not.toContain('internal detail') + }) +}) diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index 20b8a5a..c1d194c 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -908,6 +908,249 @@ async function loadRunBarcodes(assetIds: string[]): Promise { return Array.from(new Set(rows.map((row) => row.barcode).filter(Boolean))).sort() } +// ---------- 批量编辑:序列应用 + 一次重建 ---------- + +interface PreparedBatchOperation { + assetId: string + mutation: EditMutation + /** replace 操作消费的上传会话,提交成功后清理 */ + uploadId?: string +} + +/** + * 批量执行:全部操作在内存中按顺序应用完毕后,只做一次分组 ZIP 重建、 + * 一次 all.zip 重建、一次数据库事务。复用 RebuildOutcome 与提交链路。 + */ +async function executeBatchRebuild( + context: EditContext, + operations: PreparedBatchOperation[], +): Promise { + const { group, entryNames } = context + const previewType = GROUP_PREVIEW_TYPES[group] + const workspace = await createTaskDetailEditWorkspace() + + try { + const groupZip = context.groupZipAssets[group]! + const groupZipAbsolute = path.isAbsolute(groupZip.fileUrl) + ? groupZip.fileUrl + : resolveStoredUploadPath(groupZip.fileUrl) + + const extracted = await extractZipEntriesToDirectory(groupZipAbsolute, workspace.dir) + + // 存活条目与存活 run 一一对应,随操作同步增删 + const entries: Array<{ path: string; name: string; size: number }> = extracted.map( + (item, index) => ({ + path: item.filePath, + name: entryNames[index], + size: item.size, + }), + ) + const runs: PreviewRun[] = context.runs.map((run) => ({ ...run, rows: [...run.rows] })) + + const db: PlannedDbChanges = { + previewRowsToDelete: [], + previewRowsToInsert: [], + sortOrderUpdates: [], + fileNameUpdates: [], + removedPreviewUrls: [], + } + // 被替换 run(rows 清空)→ 其新插入行在 previewRowsToInsert 中的区间,最终统一重排 + const replacedInsertsByRunIndex = new Map() + const handledAssetIds = new Set() + + for (const operation of operations) { + if (handledAssetIds.has(operation.assetId)) { + throw new TaskDetailAssetEditError('同一张图在一次批量提交中只能操作一次,请分开提交') + } + handledAssetIds.add(operation.assetId) + + const runIndex = runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId)) + if (runIndex < 0) { + throw new TaskDetailAssetEditError('要编辑的图片不存在或已被本次批量操作移除', 404) + } + + if (operation.mutation.kind === 'delete') { + for (const row of runs[runIndex].rows) { + db.previewRowsToDelete.push(row.id) + db.removedPreviewUrls.push({ url: row.fileUrl }) + } + entries.splice(runIndex, 1) + runs.splice(runIndex, 1) + continue + } + + if (operation.mutation.kind === 'rename') { + const originalExtension = getExtension(entries[runIndex].name) + const finalEntryName = `${operation.mutation.newBaseName}${originalExtension}` + const conflict = entries.some((entry, index) => index !== runIndex && entry.name === finalEntryName) + if (conflict) { + throw new TaskDetailAssetEditError(`改名后的「${finalEntryName}」与包内其他文件重名,请换一个名字`, 409) + } + entries[runIndex] = { ...entries[runIndex], name: finalEntryName } + + // 同步预览行文件名里的来源锚(与单张 rename 分支同一套规则) + const newBaseName = operation.mutation.newBaseName + for (const row of runs[runIndex].rows) { + const currentName = String(row.fileName || '') + const parsed = parseTaskDetailPreviewFileName(currentName) + if (!parsed?.label) continue + if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) continue + const serverTail = /^.*(__srv-\d+-\d+-[a-z0-9]*)$/i.exec( + currentName.replace(/\.webp$/i, ''), + ) + let nextName: string + if (serverTail) { + nextName = `${newBaseName}${serverTail[1]}.webp` + } else { + const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) + nextName = sliceSuffixMatch + ? `${newBaseName}${sliceSuffixMatch[0]}.webp` + : `${newBaseName}.webp` + } + if (nextName !== currentName) { + db.fileNameUpdates.push({ id: row.id, fileName: nextName }) + } + } + continue + } + + // replace + const finalEntryName = buildFinalEntryName(entries[runIndex].name, operation.mutation.replacementFileName) + const conflict = entries.some((entry, index) => index !== runIndex && entry.name === finalEntryName) + if (conflict) { + throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409) + } + entries[runIndex] = { + path: operation.mutation.filePath, + name: finalEntryName, + size: 0, + } + + const generated = await generateServerPreviews( + operation.mutation.buffer, + group, + context.materialId, + stripExtension(finalEntryName), + runIndex, + ) + const barcodes = group === 'sku' + ? await loadRunBarcodes(runs[runIndex].rows.map((row) => row.id)) + : [] + for (const row of runs[runIndex].rows) { + db.previewRowsToDelete.push(row.id) + db.removedPreviewUrls.push({ url: row.fileUrl }) + } + const startIndex = db.previewRowsToInsert.length + generated.forEach((preview, index) => { + db.previewRowsToInsert.push({ + assetType: previewType, + fileUrl: preview.fileUrl, + fileName: preview.fileName, + fileSize: BigInt(preview.size), + sortOrder: runIndex + index, + skuCode: runs[runIndex].rows[0]?.skuCode?.trim() || null, + barcodes: index === 0 ? barcodes : [], + }) + }) + replacedInsertsByRunIndex.set(runIndex, { startIndex, count: generated.length }) + runs[runIndex] = { ...runs[runIndex], rows: [] } + } + + // 最终统一重排:按 run 顺序展开"存活旧行 + 替换插入行",重写 sortOrder + const finalRowUrls: string[] = [] + let cursor = 0 + for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { + const replaced = replacedInsertsByRunIndex.get(runIndex) + if (replaced) { + for (let offset = 0; offset < replaced.count; offset += 1) { + const row = db.previewRowsToInsert[replaced.startIndex + offset] + row.sortOrder = cursor + finalRowUrls.push(row.fileUrl) + cursor += 1 + } + continue + } + for (const row of runs[runIndex].rows) { + if (row.sortOrder !== cursor) { + db.sortOrderUpdates.push({ id: row.id, sortOrder: cursor }) + } + finalRowUrls.push(row.fileUrl) + cursor += 1 + } + } + + let newGroupZip: RebuildOutcome['newGroupZip'] = null + if (entries.length > 0) { + newGroupZip = await createZipFromFiles( + entries, + context.materialId, + context.productModel || 'product', + '任务-详情', + GROUP_ZIP_LABELS[group], + context.authorName || undefined, + context.categoryName || undefined, + ) + } + + const members: Array<{ path: string; name: string }> = [] + for (const memberGroup of GROUPS) { + if (memberGroup === group) { + if (newGroupZip) { + members.push({ + path: resolveStoredUploadPath(newGroupZip.filePath), + name: newGroupZip.fileName, + }) + } + continue + } + const memberAsset = context.groupZipAssets[memberGroup] + if (memberAsset?.fileUrl) { + members.push({ + path: resolveStoredUploadPath(memberAsset.fileUrl), + name: memberAsset.fileName || '', + }) + } + } + + let newAllZip: RebuildOutcome['newAllZip'] = null + if (members.length > 0) { + newAllZip = await createZipFromZips( + members, + context.materialId, + context.productModel || 'product', + '任务-详情', + ) + } + + const finalRowCountByGroup: Record = { + main: context.rowsByGroup.main.length, + sku: context.rowsByGroup.sku.length, + detail: context.rowsByGroup.detail.length, + } + finalRowCountByGroup[group] = cursor + + const firstPreviewUrlByGroup: Record = { + main: null, + sku: null, + detail: null, + } + if (finalRowCountByGroup[group] > 0) { + firstPreviewUrlByGroup[group] = finalRowUrls[0] || null + } + + return { + nextEntryNames: entries.map((entry) => entry.name), + newGroupZip, + newAllZip, + db, + finalRowCountByGroup, + firstPreviewUrlByGroup, + } + } finally { + await workspace.cleanup() + } +} + async function executeRebuild( context: EditContext, mutation: EditMutation, @@ -1065,7 +1308,7 @@ function computeNextAssetTypes(context: EditContext, outcome: RebuildOutcome): A async function commitRebuild( context: EditContext, outcome: RebuildOutcome, - operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE', + operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE' | 'BATCH_IMAGE_EDITS', ): Promise<{ nextIsFullSet: boolean }> { const { group } = context const previewType = GROUP_PREVIEW_TYPES[group] @@ -1375,3 +1618,130 @@ export async function deleteTaskDetailImage(input: DeleteTaskDetailImageInput) { return { success: true } }) } + + +// ---------- 批量编辑对外入口 ---------- + +export type BatchTaskDetailImageEditOperation = + | { kind: 'delete'; assetId: string } + | { kind: 'rename'; assetId: string; newFileName: string } + | { kind: 'replace'; assetId: string; uploadId: string } + +export interface BatchTaskDetailImageEditInput { + materialId: string + operatorUserId: string + operatorRole?: string | null + operations: BatchTaskDetailImageEditOperation[] +} + +const MAX_BATCH_OPERATIONS = 100 + +/** + * 批量单图编辑:一次提交一组删除/改名/替换操作。 + * 限制:所有操作必须落在同一分组(主图/SKU/详情)内;同一张图只能出现一次。 + * 全部操作要么整体成功,要么整体不变。 + */ +export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditInput) { + const operations = Array.isArray(input.operations) ? input.operations : [] + if (operations.length === 0) { + throw new TaskDetailAssetEditError('批量操作不能为空') + } + if (operations.length > MAX_BATCH_OPERATIONS) { + throw new TaskDetailAssetEditError(`一次最多提交 ${MAX_BATCH_OPERATIONS} 项操作,请分批提交`) + } + + return runEditOperation(input.materialId, async () => { + const context = await loadEditContext({ + materialId: input.materialId, + assetId: operations[0].assetId, + operatorUserId: input.operatorUserId, + operatorRole: input.operatorRole, + }) + + const validAssetIds = new Set( + context.runs.flatMap((run) => run.rows.map((row) => row.id)), + ) + for (const operation of operations) { + if (!validAssetIds.has(operation.assetId)) { + throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') + } + } + + const prepared: PreparedBatchOperation[] = [] + for (const operation of operations) { + if (operation.kind === 'delete') { + prepared.push({ assetId: operation.assetId, mutation: { kind: 'delete' } }) + continue + } + if (operation.kind === 'rename') { + const rawName = String(operation.newFileName || '').trim() + const baseName = sanitizeNewBaseName(stripExtension(rawName)) + if (!baseName) { + throw new TaskDetailAssetEditError('文件名不能为空') + } + const runIndex = context.runs.findIndex((run) => + run.rows.some((row) => row.id === operation.assetId), + ) + const requestedExtension = getExtension(rawName) + const originalExtension = getExtension(context.entryNames[runIndex]) + if (requestedExtension && requestedExtension.toLowerCase() !== originalExtension.toLowerCase()) { + throw new TaskDetailAssetEditError('只能修改文件名,不能更改扩展名') + } + prepared.push({ + assetId: operation.assetId, + mutation: { kind: 'rename', newBaseName: baseName }, + }) + continue + } + const upload = await resolveReplacementUpload({ + uploadId: operation.uploadId, + operatorUserId: input.operatorUserId, + }) + prepared.push({ + assetId: operation.assetId, + mutation: { + kind: 'replace', + filePath: upload.filePath, + replacementFileName: upload.fileName, + buffer: upload.buffer, + }, + uploadId: operation.uploadId.trim(), + }) + } + + const outcome = await executeBatchRebuild(context, prepared) + const { nextIsFullSet } = await commitRebuild(context, outcome, 'BATCH_IMAGE_EDITS') + await cleanupAfterCommit(context, outcome) + + for (const operation of prepared) { + if (operation.uploadId) { + await cleanupConsumedUploadSession(operation.uploadId).catch(() => undefined) + } + } + + if (!context.wasFullSet && nextIsFullSet) { + void queueTaskDetailFullSetNotification(context.materialId) + } + + if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { + const insertedAssets = await prisma.materialAsset.findMany({ + where: { + materialId: context.materialId, + assetType: GROUP_PREVIEW_TYPES.sku, + fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, + }, + select: { id: true }, + }) + void queueTaskDetailBarcodeImagesUpdatedNotification( + context.materialId, + insertedAssets.map((row) => row.id), + ) + } + + return { + success: true, + appliedCount: prepared.length, + entryCount: outcome.nextEntryNames.length, + } + }) +}