gongxiangp2/components/work-review/WorkReviewProjectCard.tsx
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

435 lines
18 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 { 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>
)
}