完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -466,7 +466,7 @@ describe('CanvasArea', () => {
expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument();
});
it('renders imported GT seed points for editable point regions', () => {
it('does not render stored GT seed points as visible editable handles', () => {
useStore.setState({
masks: [
{
@@ -482,7 +482,28 @@ describe('CanvasArea', () => {
render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.getAllByTestId('konva-circle')).toHaveLength(2);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('does not derive visible seed points for ordinary polygon masks', () => {
useStore.setState({
masks: [
{
id: 'manual-1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Manual',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 40]],
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('selects a polygon mask and drags a vertex into dirty saved state', () => {
@@ -668,6 +689,79 @@ describe('CanvasArea', () => {
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['99']);
});
it('deletes linked propagated masks while keeping independent AI inference masks', () => {
const onDeleteMaskAnnotations = vi.fn();
const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' };
useStore.setState({
masks: [
{
id: 'annotation-99',
annotationId: '99',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Seed',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[10, 10, 90, 10, 90, 40]],
},
{
id: 'annotation-100',
annotationId: '100',
frameId: 'frame-2',
pathData: 'M 12 10 L 92 10 L 92 40 Z',
label: 'Propagated A',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[12, 10, 92, 10, 92, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-101',
annotationId: '101',
frameId: 'frame-3',
pathData: 'M 14 10 L 94 10 L 94 40 Z',
label: 'Propagated B',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[14, 10, 94, 10, 94, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-102',
annotationId: '102',
frameId: 'frame-3',
pathData: 'M 200 10 L 260 10 L 260 40 Z',
label: 'AI Candidate',
color: '#22c55e',
saveStatus: 'saved',
saved: true,
segmentation: [[200, 10, 260, 10, 260, 40]],
metadata: { source: 'ai_segmentation' },
},
],
});
render(<CanvasArea activeTool="move" frame={propagatedFrame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
fireEvent.click(screen.getByTestId('konva-path'));
fireEvent.keyDown(window, { key: 'Delete' });
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99', 'annotation-102']);
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['100', '101']);
});
it('inserts a polygon vertex from an edge midpoint handle', () => {
useStore.setState({
masks: [
@@ -784,7 +878,8 @@ describe('CanvasArea', () => {
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
expect(screen.getByText('已选 1')).toBeInTheDocument();
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(0);
fireEvent.click(paths[1]);
expect(screen.getByText('已选 2')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
@@ -1069,7 +1164,8 @@ describe('CanvasArea', () => {
shape: '多边形',
}),
}));
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('shows contextual guidance for boolean selection ordering', () => {