fix: 降低任务详情选图内存峰值
This commit is contained in:
parent
3495530f3d
commit
62295a0230
@ -7,17 +7,51 @@ const images = [
|
|||||||
id: 'detail-1',
|
id: 'detail-1',
|
||||||
file: new File(['first'], '1.jpg', { type: 'image/jpeg' }),
|
file: new File(['first'], '1.jpg', { type: 'image/jpeg' }),
|
||||||
preview: 'blob:first',
|
preview: 'blob:first',
|
||||||
|
previewStatus: 'ready' as const,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'detail-2',
|
id: 'detail-2',
|
||||||
file: new File(['second'], '2.jpg', { type: 'image/jpeg' }),
|
file: new File(['second'], '2.jpg', { type: 'image/jpeg' }),
|
||||||
preview: 'blob:second',
|
preview: 'blob:second',
|
||||||
|
previewStatus: 'ready' as const,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('DetailImageSorter', () => {
|
describe('DetailImageSorter', () => {
|
||||||
|
test('加载中和不可用详情图只显示占位,不渲染空 src', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DetailImageSorter
|
||||||
|
images={[
|
||||||
|
{
|
||||||
|
id: 'loading',
|
||||||
|
file: new File(['loading'], 'loading.jpg', { type: 'image/jpeg' }),
|
||||||
|
preview: '',
|
||||||
|
previewStatus: 'loading',
|
||||||
|
previewMessage: '正在生成缩略图',
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unavailable',
|
||||||
|
file: new File(['unavailable'], 'unavailable.jpg', { type: 'image/jpeg' }),
|
||||||
|
preview: '',
|
||||||
|
previewStatus: 'unavailable',
|
||||||
|
previewMessage: '浏览器无法生成缩略图',
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={jest.fn()}
|
||||||
|
onRemove={jest.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('img', { name: 'loading.jpg:正在生成缩略图' })).toHaveLength(2)
|
||||||
|
expect(screen.getAllByRole('img', { name: 'unavailable.jpg:预览不可用' })).toHaveLength(2)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
expect(container.querySelector('img[src=""]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('外部文件拖到已有详情图上时应继续交给外层上传区域处理', () => {
|
test('外部文件拖到已有详情图上时应继续交给外层上传区域处理', () => {
|
||||||
const onOuterDrop = jest.fn()
|
const onOuterDrop = jest.fn()
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@ interface DetailImage {
|
|||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
previewStatus: 'loading' | 'ready' | 'unavailable';
|
||||||
|
previewMessage?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,6 +24,47 @@ interface DetailImageSorterProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailImagePreview({
|
||||||
|
image,
|
||||||
|
alt,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
image: DetailImage;
|
||||||
|
alt: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const status = image.previewStatus;
|
||||||
|
if (status === 'ready' && image.preview) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={image.preview}
|
||||||
|
alt={alt}
|
||||||
|
className={compact ? 'h-32 w-full object-contain' : 'block w-full'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = status === 'loading' ? '正在生成缩略图' : '预览不可用';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={`${image.file.name}:${statusLabel}`}
|
||||||
|
className={`flex w-full min-w-0 flex-col items-center justify-center gap-1 bg-gray-100 px-2 text-center dark:bg-gray-700/70 ${compact ? 'h-32' : 'h-36'}`}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M14 3v6h6M8 15l2-2 2 2 2-3 2 3" />
|
||||||
|
</svg>
|
||||||
|
<span className="w-full truncate text-xs font-medium text-gray-600 dark:text-gray-300" title={image.file.name}>
|
||||||
|
{image.file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
|
{image.previewMessage || statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DetailImageSorter({
|
export default function DetailImageSorter({
|
||||||
images,
|
images,
|
||||||
onChange,
|
onChange,
|
||||||
@ -120,9 +163,9 @@ export default function DetailImageSorter({
|
|||||||
const scrollToImage = (index: number) => {
|
const scrollToImage = (index: number) => {
|
||||||
if (!previewRef.current) return;
|
if (!previewRef.current) return;
|
||||||
|
|
||||||
const previewImages = previewRef.current.querySelectorAll('img');
|
const previewItem = previewRef.current.querySelector<HTMLElement>(`[data-detail-preview-index="${index}"]`);
|
||||||
if (previewImages[index]) {
|
if (previewItem) {
|
||||||
previewImages[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
previewItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -156,13 +199,8 @@ export default function DetailImageSorter({
|
|||||||
className="max-h-[600px] overflow-y-auto"
|
className="max-h-[600px] overflow-y-auto"
|
||||||
>
|
>
|
||||||
{sortedImages.map((img, index) => (
|
{sortedImages.map((img, index) => (
|
||||||
<div key={img.id} className="relative">
|
<div key={img.id} data-detail-preview-index={index} className="relative overflow-hidden rounded bg-white dark:bg-gray-800">
|
||||||
<img
|
<DetailImagePreview image={img} alt={`详情图 ${index + 1}`} />
|
||||||
src={img.preview}
|
|
||||||
alt={`详情图 ${index + 1}`}
|
|
||||||
className="w-full block"
|
|
||||||
style={{ display: 'block' }}
|
|
||||||
/>
|
|
||||||
<div className="absolute top-2 left-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute top-2 left-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
@ -194,11 +232,7 @@ export default function DetailImageSorter({
|
|||||||
hover:border-blue-300 dark:hover:border-blue-500
|
hover:border-blue-300 dark:hover:border-blue-500
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<img
|
<DetailImagePreview image={img} alt={`缩略图 ${index + 1}`} compact />
|
||||||
src={img.preview}
|
|
||||||
alt={`缩略图 ${index + 1}`}
|
|
||||||
className="w-full h-32 object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 序号 */}
|
{/* 序号 */}
|
||||||
<div className="absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
<div className="absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
|||||||
|
|
||||||
const mockChunkUpload = jest.fn()
|
const mockChunkUpload = jest.fn()
|
||||||
const mockPrepareTaskDetailImagePreview = jest.fn()
|
const mockPrepareTaskDetailImagePreview = jest.fn()
|
||||||
|
const mockPrepareTaskDetailSelectionThumbnail = jest.fn()
|
||||||
const mockPollTaskDetailUploadJob = jest.fn()
|
const mockPollTaskDetailUploadJob = jest.fn()
|
||||||
|
|
||||||
jest.mock('@/lib/chunk-upload', () => ({
|
jest.mock('@/lib/chunk-upload', () => ({
|
||||||
@ -17,6 +18,7 @@ jest.mock('@/lib/chunk-upload', () => ({
|
|||||||
|
|
||||||
jest.mock('@/lib/task-detail-image-browser', () => ({
|
jest.mock('@/lib/task-detail-image-browser', () => ({
|
||||||
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
|
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
|
||||||
|
prepareTaskDetailSelectionThumbnail: (...args: unknown[]) => mockPrepareTaskDetailSelectionThumbnail(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('@/lib/task-detail-upload-status', () => ({
|
jest.mock('@/lib/task-detail-upload-status', () => ({
|
||||||
@ -99,6 +101,20 @@ const capabilityError = () => new PerformanceSurveyBrowserImageError(
|
|||||||
'当前 Edge 环境无法解码这张合法图片',
|
'当前 Edge 环境无法解码这张合法图片',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function thumbnailResult(sourceName: string) {
|
||||||
|
return {
|
||||||
|
file: new File(['thumbnail'], `${sourceName}-thumbnail.webp`, { type: 'image/webp' }),
|
||||||
|
mimeType: 'image/webp' as const,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
kind: 'preview' as const,
|
||||||
|
sourceName,
|
||||||
|
sourceIndex: 0,
|
||||||
|
sliceIndex: 0,
|
||||||
|
sliceCount: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function installUploadFlowFetch(completeResponse?: Promise<unknown>) {
|
function installUploadFlowFetch(completeResponse?: Promise<unknown>) {
|
||||||
const fetchMock = jest.fn((input: RequestInfo | URL) => {
|
const fetchMock = jest.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input)
|
const url = String(input)
|
||||||
@ -124,6 +140,9 @@ describe('ProductPackageUploader', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
|
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail.mockImplementation((file: File) => (
|
||||||
|
Promise.resolve(thumbnailResult(file.name))
|
||||||
|
))
|
||||||
mockPollTaskDetailUploadJob.mockResolvedValue({
|
mockPollTaskDetailUploadJob.mockResolvedValue({
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
@ -140,10 +159,11 @@ describe('ProductPackageUploader', () => {
|
|||||||
],
|
],
|
||||||
}) as jest.Mock
|
}) as jest.Mock
|
||||||
|
|
||||||
|
let objectUrlIndex = 0
|
||||||
Object.defineProperty(URL, 'createObjectURL', {
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
value: jest.fn(() => 'blob:mock'),
|
value: jest.fn(() => `blob:mock-${++objectUrlIndex}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
@ -377,7 +397,15 @@ describe('ProductPackageUploader', () => {
|
|||||||
fireEvent.change(input, { target: { files: [second, third] } })
|
fireEvent.change(input, { target: { files: [second, third] } })
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||||||
|
expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockPrepareTaskDetailSelectionThumbnail.mock.calls.map(([file]) => (file as File).name))
|
||||||
|
.toEqual(['first.jpg', 'second.jpg'])
|
||||||
|
expect((URL.createObjectURL as jest.Mock).mock.calls.every(([file]) => (
|
||||||
|
file instanceof File && file.type === 'image/webp'
|
||||||
|
))).toBe(true)
|
||||||
|
expect((URL.createObjectURL as jest.Mock).mock.calls.some(([file]) => file === first || file === second || file === third))
|
||||||
|
.toBe(false)
|
||||||
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
|
||||||
@ -389,6 +417,187 @@ describe('ProductPackageUploader', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('多批选图共用单一缩略图队列,不会并行解码原图', async () => {
|
||||||
|
const firstThumbnail = createDeferred<ReturnType<typeof thumbnailResult>>()
|
||||||
|
const secondThumbnail = createDeferred<ReturnType<typeof thumbnailResult>>()
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail
|
||||||
|
.mockImplementationOnce(() => firstThumbnail.promise)
|
||||||
|
.mockImplementationOnce(() => secondThumbnail.promise)
|
||||||
|
|
||||||
|
render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
const input = screen.getByLabelText('主图') as HTMLInputElement
|
||||||
|
const first = new File(['first'], 'queue-first.jpg', { type: 'image/jpeg' })
|
||||||
|
const second = new File(['second'], 'queue-second.jpg', { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { files: [first] } })
|
||||||
|
fireEvent.change(input, { target: { files: [second] } })
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(1))
|
||||||
|
expect(screen.getByRole('img', { name: 'queue-first.jpg:正在生成缩略图' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('img', { name: 'queue-second.jpg:正在生成缩略图' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
firstThumbnail.resolve(thumbnailResult(first.name))
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
secondThumbnail.resolve(thumbnailResult(second.name))
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('浏览器能力不足时显示文件占位,不把原始 File 退回 img', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail.mockRejectedValueOnce(capabilityError())
|
||||||
|
const { container } = render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
const source = new File(['source'], 'no-thumbnail.jpg', { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
await user.upload(screen.getByLabelText('主图'), source)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('img', { name: 'no-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('浏览器无法生成缩略图')).toBeInTheDocument()
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('移除正在生成缩略图的文件会立即 abort,且不创建 Blob URL', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let thumbnailSignal: AbortSignal | undefined
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||||||
|
_file: File,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) => new Promise((_, reject) => {
|
||||||
|
thumbnailSignal = options?.signal
|
||||||
|
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('主图'),
|
||||||
|
new File(['source'], 'remove-loading.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '删除主图 remove-loading.jpg' }))
|
||||||
|
|
||||||
|
expect(thumbnailSignal?.aborted).toBe(true)
|
||||||
|
expect(screen.queryByRole('button', { name: '删除主图 remove-loading.jpg' })).not.toBeInTheDocument()
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('组件卸载会 abort 正在生成的缩略图且不遗留 Blob URL', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let thumbnailSignal: AbortSignal | undefined
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||||||
|
_file: File,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) => new Promise((_, reject) => {
|
||||||
|
thumbnailSignal = options?.signal
|
||||||
|
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { unmount } = render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('主图'),
|
||||||
|
new File(['source'], 'unmount-loading.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(thumbnailSignal?.aborted).toBe(true)
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('开始提交会终止选图缩略图队列,再进入正式预览处理', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||||
|
let thumbnailSignal: AbortSignal | undefined
|
||||||
|
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
|
||||||
|
_file: File,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
) => new Promise((_, reject) => {
|
||||||
|
thumbnailSignal = options?.signal
|
||||||
|
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
|
||||||
|
}))
|
||||||
|
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
|
||||||
|
installUploadFlowFetch(Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: { jobId: 'job-after-thumbnail-stop' } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await fillRequiredTaskFields(
|
||||||
|
user,
|
||||||
|
new File(['source'], 'submit-stops-thumbnail.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(thumbnailSignal).toBeDefined())
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
|
||||||
|
expect(thumbnailSignal?.aborted).toBe(true)
|
||||||
|
await waitFor(() => expect(mockPrepareTaskDetailImagePreview).toHaveBeenCalledTimes(1))
|
||||||
|
expect(screen.getByRole('img', { name: 'submit-stops-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('已生成的缩略图在删除和卸载时只回收对应 Blob URL', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const firstRender = render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('主图'),
|
||||||
|
new File(['first'], 'remove-ready.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
|
||||||
|
await user.click(screen.getByRole('button', { name: '删除主图 remove-ready.jpg' }))
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-1')
|
||||||
|
firstRender.unmount()
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const secondRender = render(<ProductPackageUploader {...defaultProps} />)
|
||||||
|
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('主图'),
|
||||||
|
new File(['second'], 'unmount-ready.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
|
||||||
|
secondRender.unmount()
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('上传成功关闭前回收所有选图缩略图 Blob URL', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSuccess = jest.fn()
|
||||||
|
mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([thumbnailResult('success.jpg')])
|
||||||
|
installUploadFlowFetch(Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: { jobId: 'job-success' } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<ProductPackageUploader {...defaultProps} onSuccess={onSuccess} />)
|
||||||
|
await fillRequiredTaskFields(
|
||||||
|
user,
|
||||||
|
new File(['success'], 'success.jpg', { type: 'image/jpeg' }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1), { timeout: 3_000 })
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-1')
|
||||||
|
})
|
||||||
|
|
||||||
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
|
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
} from '@/lib/task-detail-upload-policy';
|
} from '@/lib/task-detail-upload-policy';
|
||||||
import {
|
import {
|
||||||
prepareTaskDetailImagePreview,
|
prepareTaskDetailImagePreview,
|
||||||
|
prepareTaskDetailSelectionThumbnail,
|
||||||
type TaskDetailAssetKind,
|
type TaskDetailAssetKind,
|
||||||
type TaskDetailBrowserPreview,
|
type TaskDetailBrowserPreview,
|
||||||
} from '@/lib/task-detail-image-browser';
|
} from '@/lib/task-detail-image-browser';
|
||||||
@ -46,6 +47,7 @@ const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar
|
|||||||
// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。
|
// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。
|
||||||
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
|
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
|
||||||
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
||||||
|
const TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
function splitEmptyFiles(files: FileList | File[] | null): {
|
function splitEmptyFiles(files: FileList | File[] | null): {
|
||||||
validFiles: File[];
|
validFiles: File[];
|
||||||
@ -94,6 +96,8 @@ interface ProductPackageUploaderProps {
|
|||||||
interface AssetFile {
|
interface AssetFile {
|
||||||
file: File;
|
file: File;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
previewStatus: 'loading' | 'ready' | 'unavailable';
|
||||||
|
previewMessage?: string;
|
||||||
id: string;
|
id: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
skuCode?: string;
|
skuCode?: string;
|
||||||
@ -248,14 +252,49 @@ function parseBarcodeInput(input: string): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeAssetPreviews(groups: Array<AssetFile[]>) {
|
function AssetThumbnail({
|
||||||
const urls = new Set(
|
asset,
|
||||||
groups
|
alt,
|
||||||
.flat()
|
}: {
|
||||||
.map((item) => item.preview)
|
asset: AssetFile;
|
||||||
.filter((preview): preview is string => Boolean(preview) && preview.startsWith('blob:')),
|
alt: string;
|
||||||
|
}) {
|
||||||
|
if (asset.previewStatus === 'ready' && asset.preview) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={asset.preview}
|
||||||
|
alt={alt}
|
||||||
|
className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = asset.previewStatus === 'loading';
|
||||||
|
const statusLabel = isLoading ? '正在生成缩略图' : '预览不可用';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={`${asset.file.name}:${statusLabel}`}
|
||||||
|
className="flex h-full w-full min-w-0 flex-col items-center justify-center gap-1 bg-gray-50 px-2 text-center dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M14 3v6h6M8 15l2-2 2 2 2-3 2 3" />
|
||||||
|
</svg>
|
||||||
|
<span className="w-full truncate text-[11px] font-medium text-gray-600 dark:text-gray-300" title={asset.file.name}>
|
||||||
|
{asset.file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] leading-tight text-gray-400 dark:text-gray-500">
|
||||||
|
{asset.previewMessage || statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
urls.forEach((preview) => URL.revokeObjectURL(preview));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductPackageUploader({
|
export default function ProductPackageUploader({
|
||||||
@ -316,7 +355,12 @@ export default function ProductPackageUploader({
|
|||||||
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
const jobAcceptedRef = useRef(false);
|
const jobAcceptedRef = useRef(false);
|
||||||
const assetGroupsRef = useRef<AssetFile[][]>([]);
|
const thumbnailQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const thumbnailGenerationEpochRef = useRef(0);
|
||||||
|
const thumbnailAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const thumbnailActiveAssetIdRef = useRef<string | null>(null);
|
||||||
|
const thumbnailCancelledAssetIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const thumbnailObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||||
const selectionSnapshotRef = useRef<TaskDetailFileSelectionSnapshot>({
|
const selectionSnapshotRef = useRef<TaskDetailFileSelectionSnapshot>({
|
||||||
main: [],
|
main: [],
|
||||||
sku: [],
|
sku: [],
|
||||||
@ -330,9 +374,116 @@ export default function ProductPackageUploader({
|
|||||||
browserPreviewAbortRef.current = null;
|
browserPreviewAbortRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateAssetPreview = useCallback((
|
||||||
assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles];
|
group: ImageUploadGroup,
|
||||||
}, [mainImages, skuImages, detailImages, psdFiles]);
|
id: string,
|
||||||
|
updates: Pick<AssetFile, 'preview' | 'previewStatus' | 'previewMessage'>,
|
||||||
|
) => {
|
||||||
|
const applyUpdates = (items: AssetFile[]) => items.map((item) => (
|
||||||
|
item.id === id ? { ...item, ...updates } : item
|
||||||
|
));
|
||||||
|
if (group === 'main') setMainImages(applyUpdates);
|
||||||
|
else if (group === 'sku') setSkuImages(applyUpdates);
|
||||||
|
else setDetailImages(applyUpdates);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revokeThumbnailObjectUrl = useCallback((url: string) => {
|
||||||
|
if (!url || !thumbnailObjectUrlsRef.current.delete(url)) return;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revokeAllThumbnailObjectUrls = useCallback(() => {
|
||||||
|
thumbnailObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
thumbnailObjectUrlsRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopThumbnailGeneration = useCallback((markUnavailable: boolean) => {
|
||||||
|
thumbnailGenerationEpochRef.current += 1;
|
||||||
|
thumbnailAbortRef.current?.abort();
|
||||||
|
thumbnailAbortRef.current = null;
|
||||||
|
thumbnailActiveAssetIdRef.current = null;
|
||||||
|
// 旧队列中的任务会因 epoch 不一致而跳过;新一轮选择无需等待旧链收尾。
|
||||||
|
thumbnailQueueRef.current = Promise.resolve();
|
||||||
|
|
||||||
|
if (!markUnavailable || !mountedRef.current) return;
|
||||||
|
const mark = (items: AssetFile[]) => items.map((item) => (
|
||||||
|
item.previewStatus === 'loading'
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
previewStatus: 'unavailable' as const,
|
||||||
|
previewMessage: '上传时重新处理',
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
));
|
||||||
|
setMainImages(mark);
|
||||||
|
setSkuImages(mark);
|
||||||
|
setDetailImages(mark);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueSelectionThumbnails = useCallback((
|
||||||
|
group: ImageUploadGroup,
|
||||||
|
assets: AssetFile[],
|
||||||
|
) => {
|
||||||
|
const epoch = thumbnailGenerationEpochRef.current;
|
||||||
|
assets.forEach((asset) => {
|
||||||
|
thumbnailCancelledAssetIdsRef.current.delete(asset.id);
|
||||||
|
thumbnailQueueRef.current = thumbnailQueueRef.current
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
if (
|
||||||
|
!mountedRef.current
|
||||||
|
|| epoch !== thumbnailGenerationEpochRef.current
|
||||||
|
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
thumbnailAbortRef.current = controller;
|
||||||
|
thumbnailActiveAssetIdRef.current = asset.id;
|
||||||
|
try {
|
||||||
|
const thumbnail = await prepareTaskDetailSelectionThumbnail(asset.file, {
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!mountedRef.current
|
||||||
|
|| epoch !== thumbnailGenerationEpochRef.current
|
||||||
|
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = URL.createObjectURL(thumbnail.file);
|
||||||
|
thumbnailObjectUrlsRef.current.add(previewUrl);
|
||||||
|
updateAssetPreview(group, asset.id, {
|
||||||
|
preview: previewUrl,
|
||||||
|
previewStatus: 'ready',
|
||||||
|
previewMessage: undefined,
|
||||||
|
});
|
||||||
|
} catch (thumbnailError) {
|
||||||
|
if (
|
||||||
|
controller.signal.aborted
|
||||||
|
|| !mountedRef.current
|
||||||
|
|| epoch !== thumbnailGenerationEpochRef.current
|
||||||
|
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateAssetPreview(group, asset.id, {
|
||||||
|
preview: '',
|
||||||
|
previewStatus: 'unavailable',
|
||||||
|
previewMessage: isPerformanceSurveyBrowserCapabilityError(thumbnailError)
|
||||||
|
? '浏览器无法生成缩略图'
|
||||||
|
: '缩略图不可用,提交时校验',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (thumbnailAbortRef.current === controller) thumbnailAbortRef.current = null;
|
||||||
|
if (thumbnailActiveAssetIdRef.current === asset.id) thumbnailActiveAssetIdRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [updateAssetPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。
|
// React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。
|
||||||
@ -347,12 +498,13 @@ export default function ProductPackageUploader({
|
|||||||
activeXhrsRef.current.forEach((xhr) => xhr.abort());
|
activeXhrsRef.current.forEach((xhr) => xhr.abort());
|
||||||
activeXhrsRef.current.clear();
|
activeXhrsRef.current.clear();
|
||||||
activeXhrSets.clear();
|
activeXhrSets.clear();
|
||||||
|
stopThumbnailGeneration(false);
|
||||||
abortActiveBrowserPreview();
|
abortActiveBrowserPreview();
|
||||||
jobPollAbortRef.current?.abort();
|
jobPollAbortRef.current?.abort();
|
||||||
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
|
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
|
||||||
revokeAssetPreviews(assetGroupsRef.current);
|
revokeAllThumbnailObjectUrls();
|
||||||
};
|
};
|
||||||
}, [abortActiveBrowserPreview]);
|
}, [abortActiveBrowserPreview, revokeAllThumbnailObjectUrls, stopThumbnailGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@ -449,7 +601,10 @@ export default function ProductPackageUploader({
|
|||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
const preparedFiles: AssetFile[] = files.map((file) => ({
|
const preparedFiles: AssetFile[] = files.map((file) => ({
|
||||||
file,
|
file,
|
||||||
preview: group === 'psd' ? '' : URL.createObjectURL(file),
|
// 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。
|
||||||
|
preview: '',
|
||||||
|
previewStatus: group === 'psd' ? 'unavailable' : 'loading',
|
||||||
|
previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图',
|
||||||
id: Math.random().toString(36).substring(2, 11),
|
id: Math.random().toString(36).substring(2, 11),
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
}));
|
}));
|
||||||
@ -466,6 +621,8 @@ export default function ProductPackageUploader({
|
|||||||
else if (group === 'sku') setSkuImages(append);
|
else if (group === 'sku') setSkuImages(append);
|
||||||
else if (group === 'detail') setDetailImages(append);
|
else if (group === 'detail') setDetailImages(append);
|
||||||
else setPsdFiles(append);
|
else setPsdFiles(append);
|
||||||
|
|
||||||
|
if (group !== 'psd') enqueueSelectionThumbnails(group, preparedFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectionFeedback = (
|
const setSelectionFeedback = (
|
||||||
@ -556,7 +713,9 @@ export default function ProductPackageUploader({
|
|||||||
const removed = assets.find((item) => item.id === id);
|
const removed = assets.find((item) => item.id === id);
|
||||||
if (!removed) return;
|
if (!removed) return;
|
||||||
|
|
||||||
if (removed.preview) URL.revokeObjectURL(removed.preview);
|
thumbnailCancelledAssetIdsRef.current.add(id);
|
||||||
|
if (thumbnailActiveAssetIdRef.current === id) thumbnailAbortRef.current?.abort();
|
||||||
|
if (removed.preview) revokeThumbnailObjectUrl(removed.preview);
|
||||||
selectionSnapshotRef.current = {
|
selectionSnapshotRef.current = {
|
||||||
...selectionSnapshotRef.current,
|
...selectionSnapshotRef.current,
|
||||||
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
|
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
|
||||||
@ -774,6 +933,9 @@ export default function ProductPackageUploader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提交链会重新生成正式预览。先终止非必要的选图缩略图,避免两条 Worker
|
||||||
|
// 链同时解码大图;尚未完成的缩略图改为低干扰占位。
|
||||||
|
stopThumbnailGeneration(true);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
progressRef.current = 0;
|
progressRef.current = 0;
|
||||||
@ -1056,10 +1218,10 @@ export default function ProductPackageUploader({
|
|||||||
setProgress(100);
|
setProgress(100);
|
||||||
setProcessingStep('完成!');
|
setProcessingStep('完成!');
|
||||||
completed = true;
|
completed = true;
|
||||||
revokeAssetPreviews([mainImages, skuImages, detailImages, psdFiles]);
|
|
||||||
completionTimerRef.current = setTimeout(() => {
|
completionTimerRef.current = setTimeout(() => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setProcessingStep('');
|
setProcessingStep('');
|
||||||
|
revokeAllThumbnailObjectUrls();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@ -1242,7 +1404,7 @@ export default function ProductPackageUploader({
|
|||||||
{mainImages.map((img) => (
|
{mainImages.map((img) => (
|
||||||
<div key={img.id} className="relative group">
|
<div key={img.id} className="relative group">
|
||||||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||||
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
|
<AssetThumbnail asset={img} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveFile(img.id, 'main')}
|
onClick={() => handleRemoveFile(img.id, 'main')}
|
||||||
@ -1323,7 +1485,7 @@ export default function ProductPackageUploader({
|
|||||||
{skuImages.map((img) => (
|
{skuImages.map((img) => (
|
||||||
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
|
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
|
||||||
<div className="relative w-full h-20 rounded-apple overflow-hidden border border-gray-200 dark:border-gray-600">
|
<div className="relative w-full h-20 rounded-apple overflow-hidden border border-gray-200 dark:border-gray-600">
|
||||||
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
|
<AssetThumbnail asset={img} alt="" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveFile(img.id, 'sku')}
|
onClick={() => handleRemoveFile(img.id, 'sku')}
|
||||||
|
|||||||
@ -12,10 +12,28 @@
|
|||||||
1. 关闭匿名网络测速 POST,避免无上限读取请求正文。
|
1. 关闭匿名网络测速 POST,避免无上限读取请求正文。
|
||||||
2. 收紧媒体代理,阻止通过它访问服务器内网、回环地址、任意端口或恶意跳转地址。
|
2. 收紧媒体代理,阻止通过它访问服务器内网、回环地址、任意端口或恶意跳转地址。
|
||||||
|
|
||||||
这两项已提交并推送到 `codex/audit-remediation-20260801`,提交为 `6b909fa`;尚未合并 `main` 或部署生产。
|
这两项已提交并推送到 `codex/audit-remediation-20260801`,提交为 `6b909fa`;代码批次 2 已随 `3495530` 提交并推送。2026-08-03 又补充完成“选图时不再让浏览器直接预览原始大图”的修复,目前仍在候选分支验收,尚未合并 `main` 或部署生产。
|
||||||
|
|
||||||
## 二、生产现场阻断项
|
## 二、生产现场阻断项
|
||||||
|
|
||||||
|
### P0-R0:TrueNAS 宿主机可用内存过低,当前禁止部署
|
||||||
|
|
||||||
|
状态:**生产服务正常,但部署门禁不通过**
|
||||||
|
|
||||||
|
2026-08-03 只读核查结果:
|
||||||
|
|
||||||
|
- 正式应用仍运行在 `main@776c6bafce9a`,容器未发生 OOM,重启次数为 0,应用自身当前只占约 170MiB,健康接口、数据库和 Redis 均正常。
|
||||||
|
- 宿主机总内存约 15.5GiB、没有 Swap,可用内存仅约 1.4GiB。
|
||||||
|
- 主要占用不是素材库应用,而是 TrueNAS 的 `middlewared.service`,当前约 8.8GB,历史峰值约 9.4GB。
|
||||||
|
- 蓝绿部署会短暂同时运行新旧两个应用容器;如果此时又发生图片或视频处理,存在再次拖垮整台主机的风险。
|
||||||
|
|
||||||
|
安全边界:
|
||||||
|
|
||||||
|
1. 当前不重启、不终止 `middlewared`,不添加 Swap,也不重启 TrueNAS;这些属于 NAS 级高风险操作,需要单独维护窗口。
|
||||||
|
2. 发布前建议宿主机 `MemAvailable` 至少恢复到 4GiB,并确认没有正在执行的 `ffmpeg`/图片计算任务。
|
||||||
|
3. 发布过程中若可用内存低于 2GiB、出现新 OOM/连续 5xx,或应用内存异常上升,应立即停止并回滚。
|
||||||
|
4. 图片和分片压力测试只能在隔离测试环境进行,禁止直接压测生产 NAS。
|
||||||
|
|
||||||
### P0-R1:旧影子服务仍暴露旧漏洞
|
### P0-R1:旧影子服务仍暴露旧漏洞
|
||||||
|
|
||||||
状态:**运行入口已收口;历史凭据核查待完成**
|
状态:**运行入口已收口;历史凭据核查待完成**
|
||||||
@ -106,7 +124,7 @@
|
|||||||
|
|
||||||
## 四、其余未完成问题
|
## 四、其余未完成问题
|
||||||
|
|
||||||
### 代码批次 2(2026-08-02 已完成本地修复,待提交和部署)
|
### 代码批次 2(2026-08-02 已提交并推送,待合并和部署)
|
||||||
|
|
||||||
本批原来的 5 项已完成代码修复:
|
本批原来的 5 项已完成代码修复:
|
||||||
|
|
||||||
@ -128,6 +146,27 @@
|
|||||||
|
|
||||||
尚未验证:真实 Chrome/Edge 大图、断网/取消、线上 S3 上传,以及部署后的生产容器内存峰值和真实页面验收。这些必须在部署窗口完成后才能把本批标记为“生产已关闭”。
|
尚未验证:真实 Chrome/Edge 大图、断网/取消、线上 S3 上传,以及部署后的生产容器内存峰值和真实页面验收。这些必须在部署窗口完成后才能把本批标记为“生产已关闭”。
|
||||||
|
|
||||||
|
### 代码批次 2.1:选图预览内存峰值(2026-08-03 已完成代码修复,待隔离浏览器验收)
|
||||||
|
|
||||||
|
用户反馈使用 Edge 选择图片时仍可能失败。复核发现,虽然正式上传处理已经转移到浏览器 Worker(后台线程),但选图界面此前仍把每张原始 `File` 直接作为 `<img>` 预览。压缩文件本身可能只有几 MB,解码后的 60MP 图片却可能占用约 229MiB;同时选择多张时,Edge 可能同时解码多张原图,造成页面内存骤升甚至崩溃。
|
||||||
|
|
||||||
|
本次处理:
|
||||||
|
|
||||||
|
1. 原始图片不再直接进入页面 `<img>`。
|
||||||
|
2. 使用单通道 Worker 严格一次只处理一张,生成最大 320×320 的 WebP 小缩略图后再展示。
|
||||||
|
3. 多批快速选图仍共用同一条队列,不并行解码多张原图。
|
||||||
|
4. 缩略图生成中、失败或浏览器能力不足时显示文件占位,不退回原图预览,也不阻断后续提交校验。
|
||||||
|
5. 删除文件、开始提交、关闭组件都会终止当前缩略图任务;删除、上传成功和关闭组件都会回收临时 Blob URL。
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- 4 个直接相关测试套件、34 项测试全部通过。
|
||||||
|
- 全量 Jest 回归共 146 个测试套件、942 项测试全部通过。
|
||||||
|
- TypeScript 类型检查通过,改动文件 ESLint 为 0 error,仅保留 2 个已知的动态 Blob `<img>` 提示。
|
||||||
|
- `git diff --check` 通过;Impeccable 检测没有发现新增界面问题。
|
||||||
|
|
||||||
|
尚未关闭:仍需在隔离环境的真实 Edge/Chrome 中,用接近 60MP 图片、超长详情图、多批快速选择、处理中删除、立即提交和关闭弹窗进行验收,并记录浏览器内存是否在任务结束后回落。
|
||||||
|
|
||||||
### 后续 P1
|
### 后续 P1
|
||||||
|
|
||||||
- 普通主上传和部分 ZIP 下载仍会把完整文件或最终压缩包放入内存。
|
- 普通主上传和部分 ZIP 下载仍会把完整文件或最终压缩包放入内存。
|
||||||
@ -142,15 +181,23 @@
|
|||||||
|
|
||||||
- 详情图排序缺少键盘和触屏替代操作,并且同一批图片重复渲染。
|
- 详情图排序缺少键盘和触屏替代操作,并且同一批图片重复渲染。
|
||||||
- 上传区域按钮语义、焦点样式、小删除按钮和 reduced-motion 支持仍不完整。
|
- 上传区域按钮语义、焦点样式、小删除按钮和 reduced-motion 支持仍不完整。
|
||||||
|
- 上传弹窗仍缺完整的 dialog 语义、焦点圈定和关闭后的焦点恢复;这是既有可访问性 P1,本次内存修复未扩大范围处理。
|
||||||
- 网络状态监听器、分享页视频重复预加载、音频进度引发全部卡片重渲染仍待处理。
|
- 网络状态监听器、分享页视频重复预加载、音频进度引发全部卡片重渲染仍待处理。
|
||||||
- 上传弹窗、搜索框、登录注册标签和暗色模式等可访问性/一致性问题仍待处理。
|
- 上传弹窗、搜索框、登录注册标签和暗色模式等可访问性/一致性问题仍待处理。
|
||||||
|
|
||||||
|
### 后续凭据卫生
|
||||||
|
|
||||||
|
- 历史检查脚本中仍有一处疑似明文凭据。它不是本轮改动引入,也不会进入正式运行镜像,但不应继续保留在 Git 中。
|
||||||
|
- 下一步应先确认该凭据是否仍有效、是否被实际使用,并把脚本改为只从环境变量读取;只有确认凭据仍有效或存在异常使用证据时,才安排对应团队轮换,不能无依据扩大轮换范围。
|
||||||
|
|
||||||
## 五、处理顺序与上线门禁
|
## 五、处理顺序与上线门禁
|
||||||
|
|
||||||
1. **已完成运行态收口**:旧影子服务和 FRP 映射已停止;继续核对旧凭据是否失效及相关访问日志。
|
1. **已完成运行态收口**:旧影子服务和 FRP 映射已停止;继续核对旧凭据是否失效及相关访问日志。
|
||||||
2. **已暂缓**:uploads 数据集根目录权限保持现状,等待用户后续重新授权。
|
2. **已暂缓**:uploads 数据集根目录权限保持现状,等待用户后续重新授权。
|
||||||
3. **代码批次 1**:P1-1/P1-3 已提交并推送,待合并和部署;上线后验收网络状态 GET、POST 405、历史图片/视频预览和 Range 播放。
|
3. **代码批次 1**:P1-1/P1-3 已提交并推送,待合并和部署;上线后验收网络状态 GET、POST 405、历史图片/视频预览和 Range 播放。
|
||||||
4. **代码批次 2**:已完成本地修复和全量回归,待提交、部署及真实 Chrome/Edge 与生产内存验收。
|
4. **代码批次 2**:已提交并推送,待合并、部署及真实业务验收。
|
||||||
5. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
|
5. **代码批次 2.1**:先完成隔离环境的真实 Edge/Chrome 大图和内存验收,通过后再提交并进入发布门禁。
|
||||||
|
6. **生产发布门禁**:先处理或等待 TrueNAS 可用内存恢复,禁止在当前约 1.4GiB 余量下切换生产。
|
||||||
|
7. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
|
||||||
|
|
||||||
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。
|
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { prepareTaskDetailImagePreview } from '@/lib/task-detail-image-browser'
|
import {
|
||||||
|
prepareTaskDetailImagePreview,
|
||||||
|
prepareTaskDetailSelectionThumbnail,
|
||||||
|
} from '@/lib/task-detail-image-browser'
|
||||||
|
|
||||||
const mockPreparePerformanceSurveyImagesInBrowser = jest.fn()
|
const mockPreparePerformanceSurveyImagesInBrowser = jest.fn()
|
||||||
|
|
||||||
@ -43,4 +46,25 @@ describe('task-detail-image-browser', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('选图缩略图使用独立 task_detail_thumbnail profile 并保持 AbortSignal', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const file = new File(['image'], 'large.jpg', { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
const thumbnail = await prepareTaskDetailSelectionThumbnail(file, {
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(thumbnail.file.name).toBe('photo-preview.webp')
|
||||||
|
expect(mockPreparePerformanceSurveyImagesInBrowser).toHaveBeenCalledWith(
|
||||||
|
[file],
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
profile: 'task_detail_thumbnail',
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Ed
|
|||||||
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
|
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
export type PerformanceSurveyBrowserImageOptions = {
|
export type PerformanceSurveyBrowserImageOptions = {
|
||||||
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
|
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
@ -68,7 +68,7 @@ function assertPerformanceSurveyBrowserImageSupport() {
|
|||||||
|
|
||||||
function runImageWorker(
|
function runImageWorker(
|
||||||
files: File[],
|
files: File[],
|
||||||
profile: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail',
|
profile: NonNullable<PerformanceSurveyBrowserImageOptions['profile']>,
|
||||||
options: Pick<PerformanceSurveyBrowserImageOptions, 'signal' | 'timeoutMs'> = {},
|
options: Pick<PerformanceSurveyBrowserImageOptions, 'signal' | 'timeoutMs'> = {},
|
||||||
): Promise<WorkerProcessedImage[]> {
|
): Promise<WorkerProcessedImage[]> {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export {}
|
|||||||
type WorkerRequest = {
|
type WorkerRequest = {
|
||||||
id: string
|
id: string
|
||||||
files: File[]
|
files: File[]
|
||||||
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
|
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerProcessedImage = {
|
type WorkerProcessedImage = {
|
||||||
@ -32,6 +32,7 @@ type WorkerProcessedImage = {
|
|||||||
const SLICE_HEIGHT = 1_500
|
const SLICE_HEIGHT = 1_500
|
||||||
const PREVIEW_WIDTH = 720
|
const PREVIEW_WIDTH = 720
|
||||||
const PREVIEW_HEIGHT = 560
|
const PREVIEW_HEIGHT = 560
|
||||||
|
const TASK_DETAIL_THUMBNAIL_SIZE = 320
|
||||||
const MAX_SLICES_PER_SOURCE = 60
|
const MAX_SLICES_PER_SOURCE = 60
|
||||||
const MAX_ORDERED_SLICE_COUNT = 60
|
const MAX_ORDERED_SLICE_COUNT = 60
|
||||||
const PREVIEW_QUALITY = 0.68
|
const PREVIEW_QUALITY = 0.68
|
||||||
@ -256,7 +257,10 @@ async function renderPreviewFromBitmap(bitmap: ImageBitmap) {
|
|||||||
return { blob, width, height }
|
return { blob, width, height }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]> {
|
async function processSinglePreview(
|
||||||
|
file: File,
|
||||||
|
fileNameSuffix: 'preview' | 'thumbnail' = 'preview',
|
||||||
|
): Promise<WorkerProcessedImage[]> {
|
||||||
const bitmap = await decodeImage(file)
|
const bitmap = await decodeImage(file)
|
||||||
try {
|
try {
|
||||||
if (!bitmap.width || !bitmap.height) {
|
if (!bitmap.width || !bitmap.height) {
|
||||||
@ -265,7 +269,7 @@ async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]>
|
|||||||
const preview = await renderPreviewFromBitmap(bitmap)
|
const preview = await renderPreviewFromBitmap(bitmap)
|
||||||
return [{
|
return [{
|
||||||
...preview,
|
...preview,
|
||||||
fileName: `${removeFileExtension(file.name)}-preview.webp`,
|
fileName: `${removeFileExtension(file.name)}-${fileNameSuffix}.webp`,
|
||||||
mimeType: 'image/webp',
|
mimeType: 'image/webp',
|
||||||
kind: 'preview',
|
kind: 'preview',
|
||||||
sourceName: file.name,
|
sourceName: file.name,
|
||||||
@ -365,13 +369,17 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|||||||
}
|
}
|
||||||
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
|
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
|
||||||
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
|
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
|
||||||
const isTaskDetail = profile === 'task_detail' || profile === 'task_detail_preview' || profile === 'task_detail_detail'
|
const isTaskDetail = profile === 'task_detail'
|
||||||
|
|| profile === 'task_detail_preview'
|
||||||
|
|| profile === 'task_detail_detail'
|
||||||
|
|| profile === 'task_detail_thumbnail'
|
||||||
|
const isTaskDetailThumbnail = profile === 'task_detail_thumbnail'
|
||||||
sourceMaxPixels = isTaskDetail
|
sourceMaxPixels = isTaskDetail
|
||||||
? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS
|
? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS
|
||||||
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
|
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
|
||||||
previewWidth = isTaskDetail ? 800 : PREVIEW_WIDTH
|
previewWidth = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 800 : PREVIEW_WIDTH
|
||||||
previewHeight = isTaskDetail ? 1_200 : PREVIEW_HEIGHT
|
previewHeight = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 1_200 : PREVIEW_HEIGHT
|
||||||
previewQuality = isTaskDetail ? 0.74 : PREVIEW_QUALITY
|
previewQuality = isTaskDetailThumbnail ? 0.68 : isTaskDetail ? 0.74 : PREVIEW_QUALITY
|
||||||
previewFit = isTaskDetail ? 'inside' : 'cover'
|
previewFit = isTaskDetail ? 'inside' : 'cover'
|
||||||
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
||||||
throw new PerformanceSurveyBrowserImageError(
|
throw new PerformanceSurveyBrowserImageError(
|
||||||
@ -380,8 +388,8 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const images = files.length === 1
|
const images = files.length === 1
|
||||||
? profile === 'task_detail_preview'
|
? profile === 'task_detail_preview' || profile === 'task_detail_thumbnail'
|
||||||
? await processSinglePreview(files[0])
|
? await processSinglePreview(files[0], profile === 'task_detail_thumbnail' ? 'thumbnail' : 'preview')
|
||||||
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
|
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
|
||||||
: await processOrderedSlices(files)
|
: await processOrderedSlices(files)
|
||||||
workerScope.postMessage({ id, ok: true, images })
|
workerScope.postMessage({ id, ok: true, images })
|
||||||
|
|||||||
@ -9,6 +9,33 @@ export type TaskDetailBrowserPreview = PerformanceSurveyBrowserProcessedImage &
|
|||||||
sourceIndex: number
|
sourceIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为选图界面生成尺寸受限的小缩略图。
|
||||||
|
*
|
||||||
|
* 原始 File 绝不能直接挂到 img,否则 60MP 压缩图会在主线程额外解码出数百 MB。
|
||||||
|
* 缩略图失败只影响选图展示,真正提交时仍会按任务详情规则重新校验和处理原图。
|
||||||
|
*/
|
||||||
|
export async function prepareTaskDetailSelectionThumbnail(
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
signal?: AbortSignal
|
||||||
|
timeoutMs?: number
|
||||||
|
} = {},
|
||||||
|
): Promise<TaskDetailBrowserPreview> {
|
||||||
|
const processed = await preparePerformanceSurveyImagesInBrowser(
|
||||||
|
[file],
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
profile: 'task_detail_thumbnail',
|
||||||
|
signal: options.signal,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const thumbnail = processed.find((item) => item.kind === 'preview')
|
||||||
|
if (!thumbnail) throw new Error('浏览器未生成选图缩略图')
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在用户浏览器中逐张生成任务-详情预览。
|
* 在用户浏览器中逐张生成任务-详情预览。
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user