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');