gongxiangp2/components/work-review/WorkReviewReviewerWorkspace.tsx
2026-08-01 13:43:58 +08:00

725 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
WorkReviewImageAnnotation,
WorkReviewImageGroup,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewVersion,
} from '@/types/work-review'
import {
WorkReviewAnnotationMarker,
type WorkReviewDisplayAnnotation,
} from './WorkReviewAnnotationMarker'
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
const MIN_PANE_PERCENT = 14
const MAX_PANE_PERCENT = 86
const MAX_ANNOTATIONS_PER_IMAGE = 50
const MAX_ANNOTATION_COMMENT_LENGTH = 1000
function proxyImageUrl(value: string) {
const url = String(value || '').trim()
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const parsed = new URL(url)
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
? `/api/proxy-media?url=${encodeURIComponent(url)}`
: url
} catch {
return url
}
}
function normalizeGroups(
groups: WorkReviewImageGroup[],
imageUrls: string[],
fallbackLabel: string,
): WorkReviewImageGroup[] {
if (groups.length > 0) {
return groups.map((group, index) => ({
...group,
sourceName: group.sourceName || `${fallbackLabel} ${index + 1}`,
previewUrl: proxyImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
slices: (group.slices || []).map((slice) => ({
...slice,
url: proxyImageUrl(slice.url),
})),
})).filter((group) => Boolean(group.previewUrl))
}
return imageUrls.map((url, index) => ({
sourceName: `${fallbackLabel} ${index + 1}`,
previewUrl: proxyImageUrl(url),
previewWidth: 1_200,
previewHeight: 1_500,
width: 1_200,
height: 1_500,
slices: [{ url: proxyImageUrl(url), width: 1_200, height: 1_500 }],
}))
}
function createAnnotationId() {
if (typeof window !== 'undefined' && typeof window.crypto?.randomUUID === 'function') {
return window.crypto.randomUUID()
}
return `point-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function 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>
)
}