335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
'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>
|
||
)
|
||
}
|