725 lines
30 KiB
TypeScript
725 lines
30 KiB
TypeScript
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import type {
|
||
WorkReviewImageAnnotation,
|
||
WorkReviewImageGroup,
|
||
WorkReviewProject,
|
||
WorkReviewReviewInput,
|
||
WorkReviewVersion,
|
||
} from '@/types/work-review'
|
||
import {
|
||
WorkReviewAnnotationMarker,
|
||
type WorkReviewDisplayAnnotation,
|
||
} from './WorkReviewAnnotationMarker'
|
||
|
||
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
|
||
const MIN_PANE_PERCENT = 14
|
||
const MAX_PANE_PERCENT = 86
|
||
const MAX_ANNOTATIONS_PER_IMAGE = 50
|
||
const MAX_ANNOTATION_COMMENT_LENGTH = 1000
|
||
|
||
function proxyImageUrl(value: string) {
|
||
const url = String(value || '').trim()
|
||
if (!url || !/^https?:\/\//i.test(url)) return url
|
||
try {
|
||
const parsed = new URL(url)
|
||
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
|
||
? `/api/proxy-media?url=${encodeURIComponent(url)}`
|
||
: url
|
||
} catch {
|
||
return url
|
||
}
|
||
}
|
||
|
||
function normalizeGroups(
|
||
groups: WorkReviewImageGroup[],
|
||
imageUrls: string[],
|
||
fallbackLabel: string,
|
||
): WorkReviewImageGroup[] {
|
||
if (groups.length > 0) {
|
||
return groups.map((group, index) => ({
|
||
...group,
|
||
sourceName: group.sourceName || `${fallbackLabel} ${index + 1}`,
|
||
previewUrl: proxyImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
|
||
slices: (group.slices || []).map((slice) => ({
|
||
...slice,
|
||
url: proxyImageUrl(slice.url),
|
||
})),
|
||
})).filter((group) => Boolean(group.previewUrl))
|
||
}
|
||
|
||
return imageUrls.map((url, index) => ({
|
||
sourceName: `${fallbackLabel} ${index + 1}`,
|
||
previewUrl: proxyImageUrl(url),
|
||
previewWidth: 1_200,
|
||
previewHeight: 1_500,
|
||
width: 1_200,
|
||
height: 1_500,
|
||
slices: [{ url: proxyImageUrl(url), width: 1_200, height: 1_500 }],
|
||
}))
|
||
}
|
||
|
||
function createAnnotationId() {
|
||
if (typeof window !== 'undefined' && typeof window.crypto?.randomUUID === 'function') {
|
||
return window.crypto.randomUUID()
|
||
}
|
||
return `point-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||
}
|
||
|
||
function LongImagePane({
|
||
label,
|
||
groups,
|
||
imageUrls,
|
||
emptyMessage,
|
||
activeIndex: controlledActiveIndex,
|
||
onActiveIndexChange,
|
||
annotations = [],
|
||
annotationEnabled = false,
|
||
annotationMode = false,
|
||
onToggleAnnotationMode,
|
||
onAddAnnotation,
|
||
onSelectDraftAnnotation,
|
||
}: {
|
||
label: string
|
||
groups: WorkReviewImageGroup[]
|
||
imageUrls: string[]
|
||
emptyMessage: string
|
||
activeIndex?: number
|
||
onActiveIndexChange?: (index: number) => void
|
||
annotations?: WorkReviewDisplayAnnotation[]
|
||
annotationEnabled?: boolean
|
||
annotationMode?: boolean
|
||
onToggleAnnotationMode?: () => void
|
||
onAddAnnotation?: (x: number, y: number) => void
|
||
onSelectDraftAnnotation?: (annotationId: string) => void
|
||
}) {
|
||
const displayGroups = useMemo(
|
||
() => normalizeGroups(groups || [], imageUrls || [], label),
|
||
[groups, imageUrls, label],
|
||
)
|
||
const [internalActiveIndex, setInternalActiveIndex] = useState(0)
|
||
const pointerStartRef = useRef<{ x: number; y: number } | null>(null)
|
||
const activeIndex = Math.min(
|
||
controlledActiveIndex ?? internalActiveIndex,
|
||
Math.max(0, displayGroups.length - 1),
|
||
)
|
||
const activeGroup = displayGroups[activeIndex]
|
||
|
||
useEffect(() => {
|
||
if (controlledActiveIndex == null) setInternalActiveIndex(0)
|
||
}, [controlledActiveIndex, displayGroups.length])
|
||
|
||
const selectImage = (index: number) => {
|
||
if (controlledActiveIndex == null) setInternalActiveIndex(index)
|
||
onActiveIndexChange?.(index)
|
||
}
|
||
|
||
const placeAnnotation = (element: HTMLDivElement, clientX: number, clientY: number) => {
|
||
const bounds = element.getBoundingClientRect()
|
||
if (bounds.width <= 0 || bounds.height <= 0) return
|
||
const x = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width))
|
||
const y = Math.min(1, Math.max(0, (clientY - bounds.top) / bounds.height))
|
||
onAddAnnotation?.(x, y)
|
||
}
|
||
|
||
return (
|
||
<section className="flex h-full min-h-0 flex-col bg-white" aria-label={label}>
|
||
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-[#e4e7ec] px-3 py-1.5 sm:px-4">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<h2 className="shrink-0 text-[13px] font-bold text-[#101828]">{label}</h2>
|
||
{annotationEnabled && (
|
||
<button
|
||
type="button"
|
||
onClick={onToggleAnnotationMode}
|
||
aria-pressed={annotationMode}
|
||
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${
|
||
annotationMode
|
||
? 'bg-[#8a5200] text-white hover:bg-[#6d4100]'
|
||
: 'border border-[#d3dae5] bg-white text-[#344054] hover:border-[#8ab9ff]'
|
||
}`}
|
||
>
|
||
{annotationMode ? '取消标注' : '+ 添加标注'}
|
||
</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>
|
||
|
||
{annotationMode && (
|
||
<div className="shrink-0 bg-[#fff7e6] px-3 py-2 text-center text-[11px] font-semibold text-[#7a4a00]" role="status">
|
||
点击作品中的修改位置添加批注;滚动不会产生标记
|
||
</div>
|
||
)}
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-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)] ${annotationMode ? 'cursor-crosshair touch-pan-y outline outline-2 outline-[#d49a31]' : ''}`}
|
||
role={annotationEnabled ? 'group' : undefined}
|
||
aria-label={annotationEnabled ? `${activeGroup.sourceName},可添加位置批注` : undefined}
|
||
tabIndex={annotationMode ? 0 : undefined}
|
||
onPointerDown={(event) => {
|
||
if (!annotationMode || (event.button ?? 0) !== 0) return
|
||
pointerStartRef.current = { x: event.clientX, y: event.clientY }
|
||
}}
|
||
onPointerUp={(event) => {
|
||
if (!annotationMode || !pointerStartRef.current) return
|
||
const distance = Math.hypot(
|
||
event.clientX - pointerStartRef.current.x,
|
||
event.clientY - pointerStartRef.current.y,
|
||
)
|
||
pointerStartRef.current = null
|
||
if (distance <= 8) placeAnnotation(event.currentTarget, event.clientX, event.clientY)
|
||
}}
|
||
onPointerCancel={() => {
|
||
pointerStartRef.current = null
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (annotationMode && (event.key === 'Enter' || event.key === ' ')) {
|
||
event.preventDefault()
|
||
const bounds = event.currentTarget.getBoundingClientRect()
|
||
placeAnnotation(event.currentTarget, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
|
||
}
|
||
}}
|
||
>
|
||
{(activeGroup.slices.length > 0 ? activeGroup.slices : [{
|
||
url: activeGroup.previewUrl,
|
||
width: activeGroup.previewWidth,
|
||
height: activeGroup.previewHeight,
|
||
}]).map((slice, sliceIndex) => (
|
||
<Image
|
||
key={`${slice.url}-${sliceIndex}`}
|
||
src={slice.url}
|
||
alt={sliceIndex === 0 ? activeGroup.sourceName : ''}
|
||
width={slice.width}
|
||
height={slice.height}
|
||
unoptimized
|
||
loading={sliceIndex === 0 ? 'eager' : 'lazy'}
|
||
className="pointer-events-none block h-auto w-full select-none"
|
||
draggable={false}
|
||
/>
|
||
))}
|
||
{annotations.map((item) => (
|
||
<WorkReviewAnnotationMarker
|
||
key={`${item.draft ? 'draft' : item.reviewerRole}-${item.annotation.id}`}
|
||
item={item}
|
||
onClick={item.draft ? () => onSelectDraftAnnotation?.(item.annotation.id) : undefined}
|
||
/>
|
||
))}
|
||
</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 [annotationMode, setAnnotationMode] = useState(false)
|
||
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)
|
||
|
||
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)
|
||
setAnnotationMode(false)
|
||
setError(null)
|
||
}, [selectedVersion?.id, workImageCount])
|
||
|
||
useEffect(() => {
|
||
if (!selectedAnnotation) return
|
||
const frame = window.requestAnimationFrame(() => annotationInputRef.current?.focus())
|
||
return () => window.cancelAnimationFrame(frame)
|
||
}, [selectedAnnotation])
|
||
|
||
if (!selectedVersion || !latestVersion) return null
|
||
|
||
const isLatestSelected = selectedVersion.id === latestVersion.id
|
||
const pendingRole = isLatestSelected ? project.permissions.pendingReviewRoles[0] : undefined
|
||
const hasReferenceImages = (selectedVersion.referenceImageGroups?.length || 0) > 0
|
||
|| (selectedVersion.referenceImageUrls?.length || 0) > 0
|
||
const currentDrafts = draftAnnotations[activeWorkImageIndex] || []
|
||
const selectedDraft = selectedAnnotation
|
||
? draftAnnotations[selectedAnnotation.imageIndex]?.find((item) => item.id === selectedAnnotation.id)
|
||
: undefined
|
||
const totalDraftCount = draftAnnotations.reduce((sum, annotations) => sum + annotations.length, 0)
|
||
const submittedAnnotations: WorkReviewDisplayAnnotation[] = selectedVersion.reviews.flatMap((review) => {
|
||
const imageComment = (review.imageComments || []).find((item) => item.imageIndex === activeWorkImageIndex)
|
||
return (imageComment?.annotations || []).map((annotation, index) => ({
|
||
annotation,
|
||
markerNumber: index + 1,
|
||
reviewerName: review.reviewerName,
|
||
reviewerRole: review.reviewerRole,
|
||
}))
|
||
})
|
||
const visibleAnnotations: WorkReviewDisplayAnnotation[] = [
|
||
...submittedAnnotations,
|
||
...currentDrafts.map((annotation, index) => ({ annotation, markerNumber: index + 1, draft: true })),
|
||
]
|
||
|
||
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: number) => {
|
||
const scroller = mobileScrollerRef.current
|
||
if (!scroller) return
|
||
scroller.scrollTo({ left: scroller.clientWidth * index, behavior: 'smooth' })
|
||
}
|
||
|
||
const addAnnotation = (x: number, y: number) => {
|
||
if (!pendingRole) return
|
||
if (currentDrafts.length >= MAX_ANNOTATIONS_PER_IMAGE) {
|
||
setError(`单张图片最多添加 ${MAX_ANNOTATIONS_PER_IMAGE} 个批注`)
|
||
return
|
||
}
|
||
const annotation: WorkReviewImageAnnotation = { id: createAnnotationId(), x, y, comment: '' }
|
||
setDraftAnnotations((current) => current.map((items, index) => (
|
||
index === activeWorkImageIndex ? [...items, annotation] : items
|
||
)))
|
||
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
|
||
setAnnotationMode(false)
|
||
setError(null)
|
||
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加标注点`)
|
||
}
|
||
|
||
const updateSelectedAnnotation = (comment: string) => {
|
||
if (!selectedAnnotation) return
|
||
setDraftAnnotations((current) => current.map((items, imageIndex) => (
|
||
imageIndex === selectedAnnotation.imageIndex
|
||
? items.map((item) => item.id === selectedAnnotation.id ? { ...item, comment } : item)
|
||
: items
|
||
)))
|
||
setError(null)
|
||
}
|
||
|
||
const deleteSelectedAnnotation = () => {
|
||
if (!selectedAnnotation) return
|
||
setDraftAnnotations((current) => current.map((items, imageIndex) => (
|
||
imageIndex === selectedAnnotation.imageIndex
|
||
? items.filter((item) => item.id !== selectedAnnotation.id)
|
||
: items
|
||
)))
|
||
setSelectedAnnotation(null)
|
||
setError(null)
|
||
setLiveMessage('批注已删除')
|
||
}
|
||
|
||
const finishEditingAnnotation = () => {
|
||
if (!selectedDraft?.comment.trim()) {
|
||
setError('请填写这个标注点的修改意见')
|
||
return
|
||
}
|
||
setSelectedAnnotation(null)
|
||
setError(null)
|
||
setLiveMessage('批注已保存到本次评审草稿')
|
||
}
|
||
|
||
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 submitDecision = async (decision: 'approved' | 'revision_required') => {
|
||
if (!pendingRole || workImageCount === 0) 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)
|
||
setAnnotationMode(false)
|
||
} catch (submitError) {
|
||
setError(submitError instanceof Error ? submitError.message : '评审提交失败')
|
||
} finally {
|
||
setBusyAction(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-col">
|
||
<nav
|
||
className="mb-3 flex min-h-12 items-center gap-2 overflow-x-auto border-b border-[#dfe5ee] bg-white px-1 pb-2"
|
||
aria-label="选择作品版本"
|
||
role="tablist"
|
||
>
|
||
{project.versions.map((version) => {
|
||
const active = version.id === selectedVersion.id
|
||
const tabId = `work-review-version-tab-${version.id}`
|
||
return (
|
||
<button
|
||
key={version.id}
|
||
id={tabId}
|
||
type="button"
|
||
onClick={() => {
|
||
if (version.id !== selectedVersion.id && totalDraftCount > 0 && pendingRole) {
|
||
setError('当前版本还有未提交批注,请先完成评审或删除批注')
|
||
return
|
||
}
|
||
setSelectedVersionId(version.id)
|
||
}}
|
||
role="tab"
|
||
aria-selected={active}
|
||
aria-controls="work-review-version-panel"
|
||
className={`min-h-10 min-w-16 shrink-0 rounded px-4 text-[13px] font-bold transition-colors ${
|
||
active
|
||
? 'bg-[#101828] text-white'
|
||
: 'bg-[#eef2f6] text-[#526173] hover:bg-[#e2e8f0]'
|
||
}`}
|
||
>
|
||
V{version.version}
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
<div
|
||
id="work-review-version-panel"
|
||
role="tabpanel"
|
||
aria-labelledby={`work-review-version-tab-${selectedVersion.id}`}
|
||
className="flex h-[calc(100dvh-144px)] min-h-[600px] flex-col overflow-hidden border border-[#dfe5ee] bg-white"
|
||
>
|
||
<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={(index) => {
|
||
setActiveWorkImageIndex(index)
|
||
setSelectedAnnotation(null)
|
||
setAnnotationMode(false)
|
||
}}
|
||
annotations={visibleAnnotations}
|
||
annotationEnabled={Boolean(pendingRole)}
|
||
annotationMode={annotationMode}
|
||
onToggleAnnotationMode={() => {
|
||
setAnnotationMode((current) => !current)
|
||
setSelectedAnnotation(null)
|
||
setError(null)
|
||
}}
|
||
onAddAnnotation={addAnnotation}
|
||
onSelectDraftAnnotation={(id) => {
|
||
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
|
||
setAnnotationMode(false)
|
||
setError(null)
|
||
}}
|
||
/>
|
||
</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}
|
||
onKeyDown={(event) => {
|
||
if (!hasReferenceImages) return
|
||
if (event.key === 'ArrowLeft') moveMobilePane(0)
|
||
if (event.key === 'ArrowRight') 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={(index) => {
|
||
setActiveWorkImageIndex(index)
|
||
setSelectedAnnotation(null)
|
||
setAnnotationMode(false)
|
||
}}
|
||
annotations={visibleAnnotations}
|
||
annotationEnabled={Boolean(pendingRole)}
|
||
annotationMode={annotationMode}
|
||
onToggleAnnotationMode={() => {
|
||
setAnnotationMode((current) => !current)
|
||
setSelectedAnnotation(null)
|
||
setError(null)
|
||
}}
|
||
onAddAnnotation={addAnnotation}
|
||
onSelectDraftAnnotation={(id) => {
|
||
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
|
||
setAnnotationMode(false)
|
||
setError(null)
|
||
}}
|
||
/>
|
||
</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>
|
||
|
||
{hasReferenceImages && !annotationMode && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center md:hidden">
|
||
<span className="rounded-full bg-[#101828]/85 px-3 py-1.5 text-[11px] font-semibold text-white">
|
||
左右滑动查看作品与参考
|
||
</span>
|
||
</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} 修改意见
|
||
</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]"
|
||
/>
|
||
</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"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mb-3 flex min-h-8 flex-wrap items-center justify-between gap-2 text-[11px] font-semibold text-[#667085]">
|
||
<span>已标注 {totalDraftCount} 个修改点;点击“添加标注”后在作品上选择位置</span>
|
||
<span>两位评审均提交后,系统会通知发起人</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="mb-3 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[12px] font-semibold text-[#b42318]" role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-2 sm:ml-auto sm:max-w-[520px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => void submitDecision('approved')}
|
||
disabled={Boolean(busyAction)}
|
||
className="min-h-12 rounded bg-[#087a3c] px-3 text-[13px] font-bold text-white hover:bg-[#066332] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#087a3c] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[#9abdab]"
|
||
>
|
||
{busyAction === 'approved' ? '正在提交…' : '通过'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void submitDecision('revision_required')}
|
||
disabled={Boolean(busyAction)}
|
||
className="min-h-12 rounded bg-[#8a5200] px-3 text-[13px] font-bold text-white hover:bg-[#6d4100] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8a5200] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[#c2aa87]"
|
||
>
|
||
{busyAction === 'revision_required' ? '正在提交…' : '提交给修改人'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex min-h-11 items-center justify-center text-center text-[12px] font-semibold text-[#667085]">
|
||
{isLatestSelected ? '你的本轮评审已提交;两位评审完成后系统会通知发起人' : '历史版本仅供查看'}
|
||
</div>
|
||
)}
|
||
<div className="sr-only" aria-live="polite">{liveMessage}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|