diff --git a/components/work-review/WorkReviewDialogs.tsx b/components/work-review/WorkReviewDialogs.tsx index 6d101f9..20aea32 100644 --- a/components/work-review/WorkReviewDialogs.tsx +++ b/components/work-review/WorkReviewDialogs.tsx @@ -13,9 +13,12 @@ import { } from 'react' import { createPortal } from 'react-dom' import { getWorkReviewReviewerOptions, uploadWorkReviewImages } from '@/lib/work-review-client' -import { WORK_REVIEW_BRANDS } from '@/types/work-review' +import { WORK_REVIEW_BRANDS, WORK_REVIEW_IMAGE_LABELS, WORK_REVIEW_IMAGE_TYPES, WORK_REVIEW_REFERENCE_LABELS } from '@/types/work-review' +import { getWorkReviewImageSelectionError } from '@/lib/work-review-image-types' import type { WorkReviewDecision, + WorkReviewImageType, + WorkReviewProcessedImage, WorkReviewProject, WorkReviewReviewInput, WorkReviewReviewerOption, @@ -33,6 +36,10 @@ function todayValue() { return local.toISOString().slice(0, 10) } +function emptyImageFiles(): Record { + return { tone: [], main: [], detail: [], sku: [] } +} + function proxyImageUrl(value: string) { if (!/^https?:\/\//i.test(value)) return value try { @@ -85,7 +92,7 @@ function WorkReviewDialogShell({ } if (event.key !== 'Tab') return const elements = Array.from(dialogRef.current?.querySelectorAll( - 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + 'button:not([disabled]), input:not([disabled]):not([type="file"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ) || []) if (elements.length === 0) return const first = elements[0] @@ -228,8 +235,8 @@ export function WorkReviewSubmissionDialog({ const [reviewerLoadError, setReviewerLoadError] = useState(null) const [reviewerLoadKey, setReviewerLoadKey] = useState(0) const [submissionNote, setSubmissionNote] = useState('') - const [files, setFiles] = useState([]) - const [referenceFiles, setReferenceFiles] = useState([]) + const [files, setFiles] = useState(emptyImageFiles) + const [referenceFiles, setReferenceFiles] = useState(emptyImageFiles) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null) @@ -281,8 +288,12 @@ export function WorkReviewSubmissionDialog({ setError('请选择评审人') return } - if (files.length === 0) { - setError('请至少选择 1 张作品图片') + const selectionError = getWorkReviewImageSelectionError( + Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, files[type].length])) as Record, + Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, referenceFiles[type].length])) as Record, + ) + if (selectionError) { + setError(selectionError) return } @@ -291,23 +302,33 @@ export function WorkReviewSubmissionDialog({ setError(null) setProgress({ message: '正在准备图片' }) try { - const hasReferenceFiles = referenceFiles.length > 0 - const processedImages = await uploadWorkReviewImages(files, (message, percent) => { - setProgress({ - message: `作品图片:${message}`, - percent: typeof percent === 'number' - ? Math.round(percent * (hasReferenceFiles ? 0.6 : 1)) - : undefined, - }) - }) - const processedReferenceImages = hasReferenceFiles - ? await uploadWorkReviewImages(referenceFiles, (message, percent) => { - setProgress({ - message: `参考图片:${message}`, - percent: typeof percent === 'number' ? 60 + Math.round(percent * 0.4) : undefined, - }) + const processedImages: WorkReviewProcessedImage[] = [] + const processedReferenceImages: WorkReviewProcessedImage[] = [] + const batches = [false, true].flatMap((reference) => WORK_REVIEW_IMAGE_TYPES.map((type) => ({ + type, + reference, + files: (reference ? referenceFiles : files)[type], + }))).filter((batch) => batch.files.length > 0) + const totalFiles = batches.reduce((sum, batch) => sum + batch.files.length, 0) + let completedFiles = 0 + let workOffset = 0 + let referenceOffset = 0 + for (const batch of batches) { + const uploaded = await uploadWorkReviewImages(batch.files, (message, percent) => { + setProgress({ + message: `${WORK_REVIEW_IMAGE_LABELS[batch.type]}${batch.reference ? '参考图' : ''}:${message}`, + percent: typeof percent === 'number' + ? Math.round((completedFiles + batch.files.length * percent / 100) / totalFiles * 100) + : undefined, }) - : [] + }) + const offset = batch.reference ? referenceOffset : workOffset + const target = batch.reference ? processedReferenceImages : processedImages + target.push(...uploaded.map((image) => ({ ...image, assetType: batch.type, sourceIndex: image.sourceIndex + offset }))) + if (batch.reference) referenceOffset += batch.files.length + else workOffset += batch.files.length + completedFiles += batch.files.length + } await onSubmit({ ...(isCreate ? { @@ -344,6 +365,11 @@ export function WorkReviewSubmissionDialog({ widthClass="max-w-4xl" footer={(
+ {error && ( +
+ {error} +
+ )} {progress && (
@@ -397,7 +423,7 @@ export function WorkReviewSubmissionDialog({ maxLength={120} disabled={busy} placeholder="例如:夏季新品主视觉" - className="h-11 rounded border border-[#cfd6e1] px-3 text-[14px] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]" + className="h-11 rounded border border-[#cfd6e1] px-3 text-base outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7] sm:text-sm" /> @@ -503,40 +529,48 @@ export function WorkReviewSubmissionDialog({ disabled={busy} rows={3} placeholder={isCreate ? '补充需要评审人关注的内容(可选)' : '简要说明本次修改了什么(可选)'} - className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-[14px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]" + className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-base leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7] sm:text-sm" /> -
-
-
- 作品图片 * - {files.length} 张 -
- +
+

上传图片

+

+ 可仅上传定调图片;上传整套时,主图、详情、SKU 三项必须齐全,定调图片可选。 +

+

主图、SKU 支持多图,详情支持长图;参考图均选填,不参与逐图评审。作品图与参考图各合计最多 60 张。

+
+ {WORK_REVIEW_IMAGE_TYPES.map((type) => ( +
+
+
+

{WORK_REVIEW_IMAGE_LABELS[type]}

+ 已选 {files[type].length} 张 +
+ { setFiles((current) => ({ ...current, [type]: selected })); setError(null) }} + disabled={busy} + label={WORK_REVIEW_IMAGE_LABELS[type]} + /> +
+
+
+

{WORK_REVIEW_REFERENCE_LABELS[type]} (可选)

+ 已选 {referenceFiles[type].length} 张 +
+ { setReferenceFiles((current) => ({ ...current, [type]: selected })); setError(null) }} + disabled={busy} + label={WORK_REVIEW_REFERENCE_LABELS[type]} + /> +
+
+ ))}
-
-
- 参考图片 (可选) - {referenceFiles.length} 张 -
- -

- 仅供评审对照,不计入逐图评审数量。 -

-
-
+ - {error && ( -
- {error} -
- )}
) diff --git a/components/work-review/WorkReviewImageDropzone.tsx b/components/work-review/WorkReviewImageDropzone.tsx index 87a12f8..a3a1ed5 100644 --- a/components/work-review/WorkReviewImageDropzone.tsx +++ b/components/work-review/WorkReviewImageDropzone.tsx @@ -1,7 +1,7 @@ 'use client' import Image from 'next/image' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' const MAX_FILE_COUNT = 60 const MAX_FILE_BYTES = 200 * 1024 * 1024 @@ -22,6 +22,7 @@ export function WorkReviewImageDropzone({ label?: string }) { const inputRef = useRef(null) + const hintId = useId() const dragDepthRef = useRef(0) const [dragging, setDragging] = useState(false) const [error, setError] = useState(null) @@ -64,6 +65,7 @@ export function WorkReviewImageDropzone({ multiple disabled={disabled} aria-label={`选择${label}`} + aria-describedby={hintId} className="hidden" onChange={(event) => { addFiles(event.currentTarget.files || []) @@ -97,20 +99,21 @@ export function WorkReviewImageDropzone({ setDragging(false) addFiles(event.dataTransfer.files) }} - className={`flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 py-5 text-center transition-all disabled:cursor-not-allowed disabled:opacity-60 ${ + aria-describedby={hintId} + className={`flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 py-5 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 ${ dragging - ? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]' + ? 'border-[#0050cb] bg-[#eef5ff]' : 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]' }`} > ↑ - - {dragging ? '松开即可添加' : '拖拽图片到这里,或点击选择'} + + {dragging ? '松开即可添加' : files.length > 0 ? '继续添加图片' : '添加图片(可多选 / 拖拽)'} - - 支持 PNG、JPG、GIF、WebP;长图会自动生成预览与切片 + + JPG / PNG / GIF / WebP,单张 ≤ 200MB @@ -126,7 +129,7 @@ export function WorkReviewImageDropzone({ alt={file.name} fill unoptimized - className="object-cover" + className="object-contain" /> {index + 1} @@ -135,15 +138,15 @@ export function WorkReviewImageDropzone({ type="button" disabled={disabled} onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))} - className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/65 text-[18px] text-white opacity-90 transition-opacity hover:bg-[#b42318] disabled:cursor-not-allowed" + className="absolute right-1 top-1 flex h-11 w-11 items-center justify-center rounded-full bg-black/65 text-[18px] text-white hover:bg-[#b42318] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] disabled:cursor-not-allowed" aria-label={`移除 ${file.name}`} > ×
-
{file.name}
-
+
{file.name}
+
{file.size < 1024 * 1024 ? `${Math.max(1, Math.round(file.size / 1024))} KB` : `${(file.size / 1024 / 1024).toFixed(1)} MB`} diff --git a/components/work-review/WorkReviewImageGallery.tsx b/components/work-review/WorkReviewImageGallery.tsx index c858659..cacffec 100644 --- a/components/work-review/WorkReviewImageGallery.tsx +++ b/components/work-review/WorkReviewImageGallery.tsx @@ -6,6 +6,7 @@ import Image from 'next/image' import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { WorkReviewImageGroup } from '@/types/work-review' +import { WORK_REVIEW_IMAGE_LABELS } from '@/types/work-review' import { WorkReviewAnnotationMarker, type WorkReviewDisplayAnnotation, @@ -92,6 +93,7 @@ export function WorkReviewImageGallery({ className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]" />
+ {group.assetType &&
{WORK_REVIEW_IMAGE_LABELS[group.assetType]}
}
{group.sourceName}
{group.slices.length > 1 && ( diff --git a/components/work-review/WorkReviewReviewerSelect.tsx b/components/work-review/WorkReviewReviewerSelect.tsx index db1e7a5..4e8f186 100644 --- a/components/work-review/WorkReviewReviewerSelect.tsx +++ b/components/work-review/WorkReviewReviewerSelect.tsx @@ -127,7 +127,7 @@ export function WorkReviewReviewerSelect({ ? `${listboxId}-${filteredOptions[highlightedIndex].id}` : undefined} aria-busy={loading} - className="h-11 w-full rounded border border-[#cfd6e1] bg-white px-3 pr-20 text-[14px] outline-none placeholder:text-[#667085] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:cursor-not-allowed disabled:bg-[#f2f4f7]" + className="h-11 w-full rounded border border-[#cfd6e1] bg-white px-3 pr-20 text-base outline-none placeholder:text-[#667085] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:cursor-not-allowed disabled:bg-[#f2f4f7] sm:text-sm" />
{value && !isDisabled && ( diff --git a/components/work-review/WorkReviewReviewerWorkspace.tsx b/components/work-review/WorkReviewReviewerWorkspace.tsx index 0dbd4d3..66e3e2f 100644 --- a/components/work-review/WorkReviewReviewerWorkspace.tsx +++ b/components/work-review/WorkReviewReviewerWorkspace.tsx @@ -21,6 +21,7 @@ import { type WorkReviewDrawingTool, } from './WorkReviewAnnotationToolbar' import { canResubmitWorkReviewProject } from '@/lib/work-review-policy' +import { WORK_REVIEW_IMAGE_LABELS, WORK_REVIEW_REFERENCE_LABELS } from '@/types/work-review' const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk']) const MIN_PANE_PERCENT = 14 @@ -280,13 +281,14 @@ function LongImagePane({ type="button" onClick={() => selectImage(index)} aria-pressed={activeIndex === index} + title={group.sourceName} className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${ activeIndex === index ? 'bg-[#0050cb] text-white' : 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]' }`} > - 图 {index + 1} + {group.assetType ? WORK_REVIEW_IMAGE_LABELS[group.assetType] : `图 ${index + 1}`} ))}
@@ -523,6 +525,14 @@ export function WorkReviewReviewerWorkspace({ const workImageCount = getImageCount(selectedVersion) const hasReferenceImages = (selectedVersion?.referenceImageGroups?.length || 0) > 0 || (selectedVersion?.referenceImageUrls?.length || 0) > 0 + const activeAssetType = selectedVersion?.imageGroups?.[activeWorkImageIndex]?.assetType + const activeWorkLabel = activeAssetType ? WORK_REVIEW_IMAGE_LABELS[activeAssetType] : '作品图片' + const matchingReferenceGroups = useMemo(() => ( + (selectedVersion?.referenceImageGroups || []).filter((group) => !activeAssetType || group.assetType === activeAssetType) + ), [selectedVersion?.referenceImageGroups, activeAssetType]) + const matchingReferenceUrls = activeAssetType + ? matchingReferenceGroups.flatMap((group) => group.slices.map((slice) => slice.url)) + : selectedVersion?.referenceImageUrls || [] useEffect(() => { if (!project.versions.some((version) => version.id === selectedVersionId)) { @@ -927,7 +937,7 @@ export function WorkReviewReviewerWorkspace({
@@ -1045,7 +1055,7 @@ export function WorkReviewReviewerWorkspace({
)} diff --git a/lib/__tests__/work-review-resubmission-ui.test.tsx b/lib/__tests__/work-review-resubmission-ui.test.tsx index c3b962a..c9fc9d9 100644 --- a/lib/__tests__/work-review-resubmission-ui.test.tsx +++ b/lib/__tests__/work-review-resubmission-ui.test.tsx @@ -83,7 +83,7 @@ describe('作品修改版本入口', () => { expect(screen.queryByRole('combobox')).not.toBeInTheDocument() const file = new File(['image'], '修改稿.png', { type: 'image/png' }) - fireEvent.change(screen.getByLabelText('选择作品图片'), { target: { files: [file] } }) + fireEvent.change(screen.getByLabelText('选择定调图片'), { target: { files: [file] } }) await user.click(screen.getByRole('button', { name: '提交修改 V2' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) diff --git a/lib/__tests__/work-review-reviewer-workspace.test.tsx b/lib/__tests__/work-review-reviewer-workspace.test.tsx index 2b4ad95..952f706 100644 --- a/lib/__tests__/work-review-reviewer-workspace.test.tsx +++ b/lib/__tests__/work-review-reviewer-workspace.test.tsx @@ -63,6 +63,62 @@ const projectWithReference: WorkReviewProject = { } describe('WorkReviewReviewerWorkspace', () => { + test('switching image type shows only the matching optional reference images', async () => { + const user = userEvent.setup() + const group = project.versions[0].imageGroups[0] + render() + expect(screen.getAllByRole('img', { name: '主图参考' })).toHaveLength(2) + expect(screen.queryByRole('img', { name: 'SKU参考' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /(?:主图|详情|SKU)图片 \d+/ })).not.toBeInTheDocument() + await user.click(screen.getAllByRole('button', { name: 'SKU图片' })[0]) + expect(screen.getAllByRole('img', { name: 'SKU参考' })).toHaveLength(2) + expect(screen.queryByRole('img', { name: '主图参考' })).not.toBeInTheDocument() + await user.click(screen.getAllByRole('button', { name: '详情图片' })[0]) + expect(screen.getAllByText('当前类型未上传参考图片')).toHaveLength(2) + expect(screen.queryByRole('img', { name: 'SKU参考' })).not.toBeInTheDocument() + }) + + test('same-type images remain individually selectable without numbered labels', async () => { + const user = userEvent.setup() + const group = project.versions[0].imageGroups[0] + render() + + expect(screen.getAllByRole('button', { name: '主图图片' })).toHaveLength(4) + expect(screen.getAllByRole('button', { name: 'SKU图片' })).toHaveLength(4) + expect(screen.queryByRole('button', { name: /(?:主图|SKU)图片 \d+/ })).not.toBeInTheDocument() + + for (const sourceName of ['第二张主图', '第二张SKU', '第一张SKU', '第一张主图']) { + await user.click(screen.getAllByTitle(sourceName)[0]) + expect(screen.getAllByRole('img', { name: sourceName })).toHaveLength(2) + expect(screen.getAllByTitle(sourceName)[0]).toHaveAttribute('aria-pressed', 'true') + } + }) + test('shows the submitted work description above the image workspace', () => { render( ({ + getWorkReviewReviewerOptions: jest.fn(), + uploadWorkReviewImages: jest.fn(), +})) + +const project = { + latestVersion: 1, + title: '测试作品', + manager: { name: '评审人' }, +} as WorkReviewProject +const image = (name: string) => new File(['image'], name, { type: 'image/png' }) +const add = (label: string, names: string[]) => fireEvent.change(screen.getByLabelText(`选择${label}`), { + target: { files: names.map(image) }, +}) + +describe('typed work review uploads', () => { + beforeEach(() => { + jest.clearAllMocks() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:preview') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }) + jest.mocked(uploadWorkReviewImages).mockImplementation(async (files) => files.map((file, sourceIndex) => ({ + uploadId: `uploaded-${file.name}`, + mimeType: 'image/webp', width: 100, height: 100, kind: 'slice', + sourceName: file.name, sourceIndex, sliceIndex: 0, sliceCount: 1, + }))) + }) + + test('all 16 combinations obey tone-only or the complete three-part suite', () => { + for (let mask = 0; mask < 16; mask += 1) { + const counts = Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type, index) => [type, mask & (1 << index) ? 1 : 0])) as Record + const error = getWorkReviewImageSelectionError(counts, { tone: 0, main: 0, detail: 0, sku: 0 }) + expect(error === null).toBe([1, 14, 15].includes(mask)) + } + }) + + test('main and SKU accept multiple images, append, deduplicate and remove individual images', async () => { + const user = userEvent.setup() + render() + expect(await screen.findByLabelText('选择主图图片')).toHaveAttribute('multiple') + expect(screen.getByLabelText('选择SKU图片')).toHaveAttribute('multiple') + add('主图图片', ['主图1.png', '主图2.png']) + add('SKU图片', ['sku1.png', 'sku2.png']) + expect(screen.getByRole('img', { name: '主图2.png' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'sku2.png' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '移除 主图1.png' })) + expect(screen.queryByRole('img', { name: '主图1.png' })).not.toBeInTheDocument() + add('主图图片', ['主图3.png']) + expect(screen.getByRole('img', { name: '主图2.png' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: '主图3.png' })).toBeInTheDocument() + }) + + test('tone plus an incomplete suite is blocked with a recoverable error', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn() + render() + await screen.findByLabelText('选择定调图片') + add('定调图片', ['定调.png']) + add('主图图片', ['主图.png']) + await user.click(screen.getByRole('button', { name: '提交修改 V2' })) + expect(screen.getByRole('alert')).toHaveTextContent('还缺少:详情图片、SKU图片') + expect(uploadWorkReviewImages).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(screen.getByRole('img', { name: '定调.png' })).toBeInTheDocument() + }) + + test('complete suite preserves all images and independently indexes the typed reference sets', async () => { + const user = userEvent.setup() + const onSubmit = jest.fn().mockResolvedValue(undefined) + render() + await screen.findByLabelText('选择主图图片') + add('主图图片', ['主图1.png', '主图2.png']) + add('详情图片', ['详情长图.png']) + add('SKU图片', ['sku1.png', 'sku2.png']) + add('主图参考图片', ['主图参考.png']) + add('SKU参考图片', ['sku参考.png']) + await user.click(screen.getByRole('button', { name: '提交修改 V2' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + const payload = onSubmit.mock.calls[0][0] + expect(payload.processedImages.map((item: any) => [item.assetType, item.sourceIndex])).toEqual([ + ['main', 0], ['main', 1], ['detail', 2], ['sku', 3], ['sku', 4], + ]) + expect(payload.processedReferenceImages.map((item: any) => [item.assetType, item.sourceIndex])).toEqual([ + ['main', 0], ['sku', 1], + ]) + }) +}) diff --git a/lib/__tests__/work-review-upload-safety.test.ts b/lib/__tests__/work-review-upload-safety.test.ts index 6d70ed6..1367f1f 100644 --- a/lib/__tests__/work-review-upload-safety.test.ts +++ b/lib/__tests__/work-review-upload-safety.test.ts @@ -199,6 +199,49 @@ describe('work review processed image persistence safety', () => { await rm(tempRoot, { recursive: true, force: true }) }) + test.each(Array.from({ length: 16 }, (_, mask) => mask).filter((mask) => ![1, 14, 15].includes(mask)))( + 'rejects an empty or incomplete upload combination (%i) before storage writes', + async (mask) => { + const types = (['tone', 'main', 'detail', 'sku'] as const).filter((_, index) => mask & (1 << index)) + await expect(createWorkReviewProject(session, { + ...makeProjectBody(), + processedImages: types.map((type, index) => ({ ...makeProcessedImage(`upload-${type}`, { sourceIndex: index }), assetType: type })), + })).rejects.toBeInstanceOf(WorkReviewApiError) + expect(uploadFileToS3).not.toHaveBeenCalled() + expect(prisma.workReviewProject.create).not.toHaveBeenCalled() + }, + ) + + test.each([false, true])('persists a complete multi-image suite, optional tone=%s, and typed reference images', async (includeTone) => { + const types = [...(includeTone ? ['tone'] : []), 'main', 'main', 'detail', 'sku', 'sku'] + const result = await createWorkReviewProject(session, { + ...makeProjectBody(), + processedImages: types.map((assetType, index) => ({ ...makeProcessedImage(`work-${index}`, { sourceIndex: index }), assetType })), + processedReferenceImages: [{ ...makeProcessedImage('main-reference'), assetType: 'main' }], + }) + expect(result.versions[0].imageGroups.map((group: any) => group.assetType)).toEqual(types) + expect(result.versions[0].referenceImageGroups[0].assetType).toBe('main') + expect(result.versions[0].imageGroups.map((group: any) => group.imageId).every(Boolean)).toBe(true) + expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(types.length + 1) + }) + + test('rejects unknown types, mismatched slice types, orphaned references and malformed references', async () => { + const badPayloads = [ + { processedImages: [{ ...makeProcessedImage('bad'), assetType: 'unknown' }] }, + { processedImages: [ + { ...makeProcessedImage('slice-1'), assetType: 'tone', sliceCount: 2 }, + { ...makeProcessedImage('slice-2'), assetType: 'main', sliceCount: 2, sliceIndex: 1 }, + ] }, + { processedReferenceImages: [{ ...makeProcessedImage('orphan'), assetType: 'sku' }] }, + { processedReferenceImages: { invalid: true } }, + ] + for (const payload of badPayloads) { + await expect(createWorkReviewProject(session, { ...makeProjectBody(), ...payload })).rejects.toBeInstanceOf(WorkReviewApiError) + } + expect(uploadFileToS3).not.toHaveBeenCalled() + expect(prisma.workReviewProject.create).not.toHaveBeenCalled() + }) + test('treats a committed project with a lost acknowledgement as success and preserves both image sets', async () => { let persisted: any ;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => { diff --git a/lib/work-review-image-types.ts b/lib/work-review-image-types.ts new file mode 100644 index 0000000..eef1380 --- /dev/null +++ b/lib/work-review-image-types.ts @@ -0,0 +1,32 @@ +import { + WORK_REVIEW_IMAGE_LABELS, + WORK_REVIEW_IMAGE_TYPES, + type WorkReviewImageType, +} from '@/types/work-review' + +export function isWorkReviewImageType(value: unknown): value is WorkReviewImageType { + return WORK_REVIEW_IMAGE_TYPES.includes(value as WorkReviewImageType) +} + +export function getWorkReviewImageSelectionError( + counts: Record, + referenceCounts: Record, +) { + const suiteTypes = ['main', 'detail', 'sku'] as const + if (suiteTypes.some((type) => counts[type] > 0) && suiteTypes.some((type) => counts[type] === 0)) { + const missing = suiteTypes.filter((type) => counts[type] === 0).map((type) => WORK_REVIEW_IMAGE_LABELS[type]) + return `主图、详情、SKU 必须三项一起上传,还缺少:${missing.join('、')}` + } + if (WORK_REVIEW_IMAGE_TYPES.every((type) => counts[type] === 0)) { + return '请上传定调图片,或同时上传主图、详情、SKU 三项图片' + } + const orphanReference = WORK_REVIEW_IMAGE_TYPES.find((type) => referenceCounts[type] > 0 && counts[type] === 0) + if (orphanReference) return `上传${WORK_REVIEW_IMAGE_LABELS[orphanReference]}参考图前,请先添加对应作品图片,或移除该参考图` + if (Object.values(counts).reduce((sum, count) => sum + count, 0) > 60) { + return '作品图片合计最多上传 60 张,请移除多余图片' + } + if (Object.values(referenceCounts).reduce((sum, count) => sum + count, 0) > 60) { + return '参考图片合计最多上传 60 张,请移除多余图片' + } + return null +} diff --git a/lib/work-review-service.ts b/lib/work-review-service.ts index a1e421e..55e67e3 100644 --- a/lib/work-review-service.ts +++ b/lib/work-review-service.ts @@ -65,6 +65,8 @@ import { type FirstPassSubmitterRate, } from '@/lib/work-review-metrics' import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope' +import { getWorkReviewImageSelectionError, isWorkReviewImageType } from '@/lib/work-review-image-types' +import { WORK_REVIEW_IMAGE_TYPES, type WorkReviewImageType } from '@/types/work-review' const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES @@ -89,10 +91,12 @@ type ResolvedProcessedImage = { fileSize: number } +type WorkReviewStoredImageGroup = PerformanceSurveyStoredImageGroup & { assetType?: WorkReviewImageType } + type WorkReviewStoredImagePayload = { version: 2 - workImageGroups: PerformanceSurveyStoredImageGroup[] - referenceImageGroups: PerformanceSurveyStoredImageGroup[] + workImageGroups: WorkReviewStoredImageGroup[] + referenceImageGroups: WorkReviewStoredImageGroup[] } function isRecord(value: unknown): value is Record { @@ -101,8 +105,8 @@ function isRecord(value: unknown): value is Record { export function serializeWorkReviewImagePayload(value: unknown) { if (isRecord(value) && Number(value.version) === 2) { - const workImages = serializePerformanceSurveyImagePayload(value.workImageGroups) - const referenceImages = serializePerformanceSurveyImagePayload(value.referenceImageGroups) + const workImages = serializeTypedWorkReviewImages(value.workImageGroups) + const referenceImages = serializeTypedWorkReviewImages(value.referenceImageGroups) return { imageUrls: workImages.imageUrls, imageGroups: workImages.imageGroups, @@ -120,9 +124,30 @@ export function serializeWorkReviewImagePayload(value: unknown) { } } +function serializeTypedWorkReviewImages(value: unknown) { + const serialized = serializePerformanceSurveyImagePayload(value) + const typesByImageId = new Map() + if (Array.isArray(value)) { + for (const item of value) { + if (isRecord(item) && item.imageId && isWorkReviewImageType(item.assetType)) { + typesByImageId.set(String(item.imageId), item.assetType) + } + } + } + return { + ...serialized, + imageGroups: serialized.imageGroups.map((group): WorkReviewStoredImageGroup => ({ + ...group, + ...(group.imageId && typesByImageId.has(group.imageId) + ? { assetType: typesByImageId.get(group.imageId) } + : {}), + })), + } +} + function buildWorkReviewImagePayload( - workImageGroups: PerformanceSurveyStoredImageGroup[], - referenceImageGroups: PerformanceSurveyStoredImageGroup[], + workImageGroups: WorkReviewStoredImageGroup[], + referenceImageGroups: WorkReviewStoredImageGroup[], ): WorkReviewStoredImagePayload { return { version: 2, @@ -1008,8 +1033,8 @@ async function resolveProcessedUpload( return { declaration, filePath, fileSize: uploadSession.fileSize } } -type ValidatedWorkReviewProcessedImageGroups = ReturnType< - typeof validatePerformanceSurveyProcessedImages +type ValidatedWorkReviewProcessedImageGroups = Array< + ReturnType[number] & { assetType: WorkReviewImageType } > type WorkReviewPersistenceOutcome = @@ -1023,7 +1048,7 @@ function validateStoredImages( processedImages: unknown, options: { optional?: boolean } = {}, ): ValidatedWorkReviewProcessedImageGroups { - if (options.optional && (!Array.isArray(processedImages) || processedImages.length === 0)) { + if (options.optional && (processedImages == null || (Array.isArray(processedImages) && processedImages.length === 0))) { return [] } @@ -1036,7 +1061,16 @@ function validateStoredImages( } throw error } - return groups + // 分类属于整张源图,预览和所有切片必须一致,不能借分片绕过整套校验。 + return groups.map((group) => { + const declarations = (processedImages as Array>) + .filter((item) => Number(item.sourceIndex) === group.sourceIndex) + const types = declarations.map((item) => item.assetType === undefined ? 'tone' : item.assetType) + if (types.some((type) => !isWorkReviewImageType(type)) || new Set(types).size !== 1) { + throw new WorkReviewApiError('图片分类无效,同一张图片的预览和切片必须使用相同分类') + } + return { ...group, assetType: types[0] as WorkReviewImageType } + }) } function getProcessedImageDeclarations(groups: ValidatedWorkReviewProcessedImageGroups) { @@ -1089,7 +1123,7 @@ async function uploadStoredImageGroups(input: { assetKind: 'work' | 'reference' uploadedUrls: Set }) { - const imageGroups: PerformanceSurveyStoredImageGroup[] = [] + const imageGroups: Array = [] for (const group of input.groups) { const slices: PerformanceSurveyStoredImageGroup['slices'] = [] for (const declaration of group.slices) { @@ -1125,6 +1159,7 @@ async function uploadStoredImageGroups(input: { previewHeight = group.preview.height } imageGroups.push({ + assetType: group.assetType, version: 1, imageId: randomUUID(), sourceName: group.sourceName, @@ -1179,6 +1214,11 @@ async function withStoredImageSets(input: { }) { const workGroups = validateStoredImages(input.processedImages) const referenceGroups = validateStoredImages(input.processedReferenceImages, { optional: true }) + const selectionError = getWorkReviewImageSelectionError( + Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, workGroups.filter((group) => group.assetType === type).length])) as Record, + Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, referenceGroups.filter((group) => group.assetType === type).length])) as Record, + ) + if (selectionError) throw new WorkReviewApiError(selectionError) const uploadIds = getLockedUploadIds(workGroups, referenceGroups) const uploadedUrls = new Set() diff --git a/types/work-review.ts b/types/work-review.ts index c43fca3..eea5825 100644 --- a/types/work-review.ts +++ b/types/work-review.ts @@ -10,6 +10,21 @@ export type WorkReviewBrand = typeof WORK_REVIEW_BRANDS[number] export type WorkReviewDecision = 'approved' | 'revision_required' +export const WORK_REVIEW_IMAGE_TYPES = ['tone', 'main', 'detail', 'sku'] as const +export type WorkReviewImageType = typeof WORK_REVIEW_IMAGE_TYPES[number] +export const WORK_REVIEW_IMAGE_LABELS: Record = { + tone: '定调图片', + main: '主图图片', + detail: '详情图片', + sku: 'SKU图片', +} +export const WORK_REVIEW_REFERENCE_LABELS: Record = { + tone: '定调参考图片', + main: '主图参考图片', + detail: '详情参考图片', + sku: 'SKU参考图片', +} + export type WorkReviewAnnotationShape = 'point' | 'rectangle' | 'ellipse' | 'arrow' export type WorkReviewImageAnnotation = { @@ -30,6 +45,7 @@ export type WorkReviewImageSlice = { } export type WorkReviewImageGroup = { + assetType?: WorkReviewImageType version?: number imageId?: string sourceName: string @@ -150,6 +166,7 @@ export type WorkReviewBoardData = { } export type WorkReviewProcessedImage = { + assetType?: WorkReviewImageType uploadId: string mimeType: 'image/webp' width: number