{label}
@@ -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 ? '标注工具已打开' : '+ 添加标注'}
)}
{annotationCount != null && (
@@ -218,8 +293,8 @@ function LongImagePane({
{placementMode && (
{annotationRepositionMode
- ? '完成前可点击作品图片重新调整当前标注位置;滚动不会移动标注'
- : '点击作品中的修改位置添加批注;滚动不会产生标记'}
+ ? '完成前可在作品图片上重新拖拽框选范围;滚动不会移动标注'
+ : `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽`}
)}
@@ -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 && (
+
+
+
+ )}
) : (
@@ -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('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 ? (
- 完成前可点击作品图片重新调整标注位置
+ 完成前可在作品图片上重新拖拽框选范围
) : null}
diff --git a/lib/__tests__/work-review-policy.test.ts b/lib/__tests__/work-review-policy.test.ts
index 4e6a659..4ca2a5a 100644
--- a/lib/__tests__/work-review-policy.test.ts
+++ b/lib/__tests__/work-review-policy.test.ts
@@ -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' },
diff --git a/lib/__tests__/work-review-reviewer-workspace.test.tsx b/lib/__tests__/work-review-reviewer-workspace.test.tsx
index fa5277f..725f849 100644
--- a/lib/__tests__/work-review-reviewer-workspace.test.tsx
+++ b/lib/__tests__/work-review-reviewer-workspace.test.tsx
@@ -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: '提交给修改人' }))
diff --git a/lib/work-review-policy.ts b/lib/work-review-policy.ts
index 0ea387b..26c11f9 100644
--- a/lib/work-review-policy.ts
+++ b/lib/work-review-policy.ts
@@ -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
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,
})
}
diff --git a/lib/work-review-service.ts b/lib/work-review-service.ts
index 7e208fa..d96d7af 100644
--- a/lib/work-review-service.ts
+++ b/lib/work-review-service.ts
@@ -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)
diff --git a/types/work-review.ts b/types/work-review.ts
index bf68e6c..6c66241 100644
--- a/types/work-review.ts
+++ b/types/work-review.ts
@@ -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
}