feat: 完善分割工作区导入导出与管理流程

- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。

- 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。

- 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。

- 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。

- 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。

- 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。

- 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。

- 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。

- 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。

- 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。

- 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
This commit is contained in:
2026-05-03 03:52:32 +08:00
parent 4c1d3dba73
commit afcddfaeb9
62 changed files with 6572 additions and 849 deletions

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
@@ -67,6 +67,9 @@ describe('OntologyInspector', () => {
expect(useStore.getState().activeTemplateId).toBe('t1');
expect(screen.getByText('胆囊')).toBeInTheDocument();
expect(screen.getByText('肝脏')).toBeInTheDocument();
expect(screen.getByText('maskid:1')).toBeInTheDocument();
expect(screen.getByText('maskid:2')).toBeInTheDocument();
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
});
it('adjusts workspace mask opacity from above the semantic tree', () => {
@@ -151,13 +154,86 @@ describe('OntologyInspector', () => {
classId: 'c2',
className: '肝脏',
classZIndex: 10,
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(screen.getByText('当前选中区域:')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.queryByText('当前选中区域:')).not.toBeInTheDocument();
});
it('applies class changes to the same propagation chain across frames', () => {
useStore.setState({
selectedMaskIds: ['annotation-10'],
masks: [
{
id: 'annotation-10',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
},
{
id: 'annotation-11',
annotationId: '11',
frameId: 'frame-2',
pathData: 'M 1 1 Z',
label: '旧传播标签',
color: '#06b6d4',
metadata: {
source_annotation_id: 10,
source_mask_id: 'annotation-10',
propagation_seed_key: 'annotation:10',
},
saveStatus: 'saved',
saved: true,
},
{
id: 'annotation-99',
annotationId: '99',
frameId: 'frame-3',
pathData: 'M 2 2 Z',
label: '无关区域',
color: '#ffffff',
metadata: { source_annotation_id: 99 },
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.click(screen.getByText('肝脏'));
const updated = useStore.getState().masks;
expect(updated.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
classId: 'c2',
className: '肝脏',
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(updated.find((mask) => mask.id === 'annotation-11')).toEqual(expect.objectContaining({
classId: 'c2',
className: '肝脏',
classMaskId: 2,
label: '肝脏',
color: '#00ff00',
saveStatus: 'dirty',
saved: false,
}));
expect(updated.find((mask) => mask.id === 'annotation-99')).toEqual(expect.objectContaining({
label: '无关区域',
color: '#ffffff',
saveStatus: 'saved',
saved: true,
}));
});
it('persists custom classes to the active backend template', async () => {
@@ -187,6 +263,59 @@ describe('OntologyInspector', () => {
expect(useStore.getState().templates[0].classes).toHaveLength(3);
});
it('persists dragged semantic class order as layer priority without changing maskid', async () => {
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '腹腔镜模板',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
});
useStore.setState({
masks: [{
id: 'm-liver',
annotationId: '42',
frameId: 'frame-1',
classId: 'c2',
className: '肝脏',
classZIndex: 10,
pathData: 'M 0 0 Z',
label: '肝脏',
color: '#00ff00',
saveStatus: 'saved',
saved: true,
}],
});
render(<OntologyInspector />);
const liverButton = screen.getByRole('button', { name: /肝脏/ });
const gallbladderButton = screen.getByRole('button', { name: /胆囊/ });
const dataTransfer = {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
getData: vi.fn(() => 'c2'),
};
fireEvent.dragStart(liverButton, { dataTransfer });
fireEvent.dragOver(gallbladderButton, { dataTransfer });
fireEvent.drop(gallbladderButton, { dataTransfer });
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
],
})));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
classZIndex: 20,
saveStatus: 'dirty',
saved: false,
}));
});
it('loads selected mask properties from the backend analyzer', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
@@ -214,15 +343,40 @@ describe('OntologyInspector', () => {
expect(screen.queryByText('后端模型置信度')).not.toBeInTheDocument();
expect(screen.queryByText('0.8200')).not.toBeInTheDocument();
expect(screen.getByText('4 节点')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重新提取拓扑锚点' }));
expect(screen.queryByRole('button', { name: '重新提取拓扑锚点' })).not.toBeInTheDocument();
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
{ extractSkeleton: true },
);
});
it('applies backend edge smoothing to the selected mask and marks it dirty', async () => {
it('ignores aborted mask analysis requests without showing an error', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
apiMock.analyzeMask.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'Request aborted' });
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 20, 10, 20, 20]],
},
],
});
render(<OntologyInspector />);
await waitFor(() => expect(apiMock.analyzeMask).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByText('后端属性读取失败')).not.toBeInTheDocument());
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it('previews backend edge smoothing while moving the slider without marking the mask dirty', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
@@ -244,13 +398,100 @@ describe('OntologyInspector', () => {
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
35,
));
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
bbox: [12, 12, 16, 16],
area: 256,
saveStatus: 'saved',
saved: true,
metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
})));
expect(screen.getByText('已应用边缘平滑强度 35预览中点击应用后写入当前 mask。')).toBeInTheDocument();
});
it('debounces backend edge smoothing preview while dragging the slider', async () => {
vi.useFakeTimers();
try {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '15' } });
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
expect(screen.getByText('正在等待停止拖动后生成边缘平滑预览...')).toBeInTheDocument();
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(219);
});
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
expect.objectContaining({ id: 'm1' }),
expect.objectContaining({ id: 'frame-1' }),
35,
);
} finally {
vi.useRealTimers();
}
});
it('applies a previewed edge smoothing result to the selected mask and marks it dirty', async () => {
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
@@ -258,8 +499,87 @@ describe('OntologyInspector', () => {
area: 256,
saveStatus: 'dirty',
saved: false,
metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
})));
expect(screen.getByText('已应用边缘平滑强度 35请保存后生效')).toBeInTheDocument();
expect(useStore.getState().masks[0].metadata?.geometry_smoothing).toBeUndefined();
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('已应用边缘平滑强度 35已变为新的 mask强度已重置为 0请保存后生效')).toBeInTheDocument();
});
it('applies smoothing to linked propagation masks as one undoable geometry edit', async () => {
useStore.setState({
frames: [
{ id: 'frame-0', projectId: 'p1', index: 0, url: '/0.jpg', width: 100, height: 100 },
{ id: 'frame-1', projectId: 'p1', index: 1, url: '/1.jpg', width: 100, height: 100 },
{ id: 'frame-2', projectId: 'p1', index: 2, url: '/2.jpg', width: 100, height: 100 },
],
selectedMaskIds: ['seed-mask'],
masks: [
{
id: 'seed-mask',
annotationId: '10',
frameId: 'frame-1',
pathData: 'M 10 10 L 30 10 L 30 30 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[10, 10, 30, 10, 30, 30]],
saveStatus: 'saved',
saved: true,
},
{
id: 'prop-backward',
annotationId: '11',
frameId: 'frame-0',
pathData: 'M 11 11 L 31 11 L 31 31 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[11, 11, 31, 11, 31, 31]],
saveStatus: 'saved',
saved: true,
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
},
{
id: 'prop-forward',
annotationId: '12',
frameId: 'frame-2',
pathData: 'M 12 12 L 32 12 L 32 32 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[12, 12, 32, 12, 32, 32]],
saveStatus: 'saved',
saved: true,
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
},
],
});
render(<OntologyInspector />);
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(3));
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'seed-mask', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
expect.objectContaining({ id: 'prop-backward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
expect.objectContaining({ id: 'prop-forward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
]));
expect(useStore.getState().masks.every((mask) => !mask.metadata?.geometry_smoothing)).toBe(true);
expect(screen.getByText('已应用边缘平滑强度 35已同步应用到传播链 3 个对应 mask强度已重置为 0请保存后生效')).toBeInTheDocument();
act(() => {
useStore.getState().undoMasks();
});
expect(useStore.getState().masks.map((mask) => mask.pathData)).toEqual([
'M 10 10 L 30 10 L 30 30 Z',
'M 11 11 L 31 11 L 31 31 Z',
'M 12 12 L 32 12 L 32 32 Z',
]);
act(() => {
useStore.getState().redoMasks();
});
expect(useStore.getState().masks.every((mask) => mask.pathData === 'M 12 12 L 28 12 L 28 28 L 12 28 Z')).toBe(true);
});
});