feat: support categorized work review image submissions
This commit is contained in:
parent
5a5f0a52dc
commit
292ea7e853
@ -13,9 +13,12 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { getWorkReviewReviewerOptions, uploadWorkReviewImages } from '@/lib/work-review-client'
|
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 {
|
import type {
|
||||||
WorkReviewDecision,
|
WorkReviewDecision,
|
||||||
|
WorkReviewImageType,
|
||||||
|
WorkReviewProcessedImage,
|
||||||
WorkReviewProject,
|
WorkReviewProject,
|
||||||
WorkReviewReviewInput,
|
WorkReviewReviewInput,
|
||||||
WorkReviewReviewerOption,
|
WorkReviewReviewerOption,
|
||||||
@ -33,6 +36,10 @@ function todayValue() {
|
|||||||
return local.toISOString().slice(0, 10)
|
return local.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyImageFiles(): Record<WorkReviewImageType, File[]> {
|
||||||
|
return { tone: [], main: [], detail: [], sku: [] }
|
||||||
|
}
|
||||||
|
|
||||||
function proxyImageUrl(value: string) {
|
function proxyImageUrl(value: string) {
|
||||||
if (!/^https?:\/\//i.test(value)) return value
|
if (!/^https?:\/\//i.test(value)) return value
|
||||||
try {
|
try {
|
||||||
@ -85,7 +92,7 @@ function WorkReviewDialogShell({
|
|||||||
}
|
}
|
||||||
if (event.key !== 'Tab') return
|
if (event.key !== 'Tab') return
|
||||||
const elements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
|
const elements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
'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
|
if (elements.length === 0) return
|
||||||
const first = elements[0]
|
const first = elements[0]
|
||||||
@ -228,8 +235,8 @@ export function WorkReviewSubmissionDialog({
|
|||||||
const [reviewerLoadError, setReviewerLoadError] = useState<string | null>(null)
|
const [reviewerLoadError, setReviewerLoadError] = useState<string | null>(null)
|
||||||
const [reviewerLoadKey, setReviewerLoadKey] = useState(0)
|
const [reviewerLoadKey, setReviewerLoadKey] = useState(0)
|
||||||
const [submissionNote, setSubmissionNote] = useState('')
|
const [submissionNote, setSubmissionNote] = useState('')
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState(emptyImageFiles)
|
||||||
const [referenceFiles, setReferenceFiles] = useState<File[]>([])
|
const [referenceFiles, setReferenceFiles] = useState(emptyImageFiles)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null)
|
const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null)
|
||||||
@ -281,8 +288,12 @@ export function WorkReviewSubmissionDialog({
|
|||||||
setError('请选择评审人')
|
setError('请选择评审人')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (files.length === 0) {
|
const selectionError = getWorkReviewImageSelectionError(
|
||||||
setError('请至少选择 1 张作品图片')
|
Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, files[type].length])) as Record<WorkReviewImageType, number>,
|
||||||
|
Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, referenceFiles[type].length])) as Record<WorkReviewImageType, number>,
|
||||||
|
)
|
||||||
|
if (selectionError) {
|
||||||
|
setError(selectionError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,23 +302,33 @@ export function WorkReviewSubmissionDialog({
|
|||||||
setError(null)
|
setError(null)
|
||||||
setProgress({ message: '正在准备图片' })
|
setProgress({ message: '正在准备图片' })
|
||||||
try {
|
try {
|
||||||
const hasReferenceFiles = referenceFiles.length > 0
|
const processedImages: WorkReviewProcessedImage[] = []
|
||||||
const processedImages = await uploadWorkReviewImages(files, (message, percent) => {
|
const processedReferenceImages: WorkReviewProcessedImage[] = []
|
||||||
setProgress({
|
const batches = [false, true].flatMap((reference) => WORK_REVIEW_IMAGE_TYPES.map((type) => ({
|
||||||
message: `作品图片:${message}`,
|
type,
|
||||||
percent: typeof percent === 'number'
|
reference,
|
||||||
? Math.round(percent * (hasReferenceFiles ? 0.6 : 1))
|
files: (reference ? referenceFiles : files)[type],
|
||||||
: undefined,
|
}))).filter((batch) => batch.files.length > 0)
|
||||||
})
|
const totalFiles = batches.reduce((sum, batch) => sum + batch.files.length, 0)
|
||||||
})
|
let completedFiles = 0
|
||||||
const processedReferenceImages = hasReferenceFiles
|
let workOffset = 0
|
||||||
? await uploadWorkReviewImages(referenceFiles, (message, percent) => {
|
let referenceOffset = 0
|
||||||
setProgress({
|
for (const batch of batches) {
|
||||||
message: `参考图片:${message}`,
|
const uploaded = await uploadWorkReviewImages(batch.files, (message, percent) => {
|
||||||
percent: typeof percent === 'number' ? 60 + Math.round(percent * 0.4) : undefined,
|
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({
|
await onSubmit({
|
||||||
...(isCreate
|
...(isCreate
|
||||||
? {
|
? {
|
||||||
@ -344,6 +365,11 @@ export function WorkReviewSubmissionDialog({
|
|||||||
widthClass="max-w-4xl"
|
widthClass="max-w-4xl"
|
||||||
footer={(
|
footer={(
|
||||||
<div>
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2.5 text-sm font-semibold leading-5 text-[#b42318]" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{progress && (
|
{progress && (
|
||||||
<div className="mb-3" aria-live="polite">
|
<div className="mb-3" aria-live="polite">
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-3 text-[11px] font-semibold text-[#667085]">
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-[11px] font-semibold text-[#667085]">
|
||||||
@ -397,7 +423,7 @@ export function WorkReviewSubmissionDialog({
|
|||||||
maxLength={120}
|
maxLength={120}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
placeholder="例如:夏季新品主视觉"
|
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="grid gap-2">
|
<label className="grid gap-2">
|
||||||
@ -414,7 +440,7 @@ export function WorkReviewSubmissionDialog({
|
|||||||
aria-required="true"
|
aria-required="true"
|
||||||
list="work-review-brand-options"
|
list="work-review-brand-options"
|
||||||
placeholder="例如:亿家达"
|
placeholder="例如:亿家达"
|
||||||
className="h-11 rounded border border-[#cfd6e1] bg-white 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] bg-white px-3 text-base outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7] sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<datalist id="work-review-brand-options">
|
<datalist id="work-review-brand-options">
|
||||||
{WORK_REVIEW_BRANDS.map((option) => <option key={option} value={option} />)}
|
{WORK_REVIEW_BRANDS.map((option) => <option key={option} value={option} />)}
|
||||||
@ -456,7 +482,7 @@ export function WorkReviewSubmissionDialog({
|
|||||||
value={workDate}
|
value={workDate}
|
||||||
onChange={(event) => setWorkDate(event.target.value)}
|
onChange={(event) => setWorkDate(event.target.value)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="grid gap-2">
|
<label className="grid gap-2">
|
||||||
@ -467,7 +493,7 @@ export function WorkReviewSubmissionDialog({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="补充项目背景或本次作品目标(可选)"
|
placeholder="补充项目背景或本次作品目标(可选)"
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
@ -503,40 +529,48 @@ export function WorkReviewSubmissionDialog({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={isCreate ? '补充需要评审人关注的内容(可选)' : '简要说明本次修改了什么(可选)'}
|
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"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 sm:items-start">
|
<section className="border-t border-[#e4e7ec] pt-5" aria-label="上传作品图片">
|
||||||
<div className="min-w-0">
|
<h3 className="text-base font-bold text-[#101828]">上传图片</h3>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<p className="mt-1 text-sm leading-6 text-[#667085]">
|
||||||
<span className="text-[13px] font-bold text-[#344054]">作品图片 <span className="text-[#b42318]">*</span></span>
|
可仅上传定调图片;上传整套时,主图、详情、SKU 三项必须齐全,定调图片可选。
|
||||||
<span className="text-[11px] font-medium text-[#7b8494]">{files.length} 张</span>
|
</p>
|
||||||
</div>
|
<p className="mt-1 text-sm leading-6 text-[#667085]">主图、SKU 支持多图,详情支持长图;参考图均选填,不参与逐图评审。作品图与参考图各合计最多 60 张。</p>
|
||||||
<WorkReviewImageDropzone files={files} onChange={setFiles} disabled={busy} label="作品图片" />
|
<div className="mt-5 space-y-6">
|
||||||
|
{WORK_REVIEW_IMAGE_TYPES.map((type) => (
|
||||||
|
<section key={type} aria-label={WORK_REVIEW_IMAGE_LABELS[type]} className="grid gap-4 border-b border-[#e4e7ec] pb-6 last:border-0 last:pb-0 sm:grid-cols-2 sm:items-start">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<h4 className="font-bold text-[#344054]">{WORK_REVIEW_IMAGE_LABELS[type]}</h4>
|
||||||
|
<span className="text-[#667085]" aria-live="polite">已选 {files[type].length} 张</span>
|
||||||
|
</div>
|
||||||
|
<WorkReviewImageDropzone
|
||||||
|
files={files[type]}
|
||||||
|
onChange={(selected) => { setFiles((current) => ({ ...current, [type]: selected })); setError(null) }}
|
||||||
|
disabled={busy}
|
||||||
|
label={WORK_REVIEW_IMAGE_LABELS[type]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
|
<h4 className="font-bold text-[#344054]">{WORK_REVIEW_REFERENCE_LABELS[type]} <span className="font-normal text-[#667085]">(可选)</span></h4>
|
||||||
|
<span className="text-[#667085]" aria-live="polite">已选 {referenceFiles[type].length} 张</span>
|
||||||
|
</div>
|
||||||
|
<WorkReviewImageDropzone
|
||||||
|
files={referenceFiles[type]}
|
||||||
|
onChange={(selected) => { setReferenceFiles((current) => ({ ...current, [type]: selected })); setError(null) }}
|
||||||
|
disabled={busy}
|
||||||
|
label={WORK_REVIEW_REFERENCE_LABELS[type]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
</section>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
|
||||||
<span className="text-[13px] font-bold text-[#344054]">参考图片 <span className="font-medium text-[#7b8494]">(可选)</span></span>
|
|
||||||
<span className="text-[11px] font-medium text-[#7b8494]">{referenceFiles.length} 张</span>
|
|
||||||
</div>
|
|
||||||
<WorkReviewImageDropzone
|
|
||||||
files={referenceFiles}
|
|
||||||
onChange={setReferenceFiles}
|
|
||||||
disabled={busy}
|
|
||||||
label="参考图片"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-[11px] font-medium leading-5 text-[#7b8494]">
|
|
||||||
仅供评审对照,不计入逐图评审数量。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2.5 text-[12px] font-semibold leading-5 text-[#b42318]" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</WorkReviewDialogShell>
|
</WorkReviewDialogShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Image from 'next/image'
|
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_COUNT = 60
|
||||||
const MAX_FILE_BYTES = 200 * 1024 * 1024
|
const MAX_FILE_BYTES = 200 * 1024 * 1024
|
||||||
@ -22,6 +22,7 @@ export function WorkReviewImageDropzone({
|
|||||||
label?: string
|
label?: string
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const hintId = useId()
|
||||||
const dragDepthRef = useRef(0)
|
const dragDepthRef = useRef(0)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -64,6 +65,7 @@ export function WorkReviewImageDropzone({
|
|||||||
multiple
|
multiple
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={`选择${label}`}
|
aria-label={`选择${label}`}
|
||||||
|
aria-describedby={hintId}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
addFiles(event.currentTarget.files || [])
|
addFiles(event.currentTarget.files || [])
|
||||||
@ -97,20 +99,21 @@ export function WorkReviewImageDropzone({
|
|||||||
setDragging(false)
|
setDragging(false)
|
||||||
addFiles(event.dataTransfer.files)
|
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
|
dragging
|
||||||
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
|
? 'border-[#0050cb] bg-[#eef5ff]'
|
||||||
: 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]'
|
: 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`flex h-9 w-9 items-center justify-center rounded-full text-[21px] ${
|
<span className={`flex h-9 w-9 items-center justify-center rounded-full text-[21px] ${
|
||||||
dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eaf2ff] text-[#0050cb]'
|
dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eaf2ff] text-[#0050cb]'
|
||||||
}`}>↑</span>
|
}`}>↑</span>
|
||||||
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">
|
<span className="mt-2 text-sm font-bold text-[#0050cb]">
|
||||||
{dragging ? '松开即可添加' : '拖拽图片到这里,或点击选择'}
|
{dragging ? '松开即可添加' : files.length > 0 ? '继续添加图片' : '添加图片(可多选 / 拖拽)'}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-[11px] leading-5 text-[#7b8494]">
|
<span id={hintId} className="mt-1 text-sm leading-5 text-[#667085]">
|
||||||
支持 PNG、JPG、GIF、WebP;长图会自动生成预览与切片
|
JPG / PNG / GIF / WebP,单张 ≤ 200MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -126,7 +129,7 @@ export function WorkReviewImageDropzone({
|
|||||||
alt={file.name}
|
alt={file.name}
|
||||||
fill
|
fill
|
||||||
unoptimized
|
unoptimized
|
||||||
className="object-cover"
|
className="object-contain"
|
||||||
/>
|
/>
|
||||||
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
|
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
@ -135,15 +138,15 @@ export function WorkReviewImageDropzone({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
|
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}`}
|
aria-label={`移除 ${file.name}`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 px-2.5 py-2">
|
<div className="min-w-0 px-2.5 py-2">
|
||||||
<div className="truncate text-[11px] font-semibold text-[#344054]" title={file.name}>{file.name}</div>
|
<div className="truncate text-sm font-semibold text-[#344054]" title={file.name}>{file.name}</div>
|
||||||
<div className="mt-0.5 text-[10px] text-[#98a2b3]">
|
<div className="mt-0.5 text-sm text-[#667085]">
|
||||||
{file.size < 1024 * 1024
|
{file.size < 1024 * 1024
|
||||||
? `${Math.max(1, Math.round(file.size / 1024))} KB`
|
? `${Math.max(1, Math.round(file.size / 1024))} KB`
|
||||||
: `${(file.size / 1024 / 1024).toFixed(1)} MB`}
|
: `${(file.size / 1024 / 1024).toFixed(1)} MB`}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Image from 'next/image'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { WorkReviewImageGroup } from '@/types/work-review'
|
import type { WorkReviewImageGroup } from '@/types/work-review'
|
||||||
|
import { WORK_REVIEW_IMAGE_LABELS } from '@/types/work-review'
|
||||||
import {
|
import {
|
||||||
WorkReviewAnnotationMarker,
|
WorkReviewAnnotationMarker,
|
||||||
type WorkReviewDisplayAnnotation,
|
type WorkReviewDisplayAnnotation,
|
||||||
@ -92,6 +93,7 @@ export function WorkReviewImageGallery({
|
|||||||
className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]"
|
className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2.5 pb-2 pt-7">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2.5 pb-2 pt-7">
|
||||||
|
{group.assetType && <div className="mb-1 text-xs font-bold text-white">{WORK_REVIEW_IMAGE_LABELS[group.assetType]}</div>}
|
||||||
<div className="truncate text-[11px] font-semibold text-white">{group.sourceName}</div>
|
<div className="truncate text-[11px] font-semibold text-white">{group.sourceName}</div>
|
||||||
</div>
|
</div>
|
||||||
{group.slices.length > 1 && (
|
{group.slices.length > 1 && (
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export function WorkReviewReviewerSelect({
|
|||||||
? `${listboxId}-${filteredOptions[highlightedIndex].id}`
|
? `${listboxId}-${filteredOptions[highlightedIndex].id}`
|
||||||
: undefined}
|
: undefined}
|
||||||
aria-busy={loading}
|
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"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
||||||
{value && !isDisabled && (
|
{value && !isDisabled && (
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
type WorkReviewDrawingTool,
|
type WorkReviewDrawingTool,
|
||||||
} from './WorkReviewAnnotationToolbar'
|
} from './WorkReviewAnnotationToolbar'
|
||||||
import { canResubmitWorkReviewProject } from '@/lib/work-review-policy'
|
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 PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
|
||||||
const MIN_PANE_PERCENT = 14
|
const MIN_PANE_PERCENT = 14
|
||||||
@ -280,13 +281,14 @@ function LongImagePane({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectImage(index)}
|
onClick={() => selectImage(index)}
|
||||||
aria-pressed={activeIndex === index}
|
aria-pressed={activeIndex === index}
|
||||||
|
title={group.sourceName}
|
||||||
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${
|
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${
|
||||||
activeIndex === index
|
activeIndex === index
|
||||||
? 'bg-[#0050cb] text-white'
|
? 'bg-[#0050cb] text-white'
|
||||||
: 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]'
|
: 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
图 {index + 1}
|
{group.assetType ? WORK_REVIEW_IMAGE_LABELS[group.assetType] : `图 ${index + 1}`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -523,6 +525,14 @@ export function WorkReviewReviewerWorkspace({
|
|||||||
const workImageCount = getImageCount(selectedVersion)
|
const workImageCount = getImageCount(selectedVersion)
|
||||||
const hasReferenceImages = (selectedVersion?.referenceImageGroups?.length || 0) > 0
|
const hasReferenceImages = (selectedVersion?.referenceImageGroups?.length || 0) > 0
|
||||||
|| (selectedVersion?.referenceImageUrls?.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(() => {
|
useEffect(() => {
|
||||||
if (!project.versions.some((version) => version.id === selectedVersionId)) {
|
if (!project.versions.some((version) => version.id === selectedVersionId)) {
|
||||||
@ -927,7 +937,7 @@ export function WorkReviewReviewerWorkspace({
|
|||||||
<div className="min-w-0" style={{ width: hasReferenceImages ? `${workPanePercent}%` : '100%' }}>
|
<div className="min-w-0" style={{ width: hasReferenceImages ? `${workPanePercent}%` : '100%' }}>
|
||||||
<LongImagePane
|
<LongImagePane
|
||||||
key={`${selectedVersion.id}-work`}
|
key={`${selectedVersion.id}-work`}
|
||||||
label="作品图片"
|
label={activeWorkLabel}
|
||||||
groups={selectedVersion.imageGroups || []}
|
groups={selectedVersion.imageGroups || []}
|
||||||
imageUrls={selectedVersion.imageUrls || []}
|
imageUrls={selectedVersion.imageUrls || []}
|
||||||
emptyMessage="当前版本没有作品图片"
|
emptyMessage="当前版本没有作品图片"
|
||||||
@ -1002,11 +1012,11 @@ export function WorkReviewReviewerWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<LongImagePane
|
<LongImagePane
|
||||||
key={`${selectedVersion.id}-reference`}
|
key={`${selectedVersion.id}-reference-${activeAssetType || 'all'}`}
|
||||||
label="参考图片"
|
label={activeAssetType ? WORK_REVIEW_REFERENCE_LABELS[activeAssetType] : '参考图片'}
|
||||||
groups={selectedVersion.referenceImageGroups || []}
|
groups={matchingReferenceGroups}
|
||||||
imageUrls={selectedVersion.referenceImageUrls || []}
|
imageUrls={matchingReferenceUrls}
|
||||||
emptyMessage="当前版本未上传参考图片"
|
emptyMessage="当前类型未上传参考图片"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -1045,7 +1055,7 @@ export function WorkReviewReviewerWorkspace({
|
|||||||
<div className="h-full w-full shrink-0 snap-start">
|
<div className="h-full w-full shrink-0 snap-start">
|
||||||
<LongImagePane
|
<LongImagePane
|
||||||
key={`${selectedVersion.id}-mobile-work`}
|
key={`${selectedVersion.id}-mobile-work`}
|
||||||
label="作品图片"
|
label={activeWorkLabel}
|
||||||
groups={selectedVersion.imageGroups || []}
|
groups={selectedVersion.imageGroups || []}
|
||||||
imageUrls={selectedVersion.imageUrls || []}
|
imageUrls={selectedVersion.imageUrls || []}
|
||||||
emptyMessage="当前版本没有作品图片"
|
emptyMessage="当前版本没有作品图片"
|
||||||
@ -1074,11 +1084,11 @@ export function WorkReviewReviewerWorkspace({
|
|||||||
{hasReferenceImages && (
|
{hasReferenceImages && (
|
||||||
<div className="h-full w-full shrink-0 snap-start">
|
<div className="h-full w-full shrink-0 snap-start">
|
||||||
<LongImagePane
|
<LongImagePane
|
||||||
key={`${selectedVersion.id}-mobile-reference`}
|
key={`${selectedVersion.id}-mobile-reference-${activeAssetType || 'all'}`}
|
||||||
label="参考图片"
|
label={activeAssetType ? WORK_REVIEW_REFERENCE_LABELS[activeAssetType] : '参考图片'}
|
||||||
groups={selectedVersion.referenceImageGroups || []}
|
groups={matchingReferenceGroups}
|
||||||
imageUrls={selectedVersion.referenceImageUrls || []}
|
imageUrls={matchingReferenceUrls}
|
||||||
emptyMessage="当前版本未上传参考图片"
|
emptyMessage="当前类型未上传参考图片"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -83,7 +83,7 @@ describe('作品修改版本入口', () => {
|
|||||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||||
|
|
||||||
const file = new File(['image'], '修改稿.png', { type: 'image/png' })
|
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 user.click(screen.getByRole('button', { name: '提交修改 V2' }))
|
||||||
|
|
||||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||||
|
|||||||
@ -63,6 +63,62 @@ const projectWithReference: WorkReviewProject = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('WorkReviewReviewerWorkspace', () => {
|
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(<WorkReviewReviewerWorkspace project={{
|
||||||
|
...project,
|
||||||
|
versions: [{
|
||||||
|
...project.versions[0],
|
||||||
|
imageGroups: [
|
||||||
|
{ ...group, imageId: 'main', sourceName: '主图作品', assetType: 'main' },
|
||||||
|
{ ...group, imageId: 'sku', sourceName: 'SKU作品', assetType: 'sku' },
|
||||||
|
{ ...group, imageId: 'detail', sourceName: '详情作品', assetType: 'detail' },
|
||||||
|
],
|
||||||
|
referenceImageGroups: [
|
||||||
|
{ ...group, imageId: 'ref-main', sourceName: '主图参考', assetType: 'main' },
|
||||||
|
{ ...group, imageId: 'ref-sku', sourceName: 'SKU参考', assetType: 'sku' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}} onSubmitReview={jest.fn()} />)
|
||||||
|
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(<WorkReviewReviewerWorkspace project={{
|
||||||
|
...project,
|
||||||
|
versions: [{
|
||||||
|
...project.versions[0],
|
||||||
|
imageGroups: [
|
||||||
|
{ ...group, imageId: 'main-1', sourceName: '第一张主图', assetType: 'main' },
|
||||||
|
{ ...group, imageId: 'main-2', sourceName: '第二张主图', assetType: 'main' },
|
||||||
|
{ ...group, imageId: 'sku-1', sourceName: '第一张SKU', assetType: 'sku' },
|
||||||
|
{ ...group, imageId: 'sku-2', sourceName: '第二张SKU', assetType: 'sku' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}} onSubmitReview={jest.fn()} />)
|
||||||
|
|
||||||
|
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', () => {
|
test('shows the submitted work description above the image workspace', () => {
|
||||||
render(
|
render(
|
||||||
<WorkReviewReviewerWorkspace
|
<WorkReviewReviewerWorkspace
|
||||||
|
|||||||
95
lib/__tests__/work-review-submission-images.test.tsx
Normal file
95
lib/__tests__/work-review-submission-images.test.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/** @jest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { WorkReviewSubmissionDialog } from '@/components/work-review/WorkReviewDialogs'
|
||||||
|
import { getWorkReviewImageSelectionError } from '@/lib/work-review-image-types'
|
||||||
|
import { uploadWorkReviewImages } from '@/lib/work-review-client'
|
||||||
|
import { WORK_REVIEW_IMAGE_TYPES, type WorkReviewProject } from '@/types/work-review'
|
||||||
|
|
||||||
|
jest.mock('@/lib/work-review-client', () => ({
|
||||||
|
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<typeof WORK_REVIEW_IMAGE_TYPES[number], number>
|
||||||
|
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(<WorkReviewSubmissionDialog mode="resubmit" project={project} onClose={jest.fn()} onSubmit={jest.fn()} />)
|
||||||
|
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(<WorkReviewSubmissionDialog mode="resubmit" project={project} onClose={jest.fn()} onSubmit={onSubmit} />)
|
||||||
|
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(<WorkReviewSubmissionDialog mode="resubmit" project={project} onClose={jest.fn()} onSubmit={onSubmit} />)
|
||||||
|
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],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -199,6 +199,49 @@ describe('work review processed image persistence safety', () => {
|
|||||||
await rm(tempRoot, { recursive: true, force: true })
|
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 () => {
|
test('treats a committed project with a lost acknowledgement as success and preserves both image sets', async () => {
|
||||||
let persisted: any
|
let persisted: any
|
||||||
;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
|
;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
|
||||||
|
|||||||
32
lib/work-review-image-types.ts
Normal file
32
lib/work-review-image-types.ts
Normal file
@ -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<WorkReviewImageType, number>,
|
||||||
|
referenceCounts: Record<WorkReviewImageType, number>,
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -65,6 +65,8 @@ import {
|
|||||||
type FirstPassSubmitterRate,
|
type FirstPassSubmitterRate,
|
||||||
} from '@/lib/work-review-metrics'
|
} from '@/lib/work-review-metrics'
|
||||||
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
|
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
|
const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES
|
||||||
|
|
||||||
@ -89,10 +91,12 @@ type ResolvedProcessedImage = {
|
|||||||
fileSize: number
|
fileSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkReviewStoredImageGroup = PerformanceSurveyStoredImageGroup & { assetType?: WorkReviewImageType }
|
||||||
|
|
||||||
type WorkReviewStoredImagePayload = {
|
type WorkReviewStoredImagePayload = {
|
||||||
version: 2
|
version: 2
|
||||||
workImageGroups: PerformanceSurveyStoredImageGroup[]
|
workImageGroups: WorkReviewStoredImageGroup[]
|
||||||
referenceImageGroups: PerformanceSurveyStoredImageGroup[]
|
referenceImageGroups: WorkReviewStoredImageGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@ -101,8 +105,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
|
|
||||||
export function serializeWorkReviewImagePayload(value: unknown) {
|
export function serializeWorkReviewImagePayload(value: unknown) {
|
||||||
if (isRecord(value) && Number(value.version) === 2) {
|
if (isRecord(value) && Number(value.version) === 2) {
|
||||||
const workImages = serializePerformanceSurveyImagePayload(value.workImageGroups)
|
const workImages = serializeTypedWorkReviewImages(value.workImageGroups)
|
||||||
const referenceImages = serializePerformanceSurveyImagePayload(value.referenceImageGroups)
|
const referenceImages = serializeTypedWorkReviewImages(value.referenceImageGroups)
|
||||||
return {
|
return {
|
||||||
imageUrls: workImages.imageUrls,
|
imageUrls: workImages.imageUrls,
|
||||||
imageGroups: workImages.imageGroups,
|
imageGroups: workImages.imageGroups,
|
||||||
@ -120,9 +124,30 @@ export function serializeWorkReviewImagePayload(value: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeTypedWorkReviewImages(value: unknown) {
|
||||||
|
const serialized = serializePerformanceSurveyImagePayload(value)
|
||||||
|
const typesByImageId = new Map<string, WorkReviewImageType>()
|
||||||
|
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(
|
function buildWorkReviewImagePayload(
|
||||||
workImageGroups: PerformanceSurveyStoredImageGroup[],
|
workImageGroups: WorkReviewStoredImageGroup[],
|
||||||
referenceImageGroups: PerformanceSurveyStoredImageGroup[],
|
referenceImageGroups: WorkReviewStoredImageGroup[],
|
||||||
): WorkReviewStoredImagePayload {
|
): WorkReviewStoredImagePayload {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
@ -1008,8 +1033,8 @@ async function resolveProcessedUpload(
|
|||||||
return { declaration, filePath, fileSize: uploadSession.fileSize }
|
return { declaration, filePath, fileSize: uploadSession.fileSize }
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidatedWorkReviewProcessedImageGroups = ReturnType<
|
type ValidatedWorkReviewProcessedImageGroups = Array<
|
||||||
typeof validatePerformanceSurveyProcessedImages
|
ReturnType<typeof validatePerformanceSurveyProcessedImages>[number] & { assetType: WorkReviewImageType }
|
||||||
>
|
>
|
||||||
|
|
||||||
type WorkReviewPersistenceOutcome =
|
type WorkReviewPersistenceOutcome =
|
||||||
@ -1023,7 +1048,7 @@ function validateStoredImages(
|
|||||||
processedImages: unknown,
|
processedImages: unknown,
|
||||||
options: { optional?: boolean } = {},
|
options: { optional?: boolean } = {},
|
||||||
): ValidatedWorkReviewProcessedImageGroups {
|
): ValidatedWorkReviewProcessedImageGroups {
|
||||||
if (options.optional && (!Array.isArray(processedImages) || processedImages.length === 0)) {
|
if (options.optional && (processedImages == null || (Array.isArray(processedImages) && processedImages.length === 0))) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1036,7 +1061,16 @@ function validateStoredImages(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return groups
|
// 分类属于整张源图,预览和所有切片必须一致,不能借分片绕过整套校验。
|
||||||
|
return groups.map((group) => {
|
||||||
|
const declarations = (processedImages as Array<Record<string, unknown>>)
|
||||||
|
.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) {
|
function getProcessedImageDeclarations(groups: ValidatedWorkReviewProcessedImageGroups) {
|
||||||
@ -1089,7 +1123,7 @@ async function uploadStoredImageGroups(input: {
|
|||||||
assetKind: 'work' | 'reference'
|
assetKind: 'work' | 'reference'
|
||||||
uploadedUrls: Set<string>
|
uploadedUrls: Set<string>
|
||||||
}) {
|
}) {
|
||||||
const imageGroups: PerformanceSurveyStoredImageGroup[] = []
|
const imageGroups: Array<PerformanceSurveyStoredImageGroup & { assetType: WorkReviewImageType }> = []
|
||||||
for (const group of input.groups) {
|
for (const group of input.groups) {
|
||||||
const slices: PerformanceSurveyStoredImageGroup['slices'] = []
|
const slices: PerformanceSurveyStoredImageGroup['slices'] = []
|
||||||
for (const declaration of group.slices) {
|
for (const declaration of group.slices) {
|
||||||
@ -1125,6 +1159,7 @@ async function uploadStoredImageGroups(input: {
|
|||||||
previewHeight = group.preview.height
|
previewHeight = group.preview.height
|
||||||
}
|
}
|
||||||
imageGroups.push({
|
imageGroups.push({
|
||||||
|
assetType: group.assetType,
|
||||||
version: 1,
|
version: 1,
|
||||||
imageId: randomUUID(),
|
imageId: randomUUID(),
|
||||||
sourceName: group.sourceName,
|
sourceName: group.sourceName,
|
||||||
@ -1179,6 +1214,11 @@ async function withStoredImageSets<T>(input: {
|
|||||||
}) {
|
}) {
|
||||||
const workGroups = validateStoredImages(input.processedImages)
|
const workGroups = validateStoredImages(input.processedImages)
|
||||||
const referenceGroups = validateStoredImages(input.processedReferenceImages, { optional: true })
|
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<WorkReviewImageType, number>,
|
||||||
|
Object.fromEntries(WORK_REVIEW_IMAGE_TYPES.map((type) => [type, referenceGroups.filter((group) => group.assetType === type).length])) as Record<WorkReviewImageType, number>,
|
||||||
|
)
|
||||||
|
if (selectionError) throw new WorkReviewApiError(selectionError)
|
||||||
const uploadIds = getLockedUploadIds(workGroups, referenceGroups)
|
const uploadIds = getLockedUploadIds(workGroups, referenceGroups)
|
||||||
|
|
||||||
const uploadedUrls = new Set<string>()
|
const uploadedUrls = new Set<string>()
|
||||||
|
|||||||
@ -10,6 +10,21 @@ export type WorkReviewBrand = typeof WORK_REVIEW_BRANDS[number]
|
|||||||
|
|
||||||
export type WorkReviewDecision = 'approved' | 'revision_required'
|
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<WorkReviewImageType, string> = {
|
||||||
|
tone: '定调图片',
|
||||||
|
main: '主图图片',
|
||||||
|
detail: '详情图片',
|
||||||
|
sku: 'SKU图片',
|
||||||
|
}
|
||||||
|
export const WORK_REVIEW_REFERENCE_LABELS: Record<WorkReviewImageType, string> = {
|
||||||
|
tone: '定调参考图片',
|
||||||
|
main: '主图参考图片',
|
||||||
|
detail: '详情参考图片',
|
||||||
|
sku: 'SKU参考图片',
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkReviewAnnotationShape = 'point' | 'rectangle' | 'ellipse' | 'arrow'
|
export type WorkReviewAnnotationShape = 'point' | 'rectangle' | 'ellipse' | 'arrow'
|
||||||
|
|
||||||
export type WorkReviewImageAnnotation = {
|
export type WorkReviewImageAnnotation = {
|
||||||
@ -30,6 +45,7 @@ export type WorkReviewImageSlice = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WorkReviewImageGroup = {
|
export type WorkReviewImageGroup = {
|
||||||
|
assetType?: WorkReviewImageType
|
||||||
version?: number
|
version?: number
|
||||||
imageId?: string
|
imageId?: string
|
||||||
sourceName: string
|
sourceName: string
|
||||||
@ -150,6 +166,7 @@ export type WorkReviewBoardData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WorkReviewProcessedImage = {
|
export type WorkReviewProcessedImage = {
|
||||||
|
assetType?: WorkReviewImageType
|
||||||
uploadId: string
|
uploadId: string
|
||||||
mimeType: 'image/webp'
|
mimeType: 'image/webp'
|
||||||
width: number
|
width: number
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user