feat: 修复 7 个 Gap — 图分组布局、status 映射、详情面板丰富等 #2
847
docs/superpowers/plans/2026-03-24-v2-fix-gaps.md
Normal file
847
docs/superpowers/plans/2026-03-24-v2-fix-gaps.md
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
# V2 Gap Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 7 gaps (P0+P1) so the graph visualization shows grouped layout, real status colors, working document edges, rich detail panel, legend, back button, and edit shortcut.
|
||||
|
||||
**Architecture:** Backend changes add `parent` field to `GraphNode`, status mapping from `FileStatusEntry`, and document nodes with proper edge resolution. Frontend changes replace the single-center D3 layout with per-group forceX/forceY, add compound document view toggle, enrich the detail panel with API calls, and add legend/back-button UI.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / dataclasses (backend), Vue 3 / TypeScript / D3.js v7 / Pinia (frontend), pytest (backend tests)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Files to Modify
|
||||
| File | Responsibility | Tasks |
|
||||
|------|---------------|-------|
|
||||
| `backend/app/modules/graph/domain/entities.py` | GraphNode dataclass — add `parent` field | 1 |
|
||||
| `backend/app/modules/graph/application/services.py` | build_panorama — status mapping, document nodes, edge fix | 2, 3 |
|
||||
| `backend/app/modules/graph/interfaces/http/router.py` | Pass `design_dir` to build_panorama | 4 |
|
||||
| `backend/tests/test_graph_service.py` | Update existing tests + add new tests | 1, 2, 3, 4 |
|
||||
| `frontend/src/shared/types/api.ts` | Add `parent` to GraphNode interface | 5 |
|
||||
| `frontend/src/modules/graph/components/GraphPanorama.vue` | Group layout, compound layout, toggle, back button | 6, 8 |
|
||||
| `frontend/src/modules/graph/components/GraphDetail.vue` | Rich detail panel, edit button | 7 |
|
||||
|
||||
### Files to Create
|
||||
| File | Responsibility | Tasks |
|
||||
|------|---------------|-------|
|
||||
| `frontend/src/modules/graph/components/GraphLegend.vue` | Legend component | 9 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Domain — Add `parent` field to GraphNode
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/modules/graph/domain/entities.py:4-10`
|
||||
- Test: `backend/tests/test_graph_service.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `backend/tests/test_graph_service.py`:
|
||||
|
||||
```python
|
||||
def test_graph_node_has_parent_field(graph_service, scan_result):
|
||||
view = graph_service.build_panorama(scan_result)
|
||||
# All nodes should have a parent attribute (None for most, doc_id for some)
|
||||
for node in view.nodes:
|
||||
assert hasattr(node, 'parent')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v`
|
||||
Expected: FAIL — `GraphNode` has no `parent` attribute
|
||||
|
||||
- [ ] **Step 3: Add `parent` field to GraphNode**
|
||||
|
||||
Edit `backend/app/modules/graph/domain/entities.py` — add to the GraphNode dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
id: str
|
||||
type: str # capability, module, entity, runtime_component, document
|
||||
label: str
|
||||
status: str # FileStatus value or "unknown"
|
||||
group_id: str
|
||||
parent: str | None = None # doc_id of containing document, if any
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run all existing graph tests to verify no regression**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v`
|
||||
Expected: All 9 tests PASS (existing 8 + new 1)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/modules/graph/domain/entities.py backend/tests/test_graph_service.py
|
||||
git commit -m "feat(graph): add parent field to GraphNode domain entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Application — Status mapping in build_panorama (GAP-B1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/modules/graph/application/services.py:22-77`
|
||||
- Test: `backend/tests/test_graph_service.py`
|
||||
|
||||
- [ ] **Step 1: Add design_dir fixture and write the failing tests**
|
||||
|
||||
First, add a `design_dir` fixture to `backend/tests/test_graph_service.py` (will be used by all subsequent tests):
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def design_dir():
|
||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
||||
```
|
||||
|
||||
Then add the new tests (note: all new tests from this point forward take `design_dir` and pass it to `build_panorama`):
|
||||
|
||||
```python
|
||||
def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir):
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
statuses = {n.status for n in view.nodes}
|
||||
# At least some nodes should NOT be "unknown" since we have real file statuses
|
||||
assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working"
|
||||
|
||||
|
||||
def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir):
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"}
|
||||
for node in view.nodes:
|
||||
assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_nodes_have_real_status tests/test_graph_service.py::test_panorama_status_values_are_valid -v`
|
||||
Expected: `test_panorama_nodes_have_real_status` FAILS (all statuses are "unknown")
|
||||
|
||||
- [ ] **Step 3: Implement status mapping**
|
||||
|
||||
Edit `backend/app/modules/graph/application/services.py`. Add the `_SOURCE_FILES` constant after `_GROUPS` and modify `build_panorama` to build the file status map:
|
||||
|
||||
```python
|
||||
_SOURCE_FILES: dict[str, str] = {
|
||||
"capability": "business-architecture/02-capability-map.csv",
|
||||
"module": "application-architecture/02-modules.csv",
|
||||
"entity": "data-architecture/01-entities.csv",
|
||||
"runtime_component": "technology-architecture/01-runtime-components.csv",
|
||||
}
|
||||
```
|
||||
|
||||
At the top of `build_panorama()`, add `design_dir` parameter with default so existing tests still work:
|
||||
```python
|
||||
def build_panorama(self, scan_result: ScanResult, design_dir: str = "") -> GraphView:
|
||||
# Build file path -> status mapping
|
||||
file_status_map: dict[str, str] = {
|
||||
fs.path: fs.status.value for fs in scan_result.file_statuses
|
||||
}
|
||||
```
|
||||
|
||||
Replace each `status="unknown"` with:
|
||||
```python
|
||||
status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"), # for caps
|
||||
status=file_status_map.get(_SOURCE_FILES.get("module", ""), "unknown"), # for modules
|
||||
status=file_status_map.get(_SOURCE_FILES.get("entity", ""), "unknown"), # for entities
|
||||
status=file_status_map.get(_SOURCE_FILES.get("runtime_component", ""), "unknown"), # for runtime
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v`
|
||||
Expected: All tests PASS including the two new status tests
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
|
||||
git commit -m "feat(graph): map node status from FileStatus via source_file (GAP-B1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Application — Document nodes + doc→doc edges (GAP-B3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/modules/graph/application/services.py`
|
||||
- Test: `backend/tests/test_graph_service.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `backend/tests/test_graph_service.py`:
|
||||
|
||||
```python
|
||||
def test_panorama_has_document_nodes(graph_service, scan_result, design_dir):
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
doc_nodes = [n for n in view.nodes if n.type == "document"]
|
||||
assert len(doc_nodes) > 0, "No document nodes found"
|
||||
assert all(n.group_id == "cross-layer" for n in doc_nodes)
|
||||
|
||||
|
||||
def test_panorama_document_edges(graph_service, scan_result, design_dir):
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
doc_edges = [e for e in view.edges if e.relation == "documents"]
|
||||
assert len(doc_edges) > 0, "No document edges found"
|
||||
|
||||
|
||||
def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir):
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
cap_nodes = [n for n in view.nodes if n.type == "capability"]
|
||||
# Capability nodes should have parent pointing to a document
|
||||
nodes_with_parent = [n for n in cap_nodes if n.parent is not None]
|
||||
assert len(nodes_with_parent) > 0, "No capability nodes have a parent document"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v`
|
||||
Expected: All 3 FAIL
|
||||
|
||||
- [ ] **Step 3: Implement document nodes and edge resolution**
|
||||
|
||||
Edit `backend/app/modules/graph/application/services.py`.
|
||||
|
||||
Add path helper functions before the class:
|
||||
|
||||
```python
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
|
||||
"""Convert absolute doc.file_path to design-dir-relative path."""
|
||||
try:
|
||||
return str(PurePosixPath(doc_file_path).relative_to(design_dir))
|
||||
except ValueError:
|
||||
return doc_file_path
|
||||
|
||||
|
||||
def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
|
||||
"""Resolve a relative upstream/downstream ref against the doc's directory."""
|
||||
doc_dir = str(PurePosixPath(doc_rel_path).parent)
|
||||
resolved = str(PurePosixPath(doc_dir) / ref_path)
|
||||
parts: list[str] = []
|
||||
for part in PurePosixPath(resolved).parts:
|
||||
if part == '..':
|
||||
if parts:
|
||||
parts.pop()
|
||||
else:
|
||||
parts.append(part)
|
||||
return str(PurePosixPath(*parts)) if parts else ""
|
||||
```
|
||||
|
||||
**Important ordering:** Build `file_to_doc` mapping BEFORE Steps 2-5 so entity nodes can get their `parent`. Restructure `build_panorama` to:
|
||||
|
||||
1. Build `file_status_map` (already done in Task 2)
|
||||
2. Build `file_to_doc` from `scan_result.design_documents` + create document nodes
|
||||
3. Then create entity nodes (Steps 2-5) with `parent` set via `file_to_doc`
|
||||
4. Then create edges (Steps 6-9) with fixed Step 9
|
||||
|
||||
Step 5.5 (now moved before Steps 2-5):
|
||||
```python
|
||||
# Build document node mapping first (needed for parent refs)
|
||||
file_to_doc: dict[str, str] = {}
|
||||
for doc in scan_result.design_documents:
|
||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
||||
file_to_doc[doc_rel] = doc.doc_id
|
||||
nodes.append(GraphNode(
|
||||
id=doc.doc_id,
|
||||
type="document",
|
||||
label=doc.title or doc.doc_id,
|
||||
status=file_status_map.get(doc_rel, "unknown"),
|
||||
group_id="cross-layer",
|
||||
))
|
||||
node_ids.add(doc.doc_id)
|
||||
```
|
||||
|
||||
In each entity creation (Steps 2-5), add parent:
|
||||
```python
|
||||
# e.g. for capability:
|
||||
parent_doc_id = file_to_doc.get(_SOURCE_FILES.get("capability"))
|
||||
nodes.append(GraphNode(
|
||||
id=node_id, type="capability", label=cap.name,
|
||||
status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"),
|
||||
group_id="business",
|
||||
parent=parent_doc_id,
|
||||
))
|
||||
```
|
||||
|
||||
Replace Step 9 with path resolution + deduplication:
|
||||
```python
|
||||
# Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated)
|
||||
path_to_doc: dict[str, str] = {}
|
||||
doc_rel_paths: dict[str, str] = {}
|
||||
for doc in scan_result.design_documents:
|
||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
||||
path_to_doc[doc_rel] = doc.doc_id
|
||||
doc_rel_paths[doc.doc_id] = doc_rel
|
||||
|
||||
seen_edges: set[tuple[str, str]] = set()
|
||||
for doc in scan_result.design_documents:
|
||||
doc_rel = doc_rel_paths[doc.doc_id]
|
||||
for down_path in doc.downstream:
|
||||
resolved = _resolve_ref_path(down_path, doc_rel)
|
||||
down_doc_id = path_to_doc.get(resolved)
|
||||
if down_doc_id and down_doc_id in node_ids:
|
||||
edge_key = (doc.doc_id, down_doc_id)
|
||||
if edge_key not in seen_edges:
|
||||
seen_edges.add(edge_key)
|
||||
edges.append(GraphEdge(
|
||||
source=doc.doc_id, target=down_doc_id,
|
||||
relation="documents",
|
||||
))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run new tests to verify they pass**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v`
|
||||
Expected: All 3 PASS
|
||||
|
||||
- [ ] **Step 5: Run ALL graph tests to check no regression**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py tests/test_api_graph.py -v`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
|
||||
git commit -m "feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Interfaces — Pass design_dir from router to build_panorama
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/modules/graph/interfaces/http/router.py:40-56`
|
||||
- Test: `backend/tests/test_api_graph.py`
|
||||
|
||||
- [ ] **Step 1: Update the router to pass design_dir**
|
||||
|
||||
Edit `backend/app/modules/graph/interfaces/http/router.py`:
|
||||
|
||||
In `get_graph()`:
|
||||
```python
|
||||
@router.get("")
|
||||
def get_graph(project_id: str):
|
||||
"""Build and return the full panorama graph for a project."""
|
||||
project = _project_service.get_project(project_id)
|
||||
scan_result = _get_or_trigger_scan(project_id)
|
||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
||||
return asdict(view)
|
||||
```
|
||||
|
||||
In `get_neighbors()`:
|
||||
```python
|
||||
@router.get("/nodes/{node_id}/neighbors")
|
||||
def get_neighbors(project_id: str, node_id: str):
|
||||
"""Return the subgraph of neighbors for a given node."""
|
||||
project = _project_service.get_project(project_id)
|
||||
scan_result = _get_or_trigger_scan(project_id)
|
||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
||||
neighbors = _graph_service.get_neighbors(view, node_id)
|
||||
return asdict(neighbors)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update test_graph_service.py to pass design_dir**
|
||||
|
||||
Add a `design_dir` fixture and update all `build_panorama` calls:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def design_dir():
|
||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
||||
```
|
||||
|
||||
Update all test function signatures to include `design_dir` parameter. Update all calls from:
|
||||
```python
|
||||
view = graph_service.build_panorama(scan_result)
|
||||
```
|
||||
to:
|
||||
```python
|
||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run ALL backend tests**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/modules/graph/interfaces/http/router.py backend/tests/test_graph_service.py
|
||||
git commit -m "feat(graph): pass design_dir from router to build_panorama"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend types — Add `parent` to GraphNode interface
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/shared/types/api.ts:31-37`
|
||||
|
||||
- [ ] **Step 1: Add parent field**
|
||||
|
||||
Edit `frontend/src/shared/types/api.ts`:
|
||||
|
||||
```typescript
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
status: string
|
||||
group_id: string
|
||||
parent: string | null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/shared/types/api.ts
|
||||
git commit -m "feat(graph): add parent field to GraphNode TypeScript interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — Group-partitioned layout + compound layout + toggle (GAP-F1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
||||
|
||||
This is the largest task. The entire `drawGraph()` function needs rewriting.
|
||||
|
||||
- [ ] **Step 1: Add state variables and update constants**
|
||||
|
||||
Add after existing refs in `<script setup>`:
|
||||
|
||||
```typescript
|
||||
const showDocumentView = ref(false)
|
||||
const isDrillDown = ref(false)
|
||||
const drillDownNodeLabel = ref('')
|
||||
|
||||
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
||||
business: { x: 0.50, y: 0.15 },
|
||||
application: { x: 0.50, y: 0.38 },
|
||||
data: { x: 0.30, y: 0.65 },
|
||||
technology: { x: 0.70, y: 0.65 },
|
||||
'cross-layer': { x: 0.50, y: 0.85 },
|
||||
}
|
||||
|
||||
const EDGE_COLORS: Record<string, string> = {
|
||||
traces_to: '#666',
|
||||
depends_on: '#999',
|
||||
documents: '#42A5F5',
|
||||
integrates_with: '#AB47BC',
|
||||
}
|
||||
```
|
||||
|
||||
Also update the existing `EDGE_STYLES` constant to fix `documents` from dashed to solid:
|
||||
```typescript
|
||||
const EDGE_STYLES: Record<string, string> = {
|
||||
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite drawGraph() for default mode**
|
||||
|
||||
Replace the `drawGraph()` function. Key changes:
|
||||
- Filter out `type === "document"` nodes and `relation === "documents"` edges when `showDocumentView.value` is false
|
||||
- Replace `d3.forceCenter` with `d3.forceX`/`d3.forceY` per group with strength 0.4
|
||||
- Correct node shapes: circle r=18 for capability, rect 28x28 for module, diamond 24x24 for entity, circle r=14 for runtime_component
|
||||
- Apply `STATUS_COLORS` for fill, `EDGE_STYLES` + `EDGE_COLORS` for edges
|
||||
|
||||
Core simulation setup:
|
||||
```typescript
|
||||
const simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink(simEdges).id((d: any) => d.id).distance(60))
|
||||
.force('charge', d3.forceManyBody().strength(-150))
|
||||
.force('x', d3.forceX((d: any) =>
|
||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
|
||||
).strength(0.4))
|
||||
.force('y', d3.forceY((d: any) =>
|
||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
|
||||
).strength(0.4))
|
||||
.force('collide', d3.forceCollide(30))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add compound layout mode (document view)**
|
||||
|
||||
When `showDocumentView.value` is true:
|
||||
- Include document nodes, render as large `<rect>` containers
|
||||
- Size by child count: `width = Math.max(150, childCount * 60)`, `height = Math.max(100, childCount * 40)`
|
||||
- On each tick, clamp entity nodes with `parent` inside parent's bounds (pad=20):
|
||||
```typescript
|
||||
if (n.parent) {
|
||||
const p = nodeMap[n.parent]
|
||||
const pad = 20
|
||||
n.x = Math.max(p.x - p.w/2 + pad, Math.min(p.x + p.w/2 - pad, n.x))
|
||||
n.y = Math.max(p.y - p.h/2 + pad, Math.min(p.y + p.h/2 - pad, n.y))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add toggle button and group labels in template**
|
||||
|
||||
Add after scan-summary div:
|
||||
```html
|
||||
<div class="toolbar">
|
||||
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
||||
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add toggle function:
|
||||
```typescript
|
||||
function toggleDocumentView() {
|
||||
showDocumentView.value = !showDocumentView.value
|
||||
drawGraph()
|
||||
}
|
||||
```
|
||||
|
||||
Render group labels at each group position as static text in the SVG.
|
||||
|
||||
- [ ] **Step 5: Run type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
||||
git commit -m "feat(graph): group-partitioned layout with document view toggle (GAP-F1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — Rich GraphDetail panel + edit button (GAP-F2 + GAP-F5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/modules/graph/components/GraphDetail.vue`
|
||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue` (update props)
|
||||
|
||||
- [ ] **Step 1: Rewrite GraphDetail.vue**
|
||||
|
||||
Full rewrite to:
|
||||
|
||||
1. Accept additional props: `graphView: GraphView | null`, `projectId: string`
|
||||
2. Fetch detail API on node selection based on `node.type`:
|
||||
- `capability` → `getCapabilityDetail(projectId, node.id)`
|
||||
- `module` → `getModuleDetail(projectId, node.id)`
|
||||
- `entity` → `getEntityDetail(projectId, node.id)`
|
||||
- others → show basic fields only
|
||||
3. Display attributes section (iterate over response fields dynamically)
|
||||
4. Display related entities section (from `graphView.edges`)
|
||||
5. Edit button (navigates to `/projects/${projectId}/editor?file=${sourceFile}`)
|
||||
|
||||
Key imports:
|
||||
```typescript
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
|
||||
import type { GraphNode, GraphView } from '@/shared/types/api'
|
||||
```
|
||||
|
||||
Define `SOURCE_FILES` constant:
|
||||
```typescript
|
||||
const SOURCE_FILES: Record<string, string> = {
|
||||
capability: 'business-architecture/02-capability-map.csv',
|
||||
module: 'application-architecture/02-modules.csv',
|
||||
entity: 'data-architecture/01-entities.csv',
|
||||
runtime_component: 'technology-architecture/01-runtime-components.csv',
|
||||
}
|
||||
```
|
||||
|
||||
Emit a `selectNode` event when a related entity is clicked. The handler looks up the full `GraphNode` from `graphView.nodes` by ID before emitting:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
||||
|
||||
function onRelatedEntityClick(nodeId: string) {
|
||||
const found = props.graphView?.nodes.find(n => n.id === nodeId)
|
||||
if (found) emit('selectNode', found)
|
||||
}
|
||||
```
|
||||
|
||||
Edit button click handler:
|
||||
```typescript
|
||||
const router = useRouter()
|
||||
|
||||
function openEditor() {
|
||||
if (!sourceFile.value) return
|
||||
router.push({
|
||||
path: `/projects/${props.projectId}/editor`,
|
||||
query: { file: sourceFile.value }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update GraphPanorama.vue to pass new props**
|
||||
|
||||
```html
|
||||
<GraphDetail
|
||||
:node="selectedNode"
|
||||
:graphView="graphView"
|
||||
:projectId="route.params.id as string"
|
||||
@close="clearSelection"
|
||||
@selectNode="store.selectNode"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/modules/graph/components/GraphDetail.vue frontend/src/modules/graph/components/GraphPanorama.vue
|
||||
git commit -m "feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Frontend — Back to panorama button (GAP-F4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
||||
|
||||
- [ ] **Step 1: Wire up drill-down state**
|
||||
|
||||
The `isDrillDown` and `drillDownNodeLabel` refs were added in Task 6. Now wire them:
|
||||
|
||||
Update double-click handler:
|
||||
```typescript
|
||||
node.on('dblclick', (_event: any, d: any) => {
|
||||
const projectId = route.params.id as string
|
||||
isDrillDown.value = true
|
||||
drillDownNodeLabel.value = d.label
|
||||
store.loadNeighbors(projectId, d.id)
|
||||
})
|
||||
```
|
||||
|
||||
Add `returnToPanorama`:
|
||||
```typescript
|
||||
async function returnToPanorama() {
|
||||
const projectId = route.params.id as string
|
||||
isDrillDown.value = false
|
||||
drillDownNodeLabel.value = ''
|
||||
await store.loadGraph(projectId)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add back button in template**
|
||||
|
||||
```html
|
||||
<div v-if="isDrillDown" class="drill-down-bar">
|
||||
<button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
|
||||
<span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add styles**
|
||||
|
||||
```css
|
||||
.drill-down-bar {
|
||||
position: absolute; top: 12px; left: 12px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: white; padding: 8px 16px; border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
||||
}
|
||||
.back-btn {
|
||||
background: #1976D2; color: white; border: none;
|
||||
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.back-btn:hover { background: #1565C0; }
|
||||
.drill-down-label { font-size: 13px; color: #666; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
||||
git commit -m "feat(graph): add back-to-panorama button for drill-down mode (GAP-F4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Frontend — Graph Legend component (GAP-F3)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/modules/graph/components/GraphLegend.vue`
|
||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
||||
|
||||
- [ ] **Step 1: Create GraphLegend.vue**
|
||||
|
||||
Create `frontend/src/modules/graph/components/GraphLegend.vue` with:
|
||||
- SVG shapes matching the graph (circle, rect, diamond, small-circle, container-rect)
|
||||
- Status color swatches with labels
|
||||
- Edge style samples with labels
|
||||
- Collapsible (expanded by default)
|
||||
- Positioned bottom-right, semi-transparent background
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="graph-legend" :class="{ collapsed: !expanded }">
|
||||
<div class="legend-header" @click="expanded = !expanded">
|
||||
<span>图例</span>
|
||||
<span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div v-if="expanded" class="legend-body">
|
||||
<div class="legend-section">
|
||||
<div class="legend-title">形状</div>
|
||||
<div class="legend-item">
|
||||
<svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
|
||||
<span>Capability</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
|
||||
<span>Module</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
|
||||
<span>Entity</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
|
||||
<span>Runtime</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
|
||||
<span>Document</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-section">
|
||||
<div class="legend-title">状态</div>
|
||||
<div class="legend-item" v-for="s in statuses" :key="s.label">
|
||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-section">
|
||||
<div class="legend-title">边线</div>
|
||||
<div class="legend-item" v-for="e in edgeTypes" :key="e.label">
|
||||
<svg width="40" height="12">
|
||||
<line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
|
||||
</svg>
|
||||
<span>{{ e.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const expanded = ref(true)
|
||||
|
||||
const statuses = [
|
||||
{ label: 'OK', color: '#4CAF50' },
|
||||
{ label: 'Sparse', color: '#FFC107' },
|
||||
{ label: 'Missing', color: '#F44336' },
|
||||
{ label: 'Template Residue', color: '#FF9800' },
|
||||
{ label: 'Placeholder Heavy', color: '#9C27B0' },
|
||||
{ label: 'Unknown', color: '#9E9E9E' },
|
||||
]
|
||||
|
||||
const edgeTypes = [
|
||||
{ label: 'traces_to', color: '#666', dash: '0' },
|
||||
{ label: 'depends_on', color: '#999', dash: '6,3' },
|
||||
{ label: 'documents', color: '#42A5F5', dash: '0' },
|
||||
{ label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-legend {
|
||||
position: absolute; bottom: 16px; right: 16px;
|
||||
background: rgba(255,255,255,0.92); border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
padding: 8px 12px; z-index: 10; min-width: 180px;
|
||||
font-size: 12px; pointer-events: auto;
|
||||
}
|
||||
.legend-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
|
||||
}
|
||||
.toggle { font-size: 10px; color: #999; }
|
||||
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||
.legend-section { display: flex; flex-direction: column; gap: 2px; }
|
||||
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||
.legend-item span { color: #333; }
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import GraphLegend in GraphPanorama.vue**
|
||||
|
||||
Add import:
|
||||
```typescript
|
||||
import GraphLegend from './GraphLegend.vue'
|
||||
```
|
||||
|
||||
Add to template (inside `.graph-panorama` div, after `<svg>`):
|
||||
```html
|
||||
<GraphLegend />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/modules/graph/components/GraphLegend.vue frontend/src/modules/graph/components/GraphPanorama.vue
|
||||
git commit -m "feat(graph): add collapsible legend component (GAP-F3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Final verification
|
||||
|
||||
- [ ] **Step 1: Run all backend tests**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 2: Run frontend type check**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Run frontend build**
|
||||
|
||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npm run build`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Verify git status is clean**
|
||||
|
||||
Run: `git status`
|
||||
Expected: No uncommitted changes
|
||||
Loading…
Reference in New Issue
Block a user