Compare commits

...

10 Commits

Author SHA1 Message Date
144ec8cc80 chore: add deployment config and work records 2026-03-24 01:08:25 +00:00
d4b26a5971 fix(build): resolve vue-tsc -b build errors — add @types/node, fix tsconfig emit settings
The production build (vue-tsc -b) failed because tsconfig.node.json had
allowImportingTsExtensions without a compatible emit setting, and @types/node
was missing for node:url imports in vite.config.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:29:43 +00:00
f12c45f692 build: add Docker deployment — Compose, Dockerfiles, Nginx config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:25:02 +00:00
75e053c454 fix(build): add @/ path alias to Vite config and fix tsconfig composite setting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:21:26 +00:00
ce4f474472 feat(fe-editor): add CSV table editor and Markdown editor components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:20:38 +00:00
b7ebbcd777 feat(fe-graph): add D3.js graph visualization — panorama, drill-down, status colors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:17:52 +00:00
d8071bc9f3 feat(fe-project): add project management UI — list, create, delete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:16:32 +00:00
7328057e7d feat(frontend): add shared layer — types, API client, router, app layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:15:30 +00:00
c18f7c5f76 build: add frontend configuration — Vite, TypeScript, Vue 3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:14:10 +00:00
b77bae709b feat(impl_tracker): add ImplTrackerService and REST API — progress evaluation and manual override
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:05:11 +00:00
51 changed files with 4237 additions and 0 deletions

6
.gitignore vendored
View File

@ -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
View 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"]

View File

@ -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")

View File

@ -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)

View File

@ -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),
)
)

View 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"

View 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
View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View 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: ✅

View 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 Composebackend 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View 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"
}
}

View File

@ -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
View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

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

View File

@ -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
}

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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 }
})

View File

@ -0,0 +1 @@
export type { EditableFile, ImpactResult, ImplProgress } from '@/shared/types/api'

View File

@ -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
}

View File

@ -0,0 +1,47 @@
<template>
<div class="graph-detail" v-if="node">
<div class="detail-header">
<h3>{{ node.label }}</h3>
<button @click="$emit('close')">&#10005;</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>

View File

@ -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>

View File

@ -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 }
})

View File

@ -0,0 +1 @@
export type { GraphView, GraphNode, GraphEdge, GraphGroup, ScanResult, ScanSummary } from '@/shared/types/api'

View File

@ -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}`)
}

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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 }
})

View File

@ -0,0 +1 @@
export type { Project } from '@/shared/types/api'

View File

@ -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

View File

@ -0,0 +1,3 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export default api

View File

@ -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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View 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,
},
},
},
})