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

- 增强 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

@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
}));
describe('TemplateRegistry', () => {
const makeDataTransfer = () => {
const store = new Map<string, string>();
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn((key: string, value: string) => store.set(key, value)),
getData: vi.fn((key: string) => store.get(key) || ''),
};
};
beforeEach(() => {
resetStore();
vi.clearAllMocks();
@@ -40,6 +50,8 @@ describe('TemplateRegistry', () => {
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
expect(screen.getByText('胆囊')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0);
expect(screen.getByText('待分类')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?0/).length).toBeGreaterThan(0);
expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument();
});
@@ -49,7 +61,7 @@ describe('TemplateRegistry', () => {
id: 't2',
name: 'New Template',
description: 'desc',
classes: [],
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
rules: [],
});
@@ -62,7 +74,15 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Template',
description: 'desc',
classes: [],
classes: [
expect.objectContaining({
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
}),
],
rules: [],
color: '#06b6d4',
z_index: 0,
@@ -77,15 +97,17 @@ describe('TemplateRegistry', () => {
fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
fireEvent.click(screen.getByText('批量导入'));
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
});
expect(screen.getByText(/将导入 1 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
});
it('shows JSON import errors as transient notices instead of blocking alerts', async () => {
it('shows JSON import errors inline instead of blocking alerts', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
@@ -94,9 +116,9 @@ describe('TemplateRegistry', () => {
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{broken-json' },
});
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(await screen.findByRole('status')).toHaveTextContent('JSON 解析失败');
expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
});
it('shows template save errors as transient notices', async () => {
@@ -132,7 +154,7 @@ describe('TemplateRegistry', () => {
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: /修改库视图结构/ }));
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 旧模板' }));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -140,7 +162,10 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
name: '新模板',
description: 'new desc',
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })],
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
],
rules: [],
color: '#06b6d4',
z_index: 3,
@@ -149,6 +174,205 @@ describe('TemplateRegistry', () => {
id: 't1',
name: '新模板',
}));
expect(await screen.findAllByText('新模板')).toHaveLength(2);
});
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' }],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'new-class', name: '新类别', color: '#00ff00', zIndex: 20, maskId: 2, category: '未分类' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
expect(await screen.findByText('语义分类树(拖拽调层级)')).toBeInTheDocument();
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
expect(screen.queryByText('器官')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', maskId: 1 }),
expect.objectContaining({ name: '新类别', maskId: 2, category: '未分类' }),
expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' }),
],
})));
});
it('deletes a class directly from the semantic tree', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0, category: '系统保留' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '删除分类 肝脏' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', zIndex: 10, maskId: 1 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes).toEqual([
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
]));
expect(await screen.findByRole('status')).toHaveTextContent('分类已删除');
});
it('copies a template from the active template list into a new editable template', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '头颈部CT分割',
description: 'desc',
classes: [
{ id: 'c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
},
{
id: 't2',
name: '头颈部CT分割 副本',
description: 'existing copy',
classes: [],
rules: [],
},
]);
apiMock.createTemplate.mockResolvedValueOnce({
id: 't3',
name: '头颈部CT分割 副本 2',
description: 'desc',
classes: [
{ id: 'copy-c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'copy-c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '复制模板 头颈部CT分割' }));
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: '头颈部CT分割 副本 2',
description: 'desc',
color: '#ef4444',
z_index: 10,
rules: [expect.objectContaining({ id: 'r1' })],
classes: [
expect.objectContaining({ name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' }),
expect.objectContaining({ name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' }),
expect.objectContaining({ name: '待分类', color: '#000000', zIndex: 0, maskId: 0 }),
],
})));
const payload = apiMock.createTemplate.mock.calls[0][0];
expect(payload.classes[0].id).toMatch(/^cls-copy-/);
expect(payload.classes[0].id).not.toBe('c1');
expect(useStore.getState().templates.some((template) => template.id === 't3')).toBe(true);
expect(await screen.findByText('已复制模板头颈部CT分割 副本 2')).toBeInTheDocument();
});
it('persists dragged class layer order directly from the template detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, maskId: 2, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
const gallbladderRow = (await screen.findByText('胆囊')).closest('[draggable="true"]') as HTMLElement;
const liverRow = screen.getByText('肝脏').closest('[draggable="true"]') as HTMLElement;
const dataTransfer = makeDataTransfer();
fireEvent.dragStart(gallbladderRow, { dataTransfer });
fireEvent.dragOver(liverRow, { dataTransfer });
fireEvent.drop(liverRow, { dataTransfer });
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', zIndex: 20, maskId: 1 }),
expect.objectContaining({ id: 'c2', zIndex: 10, maskId: 2 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes[0]).toEqual(
expect.objectContaining({ id: 'c1', zIndex: 20 }),
));
});
it('deletes an existing template after confirmation', async () => {
@@ -165,8 +389,8 @@ describe('TemplateRegistry', () => {
const { container } = render(<TemplateRegistry />);
await screen.findAllByText('待删除模板');
const buttons = Array.from(container.querySelectorAll('button'));
fireEvent.click(buttons[2]);
fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
expect(useStore.getState().templates).toEqual([]);