From 085dd656e41d47d77cebd3cfa98311bdd41173f8 Mon Sep 17 00:00:00 2001 From: Jingchao Date: Tue, 10 Feb 2026 09:09:18 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20ER=20=E5=9B=BE=E4=BB=85=E8=A1=A8?= =?UTF-8?q?=E5=90=8D=E8=A7=86=E5=9B=BE=E5=88=87=E6=8D=A2=E4=B8=8E=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E7=BA=BF=E5=90=B8=E9=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ViewMode 常量与 parseDatabaseToER(database, { tableOnly }) - 仅表名时节点不渲染 list 端口,边 source/target 仅 cell 以吸附表名节点 - Viewer 增加「仅表名」Switch、viewMode 状态与 localStorage 记忆 - 新增 E2E 场景:切换仅表名 → 关系线保留 → 切回完整视图 - 规格与任务:specs/003-toggle-columns-table-names Co-authored-by: Cursor --- .../contracts/viewer-component-api.md | 86 ++++++++++ .../data-model.md | 42 +++++ specs/003-toggle-columns-table-names/plan.md | 80 ++++++++++ .../quickstart.md | 46 ++++++ .../research.md | 84 ++++++++++ specs/003-toggle-columns-table-names/spec.md | 69 ++++++++ specs/003-toggle-columns-table-names/tasks.md | 149 ++++++++++++++++++ src/components/viewer/viewer.tsx | 39 ++++- src/constants/viewMode.ts | 9 ++ src/services/er/index.ts | 72 ++++++--- tests/e2e/er-diagram.spec.ts | 57 +++++++ 11 files changed, 704 insertions(+), 29 deletions(-) create mode 100644 specs/003-toggle-columns-table-names/contracts/viewer-component-api.md create mode 100644 specs/003-toggle-columns-table-names/data-model.md create mode 100644 specs/003-toggle-columns-table-names/plan.md create mode 100644 specs/003-toggle-columns-table-names/quickstart.md create mode 100644 specs/003-toggle-columns-table-names/research.md create mode 100644 specs/003-toggle-columns-table-names/spec.md create mode 100644 specs/003-toggle-columns-table-names/tasks.md create mode 100644 src/constants/viewMode.ts diff --git a/specs/003-toggle-columns-table-names/contracts/viewer-component-api.md b/specs/003-toggle-columns-table-names/contracts/viewer-component-api.md new file mode 100644 index 0000000..238903a --- /dev/null +++ b/specs/003-toggle-columns-table-names/contracts/viewer-component-api.md @@ -0,0 +1,86 @@ +# Viewer 组件与 ER 服务契约 + +**Feature**: 003-toggle-columns-table-names +**Phase**: 1 + +本功能为纯前端;以下为组件与服务的**接口契约**(等价于 API 契约)。 + +--- + +## 1. Viewer 组件 (src/components/viewer/viewer.tsx) + +### Props(入参) + +| 属性 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| database | `import('@dbml/core').Database` 或项目内使用的 Database 类型 | 是 | 当前解析得到的 DBML 数据库结构,用于生成 ER 图。 | + +### 新增内部状态(本功能) + +| 状态 | 类型 | 说明 | +| --- | --- | --- | +| viewMode | `'full' \| 'tableOnly'` | 当前视图模式;默认 `'full'`。可选从 localStorage 初始化。 | + +### 用户动作 → 行为 + +| 用户动作 | 行为 | +| --- | --- | +| 点击「仅表名」开关(设为 tableOnly) | 设置 viewMode 为 `'tableOnly'`;使用 `parseDatabaseToER(database, { tableOnly: true })` 得到 model,执行 layout 后 `graph.fromJSON(models)`。 | +| 点击「完整」开关(设为 full) | 设置 viewMode 为 `'full'`;使用 `parseDatabaseToER(database, { tableOnly: false })` 得到 model,执行 layout 后 `graph.fromJSON(models)`。 | +| database 变更(由父组件传入) | 使用当前 viewMode 重新调用 `parseDatabaseToER(database, { tableOnly: viewMode === 'tableOnly' })`,layout 后更新图。 | + +### 工具栏新增 + +- 一个切换控件(Button 或 Switch),标签如「仅表名」/「表名」;切换时更新 viewMode 并触发上述数据流。 + +--- + +## 2. ER 服务 (src/services/er/index.ts) + +### parseDatabaseToER + +**签名(扩展后)**: + +```ts +function parseDatabaseToER( + database: Database, + options?: { tableOnly?: boolean }, +): { nodes: NodeData[]; edges: EdgeData[] }; +``` + +**参数**: + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| database | Database | 与现有一致。 | +| options.tableOnly | boolean | 可选,默认 false。为 true 时:节点不包含 list 组端口(仅表头);边的 source/target 不包含 port,仅 cell。 | + +**返回值**:与现有一致,`nodes` 与 `edges` 符合 X6 `Model.FromJSONData` 的节点/边结构。 + +### parseTableToNode(内部或导出) + +当 `options?.tableOnly === true` 时: + +- 返回的节点 `ports` 不包含 `list` 组(或 list 组为空)。 +- 节点 `height` 为单行高度(如 24)。 + +### parseRef(内部或导出) + +当 `options?.tableOnly === true` 时: + +- 返回的 edge 的 `source` 仅包含 `cell`,不包含 `port`。 +- 返回的 edge 的 `target` 仅包含 `cell`,不包含 `port`。 + +--- + +## 3. 常量(可选) + +若集中管理枚举,可新增: + +```ts +// src/constants/viewMode.ts 或等效路径 +export type ViewMode = 'full' | 'tableOnly'; +export const VIEW_MODE = { FULL: 'full', TABLE_ONLY: 'tableOnly' } as const; +``` + +以上契约在实现时须保持;测试与 E2E 可依赖这些接口编写。 diff --git a/specs/003-toggle-columns-table-names/data-model.md b/specs/003-toggle-columns-table-names/data-model.md new file mode 100644 index 0000000..4f2f450 --- /dev/null +++ b/specs/003-toggle-columns-table-names/data-model.md @@ -0,0 +1,42 @@ +# Data Model: 003-toggle-columns-table-names + +**Feature**: ER 图列切换与仅表名视图 +**Phase**: 1 + +本功能不引入新的持久化数据或后端实体;仅扩展前端**视图状态**与 ER 图生成时的**参数**。 + +--- + +## 1. 视图模式(ViewMode) + +| 字段/概念 | 类型 | 说明 | +| --- | --- | --- | +| ViewMode | `'full' \| 'tableOnly'` | 当前 ER 图展示模式:完整(表名+列)或仅表名。 | +| 持久化 | 可选 | 可存入 `localStorage`(如 key: `dbml-editor-er-view-mode`),会话间记忆用户选择。 | + +**校验规则**:仅允许枚举值;默认 `'full'`。 + +--- + +## 2. 现有实体(本功能中的使用方式) + +- **Database**(@dbml/core):不变;仍为解析结果,Viewer 的 `props.database`。 +- **ER 节点(er-rect)**:由 `parseTableToNode(table, schemaName, options?)` 生成;当 `options.tableOnly === true` 时,不向 `ports` 添加 `list` 组,仅保留表头,节点 `height` 为单行(如 24px)。 +- **ER 边(Ref)**:由 `parseRef(ref, options?)` 生成;当 `options.tableOnly === true` 时,返回的 edge 的 `source`/`target` 只包含 `cell`,不包含 `port`,以便 X6 使用节点锚点吸附。 + +--- + +## 3. 状态转换 + +- **用户点击「仅表名」**:ViewMode `full` → `tableOnly`;触发重新 `parseDatabaseToER(database, { tableOnly: true })` + layout + fromJSON。 +- **用户点击「完整」**:ViewMode `tableOnly` → `full`;触发重新 `parseDatabaseToER(database, { tableOnly: false })` + layout + fromJSON。 +- **DBML 变更**:`database` 更新,当前 ViewMode 不变,按当前模式重新生成图并更新。 + +无其他状态机;无服务端同步。 + +--- + +## 4. 与章程的一致性 + +- 数据与解析均在客户端;ViewMode 不离开浏览器(可选 localStorage)。 +- 无新增数据库表或 API 契约;仅前端组件状态与 ER 转换参数。 diff --git a/specs/003-toggle-columns-table-names/plan.md b/specs/003-toggle-columns-table-names/plan.md new file mode 100644 index 0000000..153e9c2 --- /dev/null +++ b/specs/003-toggle-columns-table-names/plan.md @@ -0,0 +1,80 @@ +# Implementation Plan: ER 图列切换与仅表名视图 + +**Branch**: `003-toggle-columns-table-names` | **Date**: 2026-02-09 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/003-toggle-columns-table-names/spec.md` + +## Summary + +在 ER 图工具栏增加「仅表名」视图切换:隐藏列、只显示表名块,关系线保留并吸附到表名节点;切换回完整视图时恢复列与端口绑定。技术上通过视图模式状态 + X6 节点/边数据或渲染分支实现,不改变 DBML 或 @dbml/core 解析结果。 + +## Technical Context + +**Language/Version**: TypeScript 5.x, Node.js 18 +**Primary Dependencies**: Umi 4, Ant Design 5.x, AntV X6, @antv/layout (Dagre), @dbml/core, Monaco Editor +**Storage**: N/A(纯前端;可选 localStorage 记忆视图模式) +**Testing**: Jest(单元), Playwright(E2E) +**Target Platform**: 现代浏览器(客户端 Web);**Project Type**: single(前端单体) +**Performance Goals**: 100 表以内 schema 下视图切换与图同步 <500ms(章程要求) +**Constraints**: 隐私优先(全部客户端)、TypeScript strict、无 any/ts-ignore(章程) +**Scale/Scope**: 单页 ER 编辑与可视化,表数量级 10² 以内 + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +| 章程条款 | 状态 | 说明 | +| --- | --- | --- | +| I. 隐私优先 | ✅ | 视图模式与渲染仅在前端,无数据上传。 | +| II. 实时可视化 | ✅ | 切换为展示层状态,不改变解析;DBML 变更后仍按现有流程同步,模式保持。 | +| III. 类型安全 | ✅ | 新增 ViewMode 等类型,无 any;X6/Monaco 等已有例外在章程允许范围内。 | +| IV. 测试纪律 | ✅ | 计划新增 E2E 覆盖「仅表名」切换与关系线吸附;单元测试覆盖 viewMode 与 ER 转换逻辑(若有新函数)。 | +| V. 浏览器优先 | ✅ | 功能纯前端,无服务端依赖。 | +| 质量门禁 | ✅ | TypeScript strict、lint、单元/E2E 通过方可合并。 | + +**结论**: 无违规,通过门禁。 + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-toggle-columns-table-names/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (组件/状态契约) +└── tasks.md # Phase 2 output (/speckit.tasks - NOT created by plan) +``` + +### Source Code (repository root) + +```text +src/ +├── components/ +│ ├── editor/ +│ └── viewer/ # ER 图:Viewer.tsx,视图模式状态与工具栏开关 +├── constants/ # 可选:ViewMode 枚举 +├── models/ +├── nodes/ +│ └── er.ts # er-rect 注册;可能扩展「仅表头」或端口可见性 +├── pages/ +│ └── Home/ +├── services/ +│ ├── dbml/ +│ └── er/ # parseDatabaseToER;可能支持 tableOnly 的 model 形态 +└── utils/ + +tests/ +├── e2e/ # 新增:仅表名切换与关系线吸附场景 +├── setup.ts +└── (unit under src/**/__tests__) +``` + +**Structure Decision**: 单项目结构(Umi 4 前端),ER 相关逻辑在 `src/components/viewer`、`src/services/er`、`src/nodes/er.ts`;本功能不新增后端或新包。 + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +(无违规,本节留空。) diff --git a/specs/003-toggle-columns-table-names/quickstart.md b/specs/003-toggle-columns-table-names/quickstart.md new file mode 100644 index 0000000..51026ce --- /dev/null +++ b/specs/003-toggle-columns-table-names/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart: 003-toggle-columns-table-names + +**Feature**: ER 图列切换与仅表名视图 +**Branch**: `003-toggle-columns-table-names` + +## 前置条件 + +- Node.js 18+ +- pnpm +- 仓库根目录已执行 `pnpm install` + +## 本地运行 + +```bash +cd /Users/alswl/dev/my/dbml-editor +pnpm run dev +``` + +在浏览器中打开控制台输出的本地地址(通常为 http://localhost:8000)。在编辑器中输入或加载包含多表与 Ref 的 DBML,右侧 ER 图会显示。 + +## 本功能验证步骤 + +1. **打开应用**:确保 ER 图已渲染(有至少 2 个表及 1 条关系线)。 +2. **找切换控件**:在 ER 图区域旁的缩放工具栏附近,应有「仅表名」或类似开关/按钮。 +3. **切换到仅表名**:点击后,图中每个表应只显示表名矩形,不显示列;关系线仍存在且两端吸附在表名节点上。 +4. **切回完整视图**:再次点击切换,列重新显示,关系线连接到对应列。 +5. **编辑 DBML**:在左侧编辑器中修改表或 Ref,确认图在约 500ms 内更新,且当前视图模式(仅表名/完整)保持正确。 + +## 测试 + +```bash +# 单元测试 +pnpm test + +# E2E(需先启动或使用 playwright 默认) +pnpm run test:e2e +``` + +本功能完成后,应至少有一条 E2E 场景覆盖:进入页面 → 等待 ER 图加载 → 点击仅表名 → 断言关系线存在且连接节点 → 切回完整 → 断言列显示。 + +## 相关文件 + +- 规格与计划:`specs/003-toggle-columns-table-names/spec.md`、`plan.md` +- 研究与数据模型:`research.md`、`data-model.md` +- 契约:`contracts/viewer-component-api.md` +- 实现涉及:`src/components/viewer/viewer.tsx`、`src/services/er/index.ts`、可选 `src/constants/`、`src/nodes/er.ts`(若需微调节点) diff --git a/specs/003-toggle-columns-table-names/research.md b/specs/003-toggle-columns-table-names/research.md new file mode 100644 index 0000000..59b55b6 --- /dev/null +++ b/specs/003-toggle-columns-table-names/research.md @@ -0,0 +1,84 @@ +# Research: 仅表名视图与关系线吸附 + +**Feature**: 003-toggle-columns-table-names +**Phase**: 0 + +## 1. 仅表名视图在 AntV X6 中的实现方式 + +### Decision(决策) + +采用 **同一节点类型 `er-rect` + 视图模式控制「是否渲染 list 组端口」** 的方案:在 `tableOnly` 模式下,生成/更新到画布的节点不包含 `list` 组端口(或端口数量为 0),节点高度仅保留表头一行;关系线在 tableOnly 模式下改为连接**节点主体**(或每个表一个虚拟的「表级」端口),通过 X6 的 `source/target: { cell, port? }` 在 tableOnly 时只指定 `cell` 并使用节点锚点(如 `midSide`)实现吸附。 + +### Rationale(理由) + +- 现有 `parseDatabaseToER` 已按「表 → 节点 + 列 → ports」生成数据;边通过 `source.port` / `target.port` 连到具体列。若在 tableOnly 时改为不传 ports(或传空 list 组),节点自然只渲染表头;边若仍带 port 会失效,故需在 tableOnly 下改为仅指定 `cell` 并用节点默认锚点,这样关系线会吸附到表名节点边框/中心,满足「snap to table names」。 +- 不新增节点类型可避免重复维护两套 markup/attrs,且切换时只需用同一套 `parseTableToNode` 的「是否包含列」分支即可。 + +### Alternatives considered(考虑的备选) + +- **备选 A**:注册第二种节点 `er-rect-table-only`,无 list 端口,边连节点。缺点:两套节点样式需同步,切换时需整图替换 shape,复杂度更高。 +- **备选 B**:保留所有端口但用 CSS/attrs 隐藏端口视觉。缺点:布局上节点仍占列高,无法实现「只显示表名」的紧凑布局;边仍连到隐藏端口,对「吸附到表名」的语义不直观。 + +--- + +## 2. 关系线在仅表名视图下的锚点策略 + +### Decision(决策) + +在 **tableOnly** 模式下,边的 `source`/`target` 只设置 `cell`,不设置 `port`;依赖 Graph 的 `connecting.anchor` 或边的 `anchor` 使用 **`midSide`(或 `left`/`right` 按方向)**,使连线吸附在表名节点的侧面中点,保持与 Dagre 水平布局(LR)一致。 + +### Rationale(理由) + +- X6 当 target/source 不指定 port 时,会使用节点的默认锚点;`midSide` 已在本项目 Graph 配置中使用,表名节点在 tableOnly 下为单行矩形,侧面中点即表名块边缘,语义清晰。 +- Dagre 的 `rankdir: 'LR'` 下,边从节点左右两侧连出,用水平方向的 midSide 可避免连线穿过节点。 + +### Alternatives considered(考虑的备选) + +- **备选 A**:为每个表在 tableOnly 下暴露一个虚拟 port(如 `table-only`),边仍连到该 port。实现略复杂,且效果与「不设 port + 节点锚点」等价。 +- **备选 B**:使用 `anchor: 'center'`。缺点:多条边时都从中心连出,易重叠,可读性差。 + +--- + +## 3. 视图模式状态与数据流 + +### Decision(决策) + +- 在 **Viewer 组件** 内用 `useState<'full'|'tableOnly'>` 保存视图模式;工具栏增加一个 Switch/Button 切换。 +- **parseDatabaseToER** 增加可选参数 `viewMode`(或等价的 `tableOnly: boolean`):为 `tableOnly` 时,`parseTableToNode` 不添加 list 组端口;`parseRef` 在 tableOnly 时返回的 edge 不包含 `source.port`/`target.port`,仅包含 `source.cell`/`target.cell`。 +- 当 `props.database` 或视图模式变化时,重新执行 `parseDatabaseToER(..., viewMode)` 并 `setModels(layout.layout(...))`,保证图与模式同步。 + +### Rationale(理由) + +- 单一数据源:ER 图数据仍由 `database + viewMode` 推导,不保留「两套 model」;切换时重算 model 并 fromJSON,逻辑简单且与现有 `useEffect([props.database])` 一致。 +- 可选:将 viewMode 写入 localStorage,在 Viewer 初始化时读回,实现「会话内记忆」。 + +### Alternatives considered(考虑的备选) + +- **备选 A**:在 X6 层动态显示/隐藏端口。需要遍历节点改 attrs 或 port 的 visible,且边仍连到 port,需同时改边的 source/target,与「吸附到表名」目标不符。 +- **备选 B**:维护两套 JSON model 并切换时替换。冗余且易不同步,不采用。 + +--- + +## 4. 布局(Dagre)与节点尺寸 + +### Decision(决策) + +- tableOnly 下节点 `height` 为单行(如 24px),`width` 可与完整视图一致(如 150px);Dagre 布局在每次 `layout.layout(m)` 时按当前节点尺寸计算,因此切换视图后重新执行一次 layout,避免重叠或留白过大。 +- 不改变 Dagre 的 `rankdir`/`ranksep`/`nodesep` 等配置;仅因节点变小,整体图会更紧凑。 + +### Rationale(理由) + +- 现有代码已在 `props.database` 变化时执行 `setModels(layout.layout(m))`;扩展为 `viewMode` 变化也触发同样流程即可,Dagre 会根据新尺寸重新排布,关系线由 router 重算,自然「snap」到新位置。 + +### Alternatives considered(考虑的备选) + +- **备选 A**:不重新 layout,只改节点高度。可能导致节点重叠(原为多行高度,现为单行),不采用。 +- **备选 B**:tableOnly 使用不同 ranksep/nodesep。可后续做 UX 微调,非必须。 + +--- + +## 5. 小结(Phase 0 输出) + +- **实现路径**:Viewer 内 viewMode 状态 + 工具栏开关;`parseDatabaseToER(database, { tableOnly })` 在 tableOnly 时不输出列端口、边不带 port;切换或 database 变化时重新 layout 并 fromJSON。 +- **锚点**:tableOnly 下边仅指定 cell,用节点默认锚点(midSide)实现吸附到表名节点。 +- **无未决项**:技术上下文无 NEEDS CLARIFICATION,可直接进入 Phase 1 设计与契约。 diff --git a/specs/003-toggle-columns-table-names/spec.md b/specs/003-toggle-columns-table-names/spec.md new file mode 100644 index 0000000..bd9312a --- /dev/null +++ b/specs/003-toggle-columns-table-names/spec.md @@ -0,0 +1,69 @@ +# Feature Specification: ER 图列切换与仅表名视图 + +**Feature Branch**: `003-toggle-columns-table-names` +**Created**: 2026-02-09 +**Status**: Draft +**Input**: User description: "A way to toggle the columns and just view the table names. The relationship should be preserved and snap to the table names. 后续我们使用中文" + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - 在 ER 图中切换「仅表名」视图 (Priority: P1) + +用户希望在一键切换后,ER 图只显示表名(不显示列/字段),以便在表很多时快速浏览表与表之间的关系;切换回「完整视图」后,列信息重新显示,关系线仍正确连接对应列。 + +**Why this priority**: 这是本功能的核心价值:简化视觉、保留关系语义。 + +**Independent Test**: 打开含多表的 DBML,点击工具栏「仅表名」开关,图仅显示表名块与关系线;再点击恢复,列重新显示且关系线仍连到正确列。可完全通过 UI 操作与视觉断言验证。 + +**Acceptance Scenarios**: + +1. **Given** ER 图处于完整视图(显示表名+列),**When** 用户点击「仅表名」切换,**Then** 图中每个表只显示表名矩形,不显示列行;关系线仍存在且端点吸附在表名节点上。 +2. **Given** ER 图处于仅表名视图,**When** 用户再次点击切换恢复完整视图,**Then** 列重新显示,关系线端点仍连接到正确的列(端口)。 +3. **Given** 任意视图模式,**When** 用户编辑 DBML 导致表/列变化,**Then** 图在 500ms 内同步,且当前「仅表名/完整」模式保持生效。 + +--- + +### User Story 2 - 关系线在仅表名视图下吸附表名节点 (Priority: P1) + +在「仅表名」模式下,关系线(Ref)的端点必须吸附在表名节点上(例如节点边框或中心),而不是悬空或错位,以便用户仍能清晰看到表与表之间的连线。 + +**Why this priority**: 与 Story 1 同属核心体验,无此则「仅表名」视图关系不可读。 + +**Independent Test**: 在仅表名视图下,拖拽画布或缩放,观察所有关系线两端均终止于对应表名节点边缘/端口位置,无断线或错连。 + +**Acceptance Scenarios**: + +1. **Given** 仅表名视图,**When** 图中存在至少一条 Ref,**Then** 该 Ref 的 source/target 端点均吸附在对应表节点的可见边界或约定锚点上。 +2. **Given** 仅表名视图且布局为 Dagre,**When** 用户切换为仅表名或从完整切回仅表名,**Then** 布局不因节点尺寸变化而错乱,关系线随节点位置正确重绘。 + +--- + +### Edge Cases + +- 无列的表(仅表名):仅表名视图与完整视图视觉上可能一致,切换时无异常。 +- 空 schema / 无表:切换按钮可禁用或保持可点,图中无节点时切换不报错。 +- 解析错误导致 database 为空或部分表缺失:当前行为保持(图不更新或显示空),不因「仅表名」逻辑引入新错误。 + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: 系统必须在 ER 图工具栏或等效入口提供「仅表名」视图的开关(如按钮、Switch),且开关状态在会话内可记忆(可选:localStorage)。 +- **FR-002**: 系统必须在「仅表名」视图中仅渲染表名(表头),不渲染列(ports 的 list 组);表头样式与现有 er-rect 一致。 +- **FR-003**: 系统必须在「仅表名」视图中保持所有 Ref 关系线可见,且关系线端点吸附在对应表名节点上(通过节点锚点或边框吸附实现)。 +- **FR-004**: 系统必须在切换回「完整」视图时,恢复列(ports)的渲染,并将关系线端点重新绑定到正确的列端口。 +- **FR-005**: 视图切换后,DBML 源码与图的同步行为须符合章程「实时可视化」:解析结果变更后 500ms 内图更新,且当前视图模式(仅表名/完整)仍正确应用。 + +### Key Entities _(include if feature involves data)_ + +- **视图模式 (ViewMode)**: 枚举 `full` | `tableOnly`,表示当前 ER 图显示模式;仅影响展示层,不改变 DBML 或 database 结构。 +- **ER 节点 (er-rect)**: 现有节点类型;在 `tableOnly` 下通过不渲染 list 组或使用「仅表头」变体实现仅表名;关系线在 tableOnly 下连接节点主体或专用锚点。 + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: 用户可在一次点击内切换「仅表名」与「完整」视图,且切换在 100 表以内 schema 下无明显卡顿(主观流畅)。 +- **SC-002**: 在仅表名视图下,所有关系线端点均吸附在表名节点上,无断线或明显错位。 +- **SC-003**: 切换视图后,Dagre 布局保留(或重新跑一次布局),不出现节点重叠或关系线交叉恶化。 +- **SC-004**: 现有 E2E 测试(如 ER 图渲染、导出)仍通过;新增至少 1 个 E2E 场景覆盖「切换仅表名 → 校验关系线 → 切回完整」路径。 diff --git a/specs/003-toggle-columns-table-names/tasks.md b/specs/003-toggle-columns-table-names/tasks.md new file mode 100644 index 0000000..95846e5 --- /dev/null +++ b/specs/003-toggle-columns-table-names/tasks.md @@ -0,0 +1,149 @@ +# Tasks: ER 图列切换与仅表名视图 + +**Input**: Design documents from `/specs/003-toggle-columns-table-names/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: 功能规格要求新增至少 1 个 E2E 场景(SC-004),已包含在收尾阶段。 + +**Organization**: 按用户故事分组,US1 与 US2 均为 P1;US2 依赖 US1 的数据流(同属一个 MVP 交付)。 + +## Format: `[ID] [P?] [Story?] Description` + +- **[P]**: 可并行(不同文件、无未完成依赖) +- **[Story]**: 所属用户故事(US1, US2) +- 描述中含精确文件路径 + +## Path Conventions + +- 单项目:仓库根下 `src/`、`tests/`(与 plan.md 一致) + +--- + +## Phase 1: Setup(共享基础设施) + +**Purpose**: 确认项目就绪,无需新建后端或新包 + +- [x] T001 按 plan.md 确认项目为单前端结构(src/、tests/),无新增后端或 npm 包,便于在 Viewer 与 er 服务中实现本功能 + +--- + +## Phase 2: Foundational(阻塞性前置) + +**Purpose**: 所有用户故事实现前必须满足的前置条件 + +**⚠️ CRITICAL**: 本阶段完成前不得开始用户故事开发 + +- [x] T002 确认 ER 图数据流唯一入口为 `src/components/viewer/viewer.tsx` 与 `src/services/er/index.ts`(database → parseDatabaseToER → layout → fromJSON),便于注入 viewMode 与 tableOnly 参数 + +**Checkpoint**: 基础就绪,可开始用户故事实现 + +--- + +## Phase 3: User Story 1 - 在 ER 图中切换「仅表名」视图 (Priority: P1) 🎯 MVP + +**Goal**: 用户可通过工具栏一键切换「仅表名」与「完整」视图;仅表名时图中只显示表名块与关系线,切换回完整时恢复列与端口。 + +**Independent Test**: 打开含多表与 Ref 的 DBML,点击「仅表名」→ 图仅显示表名块与关系线;再点击恢复 → 列重新显示且关系线连到正确列。 + +### Implementation for User Story 1 + +- [x] T003 [P] [US1] 在 `src/constants/viewMode.ts` 中新增 ViewMode 类型与常量(`'full' | 'tableOnly'`,可选;若集中到 Viewer 内则可省略本任务) +- [x] T004 [US1] 在 `src/services/er/index.ts` 中为 `parseDatabaseToER` 增加可选参数 `options?: { tableOnly?: boolean }`,并向下传递给 `parseTableToNode` 与 `parseRef` +- [x] T005 [US1] 在 `src/services/er/index.ts` 中修改 `parseTableToNode`:当 `options?.tableOnly === true` 时不向 `ports` 添加 list 组,节点 `height` 设为单行(如 24) +- [x] T006 [US1] 在 `src/components/viewer/viewer.tsx` 中新增状态 `viewMode: 'full' | 'tableOnly'`(默认 `'full'`),并在缩放工具栏旁增加「仅表名」切换控件(Button 或 Switch) +- [x] T007 [US1] 在 `src/components/viewer/viewer.tsx` 中使 `useEffect` 依赖 `props.database` 与 `viewMode`,在二者任一变化时使用 `parseDatabaseToER(database, { tableOnly: viewMode === 'tableOnly' })` 得到 model,经 layout 后 `setModels` 并保证 graph.fromJSON 使用新 model + +**Checkpoint**: 用户可切换仅表名/完整视图,图中节点在仅表名时仅显示表头 + +--- + +## Phase 4: User Story 2 - 关系线在仅表名视图下吸附表名节点 (Priority: P1) + +**Goal**: 在仅表名模式下,关系线端点吸附在表名节点上(节点边框/锚点),无断线或错位。 + +**Independent Test**: 在仅表名视图下观察所有 Ref 两端均终止于对应表名节点边缘,拖拽画布或缩放时关系线随节点正确重绘。 + +### Implementation for User Story 2 + +- [x] T008 [US2] 在 `src/services/er/index.ts` 中修改 `parseRef`:当 `options?.tableOnly === true` 时返回的 edge 的 `source`/`target` 仅包含 `cell`,不包含 `port` +- [x] T009 [US2] 在 `src/components/viewer/viewer.tsx` 或 `src/services/er/index.ts` 中确保 tableOnly 下边不指定 port 时,X6 使用节点锚点(如 midSide);若默认行为不符合,则在生成 edge 时显式设置 `anchor: 'midSide'` 或等效配置 + +**Checkpoint**: 仅表名视图下关系线正确吸附到表名节点,布局与重绘正常 + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: 横切关注点与规格要求的测试、可选增强 + +- [x] T010 [P] 在 `tests/e2e/` 中新增 E2E 场景:进入页面 → 等待 ER 图加载(含多表与 Ref)→ 点击「仅表名」→ 断言关系线存在且连接节点 → 切回「完整」→ 断言列显示(满足 SC-004) +- [x] T011 [P] 可选:在 `src/components/viewer/viewer.tsx` 中从 localStorage 读取/写入 `dbml-editor-er-view-mode`,实现视图模式会话记忆(FR-001 可选) +- [x] T012 运行 `pnpm test` 与 `pnpm run test:e2e`,确认现有单测与 E2E 通过且无新增 lint/类型错误;按 quickstart.md 做一次手动验证 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: 无依赖,可立即开始 +- **Phase 2 (Foundational)**: 依赖 Phase 1,阻塞所有用户故事 +- **Phase 3 (US1)**: 依赖 Phase 2,实现切换与仅表名节点 +- **Phase 4 (US2)**: 依赖 Phase 3(同一 ER 数据流下补全边的 tableOnly 行为) +- **Phase 5 (Polish)**: 依赖 Phase 3、Phase 4 完成 + +### User Story Dependencies + +- **User Story 1 (P1)**: 仅依赖 Phase 2;交付「仅表名」切换与节点仅表头 +- **User Story 2 (P1)**: 依赖 US1 的 parseDatabaseToER/parseRef 扩展;交付关系线吸附到表名节点 + +### Within Each User Story + +- US1:T004 → T005 与 T006、T007 可部分并行(T004+T005 在 er 服务,T006+T007 在 Viewer;T007 依赖 T004/T005 的接口) +- US2:T008 在 er 服务,T009 可能依赖 T008 或仅配置;T008 完成后即可验证吸附 + +### Parallel Opportunities + +- T003 与 T004 可并行(不同文件) +- T005 与 T006 可并行(er vs viewer) +- T010 与 T011 可并行(E2E 与 localStorage 互不依赖) + +--- + +## Parallel Example: User Story 1 + +```text +# 可先并行:常量 vs 服务签名 +T003: 在 src/constants/viewMode.ts 中新增 ViewMode 类型与常量 +T004: 在 src/services/er/index.ts 中为 parseDatabaseToER 增加 options + +# 随后:表节点逻辑 + UI 状态与控件 +T005: parseTableToNode 在 tableOnly 时不添加 list 端口 +T006: Viewer 增加 viewMode 与工具栏切换 + +# 最后:数据流串联 +T007: useEffect(database, viewMode) → parseDatabaseToER(...) → layout → setModels +``` + +--- + +## Implementation Strategy + +### MVP First(User Story 1 + User Story 2) + +1. 完成 Phase 1、Phase 2 +2. 完成 Phase 3(US1):切换 + 仅表名节点 +3. 完成 Phase 4(US2):关系线吸附 +4. **STOP and VALIDATE**:按 Independent Test 验证仅表名视图与关系线 +5. 完成 Phase 5:E2E + 可选 localStorage + 全量测试 + +### Incremental Delivery + +1. Phase 1 + 2 → 基础就绪 +2. Phase 3 + 4 → 可演示「仅表名」切换与关系线吸附(MVP) +3. Phase 5 → 自动化测试与体验增强 + +### Format Verification + +- 所有任务均含:`- [ ]`、任务 ID(T001–T012)、[P]/[US1]/[US2] 标签(按规则)、描述与文件路径 +- 用户故事阶段任务均带 [US1] 或 [US2] 标签;搭建与基础阶段无故事标签;收尾阶段无故事标签 diff --git a/src/components/viewer/viewer.tsx b/src/components/viewer/viewer.tsx index b2aa4c8..d151b8c 100644 --- a/src/components/viewer/viewer.tsx +++ b/src/components/viewer/viewer.tsx @@ -1,3 +1,5 @@ +import type { ViewMode } from '@/constants/viewMode'; +import { VIEW_MODE } from '@/constants/viewMode'; import parseDatabaseToER from '@/services/er'; import { CompressOutlined, @@ -8,13 +10,23 @@ import { import { DagreLayout } from '@antv/layout'; import { Graph, Model } from '@antv/x6'; import { Snapline } from '@antv/x6-plugin-snapline'; -import { Button, Space, Tooltip } from 'antd'; +import { Button, Space, Switch, Tooltip } from 'antd'; import React, { useCallback, useEffect, useRef, useState } from 'react'; interface Props { database: any; } +const VIEW_MODE_STORAGE_KEY = 'dbml-editor-er-view-mode'; + +function loadViewModeFromStorage(): ViewMode { + if (typeof window === 'undefined') return VIEW_MODE.FULL; + const stored = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY); + if (stored === VIEW_MODE.TABLE_ONLY || stored === VIEW_MODE.FULL) + return stored; + return VIEW_MODE.FULL; +} + // Viewer is a component that renders the ER diagram const Viewer: React.FC = (props: Props) => { const containerRef = useRef(null); @@ -23,6 +35,7 @@ const Viewer: React.FC = (props: Props) => { const [models, setModels] = useState({}); const [zoom, setZoom] = useState(1); + const [viewMode, setViewMode] = useState(loadViewModeFromStorage); // new GridLayout({ // type: 'grid', @@ -156,15 +169,35 @@ const Viewer: React.FC = (props: Props) => { }, [models]); useEffect(() => { - let m = parseDatabaseToER(props.database); + const tableOnly = viewMode === VIEW_MODE.TABLE_ONLY; + const m = parseDatabaseToER(props.database, { tableOnly }); setModels(layout.layout(m)); - }, [props.database]); + }, [props.database, viewMode]); return (
+ +
+ { + const next = checked ? VIEW_MODE.TABLE_ONLY : VIEW_MODE.FULL; + setViewMode(next); + try { + window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, next); + } catch { + // ignore + } + }} + /> + 仅表名 +
+