fix: 降低任务详情选图内存峰值

This commit is contained in:
chenpei 2026-08-03 09:53:20 +08:00
parent 3495530f3d
commit 62295a0230
9 changed files with 596 additions and 51 deletions

View File

@ -7,17 +7,51 @@ const images = [
id: 'detail-1',
file: new File(['first'], '1.jpg', { type: 'image/jpeg' }),
preview: 'blob:first',
previewStatus: 'ready' as const,
sortOrder: 0,
},
{
id: 'detail-2',
file: new File(['second'], '2.jpg', { type: 'image/jpeg' }),
preview: 'blob:second',
previewStatus: 'ready' as const,
sortOrder: 1,
},
]
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('外部文件拖到已有详情图上时应继续交给外层上传区域处理', () => {
const onOuterDrop = jest.fn()

View File

@ -12,6 +12,8 @@ interface DetailImage {
id: string;
file: File;
preview: string;
previewStatus: 'loading' | 'ready' | 'unavailable';
previewMessage?: string;
sortOrder: number;
}
@ -22,6 +24,47 @@ interface DetailImageSorterProps {
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({
images,
onChange,
@ -120,9 +163,9 @@ export default function DetailImageSorter({
const scrollToImage = (index: number) => {
if (!previewRef.current) return;
const previewImages = previewRef.current.querySelectorAll('img');
if (previewImages[index]) {
previewImages[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
const previewItem = previewRef.current.querySelector<HTMLElement>(`[data-detail-preview-index="${index}"]`);
if (previewItem) {
previewItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
@ -156,13 +199,8 @@ export default function DetailImageSorter({
className="max-h-[600px] overflow-y-auto"
>
{sortedImages.map((img, index) => (
<div key={img.id} className="relative">
<img
src={img.preview}
alt={`详情图 ${index + 1}`}
className="w-full block"
style={{ display: 'block' }}
/>
<div key={img.id} data-detail-preview-index={index} className="relative overflow-hidden rounded bg-white dark:bg-gray-800">
<DetailImagePreview image={img} alt={`详情图 ${index + 1}`} />
<div className="absolute top-2 left-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
{index + 1}
</div>
@ -194,11 +232,7 @@ export default function DetailImageSorter({
hover:border-blue-300 dark:hover:border-blue-500
`}
>
<img
src={img.preview}
alt={`缩略图 ${index + 1}`}
className="w-full h-32 object-cover"
/>
<DetailImagePreview image={img} alt={`缩略图 ${index + 1}`} compact />
{/* 序号 */}
<div className="absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">

View File

@ -9,6 +9,7 @@ import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
const mockChunkUpload = jest.fn()
const mockPrepareTaskDetailImagePreview = jest.fn()
const mockPrepareTaskDetailSelectionThumbnail = jest.fn()
const mockPollTaskDetailUploadJob = jest.fn()
jest.mock('@/lib/chunk-upload', () => ({
@ -17,6 +18,7 @@ jest.mock('@/lib/chunk-upload', () => ({
jest.mock('@/lib/task-detail-image-browser', () => ({
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
prepareTaskDetailSelectionThumbnail: (...args: unknown[]) => mockPrepareTaskDetailSelectionThumbnail(...args),
}))
jest.mock('@/lib/task-detail-upload-status', () => ({
@ -99,6 +101,20 @@ const capabilityError = () => new PerformanceSurveyBrowserImageError(
'当前 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>) {
const fetchMock = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
@ -124,6 +140,9 @@ describe('ProductPackageUploader', () => {
beforeEach(() => {
jest.clearAllMocks()
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
mockPrepareTaskDetailSelectionThumbnail.mockImplementation((file: File) => (
Promise.resolve(thumbnailResult(file.name))
))
mockPollTaskDetailUploadJob.mockResolvedValue({
status: 'completed',
progress: 100,
@ -140,10 +159,11 @@ describe('ProductPackageUploader', () => {
],
}) as jest.Mock
let objectUrlIndex = 0
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: jest.fn(() => 'blob:mock'),
value: jest.fn(() => `blob:mock-${++objectUrlIndex}`),
})
Object.defineProperty(URL, 'revokeObjectURL', {
@ -377,7 +397,15 @@ describe('ProductPackageUploader', () => {
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: '删除主图 second.jpg' })).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 () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)

View File

@ -27,6 +27,7 @@ import {
} from '@/lib/task-detail-upload-policy';
import {
prepareTaskDetailImagePreview,
prepareTaskDetailSelectionThumbnail,
type TaskDetailAssetKind,
type TaskDetailBrowserPreview,
} 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 等当前处理链不支持的类型,避免最后提交时才报错。
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_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000;
function splitEmptyFiles(files: FileList | File[] | null): {
validFiles: File[];
@ -94,6 +96,8 @@ interface ProductPackageUploaderProps {
interface AssetFile {
file: File;
preview: string;
previewStatus: 'loading' | 'ready' | 'unavailable';
previewMessage?: string;
id: string;
sortOrder: number;
skuCode?: string;
@ -248,14 +252,49 @@ function parseBarcodeInput(input: string): string[] {
);
}
function revokeAssetPreviews(groups: Array<AssetFile[]>) {
const urls = new Set(
groups
.flat()
.map((item) => item.preview)
.filter((preview): preview is string => Boolean(preview) && preview.startsWith('blob:')),
function AssetThumbnail({
asset,
alt,
}: {
asset: AssetFile;
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({
@ -316,7 +355,12 @@ export default function ProductPackageUploader({
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
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>({
main: [],
sku: [],
@ -330,9 +374,116 @@ export default function ProductPackageUploader({
browserPreviewAbortRef.current = null;
}, []);
useEffect(() => {
assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles];
}, [mainImages, skuImages, detailImages, psdFiles]);
const updateAssetPreview = useCallback((
group: ImageUploadGroup,
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(() => {
// React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。
@ -347,12 +498,13 @@ export default function ProductPackageUploader({
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSets.clear();
stopThumbnailGeneration(false);
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
revokeAssetPreviews(assetGroupsRef.current);
revokeAllThumbnailObjectUrls();
};
}, [abortActiveBrowserPreview]);
}, [abortActiveBrowserPreview, revokeAllThumbnailObjectUrls, stopThumbnailGeneration]);
useEffect(() => {
let active = true;
@ -449,7 +601,10 @@ export default function ProductPackageUploader({
if (files.length === 0) return;
const preparedFiles: AssetFile[] = files.map((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),
sortOrder: 0,
}));
@ -466,6 +621,8 @@ export default function ProductPackageUploader({
else if (group === 'sku') setSkuImages(append);
else if (group === 'detail') setDetailImages(append);
else setPsdFiles(append);
if (group !== 'psd') enqueueSelectionThumbnails(group, preparedFiles);
};
const setSelectionFeedback = (
@ -556,7 +713,9 @@ export default function ProductPackageUploader({
const removed = assets.find((item) => item.id === id);
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,
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
@ -774,6 +933,9 @@ export default function ProductPackageUploader({
}
}
// 提交链会重新生成正式预览。先终止非必要的选图缩略图,避免两条 Worker
// 链同时解码大图;尚未完成的缩略图改为低干扰占位。
stopThumbnailGeneration(true);
setUploading(true);
setUploadProgress(0);
progressRef.current = 0;
@ -1056,10 +1218,10 @@ export default function ProductPackageUploader({
setProgress(100);
setProcessingStep('完成!');
completed = true;
revokeAssetPreviews([mainImages, skuImages, detailImages, psdFiles]);
completionTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return;
setProcessingStep('');
revokeAllThumbnailObjectUrls();
onSuccess();
}, 500);
} catch (error: unknown) {
@ -1242,7 +1404,7 @@ export default function ProductPackageUploader({
{mainImages.map((img) => (
<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">
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
<AssetThumbnail asset={img} alt="" />
</div>
<button
onClick={() => handleRemoveFile(img.id, 'main')}
@ -1323,7 +1485,7 @@ export default function ProductPackageUploader({
{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 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>
<button
onClick={() => handleRemoveFile(img.id, 'sku')}

View File

@ -12,10 +12,28 @@
1. 关闭匿名网络测速 POST,避免无上限读取请求正文。
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:旧影子服务仍暴露旧漏洞
状态:**运行入口已收口;历史凭据核查待完成**
@ -106,7 +124,7 @@
## 四、其余未完成问题
### 代码批次 2(2026-08-02 已完成本地修复,待提交和部署)
### 代码批次 2(2026-08-02 已提交并推送,待合并和部署)
本批原来的 5 项已完成代码修复:
@ -128,6 +146,27 @@
尚未验证:真实 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
- 普通主上传和部分 ZIP 下载仍会把完整文件或最终压缩包放入内存。
@ -142,15 +181,23 @@
- 详情图排序缺少键盘和触屏替代操作,并且同一批图片重复渲染。
- 上传区域按钮语义、焦点样式、小删除按钮和 reduced-motion 支持仍不完整。
- 上传弹窗仍缺完整的 dialog 语义、焦点圈定和关闭后的焦点恢复;这是既有可访问性 P1,本次内存修复未扩大范围处理。
- 网络状态监听器、分享页视频重复预加载、音频进度引发全部卡片重渲染仍待处理。
- 上传弹窗、搜索框、登录注册标签和暗色模式等可访问性/一致性问题仍待处理。
### 后续凭据卫生
- 历史检查脚本中仍有一处疑似明文凭据。它不是本轮改动引入,也不会进入正式运行镜像,但不应继续保留在 Git 中。
- 下一步应先确认该凭据是否仍有效、是否被实际使用,并把脚本改为只从环境变量读取;只有确认凭据仍有效或存在异常使用证据时,才安排对应团队轮换,不能无依据扩大轮换范围。
## 五、处理顺序与上线门禁
1. **已完成运行态收口**:旧影子服务和 FRP 映射已停止;继续核对旧凭据是否失效及相关访问日志。
2. **已暂缓**:uploads 数据集根目录权限保持现状,等待用户后续重新授权。
3. **代码批次 1**:P1-1/P1-3 已提交并推送,待合并和部署;上线后验收网络状态 GET、POST 405、历史图片/视频预览和 Range 播放。
4. **代码批次 2**:已完成本地修复和全量回归,待提交、部署及真实 Chrome/Edge 与生产内存验收。
5. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
4. **代码批次 2**:已提交并推送,待合并、部署及真实业务验收。
5. **代码批次 2.1**:先完成隔离环境的真实 Edge/Chrome 大图和内存验收,通过后再提交并进入发布门禁。
6. **生产发布门禁**:先处理或等待 TrueNAS 可用内存恢复,禁止在当前约 1.4GiB 余量下切换生产。
7. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。

View File

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

View File

@ -43,7 +43,7 @@ const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Ed
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
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
timeoutMs?: number
}
@ -68,7 +68,7 @@ function assertPerformanceSurveyBrowserImageSupport() {
function runImageWorker(
files: File[],
profile: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail',
profile: NonNullable<PerformanceSurveyBrowserImageOptions['profile']>,
options: Pick<PerformanceSurveyBrowserImageOptions, 'signal' | 'timeoutMs'> = {},
): Promise<WorkerProcessedImage[]> {
if (options.signal?.aborted) {

View File

@ -13,7 +13,7 @@ export {}
type WorkerRequest = {
id: string
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 = {
@ -32,6 +32,7 @@ type WorkerProcessedImage = {
const SLICE_HEIGHT = 1_500
const PREVIEW_WIDTH = 720
const PREVIEW_HEIGHT = 560
const TASK_DETAIL_THUMBNAIL_SIZE = 320
const MAX_SLICES_PER_SOURCE = 60
const MAX_ORDERED_SLICE_COUNT = 60
const PREVIEW_QUALITY = 0.68
@ -256,7 +257,10 @@ async function renderPreviewFromBitmap(bitmap: ImageBitmap) {
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)
try {
if (!bitmap.width || !bitmap.height) {
@ -265,7 +269,7 @@ async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]>
const preview = await renderPreviewFromBitmap(bitmap)
return [{
...preview,
fileName: `${removeFileExtension(file.name)}-preview.webp`,
fileName: `${removeFileExtension(file.name)}-${fileNameSuffix}.webp`,
mimeType: 'image/webp',
kind: 'preview',
sourceName: file.name,
@ -365,13 +369,17 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
}
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
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
? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
previewWidth = isTaskDetail ? 800 : PREVIEW_WIDTH
previewHeight = isTaskDetail ? 1_200 : PREVIEW_HEIGHT
previewQuality = isTaskDetail ? 0.74 : PREVIEW_QUALITY
previewWidth = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 800 : PREVIEW_WIDTH
previewHeight = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 1_200 : PREVIEW_HEIGHT
previewQuality = isTaskDetailThumbnail ? 0.68 : isTaskDetail ? 0.74 : PREVIEW_QUALITY
previewFit = isTaskDetail ? 'inside' : 'cover'
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
throw new PerformanceSurveyBrowserImageError(
@ -380,8 +388,8 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
)
}
const images = files.length === 1
? profile === 'task_detail_preview'
? await processSinglePreview(files[0])
? profile === 'task_detail_preview' || profile === 'task_detail_thumbnail'
? await processSinglePreview(files[0], profile === 'task_detail_thumbnail' ? 'thumbnail' : 'preview')
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
: await processOrderedSlices(files)
workerScope.postMessage({ id, ok: true, images })

View File

@ -9,6 +9,33 @@ export type TaskDetailBrowserPreview = PerformanceSurveyBrowserProcessedImage &
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
}
/**
* 在用户浏览器中逐张生成任务-详情预览。
*