任务详情单图编辑支持批量攒批提交,一次重建下载包与事务落库
This commit is contained in:
parent
9e8714af73
commit
beea679525
102
app/api/materials/[id]/assets/batch/route.ts
Normal file
102
app/api/materials/[id]/assets/batch/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
128
lib/__tests__/task-detail-asset-batch-route.test.ts
Normal file
128
lib/__tests__/task-detail-asset-batch-route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user