gongxiangp2/components/work-review/WorkReviewReviewerWorkspace.tsx
2026-08-01 16:16:20 +08:00

948 lines
40 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, useRef, useState } from 'react'
import type {
WorkReviewImageAnnotation,
WorkReviewImageGroup,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewVersion,
} from '@/types/work-review'
import {
WorkReviewAnnotationMarker,
type WorkReviewDisplayAnnotation,
} from './WorkReviewAnnotationMarker'
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
const MIN_PANE_PERCENT = 14
const MAX_PANE_PERCENT = 86
const MAX_ANNOTATIONS_PER_IMAGE = 50
const MAX_ANNOTATION_COMMENT_LENGTH = 1000
function proxyImageUrl(value: string) {
const url = String(value || '').trim()
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const parsed = new URL(url)
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
? `/api/proxy-media?url=${encodeURIComponent(url)}`
: url
} catch {
return url
}
}
function normalizeGroups(
groups: WorkReviewImageGroup[],
imageUrls: string[],
fallbackLabel: string,
): WorkReviewImageGroup[] {
if (groups.length > 0) {
return groups.map((group, index) => ({
...group,
sourceName: group.sourceName || `${fallbackLabel} ${index + 1}`,
previewUrl: proxyImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
slices: (group.slices || []).map((slice) => ({
...slice,
url: proxyImageUrl(slice.url),
})),
})).filter((group) => Boolean(group.previewUrl))
}
return imageUrls.map((url, index) => ({
sourceName: `${fallbackLabel} ${index + 1}`,
previewUrl: proxyImageUrl(url),
previewWidth: 1_200,
previewHeight: 1_500,
width: 1_200,
height: 1_500,
slices: [{ url: proxyImageUrl(url), width: 1_200, height: 1_500 }],
}))
}
function createAnnotationId() {
if (typeof window !== 'undefined' && typeof window.crypto?.randomUUID === 'function') {
return window.crypto.randomUUID()
}
return `point-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function scrollPaneTo(element: HTMLDivElement | null, left: number, behavior: ScrollBehavior) {
if (!element) return
if (typeof element.scrollTo === 'function') {
element.scrollTo({ left, behavior })
return
}
element.scrollLeft = left
}
function LongImagePane({
label,
groups,
imageUrls,
emptyMessage,
activeIndex: controlledActiveIndex,
onActiveIndexChange,
annotations = [],
annotationEnabled = false,
annotationMode = false,
annotationRepositionMode = false,
annotationPosition,
annotationCount,
showAnnotationComments = true,
onToggleAnnotationMode,
onToggleAnnotationComments,
onAddAnnotation,
onMoveAnnotation,
onSelectDraftAnnotation,
}: {
label: string
groups: WorkReviewImageGroup[]
imageUrls: string[]
emptyMessage: string
activeIndex?: number
onActiveIndexChange?: (index: number) => void
annotations?: WorkReviewDisplayAnnotation[]
annotationEnabled?: boolean
annotationMode?: boolean
annotationRepositionMode?: boolean
annotationPosition?: Pick<WorkReviewImageAnnotation, 'x' | 'y'>
annotationCount?: number
showAnnotationComments?: boolean
onToggleAnnotationMode?: () => void
onToggleAnnotationComments?: () => void
onAddAnnotation?: (x: number, y: number) => void
onMoveAnnotation?: (x: number, y: number) => void
onSelectDraftAnnotation?: (annotationId: string) => void
}) {
const displayGroups = useMemo(
() => normalizeGroups(groups || [], imageUrls || [], label),
[groups, imageUrls, label],
)
const [internalActiveIndex, setInternalActiveIndex] = useState(0)
const pointerStartRef = useRef<{ x: number; y: number } | null>(null)
const activeIndex = Math.min(
controlledActiveIndex ?? internalActiveIndex,
Math.max(0, displayGroups.length - 1),
)
const activeGroup = displayGroups[activeIndex]
const placementMode = annotationMode || annotationRepositionMode
useEffect(() => {
if (controlledActiveIndex == null) setInternalActiveIndex(0)
}, [controlledActiveIndex, displayGroups.length])
const selectImage = (index: number) => {
if (controlledActiveIndex == null) setInternalActiveIndex(index)
onActiveIndexChange?.(index)
}
const placeAnnotation = (element: HTMLDivElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) return
const x = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width))
const y = Math.min(1, Math.max(0, (clientY - bounds.top) / bounds.height))
if (annotationRepositionMode) {
onMoveAnnotation?.(x, y)
} else {
onAddAnnotation?.(x, y)
}
}
return (
<section className="flex h-full min-h-0 flex-col bg-white" aria-label={label}>
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-[#e4e7ec] px-3 py-1.5 sm:px-4">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<h2 className="shrink-0 text-[13px] font-bold text-[#101828]">{label}</h2>
{annotationEnabled && (
<button
type="button"
onClick={onToggleAnnotationMode}
disabled={annotationRepositionMode}
aria-pressed={annotationMode}
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
annotationMode
? 'bg-[#8a5200] text-white hover:bg-[#6d4100]'
: 'border border-[#d3dae5] bg-white text-[#344054] hover:border-[#8ab9ff]'
}`}
>
{annotationMode ? '取消标注' : '+ 添加标注'}
</button>
)}
{annotationCount != null && (
<span className="shrink-0 text-[11px] font-semibold text-[#667085]">
已标注 {annotationCount} 个修改点
</span>
)}
{onToggleAnnotationComments && (
<button
type="button"
onClick={onToggleAnnotationComments}
disabled={!annotationCount}
aria-pressed={showAnnotationComments}
aria-label={`${showAnnotationComments ? '隐藏' : '显示'}标注点备注文字`}
className="min-h-8 shrink-0 rounded border border-[#d3dae5] bg-white px-2.5 text-[11px] font-bold text-[#344054] hover:border-[#8ab9ff] disabled:cursor-not-allowed disabled:text-[#98a2b3]"
>
{showAnnotationComments ? '隐藏备注' : '显示备注'}
</button>
)}
</div>
{displayGroups.length > 1 ? (
<div className="flex max-w-[52%] gap-1 overflow-x-auto py-1" aria-label={`${label}选择`}>
{displayGroups.map((group, index) => (
<button
key={group.imageId || `${group.sourceName}-${index}`}
type="button"
onClick={() => selectImage(index)}
aria-pressed={activeIndex === index}
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${
activeIndex === index
? 'bg-[#0050cb] text-white'
: 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]'
}`}
>
图 {index + 1}
</button>
))}
</div>
) : (
<span className="text-[11px] font-medium text-[#7b8494]">
{displayGroups.length > 0 ? '1 张' : '0 张'}
</span>
)}
</div>
{placementMode && (
<div className="shrink-0 bg-[#fff7e6] px-3 py-2 text-center text-[11px] font-semibold text-[#7a4a00]" role="status">
{annotationRepositionMode
? '完成前可点击作品图片重新调整当前标注位置;滚动不会移动标注'
: '点击作品中的修改位置添加批注;滚动不会产生标记'}
</div>
)}
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-[#eef1f5] p-2 sm:p-3">
{activeGroup ? (
<div
className={`relative mx-auto max-w-[1200px] overflow-hidden bg-white shadow-[0_8px_28px_rgba(15,23,42,0.08)] ${placementMode ? 'cursor-crosshair touch-pan-y outline outline-2 outline-[#d49a31]' : ''}`}
role={annotationEnabled ? 'group' : undefined}
aria-label={annotationEnabled
? `${activeGroup.sourceName},${annotationRepositionMode ? '可调整当前标注位置,使用方向键微调' : '可添加位置批注'}`
: undefined}
tabIndex={placementMode ? 0 : undefined}
onPointerDown={(event) => {
if (!placementMode || event.isPrimary === false || (event.button ?? 0) !== 0) return
pointerStartRef.current = { x: event.clientX, y: event.clientY }
}}
onPointerUp={(event) => {
if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return
const distance = Math.hypot(
event.clientX - pointerStartRef.current.x,
event.clientY - pointerStartRef.current.y,
)
pointerStartRef.current = null
if (distance <= 8) placeAnnotation(event.currentTarget, event.clientX, event.clientY)
}}
onPointerCancel={() => {
pointerStartRef.current = null
}}
onKeyDown={(event) => {
if (annotationRepositionMode && annotationPosition && [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
].includes(event.key)) {
event.preventDefault()
const step = event.shiftKey ? 0.05 : 0.01
const nextX = event.key === 'ArrowLeft'
? annotationPosition.x - step
: event.key === 'ArrowRight'
? annotationPosition.x + step
: annotationPosition.x
const nextY = event.key === 'ArrowUp'
? annotationPosition.y - step
: event.key === 'ArrowDown'
? annotationPosition.y + step
: annotationPosition.y
onMoveAnnotation?.(
Math.min(1, Math.max(0, nextX)),
Math.min(1, Math.max(0, nextY)),
)
} else if (annotationMode && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
placeAnnotation(event.currentTarget, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
}
}}
>
{(activeGroup.slices.length > 0 ? activeGroup.slices : [{
url: activeGroup.previewUrl,
width: activeGroup.previewWidth,
height: activeGroup.previewHeight,
}]).map((slice, sliceIndex) => (
<Image
key={`${slice.url}-${sliceIndex}`}
src={slice.url}
alt={sliceIndex === 0 ? activeGroup.sourceName : ''}
width={slice.width}
height={slice.height}
unoptimized
loading={sliceIndex === 0 ? 'eager' : 'lazy'}
className="pointer-events-none block h-auto w-full select-none"
draggable={false}
/>
))}
{annotations.map((item) => (
<WorkReviewAnnotationMarker
key={`${item.draft ? 'draft' : item.reviewerRole}-${item.annotation.id}`}
item={item}
onClick={item.draft ? () => onSelectDraftAnnotation?.(item.annotation.id) : undefined}
showComment={showAnnotationComments}
/>
))}
</div>
) : (
<div className="flex min-h-full items-center justify-center px-6 py-16 text-center">
<p className="max-w-[260px] text-[13px] font-semibold leading-6 text-[#7b8494]">{emptyMessage}</p>
</div>
)}
</div>
</section>
)
}
function getImageCount(version?: WorkReviewVersion) {
if (!version) return 0
return version.imageGroups?.length || version.imageUrls?.length || 0
}
function summarizeAnnotations(annotations: WorkReviewImageAnnotation[]) {
if (annotations.length === 0) return '通过'
return annotations
.map((annotation, index) => `标注 ${index + 1}:${annotation.comment.trim()}`)
.join('\n')
.slice(0, 2000)
}
export function WorkReviewReviewerWorkspace({
project,
onSubmitReview,
}: {
project: WorkReviewProject
onSubmitReview: (version: WorkReviewVersion, input: WorkReviewReviewInput) => Promise<void>
}) {
const latestVersion = project.versions[0]
const [selectedVersionId, setSelectedVersionId] = useState(latestVersion?.id || '')
const [workPanePercent, setWorkPanePercent] = useState(50)
const [activeWorkImageIndex, setActiveWorkImageIndex] = useState(0)
const [draftAnnotations, setDraftAnnotations] = useState<WorkReviewImageAnnotation[][]>(() => (
Array.from({ length: getImageCount(latestVersion) }, () => [])
))
const [selectedAnnotation, setSelectedAnnotation] = useState<{ imageIndex: number; id: string } | null>(null)
const [positionEditableAnnotation, setPositionEditableAnnotation] = useState<{ imageIndex: number; id: string } | null>(null)
const [annotationMode, setAnnotationMode] = useState(false)
const [showAnnotationComments, setShowAnnotationComments] = useState(true)
const [activeMobilePane, setActiveMobilePane] = useState<0 | 1>(0)
const [busyAction, setBusyAction] = useState<'approved' | 'revision_required' | null>(null)
const [error, setError] = useState<string | null>(null)
const [liveMessage, setLiveMessage] = useState('')
const comparisonRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const mobileScrollerRef = useRef<HTMLDivElement | null>(null)
const annotationInputRef = useRef<HTMLTextAreaElement | null>(null)
const selectedVersion = useMemo(
() => project.versions.find((version) => version.id === selectedVersionId) || latestVersion,
[latestVersion, project.versions, selectedVersionId],
)
const workImageCount = getImageCount(selectedVersion)
const hasReferenceImages = (selectedVersion?.referenceImageGroups?.length || 0) > 0
|| (selectedVersion?.referenceImageUrls?.length || 0) > 0
useEffect(() => {
if (!project.versions.some((version) => version.id === selectedVersionId)) {
setSelectedVersionId(project.versions[0]?.id || '')
}
}, [project.versions, selectedVersionId])
useEffect(() => {
setActiveWorkImageIndex(0)
setDraftAnnotations(Array.from({ length: workImageCount }, () => []))
setSelectedAnnotation(null)
setPositionEditableAnnotation(null)
setAnnotationMode(false)
setShowAnnotationComments(true)
setActiveMobilePane(0)
setError(null)
const frame = window.requestAnimationFrame(() => {
scrollPaneTo(mobileScrollerRef.current, 0, 'auto')
})
return () => window.cancelAnimationFrame(frame)
}, [selectedVersion?.id, workImageCount])
useEffect(() => {
if (hasReferenceImages) return
setActiveMobilePane(0)
scrollPaneTo(mobileScrollerRef.current, 0, 'auto')
}, [hasReferenceImages])
useEffect(() => {
const alignActivePane = () => {
const scroller = mobileScrollerRef.current
if (!scroller) return
scrollPaneTo(scroller, scroller.clientWidth * activeMobilePane, 'auto')
}
window.addEventListener('resize', alignActivePane)
return () => window.removeEventListener('resize', alignActivePane)
}, [activeMobilePane])
useEffect(() => {
if (!selectedAnnotation) return
const frame = window.requestAnimationFrame(() => annotationInputRef.current?.focus())
return () => window.cancelAnimationFrame(frame)
}, [selectedAnnotation])
if (!selectedVersion || !latestVersion) return null
const isLatestSelected = selectedVersion.id === latestVersion.id
const pendingRole = isLatestSelected ? project.permissions.pendingReviewRoles[0] : undefined
const currentDrafts = draftAnnotations[activeWorkImageIndex] || []
const selectedDraft = selectedAnnotation
? draftAnnotations[selectedAnnotation.imageIndex]?.find((item) => item.id === selectedAnnotation.id)
: undefined
const positionEditableDraft = positionEditableAnnotation
? draftAnnotations[positionEditableAnnotation.imageIndex]?.find((item) => item.id === positionEditableAnnotation.id)
: undefined
const totalDraftCount = draftAnnotations.reduce((sum, annotations) => sum + annotations.length, 0)
const totalSubmittedAnnotationCount = selectedVersion.reviews.reduce((reviewSum, review) => (
reviewSum + (review.imageComments || []).reduce((imageSum, imageComment) => (
imageSum + (imageComment.annotations?.length || 0)
), 0)
), 0)
const totalAnnotationCount = totalSubmittedAnnotationCount + totalDraftCount
const submittedAnnotations: WorkReviewDisplayAnnotation[] = selectedVersion.reviews.flatMap((review) => {
const imageComment = (review.imageComments || []).find((item) => item.imageIndex === activeWorkImageIndex)
return (imageComment?.annotations || []).map((annotation, index) => ({
annotation,
markerNumber: index + 1,
reviewerName: review.reviewerName,
reviewerRole: review.reviewerRole,
}))
})
const visibleAnnotations: WorkReviewDisplayAnnotation[] = [
...submittedAnnotations,
...currentDrafts.map((annotation, index) => ({
annotation,
markerNumber: index + 1,
draft: true,
positionEditable: positionEditableAnnotation?.imageIndex === activeWorkImageIndex
&& positionEditableAnnotation.id === annotation.id,
})),
]
const updateSplitFromPointer = (clientX: number) => {
const bounds = comparisonRef.current?.getBoundingClientRect()
if (!bounds || bounds.width <= 0) return
const percent = ((clientX - bounds.left) / bounds.width) * 100
setWorkPanePercent(Math.min(MAX_PANE_PERCENT, Math.max(MIN_PANE_PERCENT, Math.round(percent))))
}
const moveMobilePane = (index: 0 | 1) => {
const scroller = mobileScrollerRef.current
if (!scroller) return
setActiveMobilePane(index)
if (index === 1) {
setAnnotationMode(false)
if (!positionEditableAnnotation) setSelectedAnnotation(null)
}
scrollPaneTo(scroller, scroller.clientWidth * index, 'smooth')
}
const addAnnotation = (x: number, y: number) => {
if (!pendingRole) return
if (currentDrafts.length >= MAX_ANNOTATIONS_PER_IMAGE) {
setError(`单张图片最多添加 ${MAX_ANNOTATIONS_PER_IMAGE} 个批注`)
return
}
const annotation: WorkReviewImageAnnotation = { id: createAnnotationId(), x, y, comment: '' }
setDraftAnnotations((current) => current.map((items, index) => (
index === activeWorkImageIndex ? [...items, annotation] : items
)))
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
setPositionEditableAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
setAnnotationMode(false)
setError(null)
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加标注点,完成前可点击图片调整位置`)
}
const movePositionEditableAnnotation = (x: number, y: number) => {
if (!positionEditableAnnotation || !positionEditableDraft) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === positionEditableAnnotation.imageIndex
? items.map((item) => item.id === positionEditableAnnotation.id ? { ...item, x, y } : item)
: items
)))
setSelectedAnnotation(positionEditableAnnotation)
setError(null)
const markerIndex = (draftAnnotations[positionEditableAnnotation.imageIndex] || [])
.findIndex((item) => item.id === positionEditableAnnotation.id)
setLiveMessage(`标注 ${markerIndex + 1} 已移动到新位置`)
}
const updateSelectedAnnotation = (comment: string) => {
if (!selectedAnnotation) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === selectedAnnotation.imageIndex
? items.map((item) => item.id === selectedAnnotation.id ? { ...item, comment } : item)
: items
)))
setError(null)
}
const deleteSelectedAnnotation = () => {
if (!selectedAnnotation) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === selectedAnnotation.imageIndex
? items.filter((item) => item.id !== selectedAnnotation.id)
: items
)))
setSelectedAnnotation(null)
if (positionEditableAnnotation?.imageIndex === selectedAnnotation.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation.id) {
setPositionEditableAnnotation(null)
}
setError(null)
setLiveMessage('批注已删除')
}
const finishEditingAnnotation = () => {
if (!selectedDraft?.comment.trim()) {
setError('请填写这个标注点的修改意见')
return
}
const lockedPosition = positionEditableAnnotation?.imageIndex === selectedAnnotation?.imageIndex
&& positionEditableAnnotation?.id === selectedAnnotation?.id
setSelectedAnnotation(null)
if (lockedPosition) setPositionEditableAnnotation(null)
setError(null)
setLiveMessage(lockedPosition ? '标注位置已锁定' : '批注修改意见已保存')
}
const findIncompleteAnnotation = () => {
for (let imageIndex = 0; imageIndex < draftAnnotations.length; imageIndex += 1) {
const annotation = draftAnnotations[imageIndex].find((item) => !item.comment.trim())
if (annotation) return { imageIndex, id: annotation.id }
}
return null
}
const changeActiveWorkImage = (index: number) => {
if (positionEditableAnnotation) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
if (index !== activeWorkImageIndex) {
setError('请先点击“完成”锁定当前标注位置,再切换图片')
}
return
}
setActiveWorkImageIndex(index)
setSelectedAnnotation(null)
setAnnotationMode(false)
}
const toggleAnnotationMode = () => {
if (positionEditableAnnotation) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先完成或删除当前标注')
return
}
setAnnotationMode((current) => !current)
setSelectedAnnotation(null)
setError(null)
}
const selectDraftAnnotation = (id: string) => {
if (positionEditableAnnotation && positionEditableAnnotation.id !== id) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先完成或删除当前标注')
return
}
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
setAnnotationMode(false)
setError(null)
}
const submitDecision = async (decision: 'approved' | 'revision_required') => {
if (!pendingRole || workImageCount === 0) return
if (positionEditableAnnotation) {
setActiveWorkImageIndex(positionEditableAnnotation.imageIndex)
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先点击“完成”锁定当前标注位置')
return
}
const incomplete = findIncompleteAnnotation()
if (incomplete) {
setActiveWorkImageIndex(incomplete.imageIndex)
setSelectedAnnotation(incomplete)
setAnnotationMode(false)
setError('请先填写未完成标注点的修改意见')
return
}
if (decision === 'revision_required' && totalDraftCount === 0) {
setError('请先在作品图片上标注至少一个需要修改的位置')
setAnnotationMode(true)
return
}
setBusyAction(decision)
setError(null)
try {
await onSubmitReview(selectedVersion, {
reviewerRole: pendingRole,
imageComments: Array.from({ length: workImageCount }, (_, imageIndex) => {
const annotations = draftAnnotations[imageIndex] || []
return {
imageIndex,
decision: decision === 'revision_required' && annotations.length > 0
? 'revision_required'
: 'approved',
comment: summarizeAnnotations(annotations),
annotations,
}
}),
generalComment: '',
})
setDraftAnnotations(Array.from({ length: workImageCount }, () => []))
setSelectedAnnotation(null)
setPositionEditableAnnotation(null)
setAnnotationMode(false)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '评审提交失败')
} finally {
setBusyAction(null)
}
}
return (
<div className="flex min-h-0 flex-col">
<nav
className="mb-3 flex min-h-12 items-center gap-2 overflow-x-auto border-b border-[#dfe5ee] bg-white px-1 pb-2"
aria-label="选择作品版本"
role="tablist"
>
{project.versions.map((version) => {
const active = version.id === selectedVersion.id
const tabId = `work-review-version-tab-${version.id}`
return (
<button
key={version.id}
id={tabId}
type="button"
onClick={() => {
if (version.id !== selectedVersion.id && totalDraftCount > 0 && pendingRole) {
setError('当前版本还有未提交批注,请先完成评审或删除批注')
return
}
setSelectedVersionId(version.id)
}}
role="tab"
aria-selected={active}
aria-controls="work-review-version-panel"
className={`min-h-10 min-w-16 shrink-0 rounded px-4 text-[13px] font-bold transition-colors ${
active
? 'bg-[#101828] text-white'
: 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]'
}`}
>
V{version.version}
</button>
)
})}
</nav>
<div
id="work-review-version-panel"
role="tabpanel"
aria-labelledby={`work-review-version-tab-${selectedVersion.id}`}
className="flex h-[calc(100dvh-144px)] min-h-[600px] flex-col overflow-hidden border border-[#dfe5ee] bg-white"
>
{hasReferenceImages && (
<div className="shrink-0 border-b border-[#dfe5ee] bg-white p-2 md:hidden">
<div
className="grid grid-cols-2 gap-1 rounded bg-[#eef2f6] p-1"
role="group"
aria-label="切换作品图与参考图"
>
{([
{ index: 0 as const, label: '作品图' },
{ index: 1 as const, label: '参考图' },
]).map((item) => {
const active = activeMobilePane === item.index
return (
<button
key={item.label}
type="button"
onClick={() => moveMobilePane(item.index)}
aria-pressed={active}
className={`min-h-11 rounded px-4 text-[13px] font-bold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-1 ${
active
? 'bg-[#dcecff] text-[#0050cb]'
: 'bg-transparent text-[#526173] hover:bg-white'
}`}
>
{item.label}
</button>
)
})}
</div>
</div>
)}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div
ref={comparisonRef}
className="hidden h-full min-h-0 md:flex"
aria-label="作品与参考图片对照区"
>
<div className="min-w-0" style={{ width: hasReferenceImages ? `${workPanePercent}%` : '100%' }}>
<LongImagePane
key={`${selectedVersion.id}-work`}
label="作品图片"
groups={selectedVersion.imageGroups || []}
imageUrls={selectedVersion.imageUrls || []}
emptyMessage="当前版本没有作品图片"
activeIndex={activeWorkImageIndex}
onActiveIndexChange={changeActiveWorkImage}
annotations={visibleAnnotations}
annotationEnabled={Boolean(pendingRole)}
annotationMode={annotationMode}
annotationRepositionMode={Boolean(positionEditableDraft)}
annotationPosition={positionEditableDraft}
annotationCount={totalAnnotationCount}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={toggleAnnotationMode}
onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)}
onAddAnnotation={addAnnotation}
onMoveAnnotation={movePositionEditableAnnotation}
onSelectDraftAnnotation={selectDraftAnnotation}
/>
</div>
{hasReferenceImages && (
<>
<div
role="separator"
aria-label="调整作品图片与参考图片宽度"
aria-orientation="vertical"
aria-valuemin={MIN_PANE_PERCENT}
aria-valuemax={MAX_PANE_PERCENT}
aria-valuenow={workPanePercent}
aria-valuetext={`作品图片 ${workPanePercent}%,参考图片 ${100 - workPanePercent}%`}
tabIndex={0}
onDoubleClick={() => setWorkPanePercent(50)}
onPointerDown={(event) => {
draggingRef.current = true
event.currentTarget.setPointerCapture(event.pointerId)
updateSplitFromPointer(event.clientX)
}}
onPointerMove={(event) => {
if (draggingRef.current) updateSplitFromPointer(event.clientX)
}}
onPointerUp={(event) => {
draggingRef.current = false
event.currentTarget.releasePointerCapture(event.pointerId)
}}
onPointerCancel={() => {
draggingRef.current = false
}}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
setWorkPanePercent((current) => Math.max(MIN_PANE_PERCENT, current - 5))
} else if (event.key === 'ArrowRight') {
event.preventDefault()
setWorkPanePercent((current) => Math.min(MAX_PANE_PERCENT, current + 5))
} else if (event.key === 'Home') {
event.preventDefault()
setWorkPanePercent(MIN_PANE_PERCENT)
} else if (event.key === 'End') {
event.preventDefault()
setWorkPanePercent(MAX_PANE_PERCENT)
}
}}
className="group relative z-10 flex w-4 shrink-0 touch-none cursor-col-resize items-center justify-center bg-[#dfe5ee] outline-none transition-colors hover:bg-[#b8cae2] focus-visible:bg-[#8ab9ff] focus-visible:ring-2 focus-visible:ring-[#0050cb]"
title="左右拖动调整宽度,双击恢复等分"
>
<span className="h-12 w-1 rounded-full bg-[#7b91ad] transition-colors group-hover:bg-[#0050cb]" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<LongImagePane
key={`${selectedVersion.id}-reference`}
label="参考图片"
groups={selectedVersion.referenceImageGroups || []}
imageUrls={selectedVersion.referenceImageUrls || []}
emptyMessage="当前版本未上传参考图片"
/>
</div>
</>
)}
</div>
<div
ref={mobileScrollerRef}
className="flex h-full snap-x snap-mandatory overflow-x-auto overscroll-x-contain md:hidden"
aria-label={hasReferenceImages ? '作品与参考图片对照区,左右滑动切换' : '作品图片'}
tabIndex={hasReferenceImages ? 0 : undefined}
onScroll={(event) => {
if (!hasReferenceImages || event.currentTarget.clientWidth <= 0) return
const nextPane = Math.min(
1,
Math.max(0, Math.round(event.currentTarget.scrollLeft / event.currentTarget.clientWidth)),
) as 0 | 1
setActiveMobilePane((current) => current === nextPane ? current : nextPane)
if (nextPane === 1 && annotationMode) {
setAnnotationMode(false)
setSelectedAnnotation(null)
}
}}
onKeyDown={(event) => {
if (!hasReferenceImages) return
if (event.key === 'ArrowLeft') {
event.preventDefault()
moveMobilePane(0)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
moveMobilePane(1)
}
}}
>
<div className="h-full w-full shrink-0 snap-start">
<LongImagePane
key={`${selectedVersion.id}-mobile-work`}
label="作品图片"
groups={selectedVersion.imageGroups || []}
imageUrls={selectedVersion.imageUrls || []}
emptyMessage="当前版本没有作品图片"
activeIndex={activeWorkImageIndex}
onActiveIndexChange={changeActiveWorkImage}
annotations={visibleAnnotations}
annotationEnabled={Boolean(pendingRole)}
annotationMode={annotationMode}
annotationRepositionMode={Boolean(positionEditableDraft)}
annotationPosition={positionEditableDraft}
annotationCount={totalAnnotationCount}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={toggleAnnotationMode}
onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)}
onAddAnnotation={addAnnotation}
onMoveAnnotation={movePositionEditableAnnotation}
onSelectDraftAnnotation={selectDraftAnnotation}
/>
</div>
{hasReferenceImages && (
<div className="h-full w-full shrink-0 snap-start">
<LongImagePane
key={`${selectedVersion.id}-mobile-reference`}
label="参考图片"
groups={selectedVersion.referenceImageGroups || []}
imageUrls={selectedVersion.referenceImageUrls || []}
emptyMessage="当前版本未上传参考图片"
/>
</div>
)}
</div>
</div>
<div className="shrink-0 border-t border-[#dfe5ee] bg-white px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4" aria-busy={Boolean(busyAction)}>
{pendingRole ? (
<>
{selectedDraft ? (
<div className="mb-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<label className="grid min-w-0 gap-1.5">
<span className="text-[12px] font-bold text-[#344054]">
图 {selectedAnnotation!.imageIndex + 1} · 标注 {(draftAnnotations[selectedAnnotation!.imageIndex] || []).findIndex((item) => item.id === selectedAnnotation!.id) + 1} 修改意见
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id
? '(位置可调整)'
: ''}
</span>
<textarea
ref={annotationInputRef}
value={selectedDraft.comment}
onChange={(event) => updateSelectedAnnotation(event.target.value.slice(0, MAX_ANNOTATION_COMMENT_LENGTH))}
rows={2}
disabled={Boolean(busyAction)}
placeholder="说明这个位置需要如何调整"
className="min-h-16 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]"
/>
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id ? (
<span className="text-[11px] font-semibold text-[#7a4a00]">
完成前可点击作品图片重新调整标注位置
</span>
) : null}
</label>
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
type="button"
onClick={deleteSelectedAnnotation}
disabled={Boolean(busyAction)}
className="min-h-10 rounded border border-[#e4b3b3] px-3 text-[12px] font-bold text-[#b42318] hover:bg-[#fff5f5] disabled:opacity-45"
>
删除
</button>
<button
type="button"
onClick={finishEditingAnnotation}
disabled={Boolean(busyAction)}
className="min-h-10 rounded bg-[#101828] px-4 text-[12px] font-bold text-white hover:bg-[#26354a] disabled:opacity-45"
>
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id
? '完成'
: '保存修改'}
</button>
</div>
</div>
) : null}
{error && (
<div className="mb-3 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[12px] font-semibold text-[#b42318]" role="alert">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-2 sm:ml-auto sm:max-w-[520px]">
<button
type="button"
onClick={() => void submitDecision('approved')}
disabled={Boolean(busyAction)}
className="min-h-12 rounded bg-[#087a3c] px-3 text-[13px] font-bold text-white hover:bg-[#066332] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#087a3c] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[#9abdab]"
>
{busyAction === 'approved' ? '正在提交…' : '通过'}
</button>
<button
type="button"
onClick={() => void submitDecision('revision_required')}
disabled={Boolean(busyAction)}
className="min-h-12 rounded bg-[#8a5200] px-3 text-[13px] font-bold text-white hover:bg-[#6d4100] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8a5200] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[#c2aa87]"
>
{busyAction === 'revision_required' ? '正在提交…' : '提交给修改人'}
</button>
</div>
</>
) : (
<div className="flex min-h-11 items-center justify-center text-center text-[12px] font-semibold text-[#667085]">
{isLatestSelected ? '你的本轮评审已提交;两位评审完成后系统会通知发起人' : '历史版本仅供查看'}
</div>
)}
<div className="sr-only" aria-live="polite">{liveMessage}</div>
</div>
</div>
</div>
)
}