'use client' // LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。 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 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(null) const pointerStartRef = useRef<{ pointerId: number 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 (
{annotationEnabled && annotationMode && !annotationRepositionMode && ( onAnnotationToolChange?.(nextTool)} onColorChange={(nextColor) => onAnnotationColorChange?.(nextColor)} onUndo={() => onUndoAnnotation?.()} onClose={() => onToggleAnnotationMode?.()} /> )}

{label}

{annotationEnabled && ( )} {annotationCount != null && ( 已标注 {annotationCount} 个修改点 )} {onToggleAnnotationComments && ( )}
{displayGroups.length > 1 ? (
{displayGroups.map((group, index) => ( ))}
) : ( {displayGroups.length > 0 ? '1 张' : '0 张'} )}
{placementMode && (
{annotationRepositionMode ? '完成前可重新拖拽框选范围;手机端如需滚动,请先完成当前标注' : `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽;手机端如需滚动,请先关闭标注工具`}
)}
{activeGroup ? (
{ if (!placementMode || event.isPrimary === false || (event.button ?? 0) !== 0) return event.preventDefault() const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY) if (!point) return pointerStartRef.current = { pointerId: event.pointerId, point, clientX: event.clientX, clientY: event.clientY, } setDraftGeometry(createAnnotationGeometry(point, point, drawingTool, drawingColor)) if (typeof event.currentTarget.setPointerCapture === 'function') { try { event.currentTarget.setPointerCapture(event.pointerId) } catch { // 部分移动端浏览器在手势切换瞬间会拒绝捕获;touch-none 仍会保持框选手势。 } } }} onPointerMove={(event) => { if (!placementMode || event.isPrimary === false || !pointerStartRef.current || pointerStartRef.current.pointerId !== event.pointerId) return event.preventDefault() 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 || pointerStartRef.current.pointerId !== event.pointerId) return event.preventDefault() 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)) { try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { // 指针已由系统释放时无需再次处理。 } } if (!point || distance < 10) { setDraftGeometry(null) return } commitGeometry(createAnnotationGeometry(start, point, drawingTool, drawingColor)) }} onPointerCancel={(event) => { if (pointerStartRef.current?.pointerId !== event.pointerId) return pointerStartRef.current = null setDraftGeometry(null) }} onLostPointerCapture={(event) => { if (pointerStartRef.current?.pointerId !== event.pointerId) return pointerStartRef.current = null setDraftGeometry(null) }} onContextMenu={(event) => { if (placementMode) event.preventDefault() }} 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) => ( {sliceIndex ))} {annotations.map((item) => ( onSelectDraftAnnotation?.(item.annotation.id) : undefined} showComment={showAnnotationComments} /> ))} {draftGeometry && (
)}
) : (

{emptyMessage}

)}
) } 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 }) { 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(() => ( 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('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(null) const [liveMessage, setLiveMessage] = useState('') const comparisonRef = useRef(null) const draggingRef = useRef(false) const mobileScrollerRef = useRef(null) const annotationInputRef = useRef(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 (
{singleReviewerTest && (
测试模式:当前作品由指定评审人单独完成评审,已跳过元泽。
)}
{hasReferenceImages && (
{([ { index: 0 as const, label: '作品图' }, { index: 1 as const, label: '参考图' }, ]).map((item) => { const active = activeMobilePane === item.index return ( ) })}
)}

作品说明

{project.description?.trim() || '未填写作品说明'}

0} showAnnotationComments={showAnnotationComments} onToggleAnnotationMode={toggleAnnotationMode} onAnnotationToolChange={setAnnotationTool} onAnnotationColorChange={setAnnotationColor} onUndoAnnotation={undoLastAnnotation} onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)} onAddAnnotation={addAnnotation} onMoveAnnotation={movePositionEditableAnnotation} onSelectDraftAnnotation={selectDraftAnnotation} />
{hasReferenceImages && ( <>
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="左右拖动调整宽度,双击恢复等分" >
)}
{ 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) } }} >
0} showAnnotationComments={showAnnotationComments} onToggleAnnotationMode={toggleAnnotationMode} onAnnotationToolChange={setAnnotationTool} onAnnotationColorChange={setAnnotationColor} onUndoAnnotation={undoLastAnnotation} onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)} onAddAnnotation={addAnnotation} onMoveAnnotation={movePositionEditableAnnotation} onSelectDraftAnnotation={selectDraftAnnotation} />
{hasReferenceImages && (
)}
{pendingRole ? ( <> {selectedDraft ? (