修复陈旧标注保存404

- dirty 标注 PATCH 404 时改用 POST 重新创建,保留几何、分类和传播 lineage metadata

- 保存后回显替换本地旧 annotationId,避免保存改动和开始传播被陈旧 id 中断

- 增加工作区回归测试,覆盖本地旧 annotationId 重新创建流程

- 更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
2026-05-03 19:26:07 +08:00
parent 4d6bbf2b80
commit 0ba5a8c094
7 changed files with 106 additions and 13 deletions

View File

@@ -445,6 +445,79 @@ describe('VideoWorkspace', () => {
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
});
it('recreates a dirty local annotation when the backend PATCH target is already 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([{ id: 120, 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: 120 });
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]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
},
}],
});
});
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: '胆囊',
source: 'sam2.1_hiera_tiny_propagation',
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
},
});
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-120', annotationId: '120', saveStatus: 'saved' }),
]));
expect(screen.getByText('已保存 1 个标注,其中 1 个本地旧标注已重新创建')).toBeInTheDocument();
});
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

@@ -741,39 +741,57 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const updatePayloads = dirtyMasks
const updateItems = dirtyMasks
.map((mask) => {
const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null;
const savedMetadata = persistentMaskMetadata(mask.metadata);
const mergedMaskData = { ...savedMetadata, ...payload.mask_data };
const updatePayload = {
template_id: payload.template_id,
mask_data: { ...savedMetadata, ...payload.mask_data },
mask_data: mergedMaskData,
points: payload.points,
bbox: payload.bbox,
};
return { annotationId: mask.annotationId, payload: updatePayload };
const createPayload = {
...payload,
mask_data: mergedMaskData,
};
return { maskId: mask.id, annotationId: mask.annotationId, updatePayload, createPayload };
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
if (createItems.length === 0 && updatePayloads.length === 0) {
if (createItems.length === 0 && updateItems.length === 0) {
setStatusMessage('没有可保存的标注数据');
return 0;
}
let recreatedMissingCount = 0;
const recreatedMaskIds: string[] = [];
await Promise.all([
...createItems.map(({ payload }) => saveAnnotation(payload)),
...updatePayloads.map(({ annotationId, payload }) => updateAnnotation(annotationId, payload)),
...updateItems.map(async ({ maskId, annotationId, updatePayload, createPayload }) => {
try {
await updateAnnotation(annotationId, updatePayload);
} catch (error) {
if (!isNotFoundError(error)) throw error;
recreatedMissingCount += 1;
recreatedMaskIds.push(maskId);
await saveAnnotation(createPayload);
}
}),
]);
await hydrateSavedAnnotations(
currentProject.id,
frames,
useStore.getState().selectedMaskIds,
createItems.map(({ maskId }) => maskId),
[...createItems.map(({ maskId }) => maskId), ...recreatedMaskIds],
);
const savedCount = createItems.length + updatePayloads.length;
setStatusMessage(`已保存 ${savedCount} 个标注`);
const savedCount = createItems.length + updateItems.length;
setStatusMessage(recreatedMissingCount > 0
? `已保存 ${savedCount} 个标注,其中 ${recreatedMissingCount} 个本地旧标注已重新创建`
: `已保存 ${savedCount} 个标注`);
return savedCount;
} catch (err) {
console.error('Save annotations failed:', err);