1135 lines
48 KiB
TypeScript
1135 lines
48 KiB
TypeScript
'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>
|
||
)
|
||
}
|