Compare commits
10 Commits
e523d5b31c
...
144ec8cc80
| Author | SHA1 | Date | |
|---|---|---|---|
| 144ec8cc80 | |||
| d4b26a5971 | |||
| f12c45f692 | |||
| 75e053c454 | |||
| ce4f474472 | |||
| b7ebbcd777 | |||
| d8071bc9f3 | |||
| 7328057e7d | |||
| c18f7c5f76 | |||
| b77bae709b |
6
.gitignore
vendored
|
|
@ -2,3 +2,9 @@ __pycache__/
|
|||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
!src/**/*.d.ts
|
||||
frontend/vite.config.js
|
||||
|
|
|
|||
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM localhost:8082/python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN pip install --index-url http://localhost:8081/repository/pypi-group/simple --trusted-host localhost uv && \
|
||||
uv sync --frozen --no-dev --index-url http://localhost:8081/repository/pypi-group/simple
|
||||
COPY app/ app/
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8900"]
|
||||
|
|
@ -15,6 +15,8 @@ from app.modules.graph.application.services import GraphService
|
|||
from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router
|
||||
from app.modules.editor.application.services import EditorService
|
||||
from app.modules.editor.interfaces.http.router import router as editor_router, init_router as init_editor_router
|
||||
from app.modules.impl_tracker.application.services import ImplTrackerService
|
||||
from app.modules.impl_tracker.interfaces.http.router import router as impl_tracker_router, init_router as init_impl_tracker_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
|
@ -40,11 +42,16 @@ def create_app() -> FastAPI:
|
|||
editor_service = EditorService(scan_service)
|
||||
init_editor_router(project_service, scan_service, editor_service)
|
||||
|
||||
# Wire Impl-tracker module
|
||||
impl_tracker_service = ImplTrackerService()
|
||||
init_impl_tracker_router(project_service, scan_service, impl_tracker_service)
|
||||
|
||||
# Register routers
|
||||
app.include_router(project_router, prefix="/api")
|
||||
app.include_router(scanner_router, prefix="/api")
|
||||
app.include_router(graph_router, prefix="/api")
|
||||
app.include_router(editor_router, prefix="/api")
|
||||
app.include_router(impl_tracker_router, prefix="/api")
|
||||
|
||||
# Health check
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from app.modules.impl_tracker.domain.entities import ImplProgress
|
||||
from app.modules.impl_tracker.infrastructure.code_scanner import scan_code_directory
|
||||
from app.modules.project.domain.entities import Project
|
||||
from app.modules.scanner.domain.entities import ScanResult
|
||||
|
||||
|
||||
class ImplTrackerService:
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, list[ImplProgress]] = {}
|
||||
self._manual_overrides: dict[str, dict[str, float]] = {} # project_id -> {module_id: percentage}
|
||||
|
||||
def evaluate(self, project: Project, scan_result: ScanResult) -> list[ImplProgress]:
|
||||
progress_list: list[ImplProgress] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not project.code_dir:
|
||||
# No code dir -> all modules at 0%
|
||||
for mod in scan_result.modules:
|
||||
progress_list.append(ImplProgress(
|
||||
module_id=mod.module_id, percentage=0.0, source="auto", evaluated_at=now,
|
||||
))
|
||||
else:
|
||||
code_structure = scan_code_directory(project.code_dir, scan_result)
|
||||
for mod in scan_result.modules:
|
||||
if mod.module_id in code_structure.matched_modules:
|
||||
percentage = 50.0 # Basic: module directory exists
|
||||
else:
|
||||
percentage = 0.0
|
||||
progress_list.append(ImplProgress(
|
||||
module_id=mod.module_id, percentage=percentage, source="auto", evaluated_at=now,
|
||||
))
|
||||
|
||||
# Apply manual overrides
|
||||
overrides = self._manual_overrides.get(project.id, {})
|
||||
for p in progress_list:
|
||||
if p.module_id in overrides:
|
||||
p.percentage = overrides[p.module_id]
|
||||
p.source = "manual"
|
||||
|
||||
self._cache[project.id] = progress_list
|
||||
return progress_list
|
||||
|
||||
def get_progress(self, project_id: str) -> list[ImplProgress] | None:
|
||||
return self._cache.get(project_id)
|
||||
|
||||
def set_manual_progress(self, project_id: str, module_id: str, percentage: float) -> None:
|
||||
if project_id not in self._manual_overrides:
|
||||
self._manual_overrides[project_id] = {}
|
||||
self._manual_overrides[project_id][module_id] = percentage
|
||||
# Update cache if exists
|
||||
if project_id in self._cache:
|
||||
for p in self._cache[project_id]:
|
||||
if p.module_id == module_id:
|
||||
p.percentage = percentage
|
||||
p.source = "manual"
|
||||
p.evaluated_at = datetime.now(timezone.utc)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"""Impl-tracker HTTP router — progress evaluation and manual override endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.modules.impl_tracker.application.services import ImplTrackerService
|
||||
from app.modules.impl_tracker.domain.entities import ImplProgress
|
||||
from app.modules.project.application.services import ProjectService
|
||||
from app.modules.scanner.application.services import ScanService
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/impl-progress", tags=["impl-tracker"])
|
||||
|
||||
_project_service: ProjectService | None = None
|
||||
_scan_service: ScanService | None = None
|
||||
_impl_tracker_service: ImplTrackerService | None = None
|
||||
|
||||
|
||||
def init_router(
|
||||
project_service: ProjectService,
|
||||
scan_service: ScanService,
|
||||
impl_tracker_service: ImplTrackerService,
|
||||
) -> None:
|
||||
global _project_service, _scan_service, _impl_tracker_service
|
||||
_project_service = project_service
|
||||
_scan_service = scan_service
|
||||
_impl_tracker_service = impl_tracker_service
|
||||
|
||||
|
||||
class ManualProgressRequest(BaseModel):
|
||||
percentage: float
|
||||
|
||||
|
||||
def _progress_to_dict(p) -> dict:
|
||||
d = asdict(p)
|
||||
d["evaluated_at"] = p.evaluated_at.isoformat()
|
||||
return d
|
||||
|
||||
|
||||
@router.post("")
|
||||
def evaluate_progress(project_id: str):
|
||||
"""Evaluate implementation progress for all modules."""
|
||||
project = _project_service.get_project(project_id)
|
||||
scan_result = _scan_service.get_latest_scan(project_id)
|
||||
if scan_result is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "No scan available. Run POST /scan first."},
|
||||
)
|
||||
progress = _impl_tracker_service.evaluate(project, scan_result)
|
||||
return [_progress_to_dict(p) for p in progress]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_progress(project_id: str):
|
||||
"""Get cached implementation progress."""
|
||||
_project_service.get_project(project_id) # Ensure project exists (raises 404)
|
||||
progress = _impl_tracker_service.get_progress(project_id)
|
||||
if progress is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "No progress evaluated yet. Run POST /impl-progress first."},
|
||||
)
|
||||
return [_progress_to_dict(p) for p in progress]
|
||||
|
||||
|
||||
@router.put("/{module_id}")
|
||||
def set_manual_progress(project_id: str, module_id: str, body: ManualProgressRequest):
|
||||
"""Set manual progress override for a module."""
|
||||
_project_service.get_project(project_id) # Ensure project exists (raises 404)
|
||||
_impl_tracker_service.set_manual_progress(project_id, module_id, body.percentage)
|
||||
|
||||
# Return the updated progress entry
|
||||
progress = _impl_tracker_service.get_progress(project_id)
|
||||
if progress:
|
||||
for p in progress:
|
||||
if p.module_id == module_id:
|
||||
return _progress_to_dict(p)
|
||||
|
||||
# If no cached progress, return a constructed response
|
||||
return _progress_to_dict(
|
||||
ImplProgress(
|
||||
module_id=module_id,
|
||||
percentage=body.percentage,
|
||||
source="manual",
|
||||
evaluated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
46
backend/tests/test_api_impl_tracker.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import pytest
|
||||
|
||||
DESIGN_DIR = "/workspace/arch-design-agent-skill-dashboard/design"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_id(client):
|
||||
r = client.post("/api/projects", json={"name": "test", "design_dir": DESIGN_DIR})
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_evaluate_progress(client, project_id):
|
||||
# Need to scan first
|
||||
client.post(f"/api/projects/{project_id}/scan")
|
||||
r = client.post(f"/api/projects/{project_id}/impl-progress")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) > 0
|
||||
assert "module_id" in data[0]
|
||||
assert "percentage" in data[0]
|
||||
|
||||
|
||||
def test_get_progress_not_evaluated(client, project_id):
|
||||
r = client.get(f"/api/projects/{project_id}/impl-progress")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_get_progress_after_evaluate(client, project_id):
|
||||
client.post(f"/api/projects/{project_id}/scan")
|
||||
client.post(f"/api/projects/{project_id}/impl-progress")
|
||||
r = client.get(f"/api/projects/{project_id}/impl-progress")
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json(), list)
|
||||
|
||||
|
||||
def test_set_manual_progress(client, project_id):
|
||||
client.post(f"/api/projects/{project_id}/scan")
|
||||
client.post(f"/api/projects/{project_id}/impl-progress")
|
||||
r = client.put(
|
||||
f"/api/projects/{project_id}/impl-progress/MOD-PROJECT",
|
||||
json={"percentage": 80.0},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["percentage"] == 80.0
|
||||
assert r.json()["source"] == "manual"
|
||||
59
backend/tests/test_impl_tracker.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from app.modules.impl_tracker.application.services import ImplTrackerService
|
||||
from app.modules.project.domain.entities import Project
|
||||
from app.modules.scanner.application.services import ScanService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def impl_service():
|
||||
return ImplTrackerService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_result():
|
||||
svc = ScanService()
|
||||
project = Project(
|
||||
id="test", name="test",
|
||||
design_dir="/workspace/arch-design-agent-skill-dashboard/design",
|
||||
code_dir=None, created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
return svc.scan(project)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project():
|
||||
return Project(
|
||||
id="test", name="test",
|
||||
design_dir="/workspace/arch-design-agent-skill-dashboard/design",
|
||||
code_dir=None, created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def test_evaluate_no_code_dir(impl_service, test_project, scan_result):
|
||||
progress = impl_service.evaluate(test_project, scan_result)
|
||||
assert len(progress) > 0
|
||||
assert all(p.percentage == 0.0 for p in progress)
|
||||
assert all(p.source == "auto" for p in progress)
|
||||
|
||||
|
||||
def test_get_progress_before_evaluate(impl_service):
|
||||
assert impl_service.get_progress("nonexistent") is None
|
||||
|
||||
|
||||
def test_get_progress_after_evaluate(impl_service, test_project, scan_result):
|
||||
impl_service.evaluate(test_project, scan_result)
|
||||
cached = impl_service.get_progress("test")
|
||||
assert cached is not None
|
||||
assert len(cached) > 0
|
||||
|
||||
|
||||
def test_set_manual_progress(impl_service, test_project, scan_result):
|
||||
impl_service.evaluate(test_project, scan_result)
|
||||
impl_service.set_manual_progress("test", "MOD-PROJECT", 75.0)
|
||||
cached = impl_service.get_progress("test")
|
||||
mod_progress = [p for p in cached if p.module_id == "MOD-PROJECT"]
|
||||
assert len(mod_progress) == 1
|
||||
assert mod_progress[0].percentage == 75.0
|
||||
assert mod_progress[0].source == "manual"
|
||||
21
docker-compose.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8900:8900"
|
||||
volumes:
|
||||
- ${DESIGN_DIR:-.}:/data/design:rw
|
||||
- ${CODE_DIR:-/dev/null}:/data/code:ro
|
||||
- registry-data:/data/registry
|
||||
environment:
|
||||
- REGISTRY_PATH=/data/registry/projects.json
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "8899:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
registry-data:
|
||||
BIN
features/full-implementation/test-images/01-homepage.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
features/full-implementation/test-images/02-project-list.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 135 KiB |
BIN
features/full-implementation/test-images/04-graph-drilldown.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
features/full-implementation/test-images/05-editor-page.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
features/full-implementation/test-images/06-csv-editor.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
features/full-implementation/test-images/07-md-editor.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
32
features/full-implementation/works/progress.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# arch-design-agent-skill-dashboard — full-implementation 进度
|
||||
|
||||
## 步骤
|
||||
|
||||
1. ✅ Pre-flight: 确认容器运行、项目可见
|
||||
2. ✅ 项目移入 develops/ 目录
|
||||
3. ✅ 创建 feature branch: feat/full-implementation
|
||||
4. ✅ 启动桥接 (bridge-supervisor): run 20260323-134055-524432
|
||||
5. ✅ CC 开发完成(桥接交互式)
|
||||
6. ✅ CC 完成,进入后续流程
|
||||
7. ✅ QA 测试(宿主机 agent-browser)
|
||||
8. ✅ 工作记录
|
||||
9. ⏳ 提交 PR
|
||||
10. 🔲 更新项目索引 (projects.md)
|
||||
11. 🔲 回复报告
|
||||
|
||||
## 桥接信息
|
||||
- Run ID: 20260323-134055-524432
|
||||
- Session: arch-dashboard-full-impl
|
||||
- CC Talk Room: p94wpqsv
|
||||
- Trigger Room: sj2fua7t
|
||||
|
||||
## 测试结果摘要
|
||||
- 前端地址: http://192.168.0.150:8899
|
||||
- 后端地址: http://192.168.0.150:8900
|
||||
- 全景关系图: ✅(63 nodes / 94 edges)
|
||||
- 图节点下钻: ✅
|
||||
- CSV 编辑器: ✅
|
||||
- Markdown 编辑器: ✅
|
||||
- 设计扫描: ✅
|
||||
- 实现进度评估: ✅
|
||||
- Health API: ✅
|
||||
67
features/full-implementation/works/summary.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# arch-design-agent-skill-dashboard — full-implementation 开发记录
|
||||
|
||||
## 基本信息
|
||||
- 项目: arch-design-agent-skill-dashboard
|
||||
- 功能: full-implementation(全功能实现)
|
||||
- 日期: 2026-03-23
|
||||
- 状态: ✅ 完成
|
||||
|
||||
## 需求描述
|
||||
根据项目中 design/ 目录下的架构设计文档(业务架构、应用架构、数据架构、技术架构、领域设计等),使用 Superpowers 结构化开发流程,实现 dashboard 的所有功能。项目框架和目录结构已搭建好,需补全所有模块的实际业务逻辑代码。
|
||||
|
||||
## 技术方案
|
||||
- **后端**: Python 3.12 + FastAPI + uvicorn + Pydantic,模块化 DDD 架构
|
||||
- **前端**: Vue 3 + TypeScript + Vite + D3.js(图谱可视化)
|
||||
- **部署**: Docker Compose(backend 8900 / frontend 8899)
|
||||
- **数据**: JSON 文件持久化项目注册表,设计文件直接读写
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 后端模块 (30+ commits)
|
||||
- **project**: CRUD + JSON 文件持久化
|
||||
- **scanner**: CSV/MD/YAML/OpenAPI 四种解析器,支持 20+ CSV 文件类型映射为 Design 实体
|
||||
- **editor**: 文件读写 + 影响分析(通过 frontmatter upstream/downstream 关系)
|
||||
- **graph**: 全景关系图构建 + 邻居查询(63 nodes, 94 edges)
|
||||
- **impl_tracker**: 实现进度评估 + 手动覆盖(9 modules)
|
||||
- **design**: 31 个设计实体 dataclass + FileStatus 检测 + 验证规则
|
||||
- **shared**: 配置管理 + 文件系统工具
|
||||
|
||||
### 前端模块
|
||||
- **project**: 项目列表 + 创建/删除
|
||||
- **graph**: D3.js 力导向图全景 + 节点下钻详情面板(右侧抽屉)
|
||||
- **editor**: CSV 表格编辑器(添加行/删除行/保存)+ Markdown 双栏编辑器(源码 + 实时预览)
|
||||
- **router**: 3 个路由(首页/项目图谱/编辑器)
|
||||
|
||||
### 部署
|
||||
- Docker Compose: backend (8900) + frontend via nginx (8899)
|
||||
- DESIGN_DIR 挂载项目根目录 → 容器内 /data/design
|
||||
- Dockerfile 使用 Nexus 代理(PyPI + npm)
|
||||
|
||||
## 测试结果
|
||||
| 序号 | 功能 | 结果 | 截图 |
|
||||
|------|------|------|------|
|
||||
| 1 | 首页空状态 | ✅ | test-images/01-homepage.png |
|
||||
| 2 | 项目列表 | ✅ | test-images/02-project-list.png |
|
||||
| 3 | 全景关系图(63 nodes, 94 edges) | ✅ | test-images/03-project-overview-graph.png |
|
||||
| 4 | 图节点下钻详情 | ✅ | test-images/04-graph-drilldown.png |
|
||||
| 5 | 编辑器页面 | ✅ | test-images/05-editor-page.png |
|
||||
| 6 | CSV 表格编辑器 | ✅ | test-images/06-csv-editor.png |
|
||||
| 7 | Markdown 双栏编辑器 | ✅ | test-images/07-md-editor.png |
|
||||
| 8 | API /health | ✅ | (API response: {"status": "ok"}) |
|
||||
| 9 | 设计扫描 (POST /scan) | ✅ | (94 files scanned) |
|
||||
| 10 | 实体查询 (capabilities) | ✅ | (10 capabilities) |
|
||||
| 11 | 实体查询 (modules) | ✅ | (9 modules) |
|
||||
| 12 | 影响分析 | ✅ | (API 200 OK) |
|
||||
| 13 | 实现进度评估 | ✅ | (9 modules evaluated) |
|
||||
|
||||
## API 端点 (23 个)
|
||||
- Projects CRUD, Scan, Entities (capabilities/modules/entities/integrations/value-flows/user-journeys/data-flows/external-systems/traceability-links/runtime-components + detail), Graph (panorama/neighbors), Editor (read/write + impact), Impl Progress (evaluate/override), Health
|
||||
|
||||
## 遇到的问题及解决方案
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| 宿主机 8900 端口被旧 uvicorn 占用 | kill 旧进程 |
|
||||
| .git 权限不匹配(vagrant vs dev:1001) | chown 修复 |
|
||||
| host.docker.internal 在 Linux 不可用 | 改用 --network host 构建 + localhost |
|
||||
| nginx:alpine 不在 Nexus 代理中 | dpull 通过代理下载后 docker tag |
|
||||
| Playwright input 选择器不匹配 | 检查 Vue 模板,使用正确的 `input` 选择器 |
|
||||
14
features/works.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# arch-design-agent-skill-dashboard 工作记录
|
||||
|
||||
## full-implementation
|
||||
- **日期**: 2026-03-23
|
||||
- **状态**: ✅ 完成
|
||||
- **分支**: feat/full-implementation
|
||||
- **摘要**:
|
||||
- 基于 design/ 架构文档完成前后端全功能实现
|
||||
- 后端实现 project / scanner / editor / graph / impl_tracker 五大模块 API
|
||||
- 前端实现项目管理、D3 全景关系图、图节点下钻、CSV 编辑器、Markdown 编辑器
|
||||
- 增加 Docker Compose 部署能力,服务地址:前端 `http://192.168.0.150:8899`,后端 `http://192.168.0.150:8900`
|
||||
- QA 测试通过:全景图(63 nodes / 94 edges)、扫描、编辑、进度评估、health API 全部正常
|
||||
- **详细记录**: `features/full-implementation/works/summary.md`
|
||||
- **测试截图**: `features/full-implementation/test-images/`
|
||||
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM localhost:8082/node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm --registry http://localhost:8081/repository/npm-group/ ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM localhost:8082/nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arch Design Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8900;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
2620
frontend/package-lock.json
generated
Normal file
26
frontend/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "arch-design-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"d3": "^7.9.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<h2>Arch Design Dashboard</h2>
|
||||
<ProjectSidebar />
|
||||
</aside>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProjectSidebar from '@/modules/project/components/ProjectSidebar.vue'
|
||||
</script>
|
||||
5
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import api from '@/shared/api'
|
||||
import type { EditableFile, ImpactResult, ScanResult } from '@/shared/types/api'
|
||||
|
||||
export async function getFile(projectId: string, path: string): Promise<EditableFile> {
|
||||
const { data } = await api.get<EditableFile>(`/projects/${projectId}/files/${path}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveFile(projectId: string, path: string, content: string): Promise<ScanResult> {
|
||||
const { data } = await api.put<ScanResult>(`/projects/${projectId}/files/${path}`, content, {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getFileImpact(projectId: string, path: string): Promise<ImpactResult> {
|
||||
const { data } = await api.get<ImpactResult>(`/projects/${projectId}/files/${path}/impact`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="csv-editor">
|
||||
<div class="toolbar">
|
||||
<button class="primary" @click="addRow">添加行</button>
|
||||
<button class="primary" @click="$emit('save', serialize())">保存</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(h, i) in headers" :key="i">{{ h }}</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in rows" :key="ri">
|
||||
<td v-for="(cell, ci) in row" :key="ci" contenteditable @blur="updateCell(ri, ci, $event)">{{ cell }}</td>
|
||||
<td><button class="danger" @click="removeRow(ri)">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ content: string }>()
|
||||
defineEmits<{ save: [content: string] }>()
|
||||
|
||||
const headers = ref<string[]>([])
|
||||
const rows = ref<string[][]>([])
|
||||
|
||||
onMounted(() => {
|
||||
const lines = props.content.trim().split('\n')
|
||||
if (lines.length > 0) {
|
||||
headers.value = lines[0].split(',')
|
||||
rows.value = lines.slice(1).map(l => l.split(','))
|
||||
}
|
||||
})
|
||||
|
||||
function updateCell(ri: number, ci: number, event: Event) {
|
||||
rows.value[ri][ci] = (event.target as HTMLElement).textContent || ''
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
rows.value.push(headers.value.map(() => ''))
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function serialize(): string {
|
||||
return [headers.value.join(','), ...rows.value.map(r => r.join(','))].join('\n') + '\n'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { border: 1px solid #e0e0e0; padding: 6px 8px; text-align: left; }
|
||||
th { background: #f5f5f5; font-weight: 600; }
|
||||
td[contenteditable] { cursor: text; }
|
||||
td[contenteditable]:focus { outline: 2px solid #1976D2; }
|
||||
</style>
|
||||
47
frontend/src/modules/editor/components/EditorPage.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="editor-page">
|
||||
<h1>文件编辑器</h1>
|
||||
<div v-if="!currentFile" class="empty">
|
||||
<p>选择一个文件开始编辑</p>
|
||||
<input v-model="filePath" placeholder="输入相对路径,如 business-architecture/02-capability-map.csv" />
|
||||
<button class="primary" @click="load">打开</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="meta">{{ currentFile.path }} ({{ currentFile.format }})</p>
|
||||
<CsvEditor v-if="currentFile.format === 'csv'" :content="currentFile.content" @save="handleSave" />
|
||||
<MdEditor v-else :initial-content="currentFile.content" @save="handleSave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useEditorStore } from '../composables/useEditor'
|
||||
import CsvEditor from './CsvEditor.vue'
|
||||
import MdEditor from './MdEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useEditorStore()
|
||||
const { currentFile } = storeToRefs(store)
|
||||
|
||||
const filePath = ref('')
|
||||
|
||||
function load() {
|
||||
if (filePath.value) {
|
||||
store.loadFile(route.params.id as string, filePath.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave(content: string) {
|
||||
store.saveFile(route.params.id as string, currentFile.value!.path, content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin-bottom: 16px; }
|
||||
.meta { font-size: 13px; color: #666; margin-bottom: 12px; }
|
||||
.empty { text-align: center; padding: 48px; }
|
||||
.empty input { margin: 12px 0; max-width: 500px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div class="md-editor">
|
||||
<div class="toolbar">
|
||||
<button class="primary" @click="$emit('save', content)">保存</button>
|
||||
</div>
|
||||
<div class="editor-panes">
|
||||
<textarea v-model="content" class="editor-input"></textarea>
|
||||
<div class="editor-preview" v-html="preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
defineEmits<{ save: [content: string] }>()
|
||||
|
||||
const content = ref(props.initialContent)
|
||||
|
||||
const preview = computed(() => {
|
||||
return content.value
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br>')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { margin-bottom: 12px; }
|
||||
.editor-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: calc(100vh - 200px); }
|
||||
.editor-input { font-family: monospace; font-size: 14px; resize: none; padding: 12px; border: 1px solid #e0e0e0; border-radius: 4px; }
|
||||
.editor-preview { padding: 12px; border: 1px solid #e0e0e0; border-radius: 4px; overflow-y: auto; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { EditableFile, ImpactResult } from '@/shared/types/api'
|
||||
import * as editorApi from '../api'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const currentFile = ref<EditableFile | null>(null)
|
||||
const impactResult = ref<ImpactResult | null>(null)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadFile(projectId: string, path: string) {
|
||||
try {
|
||||
currentFile.value = await editorApi.getFile(projectId, path)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(projectId: string, path: string, content: string) {
|
||||
saving.value = true
|
||||
try {
|
||||
await editorApi.saveFile(projectId, path, content)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeImpact(projectId: string, path: string) {
|
||||
try {
|
||||
impactResult.value = await editorApi.getFileImpact(projectId, path)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
return { currentFile, impactResult, saving, error, loadFile, saveFile, analyzeImpact }
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type { EditableFile, ImpactResult, ImplProgress } from '@/shared/types/api'
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import api from '@/shared/api'
|
||||
import type { ScanResult, GraphView, Capability, Module as DesignModule, Entity, Integration, ValueFlow, UserJourney, DataFlow, ExternalSystem, TraceabilityLink, RuntimeComponent, CapabilityDetail, ModuleDetail, EntityDetail, ImplProgress } from '@/shared/types/api'
|
||||
|
||||
export async function triggerScan(projectId: string): Promise<ScanResult> {
|
||||
const { data } = await api.post<ScanResult>(`/projects/${projectId}/scan`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getLatestScan(projectId: string): Promise<ScanResult> {
|
||||
const { data } = await api.get<ScanResult>(`/projects/${projectId}/scan`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getGraph(projectId: string): Promise<GraphView> {
|
||||
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getNodeNeighbors(projectId: string, nodeId: string): Promise<GraphView> {
|
||||
const { data } = await api.get<GraphView>(`/projects/${projectId}/graph/nodes/${nodeId}/neighbors`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listCapabilities(projectId: string): Promise<Capability[]> {
|
||||
const { data } = await api.get<Capability[]>(`/projects/${projectId}/entities/capabilities`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listModules(projectId: string): Promise<DesignModule[]> {
|
||||
const { data } = await api.get<DesignModule[]>(`/projects/${projectId}/entities/modules`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listEntities(projectId: string): Promise<Entity[]> {
|
||||
const { data } = await api.get<Entity[]>(`/projects/${projectId}/entities/entities`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCapabilityDetail(projectId: string, capId: string): Promise<CapabilityDetail> {
|
||||
const { data } = await api.get<CapabilityDetail>(`/projects/${projectId}/entities/capabilities/${capId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getModuleDetail(projectId: string, modId: string): Promise<ModuleDetail> {
|
||||
const { data } = await api.get<ModuleDetail>(`/projects/${projectId}/entities/modules/${modId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getEntityDetail(projectId: string, entId: string): Promise<EntityDetail> {
|
||||
const { data } = await api.get<EntityDetail>(`/projects/${projectId}/entities/entities/${entId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function evaluateImplProgress(projectId: string): Promise<ImplProgress[]> {
|
||||
const { data } = await api.post<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getImplProgress(projectId: string): Promise<ImplProgress[]> {
|
||||
const { data } = await api.get<ImplProgress[]>(`/projects/${projectId}/impl-progress`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="graph-detail" v-if="node">
|
||||
<div class="detail-header">
|
||||
<h3>{{ node.label }}</h3>
|
||||
<button @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="field"><span class="label">ID:</span> {{ node.id }}</div>
|
||||
<div class="field"><span class="label">类型:</span> {{ node.type }}</div>
|
||||
<div class="field"><span class="label">状态:</span> <span :style="{ color: statusColor }">{{ node.status }}</span></div>
|
||||
<div class="field"><span class="label">分组:</span> {{ node.group_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GraphNode } from '@/shared/types/api'
|
||||
|
||||
const props = defineProps<{ node: GraphNode | null }>()
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||
}
|
||||
|
||||
const statusColor = computed(() => STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-detail {
|
||||
position: fixed;
|
||||
right: 0; top: 0; bottom: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.detail-header h3 { font-size: 16px; }
|
||||
.detail-header button { background: none; font-size: 18px; color: #666; }
|
||||
.field { margin-bottom: 8px; font-size: 14px; }
|
||||
.label { font-weight: 600; color: #666; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div class="graph-panorama">
|
||||
<div v-if="loading" class="loading-overlay">扫描中...</div>
|
||||
|
||||
<div v-if="scanResult" class="scan-summary">
|
||||
<div class="summary-item">文件 <strong>{{ scanResult.summary.total_files }}</strong></div>
|
||||
<div class="summary-item" style="color: var(--color-ok)">OK <strong>{{ scanResult.summary.ok }}</strong></div>
|
||||
<div class="summary-item" style="color: var(--color-sparse)">Sparse <strong>{{ scanResult.summary.sparse }}</strong></div>
|
||||
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
||||
</div>
|
||||
|
||||
<svg ref="svgRef" class="graph-svg"></svg>
|
||||
|
||||
<GraphDetail :node="selectedNode" @close="clearSelection" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import * as d3 from 'd3'
|
||||
import { useGraphStore } from '../composables/useGraph'
|
||||
import GraphDetail from './GraphDetail.vue'
|
||||
import type { GraphNode, GraphEdge } from '@/shared/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useGraphStore()
|
||||
const { graphView, selectedNode, scanResult, loading } = storeToRefs(store)
|
||||
const { clearSelection } = store
|
||||
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||
}
|
||||
|
||||
const EDGE_STYLES: Record<string, string> = {
|
||||
traces_to: '0', depends_on: '6,3', owns: '0', integrates_with: '4,2', documents: '2,2',
|
||||
}
|
||||
|
||||
function getNodeColor(status: string): string {
|
||||
return STATUS_COLORS[status] || '#9E9E9E'
|
||||
}
|
||||
|
||||
function drawGraph() {
|
||||
if (!svgRef.value || !graphView.value) return
|
||||
|
||||
const svg = d3.select(svgRef.value)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const width = svgRef.value.clientWidth || 800
|
||||
const height = svgRef.value.clientHeight || 600
|
||||
|
||||
svg.attr('width', width).attr('height', height)
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
||||
svg.call(zoom)
|
||||
|
||||
const nodes = graphView.value.nodes.map(n => ({ ...n } as GraphNode & d3.SimulationNodeDatum))
|
||||
const edges = graphView.value.edges.map(e => ({
|
||||
...e,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
}))
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation(nodes as any)
|
||||
.force('link', d3.forceLink(edges as any).id((d: any) => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-200))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(30))
|
||||
|
||||
// Draw edges
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(edges)
|
||||
.join('line')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('stroke-width', (d: any) => d.relation === 'owns' ? 3 : 1.5)
|
||||
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
||||
|
||||
// Draw nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<any, any>()
|
||||
.on('start', (event, d: any) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
d.fx = d.x; d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d: any) => { d.fx = event.x; d.fy = event.y })
|
||||
.on('end', (event, d: any) => {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
d.fx = null; d.fy = null
|
||||
})
|
||||
)
|
||||
|
||||
// Node shapes
|
||||
node.each(function(this: any, d: any) {
|
||||
const el = d3.select(this)
|
||||
const color = getNodeColor(d.status)
|
||||
|
||||
if (d.type === 'module') {
|
||||
el.append('rect').attr('width', 20).attr('height', 20).attr('x', -10).attr('y', -10)
|
||||
.attr('fill', color).attr('rx', 3)
|
||||
} else if (d.type === 'entity') {
|
||||
el.append('polygon').attr('points', '0,-12 12,0 0,12 -12,0').attr('fill', color)
|
||||
} else {
|
||||
el.append('circle').attr('r', 10).attr('fill', color)
|
||||
}
|
||||
|
||||
el.append('text').text(d.label).attr('x', 14).attr('y', 4)
|
||||
.attr('font-size', '11px').attr('fill', '#333')
|
||||
})
|
||||
|
||||
// Tooltip on hover
|
||||
node.append('title').text((d: any) => `${d.id}\n类型: ${d.type}\n状态: ${d.status}`)
|
||||
|
||||
// Click -> select node
|
||||
node.on('click', (_event: any, d: any) => { store.selectNode(d) })
|
||||
|
||||
// Double-click -> drill down
|
||||
node.on('dblclick', (_event: any, d: any) => {
|
||||
const projectId = route.params.id as string
|
||||
store.loadNeighbors(projectId, d.id)
|
||||
})
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', (d: any) => d.source.x)
|
||||
.attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x)
|
||||
.attr('y2', (d: any) => d.target.y)
|
||||
|
||||
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const projectId = route.params.id as string
|
||||
await store.loadGraph(projectId)
|
||||
drawGraph()
|
||||
})
|
||||
|
||||
watch(graphView, () => { drawGraph() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.graph-panorama { position: relative; height: calc(100vh - 48px); }
|
||||
.graph-svg { width: 100%; height: 100%; }
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.8); font-size: 18px; color: #666;
|
||||
}
|
||||
.scan-summary {
|
||||
position: absolute; top: 12px; right: 340px;
|
||||
display: flex; gap: 12px;
|
||||
background: white; padding: 8px 16px; border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
||||
}
|
||||
.summary-item { font-size: 13px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { GraphView, GraphNode, ScanResult } from '@/shared/types/api'
|
||||
import * as graphApi from '../api'
|
||||
|
||||
export const useGraphStore = defineStore('graph', () => {
|
||||
const graphView = ref<GraphView | null>(null)
|
||||
const selectedNode = ref<GraphNode | null>(null)
|
||||
const scanResult = ref<ScanResult | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadGraph(projectId: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
scanResult.value = await graphApi.triggerScan(projectId)
|
||||
graphView.value = await graphApi.getGraph(projectId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(node: GraphNode | null) {
|
||||
selectedNode.value = node
|
||||
}
|
||||
|
||||
async function loadNeighbors(projectId: string, nodeId: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
graphView.value = await graphApi.getNodeNeighbors(projectId, nodeId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedNode.value = null
|
||||
}
|
||||
|
||||
return { graphView, selectedNode, scanResult, loading, loadGraph, selectNode, loadNeighbors, clearSelection }
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import api from '@/shared/api'
|
||||
import type { Project } from '@/shared/types/api'
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
const { data } = await api.get<Project[]>('/projects')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProject(name: string, designDir: string, codeDir?: string): Promise<Project> {
|
||||
const { data } = await api.post<Project>('/projects', {
|
||||
name,
|
||||
design_dir: designDir,
|
||||
code_dir: codeDir || null,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project> {
|
||||
const { data } = await api.get<Project>(`/projects/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="project-list">
|
||||
<h1>项目管理</h1>
|
||||
|
||||
<div class="add-section">
|
||||
<button class="primary" @click="showForm = !showForm">
|
||||
{{ showForm ? '取消' : '添加项目' }}
|
||||
</button>
|
||||
<form v-if="showForm" class="card add-form" @submit.prevent="handleCreate">
|
||||
<input v-model="form.name" placeholder="项目名称" required />
|
||||
<input v-model="form.designDir" placeholder="设计目录路径" required />
|
||||
<input v-model="form.codeDir" placeholder="代码目录路径(可选)" />
|
||||
<button type="submit" class="primary" :disabled="loading">创建</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && projects.length === 0" class="loading">加载中...</div>
|
||||
<div v-if="!loading && projects.length === 0" class="empty">暂无项目,点击"添加项目"开始</div>
|
||||
|
||||
<ProjectOverview
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
:project="p"
|
||||
@click="goToProject(p.id)"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '../composables/useProject'
|
||||
import ProjectOverview from './ProjectOverview.vue'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { projects, loading, error } = storeToRefs(store)
|
||||
const router = useRouter()
|
||||
|
||||
const showForm = ref(false)
|
||||
const form = ref({ name: '', designDir: '', codeDir: '' })
|
||||
|
||||
onMounted(() => { store.fetchProjects() })
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
const project = await store.createProject(form.value.name, form.value.designDir, form.value.codeDir || undefined)
|
||||
showForm.value = false
|
||||
form.value = { name: '', designDir: '', codeDir: '' }
|
||||
router.push(`/projects/${project.id}`)
|
||||
} catch { /* error handled in store */ }
|
||||
}
|
||||
|
||||
function goToProject(id: string) {
|
||||
store.selectProject(id)
|
||||
router.push(`/projects/${id}`)
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('确认删除该项目?')) {
|
||||
await store.removeProject(id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin-bottom: 16px; }
|
||||
.add-section { margin-bottom: 16px; }
|
||||
.add-form { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.empty { color: #999; padding: 24px; text-align: center; }
|
||||
.loading { color: #999; padding: 24px; text-align: center; }
|
||||
.error { color: #F44336; font-size: 13px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="card project-card">
|
||||
<div class="project-header">
|
||||
<h3>{{ project.name }}</h3>
|
||||
<button class="danger" @click.stop="$emit('delete', project.id)">删除</button>
|
||||
</div>
|
||||
<p class="meta">{{ project.design_dir }}</p>
|
||||
<p class="meta">创建于 {{ new Date(project.created_at).toLocaleDateString() }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '@/shared/types/api'
|
||||
|
||||
defineProps<{ project: Project }>()
|
||||
defineEmits<{ delete: [id: string] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-card { cursor: pointer; }
|
||||
.project-card:hover { border-color: #1976D2; }
|
||||
.project-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.project-header h3 { font-size: 16px; }
|
||||
.meta { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
</style>
|
||||
41
frontend/src/modules/project/components/ProjectSidebar.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="project-sidebar">
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<div v-for="p in projects" :key="p.id" class="project-item" :class="{ active: currentProject?.id === p.id }" @click="goToProject(p.id)">
|
||||
<span class="project-name">{{ p.name }}</span>
|
||||
</div>
|
||||
<div v-if="!loading && projects.length === 0" class="empty">暂无项目</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '../composables/useProject'
|
||||
|
||||
const store = useProjectStore()
|
||||
const { projects, currentProject, loading } = storeToRefs(store)
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => { store.fetchProjects() })
|
||||
|
||||
function goToProject(id: string) {
|
||||
store.selectProject(id)
|
||||
router.push(`/projects/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.project-item:hover { background: #f0f0f0; }
|
||||
.project-item.active { background: #e3f2fd; color: #1976D2; }
|
||||
.project-name { font-size: 14px; }
|
||||
.empty { color: #999; font-size: 13px; padding: 8px; }
|
||||
.loading { color: #999; font-size: 13px; padding: 8px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Project } from '@/shared/types/api'
|
||||
import * as projectApi from '../api'
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const projects = ref<Project[]>([])
|
||||
const currentProject = ref<Project | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchProjects() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
projects.value = await projectApi.listProjects()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string, designDir: string, codeDir?: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const project = await projectApi.createProject(name, designDir, codeDir)
|
||||
projects.value.push(project)
|
||||
return project
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(id: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
currentProject.value = await projectApi.getProject(id)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProject(id: string) {
|
||||
await projectApi.deleteProject(id)
|
||||
projects.value = projects.value.filter(p => p.id !== id)
|
||||
if (currentProject.value?.id === id) {
|
||||
currentProject.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return { projects, currentProject, loading, error, fetchProjects, createProject, selectProject, removeProject }
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type { Project } from '@/shared/types/api'
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: () => import('@/modules/project/components/ProjectList.vue') },
|
||||
{ path: '/projects/:id', component: () => import('@/modules/graph/components/GraphPanorama.vue') },
|
||||
{ path: '/projects/:id/editor', component: () => import('@/modules/editor/components/EditorPage.vue') },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import axios from 'axios'
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
export default api
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
design_dir: string
|
||||
code_dir: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface FileStatusEntry {
|
||||
path: string
|
||||
status: string
|
||||
content_lines: number
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
total_files: number
|
||||
ok: number
|
||||
sparse: number
|
||||
missing: number
|
||||
placeholder_heavy: number
|
||||
template_residue: number
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
project_id: string
|
||||
scanned_at: string
|
||||
file_statuses: FileStatusEntry[]
|
||||
summary: ScanSummary
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
status: string
|
||||
group_id: string
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
relation: string
|
||||
}
|
||||
|
||||
export interface GraphGroup {
|
||||
id: string
|
||||
label: string
|
||||
layer: string
|
||||
}
|
||||
|
||||
export interface GraphView {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
groups: GraphGroup[]
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
capability_id: string
|
||||
name: string
|
||||
description: string
|
||||
priority: string
|
||||
phase: string
|
||||
related_value_flows: string[]
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
module_id: string
|
||||
name: string
|
||||
layer: string
|
||||
description: string
|
||||
phase: string
|
||||
depends_on: string[]
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface Entity {
|
||||
entity_id: string
|
||||
name: string
|
||||
domain: string
|
||||
owner_module: string
|
||||
description: string
|
||||
phase: string
|
||||
source_file: string
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
integration_id: string
|
||||
source: string
|
||||
target: string
|
||||
target_type: string
|
||||
direction: string
|
||||
protocol: string
|
||||
trigger: string
|
||||
phase: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ValueFlow {
|
||||
value_flow_id: string
|
||||
name: string
|
||||
trigger: string
|
||||
actor: string
|
||||
steps: string
|
||||
outcome: string
|
||||
phase: string
|
||||
related_capabilities: string[]
|
||||
}
|
||||
|
||||
export interface UserJourney {
|
||||
journey_id: string
|
||||
name: string
|
||||
actor: string
|
||||
precondition: string
|
||||
steps: string
|
||||
postcondition: string
|
||||
phase: string
|
||||
related_value_flows: string[]
|
||||
}
|
||||
|
||||
export interface DataFlow {
|
||||
data_flow_id: string
|
||||
source: string
|
||||
target: string
|
||||
data_content: string
|
||||
trigger: string
|
||||
protocol: string
|
||||
phase: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ExternalSystem {
|
||||
system_id: string
|
||||
name: string
|
||||
type: string
|
||||
protocol: string
|
||||
direction: string
|
||||
phase: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface TraceabilityLink {
|
||||
trace_id: string
|
||||
capability_id: string
|
||||
module_id: string
|
||||
entity_ids: string[]
|
||||
value_flow_ids: string[]
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface RuntimeComponent {
|
||||
component_id: string
|
||||
name: string
|
||||
type: string
|
||||
technology: string
|
||||
port: string
|
||||
}
|
||||
|
||||
export interface EditableFile {
|
||||
path: string
|
||||
format: string
|
||||
content: string
|
||||
last_modified: string
|
||||
}
|
||||
|
||||
export interface AffectedFile {
|
||||
path: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface ImpactResult {
|
||||
source_file: string
|
||||
affected_files: AffectedFile[]
|
||||
}
|
||||
|
||||
export interface ImplProgress {
|
||||
module_id: string
|
||||
percentage: number
|
||||
source: string
|
||||
evaluated_at: string
|
||||
}
|
||||
|
||||
export interface CapabilityDetail {
|
||||
capability: Capability
|
||||
modules: Module[]
|
||||
value_flows: ValueFlow[]
|
||||
}
|
||||
|
||||
export interface ModuleDetail {
|
||||
module: Module
|
||||
entities: Entity[]
|
||||
integrations: Integration[]
|
||||
codebase_alignment: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface EntityDetail {
|
||||
entity: Entity
|
||||
data_flows: DataFlow[]
|
||||
}
|
||||
83
frontend/src/style.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--color-primary: #1976D2;
|
||||
--color-bg: #f5f5f5;
|
||||
--color-sidebar: #fff;
|
||||
--color-border: #e0e0e0;
|
||||
--color-ok: #4CAF50;
|
||||
--color-sparse: #FFC107;
|
||||
--color-missing: #F44336;
|
||||
--color-template-residue: #FF9800;
|
||||
--color-placeholder-heavy: #9C27B0;
|
||||
--color-unknown: #9E9E9E;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--color-sidebar);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--color-missing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
15
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
20
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8900',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||