feat: 修复 7 个 Gap — 图分组布局、status 映射、详情面板丰富等 #2

Open
openclaw wants to merge 11 commits from feat/v2-fix-gaps into feat/full-implementation
Showing only changes of commit c97f201a9c - Show all commits

View File

@ -52,6 +52,8 @@ cross-layer 0.50 0.85
**Implementation:** **Implementation:**
**Note:** `build_panorama()` will need an additional `design_dir: str` parameter to convert `DesignDocument.file_path` (absolute) to design-dir-relative paths. This parameter is passed from the route handler which already has access to the project's design directory.
1. Build `file_status_map: dict[str, str]` from `scan_result.file_statuses`: 1. Build `file_status_map: dict[str, str]` from `scan_result.file_statuses`:
```python ```python
file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses} file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses}
@ -105,16 +107,45 @@ class GraphNode:
**Implementation in build_panorama():** **Implementation in build_panorama():**
**Path normalization note:** `DesignDocument.file_path` is an **absolute** path (e.g., `/home/user/project/design/business-architecture/01-scope-and-goals.md`), while `FileStatusEntry.path` is **relative** to the design directory (e.g., `business-architecture/01-scope-and-goals.md`). The `upstream`/`downstream` fields in frontmatter are relative paths (e.g., `../business-architecture/01-scope-and-goals.md`). All paths must be normalized to design-dir-relative format before comparison.
Helper function:
```python
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
"""Convert absolute doc.file_path to design-dir-relative path."""
from pathlib import PurePosixPath
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."""
from pathlib import PurePosixPath
doc_dir = str(PurePosixPath(doc_rel_path).parent)
resolved = str(PurePosixPath(doc_dir) / ref_path)
# Normalize away ../ segments
parts = []
for part in PurePosixPath(resolved).parts:
if part == '..':
if parts:
parts.pop()
else:
parts.append(part)
return str(PurePosixPath(*parts)) if parts else ""
```
Step 5.5 — Create document nodes: Step 5.5 — Create document nodes:
```python ```python
file_to_doc: dict[str, str] = {} file_to_doc: dict[str, str] = {}
for doc in scan_result.design_documents: for doc in scan_result.design_documents:
file_to_doc[doc.source_file] = doc.doc_id doc_rel = _to_rel_path(doc.file_path, design_dir)
file_to_doc[doc_rel] = doc.doc_id
nodes.append(GraphNode( nodes.append(GraphNode(
id=doc.doc_id, id=doc.doc_id,
type="document", type="document",
label=doc.title or doc.doc_id, label=doc.title or doc.doc_id,
status=file_status_map.get(doc.source_file, "unknown"), status=file_status_map.get(doc_rel, "unknown"),
group_id="cross-layer", group_id="cross-layer",
)) ))
node_ids.add(doc.doc_id) node_ids.add(doc.doc_id)
@ -126,20 +157,27 @@ parent_doc_id = file_to_doc.get(_SOURCE_FILES.get(node_type))
node = GraphNode(..., parent=parent_doc_id) node = GraphNode(..., parent=parent_doc_id)
``` ```
Step 9 — Fix doc→doc edges using file path mapping: Step 9 — Fix doc→doc edges using file path mapping (with deduplication):
```python ```python
path_to_doc = {doc.source_file: doc.doc_id for doc in scan_result.design_documents} path_to_doc = {}
doc_rel_paths = {}
for doc in scan_result.design_documents: for doc in scan_result.design_documents:
for up_path in doc.upstream: doc_rel = _to_rel_path(doc.file_path, design_dir)
normalized = up_path.lstrip("./") path_to_doc[doc_rel] = doc.doc_id
up_doc_id = path_to_doc.get(normalized) doc_rel_paths[doc.doc_id] = doc_rel
if up_doc_id and up_doc_id in node_ids:
edges.append(GraphEdge(source=up_doc_id, target=doc.doc_id, relation="documents")) 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: for down_path in doc.downstream:
normalized = down_path.lstrip("./") resolved = _resolve_ref_path(down_path, doc_rel)
down_doc_id = path_to_doc.get(normalized) down_doc_id = path_to_doc.get(resolved)
if down_doc_id and down_doc_id in node_ids: if down_doc_id and down_doc_id in node_ids:
edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents")) 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"))
# Only process downstream to avoid duplicates (A.downstream=B ↔ B.upstream=A)
``` ```
**Acceptance criteria:** **Acceptance criteria:**
@ -174,8 +212,8 @@ for doc in scan_result.design_documents:
} }
simulation simulation
.force('x', d3.forceX(d => groupPositions[d.group_id].x * width).strength(0.4)) .force('x', d3.forceX(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).x * width).strength(0.4))
.force('y', d3.forceY(d => groupPositions[d.group_id].y * height).strength(0.4)) .force('y', d3.forceY(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).y * height).strength(0.4))
.force('collide', d3.forceCollide(30)) .force('collide', d3.forceCollide(30))
.force('link', d3.forceLink(edges).id(d => d.id).distance(60)) .force('link', d3.forceLink(edges).id(d => d.id).distance(60))
``` ```
@ -269,6 +307,8 @@ Edge styles:
| document | no API call, use node properties | — | | document | no API call, use node properties | — |
| runtime_component | no API call, use node properties | — | | runtime_component | no API call, use node properties | — |
**Note on non-API types:** For `document` and `runtime_component` nodes that have no detail API, the panel displays the basic `GraphNode` fields (`id`, `type`, `label`, `status`, `group_id`, `parent`). This is acceptable — these types have limited attributes. A detail API for these types can be added later if needed.
**Panel layout:** **Panel layout:**
``` ```
┌─ GraphDetail ──────────────────────────┐ ┌─ GraphDetail ──────────────────────────┐
@ -411,6 +451,7 @@ Edge styles:
| `frontend/src/modules/graph/components/GraphPanorama.vue` | Modify | F1, F4 | | `frontend/src/modules/graph/components/GraphPanorama.vue` | Modify | F1, F4 |
| `frontend/src/modules/graph/components/GraphDetail.vue` | Modify | F2, F5 | | `frontend/src/modules/graph/components/GraphDetail.vue` | Modify | F2, F5 |
| `frontend/src/modules/graph/components/GraphLegend.vue` | New | F3 | | `frontend/src/modules/graph/components/GraphLegend.vue` | New | F3 |
| `frontend/src/shared/types/api.ts` | Modify | B3 (add `parent` field to `GraphNode` interface) |
## 5. Out of Scope ## 5. Out of Scope