feat: add boxed work review annotations
This commit is contained in:
parent
37581ef30f
commit
7c609a5ca8
@ -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"
|
||||
|
||||
140
components/work-review/WorkReviewAnnotationToolbar.tsx
Normal file
140
components/work-review/WorkReviewAnnotationToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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: '提交给修改人' }))
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user