feat: 完善工作区交互提示与后端属性分析
功能新增: - 新增 POST /api/ai/analyze-mask 后端接口,基于 mask polygon、bbox、points 和 score 返回置信度来源、面积、拓扑锚点和后端分析提示。 - 前端新增 analyzeMask API 封装,并在本体检查面板读取选中 mask 的后端几何属性和重新提取拓扑锚点结果。 - 右侧语义分类树点击分类时,会给当前选中 mask 换标签、更新 class 元数据,并将选中 mask 移到前端渲染最上层,方便继续编辑。 - 分割工作区画布新增上下文操作提示,覆盖多边形 Enter 完成、Esc 取消、首节点闭合、拖拽图形、点区域、SAM 点/框提示、区域合并/去除选择顺序和多边形编辑。 - AI 智能分割画布新增正向点、反向点、边界框选和视口控制的上下文提示。 - 自动传播交互收敛为参考帧加起止帧范围加单个“自动传播”按钮,默认使用当前参考帧全部 mask 作为 seed。 - 时间轴改为用浅蓝色进度条区段标记自动传播生成的帧,而不是已编辑帧竖线提示。 Bugfix: - AI 分割页无当前帧时移除外部演示背景图,改为明确空状态提示,避免误以为外部图片可参与真实推理。 - 工具栏魔法棒文案改为“打开 AI 智能分割”,避免误导为直接触发 SAM 推理。 - Canvas 底部当前图层信息改为显示真实选中 mask 标签和 annotation id,不再使用固定占位文本。 - 已保存标注回显时保留 mask metadata 中的传播来源、score 等字段,供时间轴和属性面板识别。 - 清理 server.ts 中遗留的 /api/login、/api/projects、/api/templates 内存 mock API,避免和 FastAPI 真实后端混淆。 测试: - 补充 analyze-mask 后端测试,覆盖后端几何属性和锚点返回。 - 补充 api.analyzeMask 前端契约测试,覆盖 normalized polygon、bbox、points 和 extract_skeleton payload。 - 补充本体面板测试,覆盖后端属性读取、自定义分类写回后端模板、选中 mask 换标签和置顶显示。 - 补充 Canvas 测试,覆盖上下文提示、多边形完成提示、布尔选择顺序提示、当前图层真实显示和编辑优先级。 - 补充 AI 分割测试,覆盖无帧空状态和提示工具上下文提示。 - 更新 Konva 测试 mock,支持拖动过程、stroke/dash/fillRule 等渲染断言。 文档: - 更新 README 和 AGENTS,说明 server.ts 不再保留业务 mock API。 - 更新 doc/02、doc/03、doc/04、doc/05、doc/07、doc/08、doc/09,记录后端属性分析、分类置顶显示、上下文提示、自动传播按钮、传播帧标记、测试覆盖和当前剩余限制。
This commit is contained in:
@@ -297,9 +297,10 @@ describe('CanvasArea', () => {
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
annotationId: '42',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'A',
|
||||
label: '胆囊',
|
||||
color: '#fff',
|
||||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||
},
|
||||
@@ -310,6 +311,7 @@ describe('CanvasArea', () => {
|
||||
fireEvent.click(screen.getByTestId('konva-path'));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
|
||||
expect(screen.getByText('当前图层: 胆囊 #42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a mask selected when opening the workspace polygon editor from AI results', () => {
|
||||
@@ -389,6 +391,37 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('moves a polygon vertex directly while dragging without a prior vertex click', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['draft-1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 40 Z',
|
||||
label: 'Draft',
|
||||
color: '#06b6d4',
|
||||
saveStatus: 'draft',
|
||||
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||
bbox: [10, 10, 80, 30],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
||||
const handles = screen.getAllByTestId('konva-circle')
|
||||
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
|
||||
|
||||
fireEvent.mouseDown(handles[0]);
|
||||
fireEvent.mouseMove(handles[0], { clientX: 25, clientY: 35 });
|
||||
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
pathData: 'M 25 35 L 90 10 L 90 40 Z',
|
||||
segmentation: [[25, 35, 90, 10, 90, 40]],
|
||||
saveStatus: 'draft',
|
||||
}));
|
||||
});
|
||||
|
||||
it('deletes a selected polygon vertex without dropping below three points', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -626,6 +659,11 @@ describe('CanvasArea', () => {
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
const selectedPaths = screen.getAllByTestId('konva-path');
|
||||
expect(selectedPaths[0]).toHaveAttribute('data-stroke', '#facc15');
|
||||
expect(selectedPaths[0]).toHaveAttribute('data-dash', '');
|
||||
expect(selectedPaths[1]).toHaveAttribute('data-stroke', '#fb7185');
|
||||
expect(selectedPaths[1]).toHaveAttribute('data-dash', '6,4');
|
||||
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
|
||||
|
||||
expect(useStore.getState().masks).toHaveLength(2);
|
||||
@@ -796,9 +834,11 @@ describe('CanvasArea', () => {
|
||||
it('finalizes a clicked polygon with Enter', () => {
|
||||
render(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument();
|
||||
fireEvent.click(stage, { clientX: 120, clientY: 80 });
|
||||
fireEvent.click(stage, { clientX: 220, clientY: 80 });
|
||||
fireEvent.click(stage, { clientX: 180, clientY: 160 });
|
||||
expect(screen.getByText(/点击黄色首节点或按 Enter 闭合完成/)).toBeInTheDocument();
|
||||
fireEvent.keyDown(window, { key: 'Enter' });
|
||||
|
||||
expect(useStore.getState().masks).toHaveLength(1);
|
||||
@@ -831,6 +871,35 @@ describe('CanvasArea', () => {
|
||||
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows contextual guidance for boolean selection ordering', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 50 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 90, 10, 90, 50]],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 30 L 120 30 L 120 80 Z',
|
||||
label: 'B',
|
||||
color: '#ff0000',
|
||||
segmentation: [[50, 30, 120, 30, 120, 80]],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_remove" frame={frame} />);
|
||||
expect(screen.getByText(/先点击要保留的主区域/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByTestId('konva-path')[0]);
|
||||
|
||||
expect(screen.getByText(/第一个是保留主区域/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the selected class to current-frame masks and marks saved masks dirty', () => {
|
||||
useStore.setState({
|
||||
activeTemplateId: '2',
|
||||
|
||||
Reference in New Issue
Block a user