gongxiangp2/components/work-review/WorkReviewReviewerWorkspace.tsx
2026-08-03 13:40:13 +08:00

1135 lines
48 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,
WorkReviewRole,
WorkReviewVersion,
} from '@/types/work-review'
import {
WorkReviewAnnotationMarker,
type WorkReviewDisplayAnnotation,
} from './WorkReviewAnnotationMarker'
import {
WorkReviewAnnotationToolbar,
type WorkReviewDrawingTool,
} from './WorkReviewAnnotationToolbar'
import { canResubmitWorkReviewProject } from '@/lib/work-review-policy'
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
const DEFAULT_ANNOTATION_COLOR = '#0050cb'
type AnnotationGeometry = Omit<WorkReviewImageAnnotation, 'id' | 'comment'>
type NormalizedPoint = { x: number; y: number }
function clampCoordinate(value: number) {
return Math.min(1, Math.max(0, value))
}
function createAnnotationGeometry(
start: NormalizedPoint,
end: NormalizedPoint,
shape: WorkReviewDrawingTool,
color: string,
): AnnotationGeometry {
if (shape === 'arrow') {
return { x: start.x, y: start.y, x2: end.x, y2: end.y, shape, color }
}
return {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
x2: Math.max(start.x, end.x),
y2: Math.max(start.y, end.y),
shape,
color,
}
}
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,
annotationTool = 'rectangle',
annotationColor = DEFAULT_ANNOTATION_COLOR,
annotationCount,
canUndoAnnotation = false,
showAnnotationComments = true,
onToggleAnnotationMode,
onAnnotationToolChange,
onAnnotationColorChange,
onUndoAnnotation,
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?: AnnotationGeometry
annotationTool?: WorkReviewDrawingTool
annotationColor?: string
annotationCount?: number
canUndoAnnotation?: boolean
showAnnotationComments?: boolean
onToggleAnnotationMode?: () => void
onAnnotationToolChange?: (tool: WorkReviewDrawingTool) => void
onAnnotationColorChange?: (color: string) => void
onUndoAnnotation?: () => void
onToggleAnnotationComments?: () => void
onAddAnnotation?: (geometry: AnnotationGeometry) => void
onMoveAnnotation?: (geometry: AnnotationGeometry) => void
onSelectDraftAnnotation?: (annotationId: string) => void
}) {
const displayGroups = useMemo(
() => normalizeGroups(groups || [], imageUrls || [], label),
[groups, imageUrls, label],
)
const [internalActiveIndex, setInternalActiveIndex] = useState(0)
const [draftGeometry, setDraftGeometry] = useState<AnnotationGeometry | null>(null)
const pointerStartRef = useRef<{ point: NormalizedPoint; clientX: number; clientY: number } | null>(null)
const activeIndex = Math.min(
controlledActiveIndex ?? internalActiveIndex,
Math.max(0, displayGroups.length - 1),
)
const activeGroup = displayGroups[activeIndex]
const placementMode = annotationMode || annotationRepositionMode
const drawingTool = annotationRepositionMode
? (annotationPosition?.shape === 'ellipse' || annotationPosition?.shape === 'arrow'
? annotationPosition.shape
: 'rectangle')
: annotationTool
const drawingColor = annotationRepositionMode
? annotationPosition?.color || annotationColor
: annotationColor
useEffect(() => {
if (controlledActiveIndex == null) setInternalActiveIndex(0)
}, [controlledActiveIndex, displayGroups.length])
useEffect(() => {
setDraftGeometry(null)
pointerStartRef.current = null
}, [activeIndex, annotationMode, annotationRepositionMode, annotationTool, annotationColor])
const selectImage = (index: number) => {
if (controlledActiveIndex == null) setInternalActiveIndex(index)
onActiveIndexChange?.(index)
}
const pointFromPointer = (element: HTMLDivElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) return null
return {
x: clampCoordinate((clientX - bounds.left) / bounds.width),
y: clampCoordinate((clientY - bounds.top) / bounds.height),
}
}
const commitGeometry = (geometry: AnnotationGeometry) => {
if (annotationRepositionMode) {
onMoveAnnotation?.(geometry)
} else {
onAddAnnotation?.(geometry)
}
setDraftGeometry(null)
}
return (
<section className="relative flex h-full min-h-0 flex-col bg-white" aria-label={label}>
{annotationEnabled && annotationMode && !annotationRepositionMode && (
<WorkReviewAnnotationToolbar
tool={annotationTool}
color={annotationColor}
canUndo={canUndoAnnotation}
onToolChange={(nextTool) => onAnnotationToolChange?.(nextTool)}
onColorChange={(nextColor) => onAnnotationColorChange?.(nextColor)}
onUndo={() => onUndoAnnotation?.()}
onClose={() => onToggleAnnotationMode?.()}
/>
)}
<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-[#0050cb] text-white hover:bg-[#003f9f]'
: '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
? '完成前可在作品图片上重新拖拽框选范围;滚动不会移动标注'
: `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽`}
</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
const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY)
if (!point) return
pointerStartRef.current = { point, clientX: event.clientX, clientY: event.clientY }
setDraftGeometry(createAnnotationGeometry(point, point, drawingTool, drawingColor))
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
}}
onPointerMove={(event) => {
if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return
const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY)
if (!point) return
setDraftGeometry(createAnnotationGeometry(
pointerStartRef.current.point,
point,
drawingTool,
drawingColor,
))
}}
onPointerUp={(event) => {
if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return
const distance = Math.hypot(
event.clientX - pointerStartRef.current.clientX,
event.clientY - pointerStartRef.current.clientY,
)
const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY)
const start = pointerStartRef.current.point
pointerStartRef.current = null
if (typeof event.currentTarget.releasePointerCapture === 'function'
&& event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (!point || distance < 10) {
setDraftGeometry(null)
return
}
commitGeometry(createAnnotationGeometry(start, point, drawingTool, drawingColor))
}}
onPointerCancel={() => {
pointerStartRef.current = null
setDraftGeometry(null)
}}
onKeyDown={(event) => {
if (annotationRepositionMode && annotationPosition && [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
].includes(event.key)) {
event.preventDefault()
const step = event.shiftKey ? 0.05 : 0.01
const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0
const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0
const xValues = [annotationPosition.x, annotationPosition.x2 ?? annotationPosition.x]
const yValues = [annotationPosition.y, annotationPosition.y2 ?? annotationPosition.y]
const safeDx = Math.max(-Math.min(...xValues), Math.min(1 - Math.max(...xValues), dx))
const safeDy = Math.max(-Math.min(...yValues), Math.min(1 - Math.max(...yValues), dy))
onMoveAnnotation?.({
...annotationPosition,
x: annotationPosition.x + safeDx,
y: annotationPosition.y + safeDy,
...(annotationPosition.x2 == null ? {} : { x2: annotationPosition.x2 + safeDx }),
...(annotationPosition.y2 == null ? {} : { y2: annotationPosition.y2 + safeDy }),
})
} else if (annotationMode && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
commitGeometry(createAnnotationGeometry(
{ x: 0.38, y: 0.42 },
{ x: 0.62, y: 0.58 },
drawingTool,
drawingColor,
))
}
}}
>
{(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}
/>
))}
{draftGeometry && (
<div className="pointer-events-none">
<WorkReviewAnnotationMarker
item={{
annotation: {
id: 'drawing-preview',
...draftGeometry,
comment: '拖拽框选中',
},
markerNumber: annotations.length + 1,
draft: true,
positionEditable: true,
}}
showComment={false}
/>
</div>
)}
</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 [annotationTool, setAnnotationTool] = useState<WorkReviewDrawingTool>('rectangle')
const [annotationColor, setAnnotationColor] = useState(DEFAULT_ANNOTATION_COLOR)
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)
setAnnotationTool('rectangle')
setAnnotationColor(DEFAULT_ANNOTATION_COLOR)
setShowAnnotationComments(true)
setActiveMobilePane(0)
setError(null)
// 版本切换后的复位必须在本次 effect 内完成。若延迟到下一帧,用户在首帧内
// 点击“参考图”后,旧的初始化回调会把滚动位置再次拉回“作品图”。
scrollPaneTo(mobileScrollerRef.current, 0, 'auto')
}, [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 canResubmitLatestVersion = canResubmitWorkReviewProject(project)
const requiredReviewRoles: WorkReviewRole[] = project.requiredReviewRoles?.length
? project.requiredReviewRoles
: ['manager', 'final_reviewer']
const singleReviewerTest = project.reviewMode === 'single_reviewer_test'
const relevantReviews = selectedVersion.reviews.filter((review) => (
requiredReviewRoles.includes(review.reviewerRole)
))
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 = relevantReviews.reduce((reviewSum, review) => (
reviewSum + (review.imageComments || []).reduce((imageSum, imageComment) => (
imageSum + (imageComment.annotations?.length || 0)
), 0)
), 0)
const totalAnnotationCount = totalSubmittedAnnotationCount + totalDraftCount
const submittedAnnotations: WorkReviewDisplayAnnotation[] = relevantReviews.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 = (geometry: AnnotationGeometry) => {
if (!pendingRole) return
if (currentDrafts.length >= MAX_ANNOTATIONS_PER_IMAGE) {
setError(`单张图片最多添加 ${MAX_ANNOTATIONS_PER_IMAGE} 个批注`)
return
}
const annotation: WorkReviewImageAnnotation = {
id: createAnnotationId(),
...geometry,
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 = (geometry: AnnotationGeometry) => {
if (!positionEditableAnnotation || !positionEditableDraft) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === positionEditableAnnotation.imageIndex
? items.map((item) => item.id === positionEditableAnnotation.id ? { ...item, ...geometry } : item)
: items
)))
setSelectedAnnotation(positionEditableAnnotation)
setError(null)
const markerIndex = (draftAnnotations[positionEditableAnnotation.imageIndex] || [])
.findIndex((item) => item.id === positionEditableAnnotation.id)
setLiveMessage(`标注 ${markerIndex + 1} 的框选范围已更新`)
}
const undoLastAnnotation = () => {
if (positionEditableAnnotation) {
setSelectedAnnotation(positionEditableAnnotation)
setError('请先完成或删除当前标注')
return
}
let lastIndex = -1
for (let index = draftAnnotations.length - 1; index >= 0; index -= 1) {
if (draftAnnotations[index].length > 0) {
lastIndex = index
break
}
}
if (lastIndex < 0) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === lastIndex ? items.slice(0, -1) : items
)))
setSelectedAnnotation(null)
setError(null)
setLiveMessage('已撤销上一个标注')
}
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">
{singleReviewerTest && (
<div className="mb-3 rounded border border-[#f3d29b] bg-[#fffaf0] px-3 py-2 text-[12px] font-semibold leading-5 text-[#8a5200]" role="status">
测试模式:当前作品由指定评审人单独完成评审,已跳过元泽。
</div>
)}
<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}{version.status === 'approved' ? '已过审' : ''}
</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}
annotationTool={annotationTool}
annotationColor={annotationColor}
annotationCount={totalAnnotationCount}
canUndoAnnotation={totalDraftCount > 0}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={toggleAnnotationMode}
onAnnotationToolChange={setAnnotationTool}
onAnnotationColorChange={setAnnotationColor}
onUndoAnnotation={undoLastAnnotation}
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}
annotationTool={annotationTool}
annotationColor={annotationColor}
annotationCount={totalAnnotationCount}
canUndoAnnotation={totalDraftCount > 0}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={toggleAnnotationMode}
onAnnotationToolChange={setAnnotationTool}
onAnnotationColorChange={setAnnotationColor}
onUndoAnnotation={undoLastAnnotation}
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
? '历史版本仅供查看'
: canResubmitLatestVersion
? '当前版本需要修改,请通过顶部“提交修改”上传新版本'
: project.status === 'approved'
? '作品评审已通过,本作品已完结'
: singleReviewerTest
? '当前测试流程等待指定评审人完成'
: '等待评审人完成本轮评审;完成后系统会通知发起人'}
</div>
)}
<div className="sr-only" aria-live="polite">{liveMessage}</div>
</div>
</div>
</div>
)
}