gongxiangp2/components/work-review/WorkReviewProjectCard.tsx

335 lines
13 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 { useEffect, useMemo, useState } from 'react'
import type {
WorkReviewProject,
WorkReviewReview,
WorkReviewRole,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewImageGallery } from './WorkReviewImageGallery'
import { WorkReviewDecisionBadge, WorkReviewStatusBadge } from './WorkReviewStatusBadge'
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 ReviewProgress({
project,
version,
}: {
project: WorkReviewProject
version: WorkReviewVersion
}) {
const roles: Array<{ role: WorkReviewRole; label: string; name: string }> = [
{ role: 'manager', label: '主管', name: project.manager.name },
{ role: 'final_reviewer', label: '元泽', name: project.finalReviewer.name },
]
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,
onToggle,
onResubmit,
onReview,
}: {
project: WorkReviewProject
expanded: boolean
onToggle: () => void
onResubmit: () => void
onReview: (version: WorkReviewVersion, role: WorkReviewRole) => 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
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)]'
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="grid w-full gap-4 p-4 text-left sm:grid-cols-[132px_minmax(0,1fr)_auto] 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>
{project.description && (
<p className="mt-1.5 line-clamp-2 text-[12px] font-medium leading-5 text-[#667085]">{project.description}</p>
)}
<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>
<span className={`hidden h-9 w-9 items-center justify-center rounded-full bg-[#f2f4f7] text-[18px] text-[#667085] transition-transform sm:flex ${
expanded ? 'rotate-180' : ''
}`}>
⌄
</span>
</button>
{expanded && (
<div 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}</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 && project.permissions.canResubmit && (
<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>
{selectedVersion.submissionNote && (
<div className="mb-4 rounded-lg border-l-2 border-[#8aafff] 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>
)}
<WorkReviewImageGallery
groups={selectedVersion.imageGroups || []}
imageUrls={selectedVersion.imageUrls || []}
/>
<div className="mt-6">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-[15px] font-bold text-[#101828]">双评审意见</h3>
<ReviewProgress project={project} version={selectedVersion} />
</div>
<div className="grid gap-3 xl:grid-cols-2">
<ReviewPanel
label="直属主管评审"
reviewerName={project.manager.name}
review={getVersionReview(selectedVersion, 'manager')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('manager')
)}
onReview={() => onReview(selectedVersion, 'manager')}
/>
<ReviewPanel
label="元泽评审"
reviewerName={project.finalReviewer.name}
review={getVersionReview(selectedVersion, 'final_reviewer')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('final_reviewer')
)}
onReview={() => onReview(selectedVersion, 'final_reviewer')}
/>
</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>
)
}