fix: preserve insertion seams through approved image deletions

This commit is contained in:
chenpei 2026-08-31 17:03:00 +08:00
parent 87919a3943
commit b7592fa687
6 changed files with 376 additions and 37 deletions

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TaskDetailChangeRequestPage from './page'
@ -11,11 +11,20 @@ const createJsonResponse = <T,>(body: T, status = 200) => ({
json: async () => body,
})
const requestRecord = (status = 'pending_manager_approval', applyError: string | null = null) => ({
success: true,
data: { id: 'request-1', status, applyError, canDecide: status === 'pending_manager_approval',
managerName: '测试主管', requesterName: '测试提交人', material: { productModel: '测试素材', materialType: '任务-详情' },
operations: [{ kind: 'delete', assetId: 'asset-1' }],
},
})
jest.mock('next/navigation', () => ({
useParams: () => ({ id: 'request-1' }),
}))
describe('TaskDetailChangeRequestPage', () => {
afterEach(() => { jest.useRealTimers() })
afterAll(() => {
global.fetch = originalFetch
})
@ -82,4 +91,84 @@ describe('TaskDetailChangeRequestPage', () => {
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '否决' })).not.toBeInTheDocument()
})
test('applying is shown as approved and processing, with read-only refresh instead of decision buttons', async () => {
const user = userEvent.setup()
const fetchMock = jest.fn(async () => createJsonResponse(requestRecord('applying')))
global.fetch = fetchMock as jest.Mock
render(<TaskDetailChangeRequestPage />)
expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '刷新状态' }))
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.every((call: unknown[]) => !(call[1] as RequestInit)?.method)).toBe(true)
})
test('failed decision refreshes persisted state and retains the specific apply error', async () => {
const user = userEvent.setup()
let submitted = false
const reason = '插图位置不存在或已被本次批量操作改动,请刷新后重试'
global.fetch = jest.fn(async (_input, init?: RequestInit) => {
if (init?.method === 'POST') {
submitted = true
return createJsonResponse({ error: reason }, 409)
}
return createJsonResponse(requestRecord(submitted ? 'apply_failed' : 'pending_manager_approval', submitted ? reason : null))
}) as jest.Mock
render(<TaskDetailChangeRequestPage />)
await user.click(await screen.findByRole('button', { name: '批准' }))
expect(await screen.findByRole('alert')).toHaveTextContent(reason)
expect(screen.getAllByText('应用失败')).toHaveLength(2)
expect(screen.getByRole('alert')).toHaveTextContent('本单不能重复批准')
await user.click(screen.getByRole('button', { name: '刷新状态' }))
expect(screen.getByRole('alert')).toHaveTextContent(reason)
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
})
test('a slow POST can be reconciled by read-only refresh without sending another approval', async () => {
const user = userEvent.setup()
let status = 'pending_manager_approval'
let finish!: (response: ReturnType<typeof createJsonResponse>) => void
const fetchMock = jest.fn((_input, init?: RequestInit) => {
if (init?.method === 'POST') {
status = 'applying'
return new Promise(resolve => { finish = resolve })
}
return Promise.resolve(createJsonResponse(requestRecord(status)))
})
global.fetch = fetchMock as jest.Mock
render(<TaskDetailChangeRequestPage />)
await user.click(await screen.findByRole('button', { name: '批准' }))
expect(screen.getByText(/正在提交审批并处理图片变更/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '刷新状态' }))
expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([,init]) => init?.method === 'POST')).toHaveLength(1)
status = 'applied'
await act(async () => { finish(createJsonResponse({ success: true })); await Promise.resolve() })
expect(await screen.findAllByText('已通过')).toHaveLength(2)
})
test('a timed-out POST is not treated as failure of the application and later success clears the timeout hint', async () => {
jest.useFakeTimers()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
let status = 'pending_manager_approval'
const fetchMock = jest.fn((_input, init?: RequestInit) => {
if (init?.method === 'POST') {
status = 'applying'
return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError'))))
}
return Promise.resolve(createJsonResponse(requestRecord(status)))
})
global.fetch = fetchMock as jest.Mock
render(<TaskDetailChangeRequestPage />)
await user.click(await screen.findByRole('button', { name: '批准' }))
await act(async () => { await jest.advanceTimersByTimeAsync(30_100) })
expect(screen.getByRole('alert')).toHaveTextContent('请求未返回结果')
expect(screen.getByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
status = 'applied'
await act(async () => { await jest.advanceTimersByTimeAsync(3100) })
expect(screen.getAllByText('已通过')).toHaveLength(2)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})

View File

@ -1,12 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { format } from 'date-fns'
type TaskDetailChangeRequestDecision = 'approve' | 'reject'
type TaskDetailChangeRequestStatus = 'pending_approval' | 'approved' | 'rejected' | 'expired' | 'apply_failed'
type TaskDetailChangeRequestStatus = 'pending_approval' | 'applying' | 'approved' | 'rejected' | 'expired' | 'apply_failed'
type TaskDetailChangeRequestOperation = {
kind: string
@ -30,6 +30,7 @@ type TaskDetailChangeRequestViewModel = {
taskNo: string | null
expiresAt: string | null
decidedAt: string | null
applyError: string | null
operations: TaskDetailChangeRequestOperation[]
}
@ -42,6 +43,10 @@ const STATUS_META: Record<TaskDetailChangeRequestStatus, { label: string; classN
label: '已通过',
className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]',
},
applying: {
label: '正在应用',
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
},
rejected: {
label: '已否决',
className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]',
@ -82,6 +87,7 @@ function normalizeStatus(value: unknown): TaskDetailChangeRequestStatus {
if (raw === 'rejected' || raw === 'reject') return 'rejected'
if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return 'expired'
if (raw === 'apply_failed') return 'apply_failed'
if (raw === 'applying') return 'applying'
return 'pending_approval'
}
@ -152,6 +158,7 @@ function normalizeChangeRequestPayload(payload: unknown): TaskDetailChangeReques
taskNo: readText(material.taskNo) || readText(record.taskNo),
expiresAt: readText(record.expiresAt) || readText(record.deadlineAt) || readText(record.expireAt),
decidedAt: readText(record.decidedAt) || readText(record.reviewedAt),
applyError: readText(record.applyError),
operations,
}
}
@ -186,54 +193,104 @@ export default function TaskDetailChangeRequestPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busyDecision, setBusyDecision] = useState<TaskDetailChangeRequestDecision | null>(null)
const mounted = useRef(true)
const controllers = useRef(new Set<AbortController>())
const readSequence = useRef(0)
const loadRequest = useCallback(async () => {
const requestJson = useCallback(async (url: string, init?: RequestInit, timeoutMs = 15_000) => {
const controller = new AbortController()
controllers.current.add(controller)
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/task-detail-change-requests/${requestId}`, {
const response = await fetch(url, { ...init, signal: controller.signal })
const payload = await response.json().catch(() => null)
return { response, payload }
} finally {
clearTimeout(timeout)
controllers.current.delete(controller)
}
}, [])
const loadRequest = useCallback(async (options: { initial?: boolean; keepError?: boolean } = {}) => {
const sequence = ++readSequence.current
try {
if (options.initial) setLoading(true)
if (!options.keepError) setError(null)
const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}`, {
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error((payload as { error?: string } | null)?.error || '加载审批详情失败')
}
const next = normalizeChangeRequestPayload(payload)
if (!next) throw new Error('审批详情格式不正确')
if (mounted.current && sequence === readSequence.current) {
setRecord(next)
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : '加载审批详情失败')
} finally {
setLoading(false)
if (['approved', 'rejected', 'expired'].includes(next.status)) setError(null)
}
}, [requestId])
return next
} catch (loadError) {
if (mounted.current && sequence === readSequence.current && !options.keepError) {
setError(loadError instanceof Error && loadError.name === 'AbortError'
? '读取状态超时,请刷新状态重试'
: loadError instanceof Error ? loadError.message : '加载审批详情失败')
}
return null
} finally {
if (mounted.current && options.initial) setLoading(false)
}
}, [requestId, requestJson])
useEffect(() => {
mounted.current = true
const activeControllers = controllers.current
if (requestId) {
void loadRequest()
void loadRequest({ initial: true })
}
return () => {
mounted.current = false
activeControllers.forEach(controller => controller.abort())
}
}, [requestId, loadRequest])
useEffect(() => {
if (!busyDecision && record?.status !== 'applying') return
let cancelled = false
const deadline = Date.now() + 2 * 60_000
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
const next = await loadRequest({ keepError: true })
if (next && !['pending_approval', 'applying'].includes(next.status)) return
if (!cancelled && Date.now() < deadline) timer = setTimeout(poll, 3000)
}
timer = setTimeout(poll, 3000)
return () => { cancelled = true; clearTimeout(timer) }
}, [busyDecision, record?.status, loadRequest])
const submitDecision = async (decision: TaskDetailChangeRequestDecision) => {
if (!record || busyDecision) return
try {
setBusyDecision(decision)
setError(null)
const response = await fetch(`/api/task-detail-change-requests/${requestId}/decision`, {
const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
})
const payload = await response.json().catch(() => null)
}, 30_000)
if (!response.ok) {
throw new Error((payload as { error?: string } | null)?.error || '提交审批结果失败')
}
await loadRequest()
if (mounted.current) await loadRequest()
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '提交审批结果失败')
if (mounted.current) {
setError(submitError instanceof Error && submitError.name === 'AbortError'
? '请求未返回结果,请刷新状态确认处理进度,不要重复批准。'
: submitError instanceof Error ? submitError.message : '提交审批结果失败')
await loadRequest({ keepError: true })
}
} finally {
setBusyDecision(null)
if (mounted.current) setBusyDecision(null)
}
}
@ -247,7 +304,7 @@ export default function TaskDetailChangeRequestPage() {
<div className="mt-2 flex flex-wrap items-center gap-2">
<h1 className="text-[28px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批详情</h1>
{!loading && record && (
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
<span role="status" aria-live="polite" className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
{statusMeta.label}
</span>
)}
@ -257,8 +314,8 @@ export default function TaskDetailChangeRequestPage() {
</p>
</header>
{error && (
<div className="mb-4 rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
{error && !record?.applyError && (
<div role="alert" className="mb-4 break-words rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
{error}
</div>
)}
@ -301,7 +358,7 @@ export default function TaskDetailChangeRequestPage() {
</dl>
</section>
<aside className="rounded-xl border border-[#e7e8ea] bg-white p-5">
<aside className="min-w-0 rounded-xl border border-[#e7e8ea] bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">处理状态</h2>
<dl className="mt-4 space-y-4">
<div>
@ -324,6 +381,23 @@ export default function TaskDetailChangeRequestPage() {
)}
</dl>
{(record.status === 'applying' || busyDecision && record.status === 'pending_approval') && (
<p role="status" className="mt-4 text-[13px] leading-6 text-[#315f91]">
{record.status === 'applying'
? '主管已批准,正在应用图片变更。可刷新状态查看处理结果。'
: '正在提交审批并处理图片变更,请勿重复操作。'}
</p>
)}
{record.status === 'apply_failed' && (
<div role="alert" className="mt-4 break-words rounded-lg border border-[#f5c2c0] bg-[#fff5f4] p-3 text-[13px] leading-6 text-[#b42318]">
<p>{record.applyError || '图片变更未能完成应用。'}</p>
<p className="mt-1">请提交人重新选择图片并提交新的申请,本单不能重复批准。</p>
</div>
)}
<button type="button" onClick={() => void loadRequest()} className="mt-4 min-h-11 rounded-lg border border-[#e7e8ea] px-3 text-[13px] font-medium hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2">
刷新状态
</button>
{record.canDecide && record.status === 'pending_approval' && (
<div className="mt-5 flex flex-col gap-2 sm:flex-row">
<button
@ -363,7 +437,7 @@ export default function TaskDetailChangeRequestPage() {
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
<p className="break-all text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
<p className="mt-1 text-[12px] text-[#86898e]">
{op.kind === 'append'
? '追加图片会在主管通过后追加到分组末尾'

View File

@ -389,6 +389,112 @@ describe('task-detail asset edit batch data-plane', () => {
])
})
test('主管批准删除和原接缝插图的六项组合时整体应用一次,允许复用已删除源文件名', async () => {
const originals = await Promise.all(Array.from({ length: 6 }, (_, i) => createSolidImage(`original-${i}.png`, 80, 120)))
const uploads = await Promise.all(Array.from({ length: 3 }, (_, i) => createSolidImage(`incoming-${i}.png`, 70, 90)))
const names = originals.map((_, i) => `1_0${i + 1}.png`)
configureAssetQueries({
previewRows: originals.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue(names.map(name => ({ name })))
mockExtractZipEntries.mockResolvedValue(originals.map((source, i) => ({ entryName: names[i], filePath: source.filePath, size: source.size })))
const result = await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-combined',
operations: uploads.flatMap((source, i) => [
{ kind: 'delete' as const, assetId: `row-${i * 2 + 1}` },
{ kind: 'insert' as const, afterAssetId: `row-${i * 2}`, beforeAssetId: `row-${i * 2 + 1}`,
stagedFile: { trusted: true as const, filePath: source.filePath, fileSize: source.size, fileName: names[i * 2 + 1] } },
]),
})
expect(result).toMatchObject({ success: true, appliedCount: 6, entryCount: 6 })
expect(mockCreateZipFromFiles).toHaveBeenCalledTimes(1)
expect(mockCreateZipFromZips).toHaveBeenCalledTimes(1)
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([
originals[0].filePath, uploads[0].filePath, originals[2].filePath, uploads[1].filePath, originals[4].filePath, uploads[2].filePath,
])
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['row-1', 'row-3', 'row-5'] } } })
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'request-combined', materialId: 'material-1', status: 'applying' },
data: expect.objectContaining({ status: 'applied' }),
}))
})
test.each([{ deleteFirst: true }, { deleteFirst: false }])('长图删除与两个接缝插图保留相同顺序,删除在前=$deleteFirst', async ({ deleteFirst }) => {
const long = await createSolidImage('deleted-long.png', 1000, 4000)
const previews = await Promise.all([1500, 1500, 1000].map((height, i) => createSolidImage(`long-00${i + 1}.webp`, 1000, height, 'webp')))
const tail = await createSolidImage('tail.png', 80, 120)
const inserted = await Promise.all([0, 1].map(i => createSolidImage(`new-${i}.png`, 80, 90)))
configureAssetQueries({
previewRows: [
...previews.map((source, i) => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
{ id: 'tail', assetType: 'detail-preview', fileUrl: tail.filePath, fileName: 'tail-preview.webp', sortOrder: 3 },
],
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }, { name: 'tail.png' }])
mockExtractZipEntries.mockResolvedValue([
{ entryName: 'long.png', filePath: long.filePath, size: long.size },
{ entryName: 'tail.png', filePath: tail.filePath, size: tail.size },
])
const remove = { kind: 'delete' as const, assetId: 'slice-1' }
const inserts = inserted.map((source, i) => ({
kind: 'insert' as const, afterAssetId: `slice-${i}`, beforeAssetId: `slice-${i + 1}`,
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `new-${i}.png`, fileSize: source.size },
}))
const result = await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: deleteFirst ? [remove, ...inserts] : [...inserts, remove],
})
expect(result.entryCount).toBe(3)
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserted[0].filePath, inserted[1].filePath, tail.filePath])
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['slice-0', 'slice-1', 'slice-2'] } } })
expect(mockUploadPreview).toHaveBeenCalledTimes(2)
})
test('接缝两侧原图都删除时,插入仍保留在原位置且按选择顺序排列', async () => {
const sources = await Promise.all([0, 1, 2].map(i => createSolidImage(`source-${i}.png`, 80, 90)))
const inserts = await Promise.all([0, 1].map(i => createSolidImage(`insert-${i}.png`, 70, 80)))
configureAssetQueries({
previewRows: sources.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue(sources.map((_, i) => ({ name: `1_0${i + 1}.png` })))
mockExtractZipEntries.mockResolvedValue(sources.map((source, i) => ({ entryName: `1_0${i + 1}.png`, filePath: source.filePath, size: source.size })))
await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: [
{ kind: 'delete', assetId: 'row-0' }, { kind: 'delete', assetId: 'row-1' },
...inserts.map((source, i) => ({ kind: 'insert' as const, afterAssetId: 'row-0', beforeAssetId: 'row-1',
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `insert-${i}.png`, fileSize: source.size } })),
],
})
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserts[0].filePath, inserts[1].filePath, sources[2].filePath])
})
test.each(['replace', 'rename'] as const)('同一源长图的不同切片不能同时删除和%s,必须在生成图片前拒绝', async (kind) => {
const source = await createSolidImage('conflicting-long.png', 80, 3000)
const replacement = await createSolidImage('replacement.png', 80, 90)
configureAssetQueries({
previewRows: [0, 1].map(i => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }])
mockExtractZipEntries.mockResolvedValue([{ entryName: 'long.png', filePath: source.filePath, size: source.size }])
await expect(batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: [
{ kind: 'delete', assetId: 'slice-1' },
kind === 'rename' ? { kind, assetId: 'slice-0', newFileName: 'renamed.png' }
: { kind, assetId: 'slice-0', stagedFile: { trusted: true, filePath: replacement.filePath, fileName: 'replacement.png', fileSize: replacement.size } },
],
})).rejects.toThrow('同一张源图不能同时删除并替换或改名')
expect(mockUploadPreview).not.toHaveBeenCalled()
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('详情同 run 静态长图插图会拆成上半/插入/下半并重建 preview', async () => {
const longSource = await createSolidImage('long-source.png', 1000, 4000)
const preview1 = await createSolidImage('long-001.webp', 1000, 1500, 'webp')

View File

@ -3,7 +3,7 @@
import { prisma } from '@/lib/prisma'
import { getEmployeeByUserId } from '@/lib/employee-directory'
import { canManageMaterial } from '@/lib/material-authorization'
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
import {
cleanupTaskDetailChangeStaging,
resolveTaskDetailStagedFile,
@ -46,7 +46,12 @@ jest.mock('@/lib/department-permission', () => ({
jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) }))
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.example.com' }))
jest.mock('@/lib/safe-redirect', () => ({ buildDingtalkEntryPath: (path: string) => path }))
jest.mock('@/lib/task-detail-asset-edit', () => ({ batchEditTaskDetailImages: jest.fn() }))
jest.mock('@/lib/task-detail-asset-edit', () => ({
batchEditTaskDetailImages: jest.fn(),
TaskDetailAssetEditError: class extends Error {
constructor(message: string, public statusCode = 400) { super(message) }
},
}))
jest.mock('@/lib/task-detail-change-notifications', () => ({
queueTaskDetailChangeNotification: jest.fn(),
}))
@ -344,6 +349,23 @@ describe('任务详情图片变更主管审批', () => {
}))
})
test('应用业务错误保存失败原因并透传状态码,不伪装成成功', async () => {
const pending = {
id: 'request-failed', materialId: 'material-1', requesterUserId: 'requester-user', requesterName: '申请人',
managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000),
baseMaterialUpdatedAt: material.updatedAt, operations: [{ kind: 'delete', assetId: 'asset-1' }], material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValueOnce(pending).mockResolvedValueOnce({ ...pending, status: 'apply_failed' })
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt })
;(batchEditTaskDetailImages as jest.Mock).mockRejectedValueOnce(new TaskDetailAssetEditError('插图位置已失效', 409))
await expect(decideTaskDetailChange('request-failed', { id: 'manager-user', role: 'USER' }, 'approve')).rejects.toMatchObject({ message: '插图位置已失效', statusCode: 409 })
expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith({ where: { id: 'request-failed' }, data: { status: 'apply_failed', applyError: '插图位置已失效' } })
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.apply_failed' }))
})
test('超过 24 小时未处理的申请自动否决并清理暂存', async () => {
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-expired' }])
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })

View File

@ -1663,6 +1663,8 @@ type BatchRowState = ExistingBatchRowState | NewBatchRowState
interface BatchRunState {
entry: { path: string; name: string; size: number }
rows: BatchRowState[]
/** 保留原始源图身份;接缝拆分出的两半仍属于同一张待删除源图。 */
sourceRunIndex?: number
}
type PreparedBatchMutation =
@ -1930,7 +1932,8 @@ async function executeBatchRebuild(
)
: []
const finalRuns: BatchRunState[] = context.runs.map((run, index) => ({
let finalRuns: BatchRunState[] = context.runs.map((run, index) => ({
sourceRunIndex: index,
entry: {
path: extracted[index]?.filePath || '',
name: entryNames[index],
@ -1941,6 +1944,29 @@ async function executeBatchRebuild(
const handledAssetIds = new Set<string>()
const repeatedInsertBoundaries = new Map<string, BatchRunState>()
const deletedAssetIds = new Set(operations
.filter((operation) => operation.mutation.kind === 'delete')
.map((operation) => operation.assetId))
const deletedSourceRuns = new Set(context.runs.flatMap((run, index) => (
run.rows.some((row) => deletedAssetIds.has(row.id)) ? [index] : []
)))
const isDeletedSourceRun = (run: BatchRunState) => (
run.sourceRunIndex !== undefined && deletedSourceRuns.has(run.sourceRunIndex)
)
const survivingEntryNames = () => finalRuns.filter((run) => !isDeletedSourceRun(run)).map((run) => run.entry.name)
// 所有接缝都来自用户提交时看到的原始图片序列。先验证,删除仅在最终输出时生效,
// 保留定位节点,使“删后图 + 在其前方插图”等组合不依赖操作排列顺序。
for (const operation of operations) {
if (operation.mutation.kind === 'insert') {
resolveInsertSeam(finalRuns, operation.mutation.afterAssetId, operation.mutation.beforeAssetId)
} else if (operation.assetId && operation.mutation.kind !== 'delete') {
const sourceIndex = context.runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId))
if (deletedSourceRuns.has(sourceIndex)) {
throw new TaskDetailAssetEditError('同一张源图不能同时删除并替换或改名,请只保留一种操作', 409)
}
}
}
for (const operation of operations) {
if (operation.assetId) {
@ -1950,13 +1976,15 @@ async function executeBatchRebuild(
handledAssetIds.add(operation.assetId)
}
if (operation.mutation.kind === 'delete') continue
if (operation.mutation.kind === 'append') {
if (group !== operation.mutation.group) {
throw new TaskDetailAssetEditError('追加图片只支持当前分组,请刷新后重试')
}
const entryName = ensureUniqueEntryName(
operation.mutation.source.fileName,
finalRuns.map((run) => run.entry.name),
survivingEntryNames(),
'追加图片的文件名与包内其他文件重名,请先改名后再追加',
)
finalRuns.push(await buildNewBatchRunState({
@ -1980,7 +2008,7 @@ async function executeBatchRebuild(
}
const seamKey = `${operation.mutation.afterAssetId}\u0000${operation.mutation.beforeAssetId}`
const existingEntryNames = finalRuns.map((run) => run.entry.name)
const existingEntryNames = survivingEntryNames()
const insertedEntryName = ensureUniqueEntryName(
operation.mutation.source.fileName,
existingEntryNames,
@ -2031,6 +2059,24 @@ async function executeBatchRebuild(
}
const targetRun = finalRuns[seam.afterRunIndex]
if (isDeletedSourceRun(targetRun)) {
// 整张原图将被删除:只拆分内存中的定位节点,不生成或上传会被丢弃的两半预览。
const topRun = { ...targetRun, rows: targetRun.rows.slice(0, seam.afterRowIndex + 1) }
const bottomRun = { ...targetRun, rows: targetRun.rows.slice(seam.beforeRowIndex) }
const insertedRun = await buildNewBatchRunState({
source: operation.mutation.source,
entryName: insertedEntryName,
group,
materialId: context.materialId,
isPrivate: context.taskDetailPrivate,
entryIndex: seam.afterRunIndex + 1,
workspaceDir: workspace.dir,
generatedPreviewLocations,
})
finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun)
repeatedInsertBoundaries.set(seamKey, bottomRun)
continue
}
const sourceAssetIds = targetRun.rows.map((row) => (
row.kind === 'existing' ? row.id : row.sourceAssetId || null
))
@ -2087,6 +2133,8 @@ async function executeBatchRebuild(
generatedPreviewLocations,
sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[],
})
topRun.sourceRunIndex = targetRun.sourceRunIndex
bottomRun.sourceRunIndex = targetRun.sourceRunIndex
finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun)
repeatedInsertBoundaries.set(seamKey, bottomRun)
continue
@ -2102,16 +2150,11 @@ async function executeBatchRebuild(
}
const run = finalRuns[located.runIndex]
if (operation.mutation.kind === 'delete') {
finalRuns.splice(located.runIndex, 1)
continue
}
if (operation.mutation.kind === 'rename') {
const originalExtension = getExtension(run.entry.name)
const newBaseName = operation.mutation.newBaseName
const finalEntryName = `${newBaseName}${originalExtension}`
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName)
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && !isDeletedSourceRun(candidate) && candidate.entry.name === finalEntryName)
if (conflict) {
throw new TaskDetailAssetEditError(`改名后的「${finalEntryName}」与包内其他文件重名,请换一个名字`, 409)
}
@ -2125,7 +2168,7 @@ async function executeBatchRebuild(
}
const finalEntryName = buildFinalEntryName(run.entry.name, operation.mutation.source.fileName)
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName)
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && !isDeletedSourceRun(candidate) && candidate.entry.name === finalEntryName)
if (conflict) {
throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409)
}
@ -2148,8 +2191,10 @@ async function executeBatchRebuild(
skuCode: run.rows[0]?.skuCode || null,
barcodes,
})
finalRuns[located.runIndex].sourceRunIndex = run.sourceRunIndex
}
finalRuns = finalRuns.filter((run) => !isDeletedSourceRun(run))
assertFinalBatchLimits(context, group, finalRuns)
const { db, finalRowUrls } = buildBatchDbChanges(context, group, finalRuns)

View File

@ -17,7 +17,7 @@ import {
type TaskDetailStagedFile,
} from '@/lib/task-detail-change-staging'
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
const REQUEST_STATUS = {
PENDING: 'pending_manager_approval',
@ -636,6 +636,9 @@ export async function decideTaskDetailChange(
if (final) {
await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined)
}
if (error instanceof TaskDetailAssetEditError) {
throw new TaskDetailChangeApprovalError(message, error.statusCode)
}
throw error
}
}