保存标注前预检陈旧id

- dirty 标注保存前拉取后端标注 id 列表,已缺失的本地旧 annotationId 直接重新创建

- 保留 PATCH 404 兜底,覆盖预检后并发删除的情况

- 扩充分割工作区测试,区分预检缺失和 PATCH 后缺失两条路径

- 同步更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
2026-05-03 19:29:36 +08:00
parent 0ba5a8c094
commit 3e998b9d6b
7 changed files with 93 additions and 12 deletions

View File

@@ -387,6 +387,10 @@ describe('VideoWorkspace', () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 99, frame_id: 10 }])
.mockResolvedValueOnce([]);
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
@@ -445,11 +449,12 @@ describe('VideoWorkspace', () => {
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
});
it('recreates a dirty local annotation when the backend PATCH target is already missing', async () => {
it('recreates a dirty local annotation without PATCH when the backend preflight says it is missing', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 120, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
@@ -469,7 +474,6 @@ describe('VideoWorkspace', () => {
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
apiMock.updateAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 120 });
render(<VideoWorkspace />);
@@ -497,9 +501,8 @@ describe('VideoWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('11859', expect.objectContaining({
template_id: 2,
})));
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalled());
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
@@ -518,6 +521,68 @@ describe('VideoWorkspace', () => {
expect(screen.getByText('已保存 1 个标注,其中 1 个本地旧标注已重新创建')).toBeInTheDocument();
});
it('recreates a dirty local annotation if the backend PATCH target disappears after preflight', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 11859, frame_id: 10 }])
.mockResolvedValueOnce([{ id: 121, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[0, 0, 10, 0, 10, 10]],
}));
apiMock.buildAnnotationPayload.mockReturnValueOnce({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
apiMock.updateAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 121 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
act(() => {
useStore.setState({
activeTemplateId: '2',
masks: [{
id: 'annotation-11859',
annotationId: '11859',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]],
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '保存 1 个改动' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('11859', expect.objectContaining({
template_id: 2,
})));
expect(apiMock.saveAnnotation).toHaveBeenCalledWith({
project_id: 1,
frame_id: 10,
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
});
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-121', annotationId: '121', saveStatus: 'saved' }),
]));
});
it('deletes saved annotations when clearing current-frame masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },

View File

@@ -767,11 +767,27 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return 0;
}
let existingAnnotationIds: Set<string> | null = null;
if (updateItems.length > 0) {
try {
const annotations = await getProjectAnnotations(currentProject.id);
existingAnnotationIds = new Set(annotations.map((annotation) => String(annotation.id)));
} catch {
existingAnnotationIds = null;
}
}
let recreatedMissingCount = 0;
const recreatedMaskIds: string[] = [];
await Promise.all([
...createItems.map(({ payload }) => saveAnnotation(payload)),
...updateItems.map(async ({ maskId, annotationId, updatePayload, createPayload }) => {
if (existingAnnotationIds && !existingAnnotationIds.has(String(annotationId))) {
recreatedMissingCount += 1;
recreatedMaskIds.push(maskId);
await saveAnnotation(createPayload);
return;
}
try {
await updateAnnotation(annotationId, updatePayload);
} catch (error) {