gongxiangp2/components/work-review/WorkReviewDialogs.tsx

530 lines
20 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'
import Image from 'next/image'
import {
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { uploadWorkReviewImages } from '@/lib/work-review-client'
import type {
WorkReviewDecision,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewRole,
WorkReviewSubmissionInput,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewImageDropzone } from './WorkReviewImageDropzone'
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 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-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 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 sm:px-5">{footer}</div>
</div>
</div>,
document.body,
)
}
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 [submissionNote, setSubmissionNote] = useState('')
const [files, setFiles] = 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 isCreate = mode === 'create'
const submit = async () => {
if (isCreate && !title.trim()) {
setError('请填写作品名称')
return
}
if (isCreate && title.trim().length > 120) {
setError('作品名称不能超过 120 个字')
return
}
if (isCreate && !workDate) {
setError('请选择作品日期')
return
}
if (files.length === 0) {
setError('请至少选择 1 张作品图片')
return
}
setBusy(true)
setError(null)
setProgress({ message: '正在准备图片' })
try {
const processedImages = await uploadWorkReviewImages(files, (message, percent) => {
setProgress({ message, percent })
})
await onSubmit({
...(isCreate
? {
title: title.trim(),
description: description.trim(),
workDate,
}
: {}),
submissionNote: submissionNote.trim(),
processedImages,
})
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '提交失败,请重试')
} finally {
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={isCreate ? '提交新作品' : `提交 ${project?.title || '作品'} 的新版本`}
subtitle={isCreate
? '提交后会同时通知直属主管和元泽进行逐图评审'
: `将创建 V${Number(project?.latestVersion || 0) + 1},历史图片和评审意见会完整保留`}
busy={busy}
onClose={onClose}
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]">
<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}
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 ? '提交并通知评审人' : '提交新版本'}
</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>
<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>
</>
) : (
<div className="rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-medium leading-5 text-[#8a5200]">
请确认已处理当前版本中的直属主管和元泽评审建议。提交后,两位评审人会收到新一轮通知。
</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>
<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} />
</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">
{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>
)
}