733 lines
29 KiB
TypeScript
733 lines
29 KiB
TypeScript
'use client'
|
||
|
||
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 type {
|
||
WorkReviewDecision,
|
||
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 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]), 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 [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<File[]>([])
|
||
const [referenceFiles, setReferenceFiles] = useState<File[]>([])
|
||
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 && !workDate) {
|
||
setError('请选择作品日期')
|
||
return
|
||
}
|
||
if (isCreate && !reviewerEmployeeId) {
|
||
setError('请选择评审人')
|
||
return
|
||
}
|
||
if (files.length === 0) {
|
||
setError('请至少选择 1 张作品图片')
|
||
return
|
||
}
|
||
|
||
submitLockRef.current = true
|
||
setBusy(true)
|
||
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,
|
||
})
|
||
})
|
||
: []
|
||
await onSubmit({
|
||
...(isCreate
|
||
? {
|
||
title: title.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>
|
||
{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-[14px] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
|
||
/>
|
||
</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-[14px] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
|
||
/>
|
||
</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-[14px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
|
||
/>
|
||
</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-[14px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
|
||
/>
|
||
</label>
|
||
|
||
<div className="grid gap-5 sm:grid-cols-2 sm:items-start">
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex items-center justify-between gap-3">
|
||
<span className="text-[13px] font-bold text-[#344054]">作品图片 <span className="text-[#b42318]">*</span></span>
|
||
<span className="text-[11px] font-medium text-[#7b8494]">{files.length} 张</span>
|
||
</div>
|
||
<WorkReviewImageDropzone files={files} onChange={setFiles} disabled={busy} label="作品图片" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<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>
|
||
</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>
|
||
)
|
||
}
|