任务详情单图编辑支持批量攒批提交,一次重建下载包与事务落库

This commit is contained in:
chenpei 2026-08-28 13:15:27 +08:00
parent 9e8714af73
commit beea679525
5 changed files with 740 additions and 59 deletions

View File

@ -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<string, unknown>
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 },
)
}
}

View File

@ -327,6 +327,16 @@ export default function SharePage() {
error: string | null
saving: boolean
} | null>(null)
// 批量编辑攒批清单:用户的一组删除/改名/替换操作先攒在本地,点「应用」一次性提交
const [taskDetailPendingOps, setTaskDetailPendingOps] = useState<Array<{
key: string
assetId: string
kind: 'delete' | 'rename' | 'replace'
label: string
uploadId?: string
newFileName?: string
}>>([])
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 === '任务-详情' ? (
<div data-detail-preview className="mx-auto">
{Boolean(material.canManageTaskDetailAssets) && taskDetailPendingOps.length > 0 && (
<div className="mb-4 rounded-xl border border-[#e7e8ea] bg-white p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[13px] font-semibold text-[#1a1c1d]">
待应用变更 {taskDetailPendingOps.length} 项
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={taskDetailApplying}
onClick={() => setTaskDetailPendingOps([])}
className="rounded-lg border border-[#e7e8ea] px-3 py-1.5 text-[12px] text-[#6b7075] transition-colors hover:bg-[#fafafa] disabled:opacity-50"
>
全部撤销
</button>
<button
type="button"
disabled={taskDetailApplying}
onClick={() => void applyTaskDetailPendingOps()}
className="rounded-lg bg-[#1a1c1d] px-4 py-1.5 text-[12px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
>
{taskDetailApplying ? '正在应用…' : `应用 ${taskDetailPendingOps.length} 项变更`}
</button>
</div>
</div>
<ul className="mt-3 space-y-1.5">
{taskDetailPendingOps.map((op) => (
<li key={op.key} className="flex items-center justify-between gap-3 rounded-lg bg-[#fafafa] px-3 py-1.5">
<span className="min-w-0 flex-1 truncate text-[12px] text-[#1a1c1d]">
<span className={
op.kind === 'delete'
? 'mr-2 text-[#b42318]'
: 'mr-2 text-[#6b7075]'
}>
{op.kind === 'delete' ? '删除' : op.kind === 'rename' ? '改名' : '替换'}
</span>
{op.label}
</span>
<button
type="button"
disabled={taskDetailApplying}
onClick={() => setTaskDetailPendingOps((prev) => prev.filter((item) => item.key !== op.key))}
className="text-[12px] text-[#86898e] transition-colors hover:text-[#b42318] disabled:opacity-50"
aria-label="撤销这项操作"
>
×
</button>
</li>
))}
</ul>
</div>
)}
<ProductPackageDetail
material={{
id: material.id,
@ -1170,6 +1238,7 @@ export default function SharePage() {
showToast={showToast}
canManageAssets={Boolean(material.canManageTaskDetailAssets)}
editingAssetId={taskDetailEditingAssetId}
pendingAssetIds={taskDetailPendingOps.map((op) => op.assetId)}
onRequestReplace={handleTaskDetailReplaceRequest}
onRequestRename={openTaskDetailRenameDialog}
onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)}

View File

@ -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 && (
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
)}
</>
)}
</div>
@ -355,6 +361,9 @@ export default function ProductPackageDetail({
正在重新打包,请稍候…
</span>
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
)}
</>
)}
</div>
@ -451,6 +460,9 @@ export default function ProductPackageDetail({
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
{pendingAssetIds.includes(img.id) && (
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
)}
</>
)}
</div>

View File

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

View File

@ -908,6 +908,249 @@ async function loadRunBarcodes(assetIds: string[]): Promise<string[]> {
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<RebuildOutcome> {
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<number, { startIndex: number; count: number }>()
const handledAssetIds = new Set<string>()
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<TaskDetailEditGroup, number> = {
main: context.rowsByGroup.main.length,
sku: context.rowsByGroup.sku.length,
detail: context.rowsByGroup.detail.length,
}
finalRowCountByGroup[group] = cursor
const firstPreviewUrlByGroup: Record<TaskDetailEditGroup, string | null> = {
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,
}
})
}