gongxiangp2/components/work-review/WorkReviewDialogs.tsx

796 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
import Image from 'next/image'
import {
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { getWorkReviewReviewerOptions, uploadWorkReviewImages } from '@/lib/work-review-client'
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,
WorkReviewRole,
WorkReviewSubmissionInput,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewImageDropzone } from './WorkReviewImageDropzone'
import { WorkReviewImageGallery } from './WorkReviewImageGallery'
import { WorkReviewReviewerSelect } from './WorkReviewReviewerSelect'
function todayValue() {
const now = new Date()
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 10)
}
function emptyImageFiles(): Record<WorkReviewImageType, File[]> {
return { tone: [], main: [], detail: [], sku: [] }
}
function proxyImageUrl(value: string) {
if (!/^https?:\/\//i.test(value)) return value
try {
const parsed = new URL(value)
if (['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'].includes(parsed.hostname.toLowerCase())) {
return `/api/proxy-media?url=${encodeURIComponent(value)}`
}
} catch {
// 保留原地址。
}
return value
}
function WorkReviewDialogShell({
title,
subtitle,
busy,
onClose,
children,
footer,
widthClass = 'max-w-2xl',
}: {
title: string
subtitle?: string
busy: boolean
onClose: () => void
children: ReactNode
footer: ReactNode
widthClass?: string
}) {
const [ready, setReady] = useState(false)
const titleId = useId()
const subtitleId = useId()
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => setReady(true), [])
useEffect(() => {
if (!ready) return
const previousOverflow = document.body.style.overflow
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.style.overflow = 'hidden'
const frame = window.requestAnimationFrame(() => closeRef.current?.focus())
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab') return
const elements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
'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]
const last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [busy, onClose, ready])
if (!ready) return null
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-end justify-center bg-black/45 sm:items-center sm:p-5 sm:backdrop-blur-[2px]"
onMouseDown={(event) => {
if (!busy && event.target === event.currentTarget) onClose()
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
aria-busy={busy}
tabIndex={-1}
className={`flex max-h-[94dvh] w-full flex-col overflow-hidden rounded-t-2xl bg-white text-[#101828] shadow-2xl sm:max-h-[calc(100dvh-40px)] sm:rounded-xl ${widthClass}`}
>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[#e4e7ec] px-4 py-4 sm:px-5">
<div className="min-w-0">
<h2 id={titleId} className="text-[18px] font-bold">{title}</h2>
{subtitle && <p id={subtitleId} className="mt-1 text-[12px] font-medium leading-5 text-[#667085]">{subtitle}</p>}
</div>
<button
ref={closeRef}
type="button"
disabled={busy}
onClick={onClose}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[25px] font-light text-[#667085] hover:bg-[#f2f4f7] hover:text-[#101828] disabled:opacity-40"
aria-label="关闭"
>
×
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">{children}</div>
<div className="shrink-0 border-t border-[#e4e7ec] bg-white px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-5">{footer}</div>
</div>
</div>,
document.body,
)
}
export function WorkReviewDeleteConfirmationDialog({
project,
busy,
error,
onClose,
onConfirm,
}: {
project: WorkReviewProject
busy: boolean
error: string | null
onClose: () => void
onConfirm: () => void
}) {
return (
<WorkReviewDialogShell
title="删除作品"
subtitle="此操作会永久删除该作品的全部版本、评审意见和通知记录。"
busy={busy}
onClose={onClose}
widthClass="max-w-md"
footer={(
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] bg-white px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:opacity-50"
>
取消
</button>
<button
type="button"
disabled={busy}
onClick={onConfirm}
className="min-h-11 rounded bg-[#b42318] px-4 text-[13px] font-bold text-white hover:bg-[#931f16] disabled:cursor-not-allowed disabled:opacity-55"
>
{busy ? '删除中…' : '确认删除'}
</button>
</div>
)}
>
<div className="rounded-lg border border-[#f0b8b8] bg-[#fff5f5] px-4 py-3">
<div className="text-[12px] font-semibold text-[#7a271a]">即将删除</div>
<div className="mt-1 break-words text-[15px] font-bold text-[#101828]">{project.title}</div>
<p className="mt-2 text-[12px] font-medium leading-5 text-[#93000a]">
删除后无法恢复,提交人如有需要只能重新发起作品评审。
</p>
</div>
{error && (
<div className="mt-3 rounded-lg border border-[#f0b8b8] bg-[#fff8f7] px-3 py-2.5 text-[12px] font-semibold leading-5 text-[#b42318]" role="alert">
{error}
</div>
)}
</WorkReviewDialogShell>
)
}
export function WorkReviewSubmissionDialog({
mode,
project,
onClose,
onSubmit,
}: {
mode: 'create' | 'resubmit'
project?: WorkReviewProject | null
onClose: () => void
onSubmit: (input: WorkReviewSubmissionInput) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [brand, setBrand] = useState('')
const [description, setDescription] = useState('')
const [workDate, setWorkDate] = useState(todayValue)
const [reviewerEmployeeId, setReviewerEmployeeId] = useState('')
const [reviewerOptions, setReviewerOptions] = useState<WorkReviewReviewerOption[]>([])
const [reviewerLoading, setReviewerLoading] = useState(mode === 'create')
const [reviewerLoadError, setReviewerLoadError] = useState<string | null>(null)
const [reviewerLoadKey, setReviewerLoadKey] = useState(0)
const [submissionNote, setSubmissionNote] = useState('')
const [files, setFiles] = useState(emptyImageFiles)
const [referenceFiles, setReferenceFiles] = useState(emptyImageFiles)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null)
const submitLockRef = useRef(false)
const isCreate = mode === 'create'
const nextVersion = Number(project?.latestVersion || 0) + 1
useEffect(() => {
if (!isCreate) return
const controller = new AbortController()
setReviewerLoading(true)
setReviewerLoadError(null)
void getWorkReviewReviewerOptions(controller.signal)
.then((options) => {
setReviewerOptions(options)
if (options.length === 0) {
setReviewerLoadError('暂无可选评审人,请先维护考核调研人员数据')
}
})
.catch((loadError) => {
if (controller.signal.aborted) return
setReviewerLoadError(loadError instanceof Error ? loadError.message : '评审人加载失败,请重试')
})
.finally(() => {
if (!controller.signal.aborted) setReviewerLoading(false)
})
return () => controller.abort()
}, [isCreate, reviewerLoadKey])
const submit = async () => {
if (submitLockRef.current) return
if (isCreate && !title.trim()) {
setError('请填写作品名称')
return
}
if (isCreate && title.trim().length > 120) {
setError('作品名称不能超过 120 个字')
return
}
if (isCreate && !brand.trim()) {
setError('请选择品牌')
return
}
if (isCreate && !workDate) {
setError('请选择作品日期')
return
}
if (isCreate && !reviewerEmployeeId) {
setError('请选择评审人')
return
}
const selectionError = getWorkReviewImageSelectionError(
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
}
submitLockRef.current = true
setBusy(true)
setError(null)
setProgress({ message: '正在准备图片' })
try {
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
? {
title: title.trim(),
brand: brand.trim(),
description: description.trim(),
workDate,
reviewerEmployeeId,
}
: {
expectedLatestVersion: project?.latestVersion,
}),
submissionNote: submissionNote.trim(),
processedImages,
processedReferenceImages,
})
} catch (submitError) {
setProgress(null)
setError(submitError instanceof Error ? submitError.message : '提交失败,请重试')
} finally {
submitLockRef.current = false
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={isCreate ? '提交新作品' : `提交作品修改 V${nextVersion}`}
subtitle={isCreate
? '手动选择一名评审人,并由元泽共同进行逐图评审'
: `沿用首次提交的作品名称和评审人;V${nextVersion} 提交后,历史图片与评审意见会完整保留`}
busy={busy}
onClose={onClose}
widthClass="max-w-4xl"
footer={(
<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 && (
<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]">
<span>{progress.message}</span>
{typeof progress.percent === 'number' && <span>{progress.percent}%</span>}
</div>
<div
className="h-1.5 overflow-hidden rounded-full bg-[#e8eef6]"
role="progressbar"
aria-label={progress.message}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={typeof progress.percent === 'number' ? progress.percent : undefined}
>
<div
className={`h-full rounded-full bg-[#0050cb] transition-[width] ${typeof progress.percent !== 'number' ? 'w-2/3 animate-pulse' : ''}`}
style={typeof progress.percent === 'number' ? { width: `${progress.percent}%` } : undefined}
/>
</div>
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-45"
>
取消
</button>
<button
type="button"
disabled={busy || (isCreate && (reviewerLoading || Boolean(reviewerLoadError)))}
onClick={() => void submit()}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#003fa3] disabled:cursor-not-allowed disabled:bg-[#9bbcec]"
>
{busy ? '正在提交...' : isCreate ? '提交并通知评审人' : `提交修改 V${nextVersion}`}
</button>
</div>
</div>
)}
>
<div className="space-y-5">
{isCreate ? (
<>
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品名称 <span className="text-[#b42318]">*</span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={120}
disabled={busy}
placeholder="例如:夏季新品主视觉"
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 className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">品牌 <span className="text-[#b42318]">*</span></span>
<input
value={brand}
onChange={(event) => {
setBrand(event.target.value)
setError(null)
}}
maxLength={80}
disabled={busy}
required
aria-required="true"
list="work-review-brand-options"
placeholder="例如:亿家达"
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">
{WORK_REVIEW_BRANDS.map((option) => <option key={option} value={option} />)}
</datalist>
</label>
<div className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">评审人 <span className="text-[#b42318]">*</span></span>
<WorkReviewReviewerSelect
options={reviewerOptions}
value={reviewerEmployeeId}
disabled={busy}
loading={reviewerLoading}
onChange={(employeeId) => {
setReviewerEmployeeId(employeeId)
setError(null)
}}
/>
<p className="text-[11px] font-medium leading-5 text-[#667085]">
该人员与元泽会收到评审通知;作品后续版本沿用本次选择。
</p>
{reviewerLoadError && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2.5" role="alert">
<span className="text-[12px] font-semibold leading-5 text-[#b42318]">{reviewerLoadError}</span>
<button
type="button"
disabled={reviewerLoading || busy}
onClick={() => setReviewerLoadKey((current) => current + 1)}
className="min-h-8 rounded border border-[#e3a7a7] bg-white px-3 text-[11px] font-bold text-[#b42318] hover:bg-[#fff0f0] disabled:opacity-45"
>
重新加载
</button>
</div>
)}
</div>
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品日期 <span className="text-[#b42318]">*</span></span>
<input
type="date"
value={workDate}
onChange={(event) => setWorkDate(event.target.value)}
disabled={busy}
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 className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品说明</span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={busy}
rows={3}
placeholder="补充项目背景或本次作品目标(可选)"
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>
</>
) : (
<>
<dl className="grid gap-x-5 gap-y-4 rounded-lg border border-[#dfe6f0] bg-[#f8fafc] px-4 py-4 sm:grid-cols-3" aria-label={`V${nextVersion} 提交信息`}>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">作品名称</dt>
<dd className="mt-1 break-words text-[13px] font-bold text-[#101828]">{project?.title || '-'}</dd>
</div>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">评审人</dt>
<dd className="mt-1 break-words text-[13px] font-bold text-[#101828]">{project?.manager.name || '-'}</dd>
</div>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">修改提交日期</dt>
<dd className="mt-1 text-[13px] font-bold tabular-nums text-[#101828]">{workDate}</dd>
</div>
</dl>
<div className="rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-medium leading-5 text-[#8a5200]">
请确认已处理 V{project?.latestVersion || 1} 中的评审建议。提交后,{project?.reviewMode === 'single_reviewer_test'
? '原评审人会收到'
: '原评审人与元泽会收到'} V{nextVersion} 的新一轮评审通知。
</div>
</>
)}
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">{isCreate ? '提交说明' : '本次修改说明'}</span>
<textarea
value={submissionNote}
onChange={(event) => setSubmissionNote(event.target.value)}
disabled={busy}
rows={3}
placeholder={isCreate ? '补充需要评审人关注的内容(可选)' : '简要说明本次修改了什么(可选)'}
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>
<section className="border-t border-[#e4e7ec] pt-5" aria-label="上传作品图片">
<h3 className="text-base font-bold text-[#101828]">上传图片</h3>
<p className="mt-1 text-sm leading-6 text-[#667085]">
可仅上传定调图片;上传整套时,主图、详情、SKU 三项必须齐全,定调图片可选。
</p>
<p className="mt-1 text-sm leading-6 text-[#667085]">主图、SKU 支持多图,详情支持长图;参考图均选填,不参与逐图评审。作品图与参考图各合计最多 60 张。</p>
<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>
</section>
</div>
</WorkReviewDialogShell>
)
}
type ReviewDraft = {
decision: WorkReviewDecision | ''
comment: string
}
export function WorkReviewReviewDialog({
project,
version,
role,
onClose,
onSubmit,
}: {
project: WorkReviewProject
version: WorkReviewVersion
role: WorkReviewRole
onClose: () => void
onSubmit: (input: WorkReviewReviewInput) => Promise<void>
}) {
const groups = version.imageGroups || []
const [drafts, setDrafts] = useState<ReviewDraft[]>(() => (
groups.map(() => ({ decision: '', comment: '' }))
))
const [generalComment, setGeneralComment] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const roleLabel = role === 'manager' ? '指定评审人评审' : '元泽评审'
const completedCount = drafts.filter((item) => item.decision && item.comment.trim()).length
const finalDecision = useMemo<WorkReviewDecision | null>(() => {
if (completedCount !== groups.length || groups.length === 0) return null
return drafts.some((item) => item.decision === 'revision_required') ? 'revision_required' : 'approved'
}, [completedCount, drafts, groups.length])
const submit = async () => {
if (groups.length === 0) {
setError('当前版本没有可评审的图片')
return
}
const incompleteIndex = drafts.findIndex((item) => !item.decision || !item.comment.trim())
if (incompleteIndex >= 0) {
setError(`请完成图片 ${incompleteIndex + 1} 的评审结论和修改建议`)
return
}
if (drafts.some((item) => item.comment.trim().length > 2000)) {
setError('单张图片的评审建议不能超过 2000 个字')
return
}
if (generalComment.trim().length > 4000) {
setError('总体意见不能超过 4000 个字')
return
}
setBusy(true)
setError(null)
try {
await onSubmit({
reviewerRole: role,
imageComments: drafts.map((item, imageIndex) => ({
imageIndex,
decision: item.decision as WorkReviewDecision,
comment: item.comment.trim(),
})),
generalComment: generalComment.trim(),
})
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '评审提交失败')
} finally {
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={`${roleLabel} · V${version.version}`}
subtitle={`${project.title} · 请逐张选择结论并填写评审建议`}
busy={busy}
onClose={onClose}
widthClass="max-w-3xl"
footer={(
<div>
<div className={`mb-3 flex items-center justify-between gap-3 rounded px-3 py-2 text-[12px] font-semibold ${
finalDecision === 'approved'
? 'bg-[#ecfdf3] text-[#087a3c]'
: finalDecision === 'revision_required'
? 'bg-[#fff8e8] text-[#8a5200]'
: 'bg-[#f2f4f7] text-[#667085]'
}`}>
<span>
{finalDecision === 'approved'
? '全部图片通过'
: finalDecision === 'revision_required'
? '本区域结论:需修改'
: `已完成 ${completedCount}/${groups.length} 张`}
</span>
<span>{generalComment.length}/4000</span>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-45"
>
取消
</button>
<button
type="button"
disabled={busy || completedCount !== groups.length}
onClick={() => void submit()}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#003fa3] disabled:cursor-not-allowed disabled:bg-[#9bbcec]"
>
{busy ? '正在提交...' : `提交${roleLabel}`}
</button>
</div>
</div>
)}
>
<div className="space-y-4">
{(version.referenceImageGroups || []).length > 0 && (
<section className="rounded-xl border border-[#dfe6f0] bg-[#f8fafc] p-3 sm:p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-[13px] font-bold text-[#344054]">参考图片</h3>
<span className="text-[11px] font-medium text-[#7b8494]">仅供对照 · 不参与评分</span>
</div>
<WorkReviewImageGallery
groups={version.referenceImageGroups}
imageUrls={version.referenceImageUrls || []}
compact
/>
</section>
)}
{groups.map((group, index) => {
const draft = drafts[index]
return (
<section key={`${group.sourceName}-${index}`} className="rounded-xl border border-[#dfe6f0] bg-white p-3 sm:p-4">
<div className="grid gap-4 sm:grid-cols-[150px_minmax(0,1fr)]">
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e1e7ef] bg-[#f6f8fb] sm:aspect-auto sm:h-[130px]">
<Image
src={proxyImageUrl(group.previewUrl || group.slices?.[0]?.url || '')}
alt={group.sourceName}
fill
unoptimized
className="object-cover object-top"
/>
<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}
</span>
</div>
<div className="min-w-0">
<div className="truncate text-[13px] font-bold text-[#101828]" title={group.sourceName}>{group.sourceName}</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{([
['approved', '通过'],
['revision_required', '需修改'],
] as const).map(([decision, label]) => (
<button
key={decision}
type="button"
disabled={busy}
onClick={() => {
setError(null)
setDrafts((current) => current.map((item, itemIndex) => (
itemIndex === index ? { ...item, decision } : item
)))
}}
className={`min-h-10 rounded border text-[12px] font-bold transition-colors ${
draft.decision === decision
? decision === 'approved'
? 'border-[#149447] bg-[#ecfdf3] text-[#087a3c]'
: 'border-[#d49a31] bg-[#fff8e8] text-[#8a5200]'
: 'border-[#d7dde7] bg-white text-[#667085] hover:border-[#9db7dd]'
}`}
>
{label}
</button>
))}
</div>
<textarea
value={draft.comment}
onChange={(event) => {
setError(null)
setDrafts((current) => current.map((item, itemIndex) => (
itemIndex === index ? { ...item, comment: event.target.value.slice(0, 2000) } : item
)))
}}
disabled={busy}
rows={3}
placeholder="填写这张图的评审建议(必填)"
className="mt-2 w-full resize-y rounded border border-[#cfd6e1] px-3 py-2 text-[13px] leading-5 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
<div className="mt-1 text-right text-[10px] text-[#98a2b3]">{draft.comment.length}/2000</div>
</div>
</div>
</section>
)
})}
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">总体意见</span>
<textarea
value={generalComment}
onChange={(event) => setGeneralComment(event.target.value.slice(0, 4000))}
disabled={busy}
rows={4}
placeholder="补充本轮整体方向或优先级(可选)"
className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-[13px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
{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>
</WorkReviewDialogShell>
)
}