修复陈旧标注保存404
- dirty 标注 PATCH 404 时改用 POST 重新创建,保留几何、分类和传播 lineage metadata - 保存后回显替换本地旧 annotationId,避免保存改动和开始传播被陈旧 id 中断 - 增加工作区回归测试,覆盖本地旧 annotationId 重新创建流程 - 更新接口契约、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user