530 lines
20 KiB
TypeScript
530 lines
20 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 { 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>
|
||
)
|
||
}
|