diff --git a/AGENTS.md b/AGENTS.md index f5dcd0c..0d5429d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ Seg_Server/ - `doc/03-frontend-element-audit.md`:哪些前端元素是真功能,哪些是 Mock/UI-only。 - `doc/04-api-contracts.md`:前后端接口契约,以及当前不一致点。 - `doc/05-implementation-plan.md`:建议的后续实施顺序。 +- `doc/11-frontend-interaction-state-machines.md`:前端 UI 交互细节、键盘规则、工具/范围/确认弹窗状态机和对应测试。 - `doc/10-installation.md`:完整安装部署流程,覆盖 PostgreSQL、Redis、MinIO、FastAPI、Celery、前端和 SAM 2.1 权重。 --- diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index 1222568..ca38031 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -26,7 +26,7 @@ | R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat、主动断开不重连 | | R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | 统一分割结果导出按钮使用导出图标和绿色强调背景、统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 | | R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 | -| R13 文档与测试 | `doc/09-test-plan.md` | 测试覆盖矩阵 | +| R13 文档与测试 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 测试覆盖矩阵、前端交互状态机、键盘规则和确认弹窗流 | ## 逐功能点追踪 @@ -45,7 +45,7 @@ | R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 | | R11 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 | | R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 | -| R13 | 文档测试矩阵与功能点追踪 | `doc/09-test-plan.md` | 已覆盖 | +| R13 | 文档测试矩阵、前端交互状态机、键盘规则、工具/范围/确认弹窗流与对应测试追踪 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 已覆盖 | ## 本轮补齐记录 @@ -73,6 +73,7 @@ - R9:补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。 - R9:补充边缘平滑滑杆防抖测试,验证连续拖动只触发最后一次后端预览请求,降低拖动卡顿。 - R9:补充边缘平滑应用到传播链并可撤销/重做的测试,验证平滑后成为新的实际 polygon、强度归零且不再只保存平滑参数。 +- R5/R13:补充 `CanvasArea.test.tsx` 中 `Esc` 交互测试,验证 `Esc` 只取消当前 mask 选中和临时多边形点,不删除已有 mask、不清空 `activeClass`;新增 `doc/11-frontend-interaction-state-machines.md` 记录工作区工具、语义分类树、范围选择、AI 页、模板确认和导入导出状态机。 ## 运行命令 diff --git a/doc/11-frontend-interaction-state-machines.md b/doc/11-frontend-interaction-state-machines.md new file mode 100644 index 0000000..c21d1f8 --- /dev/null +++ b/doc/11-frontend-interaction-state-machines.md @@ -0,0 +1,109 @@ +# 前端交互与状态机 + +本文档记录当前前端真实交互规则,重点覆盖那些不会直接体现在接口契约里的 UI 细节。测试以本文件、`doc/07-current-requirements-freeze.md` 和 `doc/09-test-plan.md` 为准。 + +## 全局状态 + +| 状态字段 | 所在文件 | 含义 | +|----------|----------|------| +| `activeModule` | `src/store/useStore.ts` | 当前页面模块;登录后默认 `dashboard`。 | +| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 | +| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 | +| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 | +| `activeTemplateId` / `activeClass` | `src/store/useStore.ts` | 当前模板和后续新建 mask 使用的语义类别。 | +| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 | + +## 工作区工具自动机 + +| 状态 | 进入事件 | 可用动作 | 退出事件 | 测试 | +|------|----------|----------|----------|------| +| `idle/no-selection` | 初始、切换到创建工具、`Esc`、切帧无对应传播结果 | 右侧语义树只设置后续新建类别;清空遮罩作用于当前帧全部 mask | 点击 mask、AI 推送、创建新 mask | `CanvasArea.test.tsx`、`OntologyInspector.test.tsx` | +| `mask-selected` | `move/edit_polygon` 下点击 mask、新建 mask 完成、AI 候选选中 | 右侧语义树给已选 mask 换类;Delete/Backspace/DEL 删除;橡皮擦可扣除;顶点可编辑 | `Esc`、切换到创建工具、删除 mask、切帧无对应传播结果 | `CanvasArea.test.tsx` | +| `polygon-drawing` | `create_polygon` 下点击画布 | 继续加点;三点后 Enter 或点击首点闭合 | Enter/首点创建新独立 mask;`Esc` 放弃临时点并清选区 | `CanvasArea.test.tsx` | +| `shape-dragging` | `create_rectangle/create_circle` 下按下鼠标 | 拖拽预览形状 | 鼠标释放创建新独立 mask;切工具取消临时状态 | `CanvasArea.test.tsx` | +| `brush-stroking` | `brush` 且已有 `activeClass` 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放创建新的独立 mask;图外落笔不创建;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` | +| `eraser-stroking` | `eraser` 且已有选中 mask 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放从选中 mask 扣除;扣空则删除该 mask;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` | +| `boolean-selecting` | `area_merge/area_remove` | 选择多个 mask;主区域黄色实线,参与区域红色虚线 | 当前帧执行、所有传播帧、按帧范围、取消、切换工具 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | + +### 细节规则 + +- `Esc` 是取消当前交互状态,不是删除:清空 `selectedMaskIds`、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦笔触和顶点选择;保留已有 mask、当前 `activeClass` 和当前工具。 +- 切换到 `create_polygon`、`create_rectangle`、`create_circle` 会清空旧 mask 选区,避免之后点击语义分类树误改旧 mask。 +- 多边形、矩形、圆和画笔创建完成后都会自动选中新创建的 mask。 +- 画笔每次松手都创建新的独立 mask,即使与旧 mask 连通或重叠也不自动合并;合并只能通过“区域合并”工具显式执行。 +- 橡皮擦只作用于当前选中 mask,不会在无选区时启动。 +- 绘制类工具点击已有 mask 时继续绘制,不触发 mask 选择。 + +## 右侧语义分类树自动机 + +| 状态 | 点击分类结果 | 后续效果 | 测试 | +|------|--------------|----------|------| +| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` | +| 有选中 mask | 更新已选 mask 的 class/label/color;同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty;已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` | +| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` | + +## 键盘交互 + +| 按键 | 前置状态 | 行为 | 测试 | +|------|----------|------|------| +| `Esc` | 任意 Canvas 工具 | 取消选中 mask 和临时绘制状态,不删除 mask,不清 active class | `CanvasArea.test.tsx` | +| `Enter` | 多边形已有至少 3 点 | 闭合并创建新 mask | `CanvasArea.test.tsx` | +| `Delete/Backspace` | 选中顶点 | 删除该顶点,保持 polygon 至少 3 点 | `CanvasArea.test.tsx` | +| `Delete/Backspace` | 选中整块 mask | 删除 mask;传播链 mask 走范围确认;人工/AI 帧按确认策略处理 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | +| `Ctrl/Cmd+Z` | 工作区且非输入控件聚焦 | 撤销 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` | +| `Ctrl/Cmd+Shift+Z` / `Ctrl/Cmd+Y` | 工作区且非输入控件聚焦 | 重做 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` | +| 左/右方向键 | 工作区时间轴 | 切换上一帧/下一帧 | `VideoWorkspace.test.tsx` | + +## 工作区范围选择自动机 + +`VideoWorkspace` 用 `rangeSelectionMode` 区分四类范围选择:`propagation`、`export`、`boolean`、`clear`。 + +| 模式 | 进入事件 | 顶栏状态 | 时间轴行为 | 确认行为 | 测试 | +|------|----------|----------|------------|----------|------| +| `propagation` | 左侧“自动传播” | 显示传播权重、向前/向后帧数和“开始传播” | 拖拽/点击设置传播起止帧 | 保存参考帧 draft/dirty seed,提交 Celery 传播任务 | `VideoWorkspace.test.tsx` | +| `export` | 打开导出菜单并选择“特定范围帧” | 导出菜单保持打开 | 拖拽/点击设置导出起止帧 | “开始导出”保存待归档 mask 后下载 ZIP | `VideoWorkspace.test.tsx` | +| `boolean` | 区域合并/去除选择“按帧范围选择” | 显示“确认区域合并/确认重叠区域去除” | 拖拽/点击设置布尔操作范围 | 弹最终确认,只同步范围内对应传播帧,保留传播 metadata | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | +| `clear` | 清空/DEL 选择“按帧范围选择” | 显示“确认清空” | 拖拽/点击设置清空范围 | 弹最终确认;如范围含人工/AI 帧,再询问是否删除这些帧 | `VideoWorkspace.test.tsx` | + +取消规则: + +- 关闭导出菜单会退出 `export` 范围选择。 +- 在布尔范围确认前重新点击“合并选中/从主区域去除”,会取消旧的顶栏范围请求。 +- 清空/删除传播链时选择“取消”不会删除任何 mask。 + +## AI 智能分割自动机 + +| 状态 | 进入事件 | 主要行为 | 退出事件 | 测试 | +|------|----------|----------|----------|------| +| `no-prompt` | 打开 AI 页或清空提示 | 等待正点/负点/框选 | 放置提示或框选 | `AISegmentation.test.tsx` | +| `box-prompt` | 框选完成 | 仅框选时发送 `box` prompt | 加正/负点后转 interactive | `AISegmentation.test.tsx` | +| `interactive-prompt` | 框选后加点或直接点选 | 发送累计正/负点;负点启用背景过滤 | 空结果移除旧候选 | `AISegmentation.test.tsx` | +| `candidate-selected` | 推理返回 mask 或点击候选 | 可通过语义树换标签;可删除候选 | 推送工作区、删除候选、重新推理 | `AISegmentation.test.tsx` | +| `send-blocked` | 候选缺少语义分类时点击推送 | 显示 error toast,不切模块、不改工具 | 选择语义分类 | `AISegmentation.test.tsx` | + +## 模板与项目确认流 + +| 交互 | 状态机 | 测试 | +|------|--------|------| +| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` | +| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 | +| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` | +| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` | +| 演示恢复出厂设置 | 管理员危险区二次确认并要求输入 `RESET_DEMO_FACTORY`;后端也校验 confirmation | `UserAdmin.test.tsx`、`backend/tests/test_admin.py` | + +## 文件与导入导出交互 + +| 交互 | 状态机 | 测试 | +|------|--------|------| +| 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` | +| 显式生成帧 | 只对视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示 | `ProjectLibrary.test.tsx` | +| GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 | +| 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 | + +## 维护要求 + +新增或修改前端交互时,应同步做三件事: + +1. 更新本文件中对应状态机或规则。 +2. 在 `doc/09-test-plan.md` 的覆盖矩阵中写明测试归属。 +3. 添加或更新组件测试,至少覆盖状态转移的进入条件、退出条件和副作用。 diff --git a/doc/README.md b/doc/README.md index 7105344..19aeeba 100644 --- a/doc/README.md +++ b/doc/README.md @@ -21,6 +21,7 @@ | [08-current-design-freeze.md](./08-current-design-freeze.md) | 当前版本设计冻结,记录模块、数据流和接口边界 | | [09-test-plan.md](./09-test-plan.md) | 需求到测试文件的覆盖矩阵和运行命令 | | [10-installation.md](./10-installation.md) | 系统安装部署指南,覆盖 PostgreSQL、Redis、MinIO、后端、Celery、前端和 SAM 2.1 权重 | +| [11-frontend-interaction-state-machines.md](./11-frontend-interaction-state-machines.md) | 前端 UI 交互细节、键盘规则、工具/范围/确认弹窗状态机和对应测试 | ## 状态标记 diff --git a/src/components/CanvasArea.test.tsx b/src/components/CanvasArea.test.tsx index 8b7106e..60120f2 100644 --- a/src/components/CanvasArea.test.tsx +++ b/src/components/CanvasArea.test.tsx @@ -90,6 +90,38 @@ describe('CanvasArea', () => { expect(maskGroup()).toHaveAttribute('data-opacity', '0.3'); }); + it('clears only the selected mask state with Escape', async () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 }, + activeClassId: 'c1', + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z', + label: '胆囊', + color: '#ff0000', + classId: 'c1', + segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]], + }, + ], + }); + + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + expect(useStore.getState().masks).toHaveLength(1); + expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ + id: 'm1', + label: '胆囊', + color: '#ff0000', + })); + expect(useStore.getState().activeClassId).toBe('c1'); + }); + it('refines one SAM2 candidate mask from an initial box with positive and negative points', async () => { apiMock.predictMask .mockResolvedValueOnce({ @@ -1817,6 +1849,21 @@ describe('CanvasArea', () => { expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); }); + it('cancels in-progress polygon creation with Escape', () => { + render(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.click(stage, { clientX: 120, clientY: 80 }); + fireEvent.click(stage, { clientX: 220, clientY: 80 }); + + expect(screen.getAllByTestId('konva-circle')).toHaveLength(2); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); + expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument(); + }); + it('closes a clicked polygon by clicking the first node again', () => { render(); const stage = screen.getByTestId('konva-stage');