feat: add boxed work review annotations

This commit is contained in:
江林铖 2026-08-03 13:40:13 +08:00
parent 37581ef30f
commit 7c609a5ca8
8 changed files with 540 additions and 61 deletions

View File

@ -17,6 +17,17 @@ function markerTone(item: WorkReviewDisplayAnnotation) {
return 'bg-[#0050cb] text-white ring-[#dcecff]'
}
function markerColor(item: WorkReviewDisplayAnnotation) {
if (item.annotation.color) return item.annotation.color
if (item.draft) return '#8a5200'
if (item.reviewerRole === 'final_reviewer') return '#087a3c'
return '#0050cb'
}
function commentPosition(x: number) {
return x > 0.62 ? 'right-9' : 'left-9'
}
export function WorkReviewAnnotationMarker({
item,
onClick,
@ -27,6 +38,8 @@ export function WorkReviewAnnotationMarker({
showComment?: boolean
}) {
const { annotation } = item
const shape = annotation.shape || 'point'
const color = markerColor(item)
const alignRight = annotation.x > 0.62
const roleLabel = item.draft
? '当前批注'
@ -34,10 +47,92 @@ export function WorkReviewAnnotationMarker({
? '元泽'
: '评审人'
const summary = annotation.comment.trim() || '待填写修改意见'
const positionState = item.positionEditable ? ',位置可调整' : ''
const positionState = item.positionEditable
? shape === 'point' ? ',位置可调整' : ',范围可调整'
: ''
const label = `${roleLabel}标注 ${item.markerNumber}${positionState}${item.reviewerName ? `,${item.reviewerName}` : ''}:${summary}`
const visualTitle = showComment ? label : `${roleLabel}标注 ${item.markerNumber}`
if (shape !== 'point' && annotation.x2 != null && annotation.y2 != null) {
const minX = Math.min(annotation.x, annotation.x2)
const minY = Math.min(annotation.y, annotation.y2)
const width = Math.max(0.008, Math.abs(annotation.x2 - annotation.x))
const height = Math.max(0.008, Math.abs(annotation.y2 - annotation.y))
const arrowPointsRight = annotation.x2 >= annotation.x
const arrowPointsDown = annotation.y2 >= annotation.y
const arrowX1 = arrowPointsRight ? 4 : 96
const arrowY1 = arrowPointsDown ? 4 : 96
const arrowX2 = arrowPointsRight ? 96 : 4
const arrowY2 = arrowPointsDown ? 96 : 4
const markerId = `work-review-arrow-${annotation.id.replace(/[^a-zA-Z0-9_-]/g, '')}`
const lightColor = color === '#ffffff'
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onClick?.()
}}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
aria-label={label}
title={visualTitle}
className={`group absolute z-20 min-h-5 min-w-5 outline-none ${item.positionEditable ? 'cursor-crosshair' : 'cursor-pointer'} focus-visible:ring-2 focus-visible:ring-[#8ab9ff] focus-visible:ring-offset-2`}
style={{
left: `${minX * 100}%`,
top: `${minY * 100}%`,
width: `${width * 100}%`,
height: `${height * 100}%`,
}}
>
{shape === 'arrow' ? (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 h-full w-full overflow-visible drop-shadow-[0_2px_3px_rgba(15,23,42,0.34)]"
aria-hidden="true"
>
<defs>
<marker id={markerId} markerWidth="12" markerHeight="12" refX="9" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L10,5 L0,10 Z" fill={color} stroke={lightColor ? '#344054' : color} strokeWidth={lightColor ? 1 : 0} />
</marker>
</defs>
<line
x1={arrowX1}
y1={arrowY1}
x2={arrowX2}
y2={arrowY2}
stroke={color}
strokeWidth="4"
vectorEffect="non-scaling-stroke"
markerEnd={`url(#${markerId})`}
/>
</svg>
) : (
<span
className={`absolute inset-0 border-[3px] shadow-[0_2px_8px_rgba(15,23,42,0.24)] ${shape === 'ellipse' ? 'rounded-[50%]' : 'rounded-sm'} ${lightColor ? 'outline outline-1 outline-[#344054]' : ''}`}
style={{ borderColor: color }}
aria-hidden="true"
/>
)}
<span
className={`absolute -left-3 -top-3 flex h-7 w-7 items-center justify-center rounded-full border-2 text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.35)] ${lightColor ? 'border-[#344054] text-[#101828]' : 'border-white text-white'} ${item.positionEditable ? 'ring-2 ring-[#8ab9ff] ring-offset-2' : ''}`}
style={{ backgroundColor: color }}
aria-hidden="true"
>
{item.markerNumber}
</span>
<span
className={`pointer-events-none absolute -top-0.5 max-w-[140px] truncate rounded-full bg-[#101828]/90 px-3 py-1.5 text-[11px] font-semibold text-white shadow-[0_4px_14px_rgba(15,23,42,0.24)] sm:max-w-[220px] ${showComment ? 'block' : 'hidden'} ${commentPosition(minX)}`}
aria-hidden={!showComment}
>
{summary}
</span>
</button>
)
}
return (
<button
type="button"

View File

@ -0,0 +1,140 @@
'use client'
import type { WorkReviewAnnotationShape } from '@/types/work-review'
export const WORK_REVIEW_ANNOTATION_COLORS = [
{ value: '#0050cb', label: '蓝色' },
{ value: '#d92d20', label: '红色' },
{ value: '#087a3c', label: '绿色' },
{ value: '#8a5200', label: '棕色' },
{ value: '#101828', label: '黑色' },
{ value: '#ffffff', label: '白色' },
] as const
export type WorkReviewDrawingTool = Exclude<WorkReviewAnnotationShape, 'point'>
const TOOLS: Array<{ value: WorkReviewDrawingTool; label: string }> = [
{ value: 'rectangle', label: '矩形框选' },
{ value: 'ellipse', label: '椭圆框选' },
{ value: 'arrow', label: '箭头标注' },
]
function ToolIcon({ tool }: { tool: WorkReviewDrawingTool }) {
if (tool === 'rectangle') {
return <rect x="4" y="4" width="16" height="16" rx="1.5" />
}
if (tool === 'ellipse') {
return <ellipse cx="12" cy="12" rx="8" ry="7" />
}
return (
<>
<path d="M5 19 19 5" />
<path d="M11 5h8v8" />
</>
)
}
export function WorkReviewAnnotationToolbar({
tool,
color,
canUndo,
onToolChange,
onColorChange,
onUndo,
onClose,
}: {
tool: WorkReviewDrawingTool
color: string
canUndo: boolean
onToolChange: (tool: WorkReviewDrawingTool) => void
onColorChange: (color: string) => void
onUndo: () => void
onClose: () => void
}) {
return (
<div
role="toolbar"
aria-label="标注工具"
className="absolute left-1/2 top-14 z-50 flex max-w-[calc(100%_-_1rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1.5 rounded-lg bg-[#172235] p-2 text-white shadow-[0_10px_30px_rgba(15,23,42,0.32)] sm:flex-nowrap"
>
<div className="flex items-center gap-1" aria-label="标注形状">
{TOOLS.map((item) => (
<button
key={item.value}
type="button"
onClick={() => onToolChange(item.value)}
aria-label={item.label}
aria-pressed={tool === item.value}
title={item.label}
className={`flex h-9 w-9 items-center justify-center rounded outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#8ab9ff] ${
tool === item.value
? 'bg-[#0050cb] text-white'
: 'text-[#dce4ef] hover:bg-[#2a3a50] hover:text-white'
}`}
>
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current stroke-2" aria-hidden="true">
<ToolIcon tool={item.value} />
</svg>
</button>
))}
</div>
<span className="mx-0.5 h-7 w-px bg-[#42526a]" aria-hidden="true" />
<div className="flex items-center gap-1" role="radiogroup" aria-label="标注颜色">
{WORK_REVIEW_ANNOTATION_COLORS.map((item) => (
<button
key={item.value}
type="button"
role="radio"
aria-label={item.label}
aria-checked={color === item.value}
title={item.label}
onClick={() => onColorChange(item.value)}
className={`flex h-9 w-9 items-center justify-center rounded outline-none transition-colors hover:bg-[#2a3a50] focus-visible:ring-2 focus-visible:ring-[#8ab9ff] ${
color === item.value ? 'bg-[#2a3a50]' : ''
}`}
>
<span
className={`h-5 w-5 rounded-sm border-2 ${color === item.value ? 'ring-2 ring-[#8ab9ff] ring-offset-1 ring-offset-[#172235]' : ''}`}
style={{
backgroundColor: item.value,
borderColor: item.value === '#ffffff' ? '#98a2b3' : item.value,
}}
aria-hidden="true"
/>
</button>
))}
</div>
<span className="mx-0.5 h-7 w-px bg-[#42526a]" aria-hidden="true" />
<div className="flex items-center gap-1">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
aria-label="撤销上一个标注"
title="撤销上一个标注"
className="flex h-9 w-9 items-center justify-center rounded text-[#dce4ef] outline-none transition-colors hover:bg-[#2a3a50] hover:text-white focus-visible:ring-2 focus-visible:ring-[#8ab9ff] disabled:cursor-not-allowed disabled:opacity-35"
>
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current stroke-2" aria-hidden="true">
<path d="M9 7 4 12l5 5" />
<path d="M5 12h8a6 6 0 0 1 6 6" />
</svg>
</button>
<button
type="button"
onClick={onClose}
aria-label="关闭标注工具"
title="关闭标注工具"
className="flex h-9 w-9 items-center justify-center rounded text-[#dce4ef] outline-none transition-colors hover:bg-[#2a3a50] hover:text-white focus-visible:ring-2 focus-visible:ring-[#8ab9ff]"
>
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current stroke-2" aria-hidden="true">
<path d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
</div>
)
}

View File

@ -14,6 +14,10 @@ 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'])
@ -21,6 +25,34 @@ 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()
@ -91,9 +123,15 @@ function LongImagePane({
annotationMode = false,
annotationRepositionMode = false,
annotationPosition,
annotationTool = 'rectangle',
annotationColor = DEFAULT_ANNOTATION_COLOR,
annotationCount,
canUndoAnnotation = false,
showAnnotationComments = true,
onToggleAnnotationMode,
onAnnotationToolChange,
onAnnotationColorChange,
onUndoAnnotation,
onToggleAnnotationComments,
onAddAnnotation,
onMoveAnnotation,
@ -109,13 +147,19 @@ function LongImagePane({
annotationEnabled?: boolean
annotationMode?: boolean
annotationRepositionMode?: boolean
annotationPosition?: Pick<WorkReviewImageAnnotation, 'x' | 'y'>
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?: (x: number, y: number) => void
onMoveAnnotation?: (x: number, y: number) => void
onAddAnnotation?: (geometry: AnnotationGeometry) => void
onMoveAnnotation?: (geometry: AnnotationGeometry) => void
onSelectDraftAnnotation?: (annotationId: string) => void
}) {
const displayGroups = useMemo(
@ -123,37 +167,68 @@ function LongImagePane({
[groups, imageUrls, label],
)
const [internalActiveIndex, setInternalActiveIndex] = useState(0)
const pointerStartRef = useRef<{ x: number; y: number } | null>(null)
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 placeAnnotation = (element: HTMLDivElement, clientX: number, clientY: number) => {
const pointFromPointer = (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))
if (annotationRepositionMode) {
onMoveAnnotation?.(x, y)
} else {
onAddAnnotation?.(x, y)
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="flex h-full min-h-0 flex-col bg-white" aria-label={label}>
<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>
@ -165,11 +240,11 @@ function LongImagePane({
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-[#8a5200] text-white hover:bg-[#6d4100]'
? 'bg-[#0050cb] text-white hover:bg-[#003f9f]'
: 'border border-[#d3dae5] bg-white text-[#344054] hover:border-[#8ab9ff]'
}`}
>
{annotationMode ? '取消标注' : '+ 添加标注'}
{annotationMode ? '标注工具已打开' : '+ 添加标注'}
</button>
)}
{annotationCount != null && (
@ -218,8 +293,8 @@ function LongImagePane({
{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>
)}
@ -234,19 +309,47 @@ function LongImagePane({
tabIndex={placementMode ? 0 : undefined}
onPointerDown={(event) => {
if (!placementMode || event.isPrimary === false || (event.button ?? 0) !== 0) return
pointerStartRef.current = { x: event.clientX, y: event.clientY }
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.x,
event.clientY - pointerStartRef.current.y,
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 (distance <= 8) placeAnnotation(event.currentTarget, event.clientX, event.clientY)
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 && [
@ -257,24 +360,27 @@ function LongImagePane({
].includes(event.key)) {
event.preventDefault()
const step = event.shiftKey ? 0.05 : 0.01
const nextX = event.key === 'ArrowLeft'
? annotationPosition.x - step
: event.key === 'ArrowRight'
? annotationPosition.x + step
: annotationPosition.x
const nextY = event.key === 'ArrowUp'
? annotationPosition.y - step
: event.key === 'ArrowDown'
? annotationPosition.y + step
: annotationPosition.y
onMoveAnnotation?.(
Math.min(1, Math.max(0, nextX)),
Math.min(1, Math.max(0, nextY)),
)
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()
const bounds = event.currentTarget.getBoundingClientRect()
placeAnnotation(event.currentTarget, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
commitGeometry(createAnnotationGeometry(
{ x: 0.38, y: 0.42 },
{ x: 0.62, y: 0.58 },
drawingTool,
drawingColor,
))
}
}}
>
@ -303,6 +409,23 @@ function LongImagePane({
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">
@ -344,6 +467,8 @@ export function WorkReviewReviewerWorkspace({
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)
@ -373,6 +498,8 @@ export function WorkReviewReviewerWorkspace({
setSelectedAnnotation(null)
setPositionEditableAnnotation(null)
setAnnotationMode(false)
setAnnotationTool('rectangle')
setAnnotationColor(DEFAULT_ANNOTATION_COLOR)
setShowAnnotationComments(true)
setActiveMobilePane(0)
setError(null)
@ -467,13 +594,17 @@ export function WorkReviewReviewerWorkspace({
scrollPaneTo(scroller, scroller.clientWidth * index, 'smooth')
}
const addAnnotation = (x: number, y: number) => {
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(), x, y, comment: '' }
const annotation: WorkReviewImageAnnotation = {
id: createAnnotationId(),
...geometry,
comment: '',
}
setDraftAnnotations((current) => current.map((items, index) => (
index === activeWorkImageIndex ? [...items, annotation] : items
)))
@ -481,21 +612,43 @@ export function WorkReviewReviewerWorkspace({
setPositionEditableAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
setAnnotationMode(false)
setError(null)
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加标注点,完成前可点击图片调整位置`)
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加框选标注,完成前可重新拖拽调整范围`)
}
const movePositionEditableAnnotation = (x: number, y: number) => {
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, x, y } : item)
? 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} 已移动到新位置`)
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) => {
@ -734,9 +887,15 @@ export function WorkReviewReviewerWorkspace({
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}
@ -846,9 +1005,15 @@ export function WorkReviewReviewerWorkspace({
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}
@ -895,7 +1060,7 @@ export function WorkReviewReviewerWorkspace({
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id ? (
<span className="text-[11px] font-semibold text-[#7a4a00]">
完成前可点击作品图片重新调整标注位置
完成前可在作品图片上重新拖拽框选范围
</span>
) : null}
</label>

View File

@ -132,10 +132,30 @@ describe('work review policy', () => {
test('normalizes image annotations without storing viewport pixels', () => {
expect(validateWorkReviewAnnotations([
{ id: 'point-1', x: 0.123456789, y: 0.75, comment: ' 调整人物位置 ' },
{
id: 'box-1',
x: 0.11111119,
y: 0.22222229,
x2: 0.55555559,
y2: 0.66666669,
shape: 'rectangle',
color: '#D92D20',
comment: ' 框选人物 ',
},
])).toEqual({
ok: true,
annotations: [
{ id: 'point-1', x: 0.123457, y: 0.75, comment: '调整人物位置' },
{
id: 'box-1',
x: 0.111111,
y: 0.222222,
x2: 0.555556,
y2: 0.666667,
shape: 'rectangle',
color: '#d92d20',
comment: '框选人物',
},
],
})
expect(validateWorkReviewAnnotations(undefined)).toEqual({ ok: true, annotations: [] })
@ -144,6 +164,9 @@ describe('work review policy', () => {
test.each([
[[{ id: 'point-1', x: -0.1, y: 0.5, comment: '越界' }], '图片批注位置无效'],
[[{ id: 'point-1', x: 0.5, y: '0.5', comment: '类型错误' }], '图片批注位置无效'],
[[{ id: 'box-1', x: 0.2, y: 0.2, shape: 'rectangle', color: '#0050cb', comment: '缺少终点' }], '图片框选范围无效'],
[[{ id: 'box-1', x: 0.2, y: 0.2, x2: 0.5, y2: 0.5, shape: 'triangle', color: '#0050cb', comment: '形状错误' }], '图片批注形状无效'],
[[{ id: 'box-1', x: 0.2, y: 0.2, x2: 0.5, y2: 0.5, shape: 'rectangle', color: '#ff00ff', comment: '颜色错误' }], '图片批注颜色无效'],
[[{ id: 'point-1', x: 0.5, y: 0.5, comment: ' ' }], '请填写每个标注点的修改意见'],
[[
{ id: 'point-1', x: 0.2, y: 0.2, comment: 'A' },

View File

@ -75,6 +75,9 @@ describe('WorkReviewReviewerWorkspace', () => {
expect(screen.queryByText('左右滑动查看作品与参考')).not.toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0])
expect(screen.getAllByRole('toolbar', { name: '标注工具' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: '矩形框选' })[0]).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getAllByRole('radio', { name: '红色' })[0])
const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0]
jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
x: 0,
@ -88,13 +91,15 @@ describe('WorkReviewReviewerWorkspace', () => {
toJSON: () => ({}),
})
fireEvent.pointerDown(canvas, { button: 0, clientX: 250, clientY: 500 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 250, clientY: 500 })
fireEvent.pointerMove(canvas, { button: 0, clientX: 500, clientY: 900 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 500, clientY: 900 })
const comment = screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ })
await user.type(comment, '人物位置向左移动')
fireEvent.pointerDown(canvas, { button: 0, clientX: 750, clientY: 1200 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 750, clientY: 1200 })
fireEvent.pointerMove(canvas, { button: 0, clientX: 850, clientY: 1400 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 850, clientY: 1400 })
await user.click(screen.getByRole('button', { name: '完成' }))
fireEvent.pointerDown(canvas, { button: 0, clientX: 100, clientY: 200 })
@ -118,7 +123,15 @@ describe('WorkReviewReviewerWorkspace', () => {
imageComments: [{
imageIndex: 0,
decision: 'revision_required',
annotations: [{ x: 0.75, y: 0.6, comment: '人物位置向左移动' }],
annotations: [{
x: 0.75,
y: 0.6,
x2: 0.85,
y2: 0.7,
shape: 'rectangle',
color: '#d92d20',
comment: '人物位置向左移动',
}],
}],
})
})
@ -143,7 +156,8 @@ describe('WorkReviewReviewerWorkspace', () => {
toJSON: () => ({}),
})
fireEvent.pointerDown(canvas, { button: 0, clientX: 250, clientY: 500 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 250, clientY: 500 })
fireEvent.pointerMove(canvas, { button: 0, clientX: 500, clientY: 900 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 500, clientY: 900 })
await user.type(screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ }), '需要调整')
await user.click(screen.getByRole('button', { name: '提交给修改人' }))

View File

@ -5,6 +5,10 @@ export type NormalizedWorkReviewAnnotation = {
id: string
x: number
y: number
x2?: number
y2?: number
shape?: 'point' | 'rectangle' | 'ellipse' | 'arrow'
color?: string
comment: string
}
@ -14,6 +18,21 @@ export type WorkReviewAnnotationValidationResult =
const WORK_REVIEW_ANNOTATION_LIMIT = 50
const WORK_REVIEW_ANNOTATION_COMMENT_LIMIT = 1000
const WORK_REVIEW_ANNOTATION_SHAPES = new Set(['point', 'rectangle', 'ellipse', 'arrow'])
const WORK_REVIEW_ANNOTATION_COLORS = new Set([
'#0050cb',
'#d92d20',
'#087a3c',
'#8a5200',
'#101828',
'#ffffff',
])
function normalizedCoordinate(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
? Math.round(value * 1_000_000) / 1_000_000
: null
}
export function validateWorkReviewAnnotations(value: unknown): WorkReviewAnnotationValidationResult {
if (value == null) return { ok: true, annotations: [] }
@ -30,16 +49,31 @@ export function validateWorkReviewAnnotations(value: unknown): WorkReviewAnnotat
}
const item = raw as Record<string, unknown>
const id = String(item.id ?? '').trim()
const x = item.x
const y = item.y
const x = normalizedCoordinate(item.x)
const y = normalizedCoordinate(item.y)
const shape = item.shape == null ? undefined : String(item.shape).trim().toLowerCase()
const color = item.color == null ? undefined : String(item.color).trim().toLowerCase()
const x2 = item.x2 == null ? undefined : normalizedCoordinate(item.x2)
const y2 = item.y2 == null ? undefined : normalizedCoordinate(item.y2)
const comment = String(item.comment ?? '').trim()
if (!id || id.length > 64 || ids.has(id)) {
return { ok: false, error: '图片批注编号无效或重复' }
}
if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > 1
|| typeof y !== 'number' || !Number.isFinite(y) || y < 0 || y > 1) {
if (x == null || y == null) {
return { ok: false, error: '图片批注位置无效' }
}
if ((item.x2 != null && x2 == null) || (item.y2 != null && y2 == null)) {
return { ok: false, error: '图片框选范围无效' }
}
if (shape != null && !WORK_REVIEW_ANNOTATION_SHAPES.has(shape)) {
return { ok: false, error: '图片批注形状无效' }
}
if (color != null && !WORK_REVIEW_ANNOTATION_COLORS.has(color)) {
return { ok: false, error: '图片批注颜色无效' }
}
if (shape && shape !== 'point' && (x2 == null || y2 == null)) {
return { ok: false, error: '图片框选范围无效' }
}
if (!comment) return { ok: false, error: '请填写每个标注点的修改意见' }
if (comment.length > WORK_REVIEW_ANNOTATION_COMMENT_LIMIT) {
return { ok: false, error: `单个标注点的修改意见不能超过 ${WORK_REVIEW_ANNOTATION_COMMENT_LIMIT} 个字` }
@ -47,8 +81,12 @@ export function validateWorkReviewAnnotations(value: unknown): WorkReviewAnnotat
ids.add(id)
annotations.push({
id,
x: Math.round(x * 1_000_000) / 1_000_000,
y: Math.round(y * 1_000_000) / 1_000_000,
x,
y,
...(x2 == null ? {} : { x2 }),
...(y2 == null ? {} : { y2 }),
...(shape == null ? {} : { shape: shape as NormalizedWorkReviewAnnotation['shape'] }),
...(color == null ? {} : { color }),
comment,
})
}

View File

@ -43,6 +43,7 @@ import {
isSameWorkReviewEmployeeAlias,
isSameWorkReviewEmployeeIdentity,
validateWorkReviewAnnotations,
type NormalizedWorkReviewAnnotation,
} from '@/lib/work-review-policy'
const REVIEW_ROLES = {
@ -1029,12 +1030,9 @@ function readImageComments(value: unknown, imageGroups: PerformanceSurveyStoredI
}
let annotationCount = 0
return imageGroups.map((group, imageIndex): ImageCommentInput & { annotations: Array<{
id: string
x: number
y: number
comment: string
}> } => {
return imageGroups.map((group, imageIndex): ImageCommentInput & {
annotations: NormalizedWorkReviewAnnotation[]
} => {
const item = byIndex.get(imageIndex)
if (!item) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`)
const decision = clean(item.decision)

View File

@ -6,10 +6,16 @@ export type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
export type WorkReviewDecision = 'approved' | 'revision_required'
export type WorkReviewAnnotationShape = 'point' | 'rectangle' | 'ellipse' | 'arrow'
export type WorkReviewImageAnnotation = {
id: string
x: number
y: number
x2?: number
y2?: number
shape?: WorkReviewAnnotationShape
color?: string
comment: string
}