Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
'use client'
|
||
|
||
// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
|
||
|
||
import Image from 'next/image'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import type {
|
||
WorkReviewProject,
|
||
WorkReviewReview,
|
||
WorkReviewRole,
|
||
WorkReviewVersion,
|
||
} from '@/types/work-review'
|
||
import { WorkReviewImageGallery } from './WorkReviewImageGallery'
|
||
import type { WorkReviewDisplayAnnotation } from './WorkReviewAnnotationMarker'
|
||
import { WorkReviewDecisionBadge, WorkReviewStatusBadge } from './WorkReviewStatusBadge'
|
||
import { canResubmitWorkReviewProject } from '@/lib/work-review-policy'
|
||
|
||
function formatDateOnly(value: string) {
|
||
const raw = String(value || '')
|
||
const datePart = raw.match(/^\d{4}-\d{2}-\d{2}/)?.[0]
|
||
return datePart || '-'
|
||
}
|
||
|
||
function formatDateTime(value?: string | null) {
|
||
if (!value) return '-'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '-'
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
}).format(date)
|
||
}
|
||
|
||
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 getVersionReview(version: WorkReviewVersion, role: WorkReviewRole) {
|
||
return version.reviews.find((review) => review.reviewerRole === role) || null
|
||
}
|
||
|
||
function getRequiredReviewRoles(project: WorkReviewProject): WorkReviewRole[] {
|
||
return project.requiredReviewRoles?.length
|
||
? project.requiredReviewRoles
|
||
: ['manager', 'final_reviewer']
|
||
}
|
||
|
||
function getVersionAnnotations(
|
||
version: WorkReviewVersion,
|
||
requiredRoles: WorkReviewRole[],
|
||
): WorkReviewDisplayAnnotation[][] {
|
||
const imageCount = version.imageGroups?.length || version.imageUrls?.length || 0
|
||
const result = Array.from({ length: imageCount }, () => [] as WorkReviewDisplayAnnotation[])
|
||
for (const review of version.reviews || []) {
|
||
if (!requiredRoles.includes(review.reviewerRole)) continue
|
||
for (const imageComment of review.imageComments || []) {
|
||
if (!result[imageComment.imageIndex]) continue
|
||
result[imageComment.imageIndex].push(...(imageComment.annotations || []).map((annotation, index) => ({
|
||
annotation,
|
||
markerNumber: index + 1,
|
||
reviewerName: review.reviewerName,
|
||
reviewerRole: review.reviewerRole,
|
||
})))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
function ReviewProgress({
|
||
project,
|
||
version,
|
||
}: {
|
||
project: WorkReviewProject
|
||
version: WorkReviewVersion
|
||
}) {
|
||
const requiredRoles = getRequiredReviewRoles(project)
|
||
const allRoles: Array<{ role: WorkReviewRole; label: string; name: string }> = [
|
||
{ role: 'manager', label: '评审人', name: project.manager.name },
|
||
{ role: 'final_reviewer', label: '元泽', name: project.finalReviewer.name },
|
||
]
|
||
const roles = allRoles.filter((item) => requiredRoles.includes(item.role))
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||
{roles.map((item) => {
|
||
const review = getVersionReview(version, item.role)
|
||
return (
|
||
<span key={item.role} className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-[#667085]" title={item.name}>
|
||
<span className={`h-2 w-2 rounded-full ${review ? 'bg-[#149447]' : 'bg-[#d6dce5]'}`} />
|
||
{item.label}{review ? '已评' : '待评'}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function WorkReviewProjectCard({
|
||
project,
|
||
expanded,
|
||
canDelete,
|
||
deleteBusy,
|
||
onToggle,
|
||
onDelete,
|
||
onResubmit,
|
||
onReview,
|
||
}: {
|
||
project: WorkReviewProject
|
||
expanded: boolean
|
||
canDelete: boolean
|
||
deleteBusy?: boolean
|
||
onToggle: () => void
|
||
onDelete: () => void
|
||
onResubmit: () => void
|
||
onReview: () => void
|
||
}) {
|
||
const latestVersion = project.versions[0]
|
||
const [selectedVersionId, setSelectedVersionId] = useState(latestVersion?.id || '')
|
||
|
||
useEffect(() => {
|
||
if (!project.versions.some((version) => version.id === selectedVersionId)) {
|
||
setSelectedVersionId(project.versions[0]?.id || '')
|
||
}
|
||
}, [project.versions, selectedVersionId])
|
||
|
||
const selectedVersion = useMemo(
|
||
() => project.versions.find((version) => version.id === selectedVersionId) || latestVersion,
|
||
[latestVersion, project.versions, selectedVersionId],
|
||
)
|
||
const cover = latestVersion?.imageGroups?.[0]?.previewUrl || latestVersion?.imageUrls?.[0] || ''
|
||
const isLatestSelected = selectedVersion?.id === latestVersion?.id
|
||
const canResubmitLatestVersion = canResubmitWorkReviewProject(project)
|
||
const requiredReviewRoles = getRequiredReviewRoles(project)
|
||
const singleReviewerTest = project.reviewMode === 'single_reviewer_test'
|
||
const annotationsByImageIndex = useMemo(
|
||
() => selectedVersion ? getVersionAnnotations(selectedVersion, requiredReviewRoles) : [],
|
||
[requiredReviewRoles, selectedVersion],
|
||
)
|
||
|
||
if (!latestVersion || !selectedVersion) return null
|
||
|
||
return (
|
||
<article
|
||
id={`work-review-project-${project.id}`}
|
||
className={`overflow-hidden rounded-xl border bg-white transition-shadow ${
|
||
expanded ? 'border-[#9ebeff] shadow-[0_14px_38px_rgba(20,52,102,0.10)]' : 'border-[#e1e7ef] hover:shadow-[0_8px_24px_rgba(15,23,42,0.07)]'
|
||
}`}
|
||
>
|
||
<div className="grid grid-cols-[minmax(0,1fr)_40px] items-center gap-3 p-4">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={expanded}
|
||
aria-label={`查看作品详情:${project.title}`}
|
||
aria-controls={`work-review-project-details-${project.id}`}
|
||
className="grid min-w-0 gap-4 rounded-lg text-left outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 sm:grid-cols-[132px_minmax(0,1fr)] sm:items-center"
|
||
>
|
||
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e5eaf1] bg-[#f2f4f7] sm:h-[92px] sm:w-[132px]">
|
||
{cover ? (
|
||
<Image
|
||
src={proxyImageUrl(cover)}
|
||
alt={project.title}
|
||
fill
|
||
unoptimized
|
||
className="object-cover object-top"
|
||
/>
|
||
) : (
|
||
<span className="flex h-full items-center justify-center text-[12px] font-semibold text-[#98a2b3]">暂无图片</span>
|
||
)}
|
||
<span className="absolute bottom-2 right-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
|
||
V{project.latestVersion}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="min-w-0 truncate text-[17px] font-bold text-[#101828]">{project.title}</h2>
|
||
<WorkReviewStatusBadge status={project.status} />
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-medium text-[#7b8494]">
|
||
<span>提交人:{project.submitter.name}</span>
|
||
<span>作品日期:{formatDateOnly(project.workDate)}</span>
|
||
<span>最近提交:{formatDateTime(latestVersion.submittedAt)}</span>
|
||
</div>
|
||
<div className="mt-2">
|
||
<ReviewProgress project={project} version={latestVersion} />
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="flex flex-col items-center gap-2">
|
||
{canDelete && (
|
||
<button
|
||
type="button"
|
||
disabled={deleteBusy}
|
||
onClick={onDelete}
|
||
className="flex min-h-9 w-10 items-center justify-center rounded border border-[#f1b4ae] bg-white text-[11px] font-bold text-[#b42318] hover:border-[#d92d20] hover:bg-[#fff5f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d92d20] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||
aria-label={`删除作品:${project.title}`}
|
||
title="删除作品"
|
||
>
|
||
删除
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={expanded}
|
||
aria-controls={`work-review-project-details-${project.id}`}
|
||
aria-label={`${expanded ? '收起' : '展开'}作品:${project.title}`}
|
||
className={`flex h-9 w-9 items-center justify-center rounded-full bg-[#f2f4f7] text-[18px] text-[#667085] transition-transform hover:bg-[#e8edf3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 ${
|
||
expanded ? 'rotate-180' : ''
|
||
}`}
|
||
>
|
||
⌄
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div id={`work-review-project-details-${project.id}`} className="border-t border-[#e5eaf1] bg-[#fbfcfe] px-4 py-5 sm:px-5">
|
||
<div className="grid gap-5 lg:grid-cols-[170px_minmax(0,1fr)]">
|
||
<aside>
|
||
<div className="mb-2 text-[11px] font-bold uppercase tracking-[0.08em] text-[#7b8494]">版本记录</div>
|
||
<div className="flex gap-2 overflow-x-auto pb-1 lg:grid lg:overflow-visible">
|
||
{project.versions.map((version, index) => {
|
||
const active = version.id === selectedVersion.id
|
||
return (
|
||
<button
|
||
key={version.id}
|
||
type="button"
|
||
onClick={() => setSelectedVersionId(version.id)}
|
||
className={`min-w-[142px] rounded-lg border px-3 py-2.5 text-left transition-colors lg:min-w-0 ${
|
||
active
|
||
? 'border-[#0050cb] bg-[#eef5ff]'
|
||
: 'border-[#dfe5ee] bg-white hover:border-[#abc7ef]'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className={`text-[13px] font-bold ${active ? 'text-[#0050cb]' : 'text-[#344054]'}`}>
|
||
V{version.version}{version.status === 'approved' ? '已过审' : ''}
|
||
</span>
|
||
{index === 0 && <span className="text-[9px] font-bold text-[#0050cb]">最新</span>}
|
||
</div>
|
||
<div className="mt-1 text-[10px] text-[#7b8494]">{formatDateTime(version.submittedAt)}</div>
|
||
<div className="mt-2"><WorkReviewStatusBadge status={version.status} compact /></div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="min-w-0">
|
||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-[17px] font-bold text-[#101828]">V{selectedVersion.version} 作品内容</h3>
|
||
{!isLatestSelected && <span className="text-[11px] font-semibold text-[#7b8494]">历史版本 · 只读</span>}
|
||
</div>
|
||
<p className="mt-1 text-[11px] font-medium text-[#7b8494]">
|
||
提交于 {formatDateTime(selectedVersion.submittedAt)}
|
||
{selectedVersion.completedAt ? ` · 评审完成于 ${formatDateTime(selectedVersion.completedAt)}` : ''}
|
||
</p>
|
||
</div>
|
||
{isLatestSelected && canResubmitLatestVersion && (
|
||
<button
|
||
type="button"
|
||
onClick={onResubmit}
|
||
className="min-h-9 rounded bg-[#0050cb] px-3.5 text-[12px] font-bold text-white hover:bg-[#003fa3]"
|
||
>
|
||
提交新版本
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{project.description && (
|
||
<div className="mb-4 rounded-lg border border-[#e2e8f0] bg-white px-3 py-2.5">
|
||
<div className="text-[11px] font-bold text-[#526173]">作品说明</div>
|
||
<p className="mt-1 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#667085]">{project.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{selectedVersion.submissionNote && (
|
||
<div className="mb-4 rounded-lg border border-[#d8e5ff] bg-[#f5f8ff] px-3 py-2.5">
|
||
<div className="text-[11px] font-bold text-[#526173]">本次提交说明</div>
|
||
<p className="mt-1 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#667085]">{selectedVersion.submissionNote}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className={`grid gap-4 ${(selectedVersion.referenceImageGroups || []).length > 0 ? 'xl:grid-cols-[minmax(0,2fr)_minmax(240px,1fr)]' : ''}`}>
|
||
<section className="min-w-0">
|
||
<h4 className="mb-2 text-[12px] font-bold text-[#526173]">作品图片</h4>
|
||
<WorkReviewImageGallery
|
||
groups={selectedVersion.imageGroups || []}
|
||
imageUrls={selectedVersion.imageUrls || []}
|
||
annotationsByImageIndex={annotationsByImageIndex}
|
||
/>
|
||
</section>
|
||
{(selectedVersion.referenceImageGroups || []).length > 0 && (
|
||
<section className="min-w-0 rounded-lg border border-[#dfe6f0] bg-[#f8fafc] p-3">
|
||
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
|
||
<h4 className="text-[12px] font-bold text-[#526173]">参考图片</h4>
|
||
<span className="text-[10px] font-medium text-[#7b8494]">仅供对照</span>
|
||
</div>
|
||
<WorkReviewImageGallery
|
||
groups={selectedVersion.referenceImageGroups}
|
||
imageUrls={selectedVersion.referenceImageUrls || []}
|
||
compact
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-6">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<h3 className="text-[15px] font-bold text-[#101828]">{singleReviewerTest ? '评审意见' : '双评审意见'}</h3>
|
||
<ReviewProgress project={project} version={selectedVersion} />
|
||
</div>
|
||
<div className={`grid gap-3 ${singleReviewerTest ? '' : 'xl:grid-cols-2'}`}>
|
||
{requiredReviewRoles.includes('manager') && (
|
||
<ReviewPanel
|
||
label="指定评审人评审"
|
||
reviewerName={project.manager.name}
|
||
review={getVersionReview(selectedVersion, 'manager')}
|
||
canReview={Boolean(
|
||
isLatestSelected
|
||
&& project.permissions.pendingReviewRoles.includes('manager')
|
||
)}
|
||
onReview={onReview}
|
||
/>
|
||
)}
|
||
{requiredReviewRoles.includes('final_reviewer') && (
|
||
<ReviewPanel
|
||
label="元泽评审"
|
||
reviewerName={project.finalReviewer.name}
|
||
review={getVersionReview(selectedVersion, 'final_reviewer')}
|
||
canReview={Boolean(
|
||
isLatestSelected
|
||
&& project.permissions.pendingReviewRoles.includes('final_reviewer')
|
||
)}
|
||
onReview={onReview}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
)
|
||
}
|
||
|
||
function ReviewPanel({
|
||
label,
|
||
reviewerName,
|
||
review,
|
||
canReview,
|
||
onReview,
|
||
}: {
|
||
label: string
|
||
reviewerName: string
|
||
review: WorkReviewReview | null
|
||
canReview: boolean
|
||
onReview: () => void
|
||
}) {
|
||
return (
|
||
<section className={`rounded-xl border bg-white p-4 ${
|
||
review ? 'border-[#dce5ef]' : canReview ? 'border-[#9ebeff]' : 'border-[#e1e7ef]'
|
||
}`}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h4 className="text-[14px] font-bold text-[#101828]">{label}</h4>
|
||
<div className="mt-0.5 truncate text-[11px] font-medium text-[#7b8494]">{review?.reviewerName || reviewerName}</div>
|
||
</div>
|
||
{review ? (
|
||
<WorkReviewDecisionBadge decision={review.decision} />
|
||
) : (
|
||
<span className="inline-flex rounded-full bg-[#f2f4f7] px-2 py-0.5 text-[11px] font-bold text-[#667085]">待评审</span>
|
||
)}
|
||
</div>
|
||
|
||
{review ? (
|
||
<div className="mt-4 space-y-3">
|
||
{(review.imageComments || []).map((item) => (
|
||
<div key={item.imageId || item.imageIndex} className="rounded-lg bg-[#f7f9fc] px-3 py-2.5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="truncate text-[11px] font-bold text-[#344054]">
|
||
图 {item.imageIndex + 1}{item.sourceName ? ` · ${item.sourceName}` : ''}
|
||
</div>
|
||
<WorkReviewDecisionBadge decision={item.decision} />
|
||
</div>
|
||
<p className="mt-1.5 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#526173]">{item.comment}</p>
|
||
</div>
|
||
))}
|
||
{review.generalComment && (
|
||
<div className="border-t border-[#e5eaf1] pt-3">
|
||
<div className="text-[11px] font-bold text-[#7b8494]">总体意见</div>
|
||
<p className="mt-1 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#526173]">{review.generalComment}</p>
|
||
</div>
|
||
)}
|
||
<div className="text-right text-[10px] font-medium text-[#98a2b3]">提交于 {formatDateTime(review.reviewedAt)}</div>
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 rounded-lg border border-dashed border-[#d7dde7] bg-[#fafbfc] px-3 py-6 text-center">
|
||
<p className="text-[12px] font-semibold text-[#7b8494]">
|
||
{canReview ? '当前版本等待你填写逐图评审建议' : '等待评审人提交'}
|
||
</p>
|
||
{canReview && (
|
||
<button
|
||
type="button"
|
||
onClick={onReview}
|
||
className="mt-3 min-h-9 rounded bg-[#0050cb] px-4 text-[12px] font-bold text-white hover:bg-[#003fa3]"
|
||
>
|
||
开始评审
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|