diff --git a/app/page.tsx b/app/page.tsx
index 608b0628..a1ad490b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -15,6 +15,7 @@ import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types'
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
+import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url'
// 首页拆分组件
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
@@ -590,19 +591,36 @@ export default function Home() {
const data = await response.json()
// 清理素材 ID,并为预览图补齐统一 fallback
const materialsList = ((data.materials || data) as Material[])
- .map((e) => {
- if (!e.id || typeof e.id !== 'string') return null
+ .flatMap((e): Material[] => {
+ if (!e.id || typeof e.id !== 'string') return []
const trimmedId = e.id.trim()
- if (!trimmedId) return null
+ if (!trimmedId) return []
- // previewUrl fallback逻辑
- const previewUrl = e.previewUrl ||
- e.videoCoverUrl ||
- (e.displayImages && e.displayImages.length > 0 ? e.displayImages[0] : '')
+ const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : [])
+ .map(normalizeMaterialPreviewUrl)
+ .filter((url): url is string => Boolean(url))
+ const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl)
+ const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url)
+ const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl)
+ const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl)
+ // 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。
+ const previewUrl = pickFirstMaterialPreviewUrl(
+ e.previewUrl,
+ videoCoverUrl,
+ ...displayImages,
+ ) || ''
- return { ...e, id: trimmedId, previewUrl }
+ return [{
+ ...e,
+ id: trimmedId,
+ previewUrl,
+ displayImages,
+ videoSourceUrl,
+ videoPreview540Url,
+ videoPreviewClipUrl,
+ videoCoverUrl,
+ }]
})
- .filter((e): e is Material => e !== null)
let appendedItems: Material[] = materialsList
if (append) {
@@ -729,9 +747,10 @@ export default function Home() {
? {
...m,
videoProcessingStatus: data.status,
- videoPreview540Url: m.videoPreview540Url || data.hasPreview540 ? data.hasPreview540 : null,
- videoPreviewClipUrl: m.videoPreviewClipUrl || data.hasClip ? data.hasClip : null,
- videoCoverUrl: m.videoCoverUrl || data.hasCover ? data.hasCover : null,
+ // 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。
+ videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url),
+ videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl),
+ videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl),
videoDuration: data.duration ?? m.videoDuration,
transcodeAttempts: data.attempts ?? m.transcodeAttempts,
transcodeProgress: data.progress ?? m.transcodeProgress,
diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx
index d223d1be..89efb158 100644
--- a/components/upload/ProductPackageUploader.test.tsx
+++ b/components/upload/ProductPackageUploader.test.tsx
@@ -1,7 +1,9 @@
import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
+import ProductPackageUploader, {
+ selectTaskDetailFilesWithinLimits,
+} from '@/components/upload/ProductPackageUploader'
import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
@@ -296,6 +298,135 @@ describe('ProductPackageUploader', () => {
.toBe('image/jpeg,image/png,image/gif,image/webp')
})
+ test('选图门禁逐个保留合格文件,并覆盖单组、总图片、总条目和总大小限制', () => {
+ const file = (name: string, size: number) => {
+ const value = new File(['x'], name, { type: 'image/jpeg' })
+ Object.defineProperty(value, 'size', { configurable: true, value: size })
+ return value
+ }
+ const emptySnapshot = () => ({ main: [], sku: [], detail: [], psd: [] })
+ const baseLimits = {
+ maxImagesPerGroup: 10,
+ maxTotalImageCount: 10,
+ maxPsdFileCount: 10,
+ maxTotalSourceFileCount: 10,
+ maxTotalSourceBytes: 100,
+ }
+
+ const groupResult = selectTaskDetailFilesWithinLimits(
+ 'main',
+ [file('a.jpg', 1), file('b.jpg', 1)],
+ emptySnapshot(),
+ { ...baseLimits, maxImagesPerGroup: 1 },
+ )
+ expect(groupResult.acceptedFiles.map((item) => item.name)).toEqual(['a.jpg'])
+ expect(groupResult.errors.join('')).toContain('主图最多选择 1 张')
+
+ const psdGroupResult = selectTaskDetailFilesWithinLimits(
+ 'psd',
+ [file('source-a.psd', 1), file('source-b.psd', 1)],
+ emptySnapshot(),
+ { ...baseLimits, maxPsdFileCount: 1 },
+ )
+ expect(psdGroupResult.acceptedFiles.map((item) => item.name)).toEqual(['source-a.psd'])
+ expect(psdGroupResult.errors.join('')).toContain('PSD 源文件最多选择 1 个')
+
+ const totalImageResult = selectTaskDetailFilesWithinLimits(
+ 'detail',
+ [file('detail.jpg', 1)],
+ { ...emptySnapshot(), main: [file('existing.jpg', 1)] },
+ { ...baseLimits, maxTotalImageCount: 1 },
+ )
+ expect(totalImageResult.acceptedFiles).toHaveLength(0)
+ expect(totalImageResult.errors.join('')).toContain('本次图片合计最多 1 张')
+
+ const totalSourceResult = selectTaskDetailFilesWithinLimits(
+ 'main',
+ [file('new.jpg', 1)],
+ { ...emptySnapshot(), psd: [file('existing.psd', 1)] },
+ { ...baseLimits, maxTotalSourceFileCount: 1 },
+ )
+ expect(totalSourceResult.acceptedFiles).toHaveLength(0)
+ expect(totalSourceResult.errors.join('')).toContain('原图和源文件合计最多 1 个')
+
+ const totalBytesResult = selectTaskDetailFilesWithinLimits(
+ 'sku',
+ [file('fits.jpg', 1), file('too-much.jpg', 2)],
+ { ...emptySnapshot(), main: [file('existing.jpg', 2)] },
+ { ...baseLimits, maxTotalSourceBytes: 4 },
+ )
+ expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg'])
+ expect(totalBytesResult.errors.join('')).toContain('原图和源文件总大小最多 4B')
+ })
+
+ test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => {
+ const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxImagesPerGroup: number }
+ const originalLimit = mutableLimits.maxImagesPerGroup
+ mutableLimits.maxImagesPerGroup = 2
+
+ try {
+ render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ const input = screen.getByLabelText('主图') as HTMLInputElement
+ const first = new File(['first'], 'first.jpg', { type: 'image/jpeg' })
+ const second = new File(['second'], 'second.jpg', { type: 'image/jpeg' })
+ const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' })
+
+ act(() => {
+ fireEvent.change(input, { target: { files: [first] } })
+ fireEvent.change(input, { target: { files: [second, third] } })
+ })
+
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
+ 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()
+ expect(screen.getByRole('alert')).toHaveTextContent('主图最多选择 2 张')
+ expect(screen.getByRole('alert')).toHaveTextContent('请删除不需要的文件,或分批提交')
+ expect(screen.getByRole('alert')).toHaveTextContent('其余 1 张符合限制的文件已保留')
+ } finally {
+ mutableLimits.maxImagesPerGroup = originalLimit
+ }
+ })
+
+ test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
+ const user = userEvent.setup()
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
+ const previewDeferred = createDeferred()
+ let previewSignal: AbortSignal | undefined
+ mockPrepareTaskDetailImagePreview.mockImplementationOnce((
+ _file: File,
+ _group: string,
+ _onProgress: unknown,
+ options?: { signal?: AbortSignal },
+ ) => {
+ previewSignal = options?.signal
+ previewSignal?.addEventListener('abort', () => {
+ previewDeferred.reject(new DOMException('图片处理已取消', 'AbortError'))
+ }, { once: true })
+ return previewDeferred.promise
+ })
+ const fetchMock = installUploadFlowFetch()
+
+ render()
+ await fillRequiredTaskFields(
+ user,
+ new File(['image'], 'cancel-processing.jpg', { type: 'image/jpeg' }),
+ )
+ await user.click(screen.getByRole('button', { name: '提交' }))
+ await waitFor(() => expect(previewSignal).toBeDefined())
+
+ await user.click(screen.getByRole('button', { name: '取消上传' }))
+
+ expect(previewSignal?.aborted).toBe(true)
+ expect(await screen.findByText('已取消上传')).toBeInTheDocument()
+ expect(mockChunkUpload).not.toHaveBeenCalled()
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/materials/upload/product-package/abort',
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => {
const user = userEvent.setup()
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx
index ff6a088d..a23cf053 100644
--- a/components/upload/ProductPackageUploader.tsx
+++ b/components/upload/ProductPackageUploader.tsx
@@ -4,7 +4,7 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import { addCategory, addScene } from './category-handlers';
@@ -102,6 +102,113 @@ interface AssetFile {
}
type UploadGroup = 'main' | 'sku' | 'detail' | 'psd';
+type ImageUploadGroup = Exclude;
+
+type TaskDetailFileSelectionSnapshot = Record;
+
+type TaskDetailFileSelectionLimitKey =
+ | 'maxImagesPerGroup'
+ | 'maxTotalImageCount'
+ | 'maxPsdFileCount'
+ | 'maxTotalSourceFileCount'
+ | 'maxTotalSourceBytes';
+
+type TaskDetailFileSelectionLimits = Readonly>;
+
+type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes';
+
+const UPLOAD_GROUP_LABELS: Record = {
+ main: '主图',
+ sku: 'SKU 图',
+ detail: '详情图',
+ psd: 'PSD 源文件',
+};
+
+function formatSelectionLimitBytes(bytes: number): string {
+ if (bytes >= 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024 / 1024)}GB`;
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}MB`;
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
+ return `${bytes}B`;
+}
+
+function summarizeRejectedFiles(files: File[]): string {
+ const names = files.slice(0, 3).map((file) => file.name || '未命名文件');
+ return files.length > names.length
+ ? `${names.join('、')}等 ${files.length} 个文件`
+ : names.join('、');
+}
+
+export function selectTaskDetailFilesWithinLimits(
+ group: UploadGroup,
+ candidates: File[],
+ existing: TaskDetailFileSelectionSnapshot,
+ limits: TaskDetailFileSelectionLimits = TASK_DETAIL_UPLOAD_LIMITS,
+): { acceptedFiles: File[]; errors: string[] } {
+ const rejected: Record = {
+ group: [],
+ totalImages: [],
+ totalSources: [],
+ totalBytes: [],
+ };
+ const acceptedFiles: File[] = [];
+ const isImageGroup = group !== 'psd';
+ let groupCount = existing[group].length;
+ let totalImageCount = existing.main.length + existing.sku.length + existing.detail.length;
+ let totalSourceCount = totalImageCount + existing.psd.length;
+ let totalSourceBytes = Object.values(existing)
+ .flat()
+ .reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
+
+ candidates.forEach((file) => {
+ const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
+ let reason: SelectionLimitReason | null = null;
+ if (groupCount >= groupLimit) {
+ reason = 'group';
+ } else if (isImageGroup && totalImageCount >= limits.maxTotalImageCount) {
+ reason = 'totalImages';
+ } else if (totalSourceCount >= limits.maxTotalSourceFileCount) {
+ reason = 'totalSources';
+ } else if (totalSourceBytes + file.size > limits.maxTotalSourceBytes) {
+ reason = 'totalBytes';
+ }
+
+ if (reason) {
+ rejected[reason].push(file);
+ return;
+ }
+
+ acceptedFiles.push(file);
+ groupCount += 1;
+ if (isImageGroup) totalImageCount += 1;
+ totalSourceCount += 1;
+ totalSourceBytes += file.size;
+ });
+
+ const errors: string[] = [];
+ const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
+ if (rejected.group.length > 0) {
+ errors.push(
+ `${UPLOAD_GROUP_LABELS[group]}最多选择 ${groupLimit} ${isImageGroup ? '张' : '个'},已忽略 ${summarizeRejectedFiles(rejected.group)};请删除不需要的文件,或分批提交`,
+ );
+ }
+ if (rejected.totalImages.length > 0) {
+ errors.push(
+ `本次图片合计最多 ${limits.maxTotalImageCount} 张,已忽略 ${summarizeRejectedFiles(rejected.totalImages)};请减少其他分组的图片,或分批提交`,
+ );
+ }
+ if (rejected.totalSources.length > 0) {
+ errors.push(
+ `本次原图和源文件合计最多 ${limits.maxTotalSourceFileCount} 个,已忽略 ${summarizeRejectedFiles(rejected.totalSources)};请删除部分已选文件,或分批提交`,
+ );
+ }
+ if (rejected.totalBytes.length > 0) {
+ errors.push(
+ `本次原图和源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分文件,或分批提交`,
+ );
+ }
+
+ return { acceptedFiles, errors };
+}
type UploadedAsset = {
fileName: string;
@@ -204,11 +311,24 @@ export default function ProductPackageUploader({
const uploadStopRef = useRef(false);
const activeXhrsRef = useRef>(new Set());
const activeXhrSetsRef = useRef