feat: 初始化DevOps平台前端
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:4300
|
||||
VITE_DEVOPS_USE_MOCK=false
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@RTK.md
|
||||
|
||||
# DevOps 运维平台前端 Agent 入口
|
||||
|
||||
## 项目规则
|
||||
|
||||
- 使用当前 Vue 3 + Vite + TypeScript 结构,不引入无关框架。
|
||||
- 页面内容、计划文档和交付说明优先使用中文。
|
||||
- UI 风格保持安静、工具型、信息密度合理的运维后台,不做营销页。
|
||||
- 只在 `devops-platform-web/**` 内修改前端文件,不修改 `devops-platform-api/**` 和 `devops-platform-docs/**`。
|
||||
- 改动可运行入口、目录或重要文件时,同步更新 `README.md`。
|
||||
|
||||
## 技术约束
|
||||
|
||||
- UI 组件优先使用 Element Plus。
|
||||
- 状态管理使用 Pinia。
|
||||
- 路由使用 Vue Router。
|
||||
- 请求封装使用 Axios,并集中在 `src/services/**`。
|
||||
- BPMN 展示使用 `bpmn-js`,不手写替代渲染器。
|
||||
- mock 数据集中在 `src/mocks/**`,后续以后端 OpenAPI/Swagger 替换。
|
||||
|
||||
## API 与安全边界
|
||||
|
||||
- 前端不得保存 Jenkins token、Gitea token、通知 webhook、LLM API key/baseURL/model。
|
||||
- 前端不得直接调用 Jenkins、Gitea、企微、飞书或模型供应商;统一通过后端 API。
|
||||
- API 契约以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 和后端 OpenAPI 为准。
|
||||
|
||||
## BPMN 与 Agent 边界
|
||||
|
||||
- BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。
|
||||
- `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致。
|
||||
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿。
|
||||
- 禁止在前端新增泛聊天入口。
|
||||
@@ -0,0 +1,66 @@
|
||||
# DevOps 运维平台前端
|
||||
|
||||
Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于管理本地项目和 DevOps 平台自身的发布状态、Jenkins/Gitea/通知平台/LLM 接入状态、BPMN 流程展示和 Agent 辅助能力。
|
||||
|
||||
## 当前范围
|
||||
|
||||
- 登录、总览、项目配置、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
||||
- 优先调用 `devops-platform-api`,后端不可用时降级到 mock 数据展示 `access-manage`、`role-admin`、`role-user`、`my-resume`、`devops-platform-api`、`devops-platform-web`。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并按 queued/running/success/failed/skipped/canceled 状态高亮节点。
|
||||
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3
|
||||
- Vite
|
||||
- TypeScript
|
||||
- Element Plus
|
||||
- Pinia
|
||||
- Vue Router
|
||||
- Axios
|
||||
- bpmn-js
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The local web app listens on `http://localhost:4301` by default. Use `VITE_API_BASE_URL=http://localhost:4300` when switching from mock services to the local API.
|
||||
|
||||
## 构建检查
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## API 契约来源
|
||||
|
||||
接口入口为 `src/services/devopsApi.ts`,默认优先调用后端统一 envelope 响应;后端不可用或 `VITE_DEVOPS_USE_MOCK=true` 时降级到 `src/mocks/devops.ts`。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。
|
||||
|
||||
浏览器端不得保存或直连以下密钥和服务:
|
||||
|
||||
- Jenkins token
|
||||
- Gitea token
|
||||
- 通知 webhook
|
||||
- LLM API key/baseURL/model
|
||||
|
||||
## BPMN 边界
|
||||
|
||||
- 前端只负责 BPMN XML 展示和节点状态高亮。
|
||||
- `run.steps[].bpmnNodeId` 必须与 BPMN XML 节点 id 一致。
|
||||
- 流程推进、失败停留、审计日志和状态机由后端负责。
|
||||
|
||||
## Agent 边界
|
||||
|
||||
前端不提供泛聊天入口。Agent 入口仅围绕 DevOps 运维场景:
|
||||
|
||||
- 发布风险摘要
|
||||
- 失败诊断
|
||||
- Runbook 问答
|
||||
- 发布说明草稿
|
||||
|
||||
模型调用、脱敏、审计、白名单知识库和密钥管理由后端代理实现。
|
||||
@@ -0,0 +1,38 @@
|
||||
# DevOps 运维平台前端 RTK
|
||||
|
||||
## 项目定位
|
||||
|
||||
`devops-platform-web` 是 DevOps 运维平台前端,用于统一查看和操作当前四个项目的部署与发布流程:
|
||||
|
||||
- `access-manage`
|
||||
- `role-admin`
|
||||
- `role-user`
|
||||
- `my-resume`
|
||||
|
||||
第一阶段为本地 mock UI,后续接入 `devops-platform-api`。
|
||||
|
||||
## 页面结构
|
||||
|
||||
- `/login`:mock 登录。
|
||||
- `/overview`:总览,展示服务状态、最近发布和项目环境。
|
||||
- `/projects`:项目配置,展示 Gitea 仓库、Jenkins Job、企微机器人和环境策略。
|
||||
- `/releases`:发布中心,提供发布参数、二次确认、触发、取消、重试和 BPMN 流程状态。
|
||||
- `/runs`:运行记录,展示 run 列表和流程轨迹。
|
||||
- `/agent`:Agent 辅助,只保留 DevOps 场景能力。
|
||||
- `/settings`:系统配置,展示 API、BPMN、Agent、通知边界。
|
||||
|
||||
## 数据约定
|
||||
|
||||
- mock 数据入口:`src/mocks/devops.ts`
|
||||
- API 封装入口:`src/services/devopsApi.ts`
|
||||
- Axios 实例:`src/services/http.ts`
|
||||
- 平台状态:`src/stores/platform.ts`
|
||||
- 登录状态:`src/stores/session.ts`
|
||||
|
||||
## 后续接入点
|
||||
|
||||
- 用后端 OpenAPI 替换 `devopsApi` mock 返回。
|
||||
- 发布中心触发按钮接入后端发布单创建接口。
|
||||
- 运行记录接入后端 run/step 查询接口。
|
||||
- BPMN XML 由后端流程定义接口返回。
|
||||
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>devops-platform-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "devops-platform-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.17.0",
|
||||
"bpmn-js": "^18.18.0",
|
||||
"element-plus": "^2.14.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
Generated
+1762
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import 'bpmn-js/dist/assets/bpmn-js.css'
|
||||
import 'bpmn-js/dist/assets/diagram-js.css'
|
||||
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import type { PipelineStep, StepStatus } from '../types/devops'
|
||||
import StatusPill from './StatusPill.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
xml: string
|
||||
steps: PipelineStep[]
|
||||
}>()
|
||||
|
||||
const canvasEl = ref<HTMLDivElement>()
|
||||
const selectedNodeId = ref('')
|
||||
const viewer = shallowRef<InstanceType<typeof BpmnViewer>>()
|
||||
|
||||
const markerClasses: Record<StepStatus, string> = {
|
||||
pending: 'bpmn-step-pending',
|
||||
queued: 'bpmn-step-queued',
|
||||
running: 'bpmn-step-running',
|
||||
success: 'bpmn-step-success',
|
||||
failed: 'bpmn-step-failed',
|
||||
skipped: 'bpmn-step-skipped',
|
||||
canceled: 'bpmn-step-canceled',
|
||||
}
|
||||
|
||||
function findStep(nodeId: string) {
|
||||
return props.steps.find((step) => step.bpmnNodeId === nodeId)
|
||||
}
|
||||
|
||||
const selectedStep = computed(() => {
|
||||
if (!selectedNodeId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return findStep(selectedNodeId.value)
|
||||
})
|
||||
|
||||
async function renderDiagram() {
|
||||
if (!canvasEl.value || !props.xml) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewer.value) {
|
||||
viewer.value = new BpmnViewer({
|
||||
container: canvasEl.value,
|
||||
})
|
||||
}
|
||||
|
||||
await viewer.value.importXML(props.xml)
|
||||
const canvas = viewer.value.get('canvas') as {
|
||||
zoom: (mode: string) => void
|
||||
addMarker: (id: string, marker: string) => void
|
||||
removeMarker: (id: string, marker: string) => void
|
||||
}
|
||||
|
||||
canvas.zoom('fit-viewport')
|
||||
applyMarkers()
|
||||
|
||||
const eventBus = viewer.value.get('eventBus') as {
|
||||
on: (event: string, callback: (event: { element?: { id: string } }) => void) => void
|
||||
}
|
||||
|
||||
eventBus.on('element.click', (event) => {
|
||||
const nodeId = event.element?.id
|
||||
if (nodeId && findStep(nodeId)) {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyMarkers() {
|
||||
if (!viewer.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = viewer.value.get('canvas') as {
|
||||
addMarker: (id: string, marker: string) => void
|
||||
removeMarker: (id: string, marker: string) => void
|
||||
}
|
||||
|
||||
props.steps.forEach((step) => {
|
||||
Object.values(markerClasses).forEach((marker) => {
|
||||
canvas.removeMarker(step.bpmnNodeId, marker)
|
||||
})
|
||||
canvas.addMarker(step.bpmnNodeId, markerClasses[step.status] ?? markerClasses.pending)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.xml, props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|')],
|
||||
async () => {
|
||||
await nextTick()
|
||||
await renderDiagram()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
viewer.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bpmn-viewer">
|
||||
<div ref="canvasEl" class="bpmn-canvas" />
|
||||
<div class="bpmn-side">
|
||||
<div class="legend">
|
||||
<span><i class="dot success" />成功</span>
|
||||
<span><i class="dot queued" />排队</span>
|
||||
<span><i class="dot running" />运行</span>
|
||||
<span><i class="dot failed" />失败</span>
|
||||
<span><i class="dot pending" />等待</span>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<button
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
class="step-row"
|
||||
:class="{ active: selectedNodeId === step.bpmnNodeId }"
|
||||
type="button"
|
||||
@click="selectedNodeId = step.bpmnNodeId"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ step.name }}</strong>
|
||||
<small>{{ step.owner }} · {{ step.duration }}</small>
|
||||
</span>
|
||||
<StatusPill :state="step.status" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedStep" class="node-detail">
|
||||
<strong>{{ selectedStep.name }}</strong>
|
||||
<p>{{ selectedStep.summary }}</p>
|
||||
<p v-if="selectedStep.message">
|
||||
<span>执行消息</span>
|
||||
{{ selectedStep.message }}
|
||||
</p>
|
||||
<p v-if="selectedStep.errorSummary">
|
||||
<span>失败摘要</span>
|
||||
{{ selectedStep.errorSummary }}
|
||||
</p>
|
||||
<pre v-if="selectedStep.logExcerpt">{{ selectedStep.logExcerpt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bpmn-viewer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 14px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.bpmn-canvas {
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.bpmn-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e6ebf3;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
color: #596578;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #18a058;
|
||||
}
|
||||
|
||||
.running {
|
||||
background: #1d5fd0;
|
||||
}
|
||||
|
||||
.queued {
|
||||
background: #b7791f;
|
||||
}
|
||||
|
||||
.failed {
|
||||
background: #d03050;
|
||||
}
|
||||
|
||||
.pending {
|
||||
background: #9aa4b2;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.step-row.active {
|
||||
border-color: #1d5fd0;
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.step-row strong,
|
||||
.step-row small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.step-row strong {
|
||||
color: #182033;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-row small {
|
||||
margin-top: 3px;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-detail {
|
||||
padding: 12px;
|
||||
border: 1px solid #d9e5ff;
|
||||
border-radius: 8px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.node-detail strong {
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-detail p {
|
||||
margin: 6px 0 0;
|
||||
color: #596578;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.node-detail p span {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
color: #8a5a12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-detail pre {
|
||||
max-height: 160px;
|
||||
margin: 10px 0 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: #172033;
|
||||
color: #eef4ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-success .djs-visual > :first-child) {
|
||||
stroke: #18a058 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-queued .djs-visual > :first-child) {
|
||||
stroke: #b7791f !important;
|
||||
stroke-width: 3px !important;
|
||||
stroke-dasharray: 6 4 !important;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-running .djs-visual > :first-child) {
|
||||
stroke: #1d5fd0 !important;
|
||||
stroke-width: 4px !important;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-failed .djs-visual > :first-child) {
|
||||
stroke: #d03050 !important;
|
||||
stroke-width: 4px !important;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-pending .djs-visual > :first-child),
|
||||
:global(.bpmn-step-skipped .djs-visual > :first-child),
|
||||
:global(.bpmn-step-canceled .djs-visual > :first-child) {
|
||||
stroke: #9aa4b2 !important;
|
||||
stroke-dasharray: 5 4 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.bpmn-viewer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { Close, Promotion } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { AgentMode, AgentPurpose } from '../types/devops'
|
||||
|
||||
type AgentMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const platform = usePlatformStore()
|
||||
const selectedMode = ref<AgentMode>('risk')
|
||||
const selectedProject = ref('')
|
||||
const selectedRun = ref('')
|
||||
const prompt = ref('帮我检查当前发布是否存在配置、构建或通知风险。')
|
||||
const sending = ref(false)
|
||||
const messages = ref<AgentMessage[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
title: 'DevOps Agent',
|
||||
content: '请选择项目和任务类型,我会基于平台已有的发布、构建、流程和配置上下文生成结果。',
|
||||
createdAt: formatTime(new Date().toISOString()),
|
||||
},
|
||||
])
|
||||
|
||||
const activeCard = computed(() => {
|
||||
return platform.agentCards.find((card) => card.mode === selectedMode.value)
|
||||
})
|
||||
|
||||
const failedRunOptions = computed(() => {
|
||||
return platform.failedRuns.filter((run) => !selectedProject.value || run.projectKey === selectedProject.value)
|
||||
})
|
||||
|
||||
const purpose = computed<AgentPurpose>(() => {
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
return 'failure-diagnosis'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'runbook') {
|
||||
return 'runbook-qa'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'releaseNotes') {
|
||||
return 'release-note'
|
||||
}
|
||||
|
||||
return 'release-risk'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => platform.projects,
|
||||
(projects) => {
|
||||
if (!selectedProject.value && projects[0]) {
|
||||
selectedProject.value = projects[0].key
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(failedRunOptions, (runs) => {
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
selectedRun.value = runs[0]?.id ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedMode, (mode) => {
|
||||
if (mode === 'diagnosis') {
|
||||
selectedRun.value = failedRunOptions.value[0]?.id ?? ''
|
||||
prompt.value = '请根据最近失败 run 的 Jenkins 日志摘要和流程节点给出排查步骤。'
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'runbook') {
|
||||
prompt.value = '生产发布失败后,应该先检查哪些平台信息?'
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'releaseNotes') {
|
||||
prompt.value = '请为本次 master 发布生成发布说明草稿,并列出验证范围。'
|
||||
return
|
||||
}
|
||||
|
||||
prompt.value = '帮我检查当前发布是否存在配置、构建或通知风险。'
|
||||
})
|
||||
|
||||
async function sendPrompt() {
|
||||
const text = prompt.value.trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
messages.value.push({
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
title: activeCard.value?.title ?? '运维问题',
|
||||
content: text,
|
||||
createdAt: formatTime(new Date().toISOString()),
|
||||
})
|
||||
|
||||
try {
|
||||
const invocation = await devopsApi.createAgentInvocation({
|
||||
type: purpose.value,
|
||||
projectKey: selectedProject.value || undefined,
|
||||
runId: selectedMode.value === 'diagnosis' ? selectedRun.value || undefined : undefined,
|
||||
promptSummary: text,
|
||||
})
|
||||
|
||||
messages.value.push({
|
||||
id: invocation.id,
|
||||
role: 'assistant',
|
||||
title: invocation.status === 'success' ? 'Agent 结果' : 'Agent 调用失败',
|
||||
content: invocation.resultMarkdown || 'Agent 未返回内容',
|
||||
createdAt: formatTime(invocation.createdAt),
|
||||
})
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : 'Agent 调用失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void sendPrompt()
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="agent-drawer">
|
||||
<header class="agent-header">
|
||||
<div>
|
||||
<strong>Agent</strong>
|
||||
<span>{{ activeCard?.title ?? 'DevOps 助手' }}</span>
|
||||
</div>
|
||||
<ElButton :icon="Close" circle text @click="emit('close')" />
|
||||
</header>
|
||||
|
||||
<div class="agent-context">
|
||||
<ElSegmented
|
||||
v-model="selectedMode"
|
||||
class="mode-switch"
|
||||
:options="[
|
||||
{ label: '风险', value: 'risk' },
|
||||
{ label: '诊断', value: 'diagnosis' },
|
||||
{ label: 'Runbook', value: 'runbook' },
|
||||
{ label: '发布说明', value: 'releaseNotes' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<ElSelect v-model="selectedProject" filterable placeholder="选择项目">
|
||||
<ElOption
|
||||
v-for="project in platform.projects"
|
||||
:key="project.key"
|
||||
:label="project.name"
|
||||
:value="project.key"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect
|
||||
v-if="selectedMode === 'diagnosis'"
|
||||
v-model="selectedRun"
|
||||
filterable
|
||||
placeholder="选择失败 Run"
|
||||
>
|
||||
<ElOption
|
||||
v-for="run in failedRunOptions"
|
||||
:key="run.id"
|
||||
:label="`${run.id} · ${run.ref}`"
|
||||
:value="run.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="message-list">
|
||||
<article v-for="message in messages" :key="message.id" class="agent-message" :class="message.role">
|
||||
<div class="message-meta">
|
||||
<strong>{{ message.title }}</strong>
|
||||
<span>{{ message.createdAt }}</span>
|
||||
</div>
|
||||
<p>{{ message.content }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="agent-composer">
|
||||
<ElInput
|
||||
v-model="prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="300"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
@keydown="handleComposerKeydown"
|
||||
/>
|
||||
<ElButton type="primary" :icon="Promotion" :loading="sending" @click="sendPrompt">
|
||||
发送
|
||||
</ElButton>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-drawer {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex: 0 0 420px;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
height: 100vh;
|
||||
border-left: 1px solid #dfe7f2;
|
||||
background: #ffffff;
|
||||
box-shadow: -8px 0 22px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.agent-header strong,
|
||||
.agent-header span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-header strong {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.agent-header span {
|
||||
margin-top: 3px;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-message {
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.agent-message.user {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-meta strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.message-meta span {
|
||||
flex-shrink: 0;
|
||||
color: #8a95a7;
|
||||
}
|
||||
|
||||
.agent-message p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.agent-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid #edf1f7;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.agent-composer .el-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agent-drawer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: min(420px, 100vw);
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
caption: string
|
||||
icon: Component
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'danger'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="metric-tile" :class="tone || 'neutral'">
|
||||
<div class="metric-icon">
|
||||
<ElIcon>
|
||||
<component :is="icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ label }}</span>
|
||||
<strong>{{ value }}</strong>
|
||||
<small>{{ caption }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-tile {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-height: 108px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
flex: 0 0 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #edf4ff;
|
||||
color: #1d5fd0;
|
||||
}
|
||||
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.success .metric-icon {
|
||||
background: #e9f8f0;
|
||||
color: #16844a;
|
||||
}
|
||||
|
||||
.warning .metric-icon {
|
||||
background: #fff6df;
|
||||
color: #a96f00;
|
||||
}
|
||||
|
||||
.danger .metric-icon {
|
||||
background: #fff0f0;
|
||||
color: #bc3434;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ReleaseRun } from '../types/devops'
|
||||
import StatusPill from './StatusPill.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
run: ReleaseRun
|
||||
}>()
|
||||
|
||||
const notableSteps = computed(() =>
|
||||
props.run.steps.filter(
|
||||
(step) =>
|
||||
step.status === 'failed' ||
|
||||
step.status === 'skipped' ||
|
||||
step.errorSummary ||
|
||||
step.message ||
|
||||
step.logExcerpt,
|
||||
),
|
||||
)
|
||||
|
||||
function noteLabel(status: string) {
|
||||
return status === 'failed' ? '失败摘要' : '执行提示'
|
||||
}
|
||||
|
||||
function displayText(value?: string | number) {
|
||||
if (value === undefined || value === null || value === '' || value === '-') {
|
||||
return '未返回'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function finishText() {
|
||||
if (props.run.finishedAt && props.run.finishedAt !== '-') {
|
||||
return props.run.finishedAt
|
||||
}
|
||||
|
||||
if (props.run.status === 'queued' || props.run.status === 'running') {
|
||||
return '未结束'
|
||||
}
|
||||
|
||||
return '未返回'
|
||||
}
|
||||
|
||||
function buildText() {
|
||||
if (props.run.jenkinsBuildNumber !== undefined && props.run.jenkinsBuildNumber !== null) {
|
||||
return `#${props.run.jenkinsBuildNumber}`
|
||||
}
|
||||
|
||||
return '未返回'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="run-execution-details">
|
||||
<dl class="detail-grid">
|
||||
<dt>Run ID</dt>
|
||||
<dd class="mono">{{ displayText(run.id) }}</dd>
|
||||
<dt>项目</dt>
|
||||
<dd>{{ displayText(run.projectName) }}</dd>
|
||||
<dt>环境</dt>
|
||||
<dd>{{ displayText(run.environment) }}</dd>
|
||||
<dt>ref/tag</dt>
|
||||
<dd class="mono">{{ displayText(run.ref) }}</dd>
|
||||
<dt>操作者</dt>
|
||||
<dd>{{ displayText(run.actor) }}</dd>
|
||||
<dt>状态</dt>
|
||||
<dd><StatusPill :state="run.status" /></dd>
|
||||
<dt>开始时间</dt>
|
||||
<dd>{{ displayText(run.startedAt) }}</dd>
|
||||
<dt>结束时间</dt>
|
||||
<dd>{{ finishText() }}</dd>
|
||||
<dt>Jenkins Queue</dt>
|
||||
<dd class="mono">{{ displayText(run.jenkinsQueueId) }}</dd>
|
||||
<dt>Jenkins Build</dt>
|
||||
<dd class="mono">{{ buildText() }}</dd>
|
||||
<dt>Jenkins 状态</dt>
|
||||
<dd>{{ displayText(run.jenkinsSummary) }}</dd>
|
||||
<dt>Build URL</dt>
|
||||
<dd>
|
||||
<a v-if="run.jenkinsBuildUrl" :href="run.jenkinsBuildUrl" target="_blank" rel="noreferrer">
|
||||
{{ run.jenkinsBuildUrl }}
|
||||
</a>
|
||||
<span v-else>未返回</span>
|
||||
</dd>
|
||||
<dt>Commit</dt>
|
||||
<dd>{{ displayText(run.commitSummary) }}</dd>
|
||||
<dt>备注</dt>
|
||||
<dd>{{ displayText(run.note) }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="failure-block">
|
||||
<div class="failure-header">
|
||||
<strong>执行提示 / 日志摘要</strong>
|
||||
<span>{{ notableSteps.length ? `${notableSteps.length} 个节点` : '未返回异常或提示' }}</span>
|
||||
</div>
|
||||
<div v-if="notableSteps.length" class="failure-list">
|
||||
<article
|
||||
v-for="step in notableSteps"
|
||||
:key="step.id"
|
||||
class="failure-item"
|
||||
:class="{ danger: step.status === 'failed' }"
|
||||
>
|
||||
<div class="failure-title">
|
||||
<strong>{{ step.name }}</strong>
|
||||
<StatusPill :state="step.status" />
|
||||
</div>
|
||||
<p v-if="step.errorSummary">
|
||||
<span>{{ noteLabel(step.status) }}</span>
|
||||
{{ step.errorSummary }}
|
||||
</p>
|
||||
<p v-if="step.message">
|
||||
<span>执行消息</span>
|
||||
{{ step.message }}
|
||||
</p>
|
||||
<pre v-if="step.logExcerpt">{{ step.logExcerpt }}</pre>
|
||||
<p v-if="!step.errorSummary && !step.message && !step.logExcerpt">
|
||||
<span>节点摘要</span>
|
||||
{{ step.summary }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty-line">后端尚未返回异常摘要、执行提示或日志摘要。</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.run-execution-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
color: #7b8798;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: #172033;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-grid a {
|
||||
color: #1d5fd0;
|
||||
}
|
||||
|
||||
.failure-block {
|
||||
border-top: 1px solid #edf1f7;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.failure-header,
|
||||
.failure-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.failure-header strong,
|
||||
.failure-title strong {
|
||||
color: #172033;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.failure-header span {
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.failure-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.failure-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.failure-item.danger {
|
||||
border-color: #f0d5d5;
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
.failure-item p {
|
||||
margin: 8px 0 0;
|
||||
color: #4b5565;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.failure-item p span {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
color: #62572c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.failure-item.danger p span {
|
||||
color: #9f2f2f;
|
||||
}
|
||||
|
||||
.failure-item pre {
|
||||
max-height: 180px;
|
||||
margin: 10px 0 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: #172033;
|
||||
color: #eef4ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
margin-top: 10px;
|
||||
color: #7b8798;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px 0;
|
||||
}
|
||||
|
||||
.detail-grid dd + dt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { HealthState, ReleaseStatus, StepStatus } from '../types/devops'
|
||||
|
||||
const props = defineProps<{
|
||||
state: HealthState | ReleaseStatus | StepStatus
|
||||
text?: string
|
||||
}>()
|
||||
|
||||
const statusMap: Record<string, { label: string; type: 'success' | 'warning' | 'danger' | 'info' | 'primary' }> = {
|
||||
healthy: { label: '正常', type: 'success' },
|
||||
warning: { label: '待配置', type: 'warning' },
|
||||
error: { label: '异常', type: 'danger' },
|
||||
offline: { label: '离线', type: 'info' },
|
||||
queued: { label: '排队中', type: 'info' },
|
||||
running: { label: '运行中', type: 'primary' },
|
||||
success: { label: '成功', type: 'success' },
|
||||
failed: { label: '失败', type: 'danger' },
|
||||
canceled: { label: '已取消', type: 'info' },
|
||||
idle: { label: '空闲', type: 'info' },
|
||||
pending: { label: '等待中', type: 'info' },
|
||||
skipped: { label: '已跳过', type: 'info' },
|
||||
}
|
||||
|
||||
const status = computed(() => statusMap[props.state] || statusMap.offline)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag :type="status.type" effect="light" round>
|
||||
{{ text || status.label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChatLineRound,
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
DocumentChecked,
|
||||
Fold,
|
||||
Monitor,
|
||||
Operation,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
Tools,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import GlobalAgentDrawer from '../components/GlobalAgentDrawer.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const platform = usePlatformStore()
|
||||
const collapsed = ref(false)
|
||||
const agentOpen = ref(false)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const menus = [
|
||||
{ path: '/overview', label: '总览', icon: DataAnalysis },
|
||||
{ path: '/projects', label: '项目配置', icon: Operation },
|
||||
{ path: '/releases', label: '发布中心', icon: DocumentChecked },
|
||||
{ path: '/runs', label: '运行记录', icon: Tickets },
|
||||
{ path: '/settings', label: '系统配置', icon: Setting },
|
||||
]
|
||||
|
||||
const jenkinsStatus = computed(() => {
|
||||
return platform.integrations.find((item) => item.key === 'jenkins')?.state || 'offline'
|
||||
})
|
||||
|
||||
const apiModeLabel = computed(() => {
|
||||
return platform.dataSource === 'backend' ? '后端 API' : 'Mock 降级'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!platform.projects.length) {
|
||||
void platform.loadAll()
|
||||
}
|
||||
})
|
||||
|
||||
function logout() {
|
||||
session.logout()
|
||||
void router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer class="layout">
|
||||
<ElAside class="sidebar" :width="collapsed ? '72px' : '232px'">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<ElIcon>
|
||||
<Monitor />
|
||||
</ElIcon>
|
||||
<span v-if="!collapsed">DevOps 运维平台</span>
|
||||
</div>
|
||||
<ElMenu :default-active="activeMenu" router :collapse="collapsed" class="nav-menu">
|
||||
<ElMenuItem v-for="item in menus" :key="item.path" :index="item.path">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
<template #title>{{ item.label }}</template>
|
||||
</ElMenuItem>
|
||||
</ElMenu>
|
||||
</ElAside>
|
||||
|
||||
<ElContainer class="workspace" :class="{ 'agent-open': agentOpen }">
|
||||
<ElContainer class="content-shell">
|
||||
<ElHeader class="topbar">
|
||||
<div class="toolbar">
|
||||
<ElButton :icon="Fold" circle @click="collapsed = !collapsed" />
|
||||
<div class="env-strip">
|
||||
<span>本地开发</span>
|
||||
<StatusPill :state="jenkinsStatus" text="Jenkins" />
|
||||
<ElTag :type="platform.dataSource === 'backend' ? 'success' : 'info'" round>
|
||||
{{ apiModeLabel }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<ElButton :icon="ChatLineRound" :type="agentOpen ? 'primary' : 'default'" @click="agentOpen = !agentOpen">
|
||||
Agent
|
||||
</ElButton>
|
||||
<ElTag type="primary" effect="plain" round>
|
||||
<ElIcon><Connection /></ElIcon>
|
||||
{{ platform.dataSource === 'backend' ? '后端已联通' : '后端不可用时自动降级' }}
|
||||
</ElTag>
|
||||
<ElDropdown>
|
||||
<ElButton>
|
||||
{{ session.operator }}
|
||||
<ElIcon class="el-icon--right"><Tools /></ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem disabled>角色:{{ session.role }}</ElDropdownItem>
|
||||
<ElDropdownItem :icon="SwitchButton" @click="logout">退出登录</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</ElHeader>
|
||||
<ElMain class="main-area" v-loading="platform.loading">
|
||||
<RouterView />
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
<GlobalAgentDrawer v-if="agentOpen" @close="agentOpen = false" />
|
||||
</ElContainer>
|
||||
</ElContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
border-right: 1px solid #e0e6ef;
|
||||
background: #101828;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 9%);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand .el-icon {
|
||||
color: #7dd3fc;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
--el-menu-bg-color: #101828;
|
||||
--el-menu-text-color: #b8c2d6;
|
||||
--el-menu-active-color: #ffffff;
|
||||
--el-menu-hover-bg-color: #1d2939;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.content-shell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace.agent-open .content-shell {
|
||||
width: calc(100% - 420px);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid #e0e6ef;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.env-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #596578;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
padding: 22px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.workspace.agent-open .content-shell {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app')
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* 本地降级数据只模拟 DevOps 运维展示,不包含任何真实 token、webhook 或服务地址。
|
||||
*/
|
||||
import type {
|
||||
AgentCard,
|
||||
DashboardSummary,
|
||||
IntegrationConfigStatus,
|
||||
IntegrationStatus,
|
||||
PipelineStep,
|
||||
ProjectConfig,
|
||||
ReleaseRun,
|
||||
SystemSetting,
|
||||
} from '../types/devops'
|
||||
|
||||
export const releaseProcessXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_DevOpsRelease" targetNamespace="http://devops-platform.local/bpmn">
|
||||
<bpmn:process id="Process_Release" name="DevOps release pipeline" isExecutable="false">
|
||||
<bpmn:startEvent id="start-release" name="提交发布">
|
||||
<bpmn:outgoing>Flow_Start_Gitea</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:serviceTask id="gitea-check" name="Gitea ref 校验">
|
||||
<bpmn:incoming>Flow_Start_Gitea</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_Gitea_Jenkins</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="jenkins-build" name="Jenkins 构建">
|
||||
<bpmn:incoming>Flow_Gitea_Jenkins</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_Jenkins_Health</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="health-check" name="健康检查">
|
||||
<bpmn:incoming>Flow_Jenkins_Health</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_Health_Notify</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="notify-wecom" name="通知平台">
|
||||
<bpmn:incoming>Flow_Health_Notify</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_Notify_End</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:endEvent id="finish-release" name="归档审计">
|
||||
<bpmn:incoming>Flow_Notify_End</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_Start_Gitea" sourceRef="start-release" targetRef="gitea-check" />
|
||||
<bpmn:sequenceFlow id="Flow_Gitea_Jenkins" sourceRef="gitea-check" targetRef="jenkins-build" />
|
||||
<bpmn:sequenceFlow id="Flow_Jenkins_Health" sourceRef="jenkins-build" targetRef="health-check" />
|
||||
<bpmn:sequenceFlow id="Flow_Health_Notify" sourceRef="health-check" targetRef="notify-wecom" />
|
||||
<bpmn:sequenceFlow id="Flow_Notify_End" sourceRef="notify-wecom" targetRef="finish-release" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_Release">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_Release" bpmnElement="Process_Release">
|
||||
<bpmndi:BPMNShape id="Shape_Start" bpmnElement="start-release">
|
||||
<dc:Bounds x="120" y="145" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_Gitea" bpmnElement="gitea-check">
|
||||
<dc:Bounds x="210" y="123" width="118" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_Jenkins" bpmnElement="jenkins-build">
|
||||
<dc:Bounds x="380" y="123" width="118" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_Health" bpmnElement="health-check">
|
||||
<dc:Bounds x="550" y="123" width="118" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_Notify" bpmnElement="notify-wecom">
|
||||
<dc:Bounds x="720" y="123" width="118" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_End" bpmnElement="finish-release">
|
||||
<dc:Bounds x="900" y="145" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Edge_Start_Gitea" bpmnElement="Flow_Start_Gitea">
|
||||
<di:waypoint x="156" y="163" />
|
||||
<di:waypoint x="210" y="163" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Edge_Gitea_Jenkins" bpmnElement="Flow_Gitea_Jenkins">
|
||||
<di:waypoint x="328" y="163" />
|
||||
<di:waypoint x="380" y="163" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Edge_Jenkins_Health" bpmnElement="Flow_Jenkins_Health">
|
||||
<di:waypoint x="498" y="163" />
|
||||
<di:waypoint x="550" y="163" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Edge_Health_Notify" bpmnElement="Flow_Health_Notify">
|
||||
<di:waypoint x="668" y="163" />
|
||||
<di:waypoint x="720" y="163" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Edge_Notify_End" bpmnElement="Flow_Notify_End">
|
||||
<di:waypoint x="838" y="163" />
|
||||
<di:waypoint x="900" y="163" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>`
|
||||
|
||||
export const integrations: IntegrationStatus[] = [
|
||||
{
|
||||
key: 'jenkins',
|
||||
name: 'Jenkins',
|
||||
state: 'healthy',
|
||||
summary: 'folder jobs 已按 my-project 规划,等待后端真实接入',
|
||||
checkedAt: '2026-06-11 17:40',
|
||||
latencyMs: 86,
|
||||
},
|
||||
{
|
||||
key: 'gitea',
|
||||
name: 'Gitea',
|
||||
state: 'healthy',
|
||||
summary: '四个仓库 mock 同步完成,生产发布优先 tag',
|
||||
checkedAt: '2026-06-11 17:40',
|
||||
latencyMs: 42,
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
name: '通知平台',
|
||||
state: 'warning',
|
||||
summary: '机器人未配置,当前保留企微/飞书/通用 webhook 方案',
|
||||
checkedAt: '2026-06-11 17:38',
|
||||
},
|
||||
{
|
||||
key: 'llm',
|
||||
name: 'LLM Agent',
|
||||
state: 'warning',
|
||||
summary: 'baseURL/key/model 待后端提供,前端不保存密钥',
|
||||
checkedAt: '2026-06-11 17:38',
|
||||
},
|
||||
]
|
||||
|
||||
export const integrationConfigStatus: IntegrationConfigStatus = {
|
||||
checkedAt: '2026-06-11T17:40:00+08:00',
|
||||
integrations: [
|
||||
{
|
||||
key: 'jenkins',
|
||||
name: 'Jenkins',
|
||||
status: 'partial',
|
||||
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
|
||||
configured: ['JENKINS_BASE_URL', 'JENKINS_USERNAME'],
|
||||
missing: ['JENKINS_API_TOKEN'],
|
||||
optional: [],
|
||||
note: '前端只展示变量名,Jenkins 凭据由后端读取。',
|
||||
},
|
||||
{
|
||||
key: 'gitea',
|
||||
name: 'Gitea',
|
||||
status: 'configured',
|
||||
required: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
|
||||
configured: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
|
||||
missing: [],
|
||||
optional: ['GITEA_WEBHOOK_SECRET'],
|
||||
note: '仓库访问统一走后端 API。',
|
||||
},
|
||||
{
|
||||
key: 'notification',
|
||||
name: '通知平台',
|
||||
status: 'missing',
|
||||
required: ['NOTIFICATION_PROVIDER'],
|
||||
configured: [],
|
||||
missing: ['NOTIFICATION_PROVIDER'],
|
||||
optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'],
|
||||
note: '浏览器不保存 webhook;后端按 provider 投递。',
|
||||
},
|
||||
{
|
||||
key: 'llm',
|
||||
name: 'LLM Agent',
|
||||
status: 'partial',
|
||||
required: ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'],
|
||||
configured: ['LLM_MODEL'],
|
||||
missing: ['LLM_BASE_URL', 'LLM_API_KEY'],
|
||||
optional: [],
|
||||
note: '模型供应商只由后端代理调用。',
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
name: 'Database',
|
||||
status: 'configured',
|
||||
required: ['DATABASE_URL'],
|
||||
configured: ['DATABASE_URL'],
|
||||
missing: [],
|
||||
optional: ['USE_DATABASE_READS', 'PRISMA_CONNECT_ON_BOOT'],
|
||||
note: '数据库连接仅服务端可见。',
|
||||
},
|
||||
{
|
||||
key: 'redis',
|
||||
name: 'Redis',
|
||||
status: 'configured',
|
||||
required: ['REDIS_URL'],
|
||||
configured: ['REDIS_URL'],
|
||||
missing: [],
|
||||
optional: [],
|
||||
note: 'Redis 连接仅服务端可见。',
|
||||
},
|
||||
{
|
||||
key: 'secrets',
|
||||
name: 'Secrets',
|
||||
status: 'missing',
|
||||
required: ['SECRET_ENCRYPTION_KEY'],
|
||||
configured: [],
|
||||
missing: ['SECRET_ENCRYPTION_KEY'],
|
||||
optional: [],
|
||||
note: '应用密钥只在后端环境中读取。',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const projects: ProjectConfig[] = [
|
||||
{
|
||||
key: 'access-manage',
|
||||
name: 'access-manage',
|
||||
repository: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||
owner: 'my-project',
|
||||
repo: 'access-manage',
|
||||
serviceType: '后端服务',
|
||||
defaultBranch: 'develop',
|
||||
tagPolicy: '生产发布必须选择 v* tag',
|
||||
environments: [
|
||||
{
|
||||
name: 'test',
|
||||
label: '测试环境',
|
||||
branchPolicy: 'develop / feature/*',
|
||||
status: 'success',
|
||||
version: 'develop@8f31a2c',
|
||||
lastRunId: 'run-1048',
|
||||
lastReleasedAt: '2026-06-11 15:10',
|
||||
lastActor: 'ops-admin',
|
||||
jenkinsJob: 'my-project/access-manage-test',
|
||||
wecomRobot: 'devops-test',
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'v* tag',
|
||||
status: 'running',
|
||||
version: 'v2026.06.11-api.1',
|
||||
lastRunId: 'run-1052',
|
||||
lastReleasedAt: '2026-06-11 17:12',
|
||||
lastActor: 'ops-admin',
|
||||
jenkinsJob: 'my-project/access-manage-prod',
|
||||
wecomRobot: 'devops-prod',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'healthy',
|
||||
gitea: 'healthy',
|
||||
wecom: 'warning',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'role-admin',
|
||||
name: 'role-admin',
|
||||
repository: 'https://gitea.ops.mrzhan.top/my-project/role-admin.git',
|
||||
owner: 'my-project',
|
||||
repo: 'role-admin',
|
||||
serviceType: '管理后台',
|
||||
defaultBranch: 'develop',
|
||||
tagPolicy: '生产发布必须由 develop 合并后 tag',
|
||||
environments: [
|
||||
{
|
||||
name: 'test',
|
||||
label: '测试环境',
|
||||
branchPolicy: 'develop',
|
||||
status: 'success',
|
||||
version: 'develop@b42e18d',
|
||||
lastRunId: 'run-1043',
|
||||
lastReleasedAt: '2026-06-11 14:35',
|
||||
lastActor: 'frontend-worker',
|
||||
jenkinsJob: 'my-project/role-admin-test',
|
||||
wecomRobot: 'devops-test',
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'v* tag',
|
||||
status: 'idle',
|
||||
version: 'v2026.06.05-envsplit.7',
|
||||
lastRunId: 'run-1026',
|
||||
lastReleasedAt: '2026-06-05 16:02',
|
||||
lastActor: 'ops-admin',
|
||||
jenkinsJob: 'my-project/role-admin-prod',
|
||||
wecomRobot: 'devops-prod',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'healthy',
|
||||
gitea: 'healthy',
|
||||
wecom: 'healthy',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'role-user',
|
||||
name: 'role-user',
|
||||
repository: 'https://gitea.ops.mrzhan.top/my-project/role-user.git',
|
||||
owner: 'my-project',
|
||||
repo: 'role-user',
|
||||
serviceType: 'C 端员工应用',
|
||||
defaultBranch: 'develop',
|
||||
tagPolicy: '生产发布仅允许 release tag',
|
||||
environments: [
|
||||
{
|
||||
name: 'test',
|
||||
label: '测试环境',
|
||||
branchPolicy: 'develop / feature/*',
|
||||
status: 'failed',
|
||||
version: 'develop@4a09fd2',
|
||||
lastRunId: 'run-1050',
|
||||
lastReleasedAt: '2026-06-11 16:24',
|
||||
lastActor: 'frontend-worker',
|
||||
jenkinsJob: 'my-project/role-user-test',
|
||||
wecomRobot: 'devops-test',
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'v* tag',
|
||||
status: 'idle',
|
||||
version: 'v2026.06.05-envsplit.7',
|
||||
lastRunId: 'run-1028',
|
||||
lastReleasedAt: '2026-06-05 16:08',
|
||||
lastActor: 'ops-admin',
|
||||
jenkinsJob: 'my-project/role-user-prod',
|
||||
wecomRobot: 'devops-prod',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'error',
|
||||
gitea: 'healthy',
|
||||
wecom: 'healthy',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'my-resume',
|
||||
name: 'my-resume',
|
||||
repository: 'https://gitea.ops.mrzhan.top/my-project/my-resume.git',
|
||||
owner: 'my-project',
|
||||
repo: 'my-resume',
|
||||
serviceType: '个人站点',
|
||||
defaultBranch: 'master',
|
||||
tagPolicy: '生产发布允许 master 或 v* tag',
|
||||
environments: [
|
||||
{
|
||||
name: 'test',
|
||||
label: '测试环境',
|
||||
branchPolicy: 'master / feature/*',
|
||||
status: 'success',
|
||||
version: 'master@6c92e81',
|
||||
lastRunId: 'run-1039',
|
||||
lastReleasedAt: '2026-06-11 12:18',
|
||||
lastActor: 'frontend-worker',
|
||||
jenkinsJob: 'my-project/my-resume-test',
|
||||
wecomRobot: 'devops-test',
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'master 或 v* tag',
|
||||
status: 'success',
|
||||
version: 'v2026.06.10-resume.2',
|
||||
lastRunId: 'run-1033',
|
||||
lastReleasedAt: '2026-06-10 21:42',
|
||||
lastActor: 'ops-admin',
|
||||
jenkinsJob: 'my-project/my-resume-prod',
|
||||
wecomRobot: 'devops-prod',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'healthy',
|
||||
gitea: 'healthy',
|
||||
wecom: 'healthy',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'devops-platform-api',
|
||||
name: 'devops-platform-api',
|
||||
repository: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-api.git',
|
||||
owner: 'devops-platform',
|
||||
repo: 'devops-platform-api',
|
||||
serviceType: 'DevOps 后端',
|
||||
defaultBranch: 'master',
|
||||
tagPolicy: '合并到 master 即发布 devops.mrzhan.top API',
|
||||
environments: [
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'master',
|
||||
status: 'idle',
|
||||
version: 'master@local',
|
||||
lastRunId: '-',
|
||||
lastReleasedAt: '-',
|
||||
lastActor: '-',
|
||||
jenkinsJob: 'devops-platform/prod-devops-platform-api-master',
|
||||
wecomRobot: '通知平台待配置',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'warning',
|
||||
gitea: 'warning',
|
||||
wecom: 'warning',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'devops-platform-web',
|
||||
name: 'devops-platform-web',
|
||||
repository: 'https://gitea.ops.mrzhan.top/devops-platform/devops-platform-web.git',
|
||||
owner: 'devops-platform',
|
||||
repo: 'devops-platform-web',
|
||||
serviceType: 'DevOps 前端',
|
||||
defaultBranch: 'master',
|
||||
tagPolicy: '合并到 master 即发布 devops.mrzhan.top Web',
|
||||
environments: [
|
||||
{
|
||||
name: 'production',
|
||||
label: '生产环境',
|
||||
branchPolicy: 'master',
|
||||
status: 'idle',
|
||||
version: 'master@local',
|
||||
lastRunId: '-',
|
||||
lastReleasedAt: '-',
|
||||
lastActor: '-',
|
||||
jenkinsJob: 'devops-platform/prod-devops-platform-web-master',
|
||||
wecomRobot: '通知平台待配置',
|
||||
},
|
||||
],
|
||||
integrations: {
|
||||
jenkins: 'warning',
|
||||
gitea: 'warning',
|
||||
wecom: 'warning',
|
||||
llm: 'warning',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const baseSteps: PipelineStep[] = [
|
||||
{
|
||||
id: 'step-submit',
|
||||
bpmnNodeId: 'start-release',
|
||||
name: '提交发布单',
|
||||
owner: 'Platform',
|
||||
status: 'success',
|
||||
duration: '2s',
|
||||
summary: '记录操作者、项目、环境、ref 与备注。',
|
||||
},
|
||||
{
|
||||
id: 'step-gitea',
|
||||
bpmnNodeId: 'gitea-check',
|
||||
name: '校验 Gitea ref',
|
||||
owner: 'Gitea',
|
||||
status: 'success',
|
||||
duration: '5s',
|
||||
summary: '验证 ref 存在并读取最近 commit 摘要。',
|
||||
},
|
||||
{
|
||||
id: 'step-jenkins',
|
||||
bpmnNodeId: 'jenkins-build',
|
||||
name: '触发 Jenkins 构建',
|
||||
owner: 'Jenkins',
|
||||
status: 'running',
|
||||
duration: '2m 18s',
|
||||
summary: '进入 queue 后解析 build number,等待构建完成。',
|
||||
},
|
||||
{
|
||||
id: 'step-health',
|
||||
bpmnNodeId: 'health-check',
|
||||
name: '环境健康检查',
|
||||
owner: 'HealthCheck',
|
||||
status: 'pending',
|
||||
duration: '-',
|
||||
summary: '构建成功后检查服务可达性和关键接口。',
|
||||
},
|
||||
{
|
||||
id: 'step-wecom',
|
||||
bpmnNodeId: 'notify-wecom',
|
||||
name: '发送通知',
|
||||
owner: 'Notification',
|
||||
status: 'pending',
|
||||
duration: '-',
|
||||
summary: '按开始、成功、失败、取消模板投递到已配置通知平台。',
|
||||
},
|
||||
{
|
||||
id: 'step-archive',
|
||||
bpmnNodeId: 'finish-release',
|
||||
name: '归档审计',
|
||||
owner: 'Platform',
|
||||
status: 'pending',
|
||||
duration: '-',
|
||||
summary: '保存 run、step、审计日志和通知投递结果。',
|
||||
},
|
||||
]
|
||||
|
||||
const successSteps = baseSteps.map((step) => ({
|
||||
...step,
|
||||
status: 'success' as const,
|
||||
duration: step.duration === '-' ? '3s' : step.duration,
|
||||
}))
|
||||
|
||||
const failedSteps: PipelineStep[] = baseSteps.map((step) => {
|
||||
if (step.bpmnNodeId === 'jenkins-build') {
|
||||
return {
|
||||
...step,
|
||||
status: 'failed',
|
||||
duration: '1m 44s',
|
||||
summary: 'pnpm build 失败,mock 日志提示类型检查未通过。',
|
||||
}
|
||||
}
|
||||
|
||||
if (['health-check', 'notify-wecom', 'finish-release'].includes(step.bpmnNodeId)) {
|
||||
return {
|
||||
...step,
|
||||
status: 'skipped',
|
||||
summary: '上游构建失败,当前节点跳过。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
status: 'success',
|
||||
}
|
||||
})
|
||||
|
||||
export const runs: ReleaseRun[] = [
|
||||
{
|
||||
id: 'run-1052',
|
||||
projectKey: 'access-manage',
|
||||
projectName: 'access-manage',
|
||||
environment: 'production',
|
||||
ref: 'v2026.06.11-api.1',
|
||||
status: 'running',
|
||||
actor: 'ops-admin',
|
||||
startedAt: '2026-06-11 17:12',
|
||||
duration: '2m 25s',
|
||||
note: '生产 API 修复发布,等待健康检查。',
|
||||
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/access-manage-prod/1052',
|
||||
commitSummary: 'fix: stabilize access policy migration and health output',
|
||||
steps: baseSteps,
|
||||
},
|
||||
{
|
||||
id: 'run-1050',
|
||||
projectKey: 'role-user',
|
||||
projectName: 'role-user',
|
||||
environment: 'test',
|
||||
ref: 'develop@4a09fd2',
|
||||
status: 'failed',
|
||||
actor: 'frontend-worker',
|
||||
startedAt: '2026-06-11 16:24',
|
||||
duration: '1m 56s',
|
||||
note: '测试环境自动部署失败,等待诊断。',
|
||||
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/role-user-test/1050',
|
||||
commitSummary: 'feat: add employee workbench filters',
|
||||
steps: failedSteps,
|
||||
},
|
||||
{
|
||||
id: 'run-1048',
|
||||
projectKey: 'access-manage',
|
||||
projectName: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop@8f31a2c',
|
||||
status: 'success',
|
||||
actor: 'ops-admin',
|
||||
startedAt: '2026-06-11 15:10',
|
||||
duration: '4m 03s',
|
||||
note: '测试环境部署成功。',
|
||||
jenkinsBuildUrl: 'https://jenkins.ops.mrzhan.top/job/my-project/job/access-manage-test/1048',
|
||||
commitSummary: 'chore: update deployment env schema',
|
||||
steps: successSteps,
|
||||
},
|
||||
]
|
||||
|
||||
export const agentCards: AgentCard[] = [
|
||||
{
|
||||
mode: 'risk',
|
||||
title: '发布风险摘要',
|
||||
description: '基于 ref diff、项目配置、历史失败记录生成检查项。',
|
||||
inputScope: ['项目', '环境', 'ref/tag', '最近失败 run'],
|
||||
outputPreview: ['配置变更影响范围', '构建和迁移风险', '发布前人工确认项'],
|
||||
},
|
||||
{
|
||||
mode: 'diagnosis',
|
||||
title: '失败诊断',
|
||||
description: '只读取 Jenkins 日志片段、run steps 和已知 runbook。',
|
||||
inputScope: ['失败 run', '失败节点', '脱敏日志片段'],
|
||||
outputPreview: ['失败原因摘要', '下一步排查命令', '建议责任边界'],
|
||||
},
|
||||
{
|
||||
mode: 'runbook',
|
||||
title: 'Runbook 问答',
|
||||
description: '围绕平台文档、项目 README、历史发布记录回答运维问题。',
|
||||
inputScope: ['白名单文档', '项目', '环境'],
|
||||
outputPreview: ['操作步骤', '风险提醒', '需要升级给后端的事项'],
|
||||
},
|
||||
{
|
||||
mode: 'releaseNotes',
|
||||
title: '发布说明草稿',
|
||||
description: '根据 commit、PR 和 tag 生成生产发布说明草稿。',
|
||||
inputScope: ['commit 列表', 'PR 信息', 'tag'],
|
||||
outputPreview: ['变更摘要', '验证范围', '回滚提示'],
|
||||
},
|
||||
]
|
||||
|
||||
export const settings: SystemSetting[] = [
|
||||
{
|
||||
key: 'api-contract',
|
||||
name: 'API 契约来源',
|
||||
status: 'warning',
|
||||
owner: '后端 OpenAPI',
|
||||
contract: '本阶段使用 mock/devops.ts,联调后切换到后端 Swagger/OpenAPI。',
|
||||
boundary: '前端不写死真实 token,不直接访问 Jenkins/Gitea/LLM。',
|
||||
},
|
||||
{
|
||||
key: 'bpmn',
|
||||
name: 'BPMN 流程定义',
|
||||
status: 'healthy',
|
||||
owner: '平台流程配置',
|
||||
contract: 'step.bpmnNodeId 必须和 BPMN XML 节点 id 一致。',
|
||||
boundary: '前端只展示流程和状态,不推进状态机。',
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
name: 'Agent 边界',
|
||||
status: 'warning',
|
||||
owner: '后端 Agent proxy',
|
||||
contract: '只支持发布风险、失败诊断、Runbook、发布说明四类用途。',
|
||||
boundary: '无泛聊天入口;模型 key/baseURL/model 仅后端保存。',
|
||||
},
|
||||
{
|
||||
key: 'notification',
|
||||
name: '通知平台',
|
||||
status: 'warning',
|
||||
owner: '后端 notification adapter',
|
||||
contract: '通知模板由后端返回,后续可接企微、飞书或通用 webhook。',
|
||||
boundary: 'webhook 不进入前端,不在浏览器保存。',
|
||||
},
|
||||
]
|
||||
|
||||
export const dashboardSummary: DashboardSummary = {
|
||||
integrations,
|
||||
projects,
|
||||
runs,
|
||||
agentCards,
|
||||
settings,
|
||||
integrationConfig: integrationConfigStatus,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import MainLayout from '../layouts/MainLayout.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import OverviewView from '../views/OverviewView.vue'
|
||||
import ProjectsView from '../views/ProjectsView.vue'
|
||||
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
|
||||
import RunsView from '../views/RunsView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
redirect: '/overview',
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'overview',
|
||||
component: OverviewView,
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
component: ProjectsView,
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
name: 'releases',
|
||||
component: ReleaseCenterView,
|
||||
},
|
||||
{
|
||||
path: 'runs',
|
||||
name: 'runs',
|
||||
component: RunsView,
|
||||
},
|
||||
{
|
||||
path: 'agent',
|
||||
redirect: '/overview',
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: SettingsView,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const session = useSessionStore()
|
||||
|
||||
if (!to.meta.public && !session.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (to.name === 'login' && session.isAuthenticated) {
|
||||
return {
|
||||
name: 'overview',
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,875 @@
|
||||
/**
|
||||
* 前端唯一 DevOps API 入口;优先联调后端,失败时降级到本地 mock 保持页面可用。
|
||||
*/
|
||||
import {
|
||||
agentCards,
|
||||
dashboardSummary,
|
||||
integrationConfigStatus as mockIntegrationConfigStatus,
|
||||
integrations,
|
||||
projects as mockProjects,
|
||||
releaseProcessXml,
|
||||
runs as mockRuns,
|
||||
settings,
|
||||
} from '../mocks/devops'
|
||||
import type {
|
||||
AgentCard,
|
||||
AgentInvocation,
|
||||
AgentMode,
|
||||
AgentPurpose,
|
||||
CreateAgentInvocationPayload,
|
||||
CreateDeployRunPayload,
|
||||
DashboardSummary,
|
||||
EnvironmentName,
|
||||
HealthState,
|
||||
IntegrationConfigKey,
|
||||
IntegrationConfigState,
|
||||
IntegrationConfigStatus,
|
||||
IntegrationStatus,
|
||||
JenkinsSyncSummary,
|
||||
PipelineStep,
|
||||
ProjectConfig,
|
||||
ProjectRefs,
|
||||
ReleaseRun,
|
||||
ReleaseStatus,
|
||||
StepStatus,
|
||||
SystemSetting,
|
||||
} from '../types/devops'
|
||||
import { http } from './http'
|
||||
|
||||
type DataSource = 'backend' | 'mock'
|
||||
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
|
||||
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
|
||||
|
||||
interface BackendHealth {
|
||||
status: BackendHealthStatus
|
||||
checkedAt: string
|
||||
dependencies: Record<string, { status: BackendHealthStatus; message?: string }>
|
||||
}
|
||||
|
||||
interface BackendProject {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
repositoryUrl: string
|
||||
defaultBranch: string
|
||||
status: 'active' | 'archived'
|
||||
environments: Array<{
|
||||
name: EnvironmentName
|
||||
displayName: string
|
||||
jenkinsJobPath: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface BackendDeployRunStep {
|
||||
id?: string
|
||||
bpmnNodeId?: string
|
||||
name?: string
|
||||
status?: BackendRunStatus
|
||||
order?: number
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
message?: string
|
||||
errorSummary?: string
|
||||
logExcerpt?: string
|
||||
}
|
||||
|
||||
interface BackendDeployRun {
|
||||
id?: string
|
||||
projectKey?: string
|
||||
environment?: EnvironmentName
|
||||
ref?: string
|
||||
status?: BackendRunStatus
|
||||
trigger?: 'manual' | 'gitea_webhook' | 'retry'
|
||||
operator?: string
|
||||
jenkinsQueueId?: string | number
|
||||
jenkinsBuildNumber?: number
|
||||
jenkinsBuildUrl?: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
createdAt?: string
|
||||
steps?: BackendDeployRunStep[]
|
||||
}
|
||||
|
||||
interface BackendJenkinsSyncSummary {
|
||||
checkedAt: string
|
||||
totalCandidates: number
|
||||
syncedRuns: BackendDeployRun[]
|
||||
failedRuns: Array<{
|
||||
id: string
|
||||
projectKey: string
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface BackendProcessDefinition {
|
||||
key: 'release'
|
||||
version: string
|
||||
xml: string
|
||||
nodeIds: string[]
|
||||
}
|
||||
|
||||
let lastDataSource: DataSource = 'mock'
|
||||
|
||||
const wait = async () => new Promise((resolve) => window.setTimeout(resolve, 180))
|
||||
const integrationConfigKeys: IntegrationConfigKey[] = [
|
||||
'jenkins',
|
||||
'gitea',
|
||||
'notification',
|
||||
'wecom',
|
||||
'llm',
|
||||
'database',
|
||||
'redis',
|
||||
'secrets',
|
||||
]
|
||||
const redactedVariableName = '非变量名内容已隐藏'
|
||||
|
||||
export const devopsApi = {
|
||||
getLastDataSource(): DataSource {
|
||||
return lastDataSource
|
||||
},
|
||||
|
||||
async getDashboard(): Promise<DashboardSummary> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const [health, backendProjects, backendRuns, integrationConfig] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchProjects(),
|
||||
fetchRuns(),
|
||||
fetchIntegrationConfigStatusWithFallback(),
|
||||
])
|
||||
const adaptedProjects = toProjectConfigs(backendProjects)
|
||||
|
||||
return {
|
||||
integrations: toIntegrationStatuses(health),
|
||||
projects: adaptedProjects,
|
||||
runs: backendRuns.map((run) => toReleaseRun(run, adaptedProjects)),
|
||||
agentCards,
|
||||
settings: toSystemSettings(health, integrationConfig),
|
||||
integrationConfig,
|
||||
}
|
||||
},
|
||||
() => dashboardSummary,
|
||||
)
|
||||
},
|
||||
|
||||
async getIntegrations(): Promise<IntegrationStatus[]> {
|
||||
return withBackendFallback(
|
||||
async () => toIntegrationStatuses(await fetchHealth()),
|
||||
() => integrations,
|
||||
)
|
||||
},
|
||||
|
||||
async getProjects(): Promise<ProjectConfig[]> {
|
||||
return withBackendFallback(
|
||||
async () => toProjectConfigs(await fetchProjects()),
|
||||
() => mockProjects,
|
||||
)
|
||||
},
|
||||
|
||||
async getProjectRefs(projectKey: string, projectList = mockProjects): Promise<ProjectRefs> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.get<ProjectRefs>(`/projects/${projectKey}/refs`)
|
||||
return data
|
||||
},
|
||||
() => createFallbackRefs(projectKey, projectList),
|
||||
)
|
||||
},
|
||||
|
||||
async getRuns(projectList = mockProjects): Promise<ReleaseRun[]> {
|
||||
return withBackendFallback(
|
||||
async () => (await fetchRuns()).map((run) => toReleaseRun(run, projectList)),
|
||||
() => mockRuns,
|
||||
)
|
||||
},
|
||||
|
||||
async createDeployRun(
|
||||
payload: CreateDeployRunPayload,
|
||||
projectList = mockProjects,
|
||||
): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>('/deploy-runs', payload)
|
||||
return toReleaseRun(data, projectList)
|
||||
},
|
||||
() => createFallbackRun(payload, projectList, 'queued'),
|
||||
)
|
||||
},
|
||||
|
||||
async cancelDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/cancel`)
|
||||
return toReleaseRun(data, mockProjects)
|
||||
},
|
||||
() => ({
|
||||
...(source ?? mockRuns[0]),
|
||||
id: source?.id ?? runId,
|
||||
status: 'canceled',
|
||||
duration: source?.duration ?? '-',
|
||||
steps: (source?.steps ?? mockRuns[0].steps).map((step) => ({
|
||||
...step,
|
||||
status: step.status === 'success' ? 'success' : 'canceled',
|
||||
summary: step.status === 'success' ? step.summary : '人工取消,本节点未继续执行。',
|
||||
})),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async retryDeployRun(runId: string, source?: ReleaseRun): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/retry`)
|
||||
return toReleaseRun(data, mockProjects)
|
||||
},
|
||||
() =>
|
||||
createFallbackRun(
|
||||
{
|
||||
projectKey: source?.projectKey ?? 'access-manage',
|
||||
environment: source?.environment ?? 'test',
|
||||
ref: source?.ref ?? 'develop',
|
||||
operator: source?.actor ?? 'ops-admin',
|
||||
remark: source ? `重试 ${source.id}` : `重试 ${runId}`,
|
||||
},
|
||||
mockProjects,
|
||||
'queued',
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
async syncJenkinsRun(
|
||||
runId: string,
|
||||
source?: ReleaseRun,
|
||||
projectList = mockProjects,
|
||||
): Promise<ReleaseRun> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendDeployRun>(`/deploy-runs/${runId}/sync-jenkins`)
|
||||
return toReleaseRun(data, projectList)
|
||||
},
|
||||
() => source ?? mockRuns.find((run) => run.id === runId) ?? mockRuns[0],
|
||||
)
|
||||
},
|
||||
|
||||
async syncJenkinsRuns(projectList = mockProjects): Promise<JenkinsSyncSummary> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<BackendJenkinsSyncSummary>(
|
||||
'/deploy-runs/sync-jenkins',
|
||||
)
|
||||
return toJenkinsSyncSummary(data, projectList)
|
||||
},
|
||||
() => ({
|
||||
checkedAt: new Date().toISOString(),
|
||||
totalCandidates: 0,
|
||||
syncedRuns: [],
|
||||
failedRuns: [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async getAgentCards(): Promise<AgentCard[]> {
|
||||
await wait()
|
||||
return agentCards
|
||||
},
|
||||
|
||||
async getIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
||||
return withBackendFallback(
|
||||
async () => fetchIntegrationConfigStatus(),
|
||||
() => mockIntegrationConfigStatus,
|
||||
)
|
||||
},
|
||||
|
||||
async createAgentInvocation(
|
||||
payload: CreateAgentInvocationPayload,
|
||||
): Promise<AgentInvocation> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.post<AgentInvocation>('/agent/invocations', payload)
|
||||
return data
|
||||
},
|
||||
() => createFallbackAgentInvocation(payload),
|
||||
)
|
||||
},
|
||||
|
||||
async getSettings(): Promise<SystemSetting[]> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const [health, integrationConfig] = await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchIntegrationConfigStatusWithFallback(),
|
||||
])
|
||||
|
||||
return toSystemSettings(health, integrationConfig)
|
||||
},
|
||||
() => settings,
|
||||
)
|
||||
},
|
||||
|
||||
async getReleaseProcessXml(): Promise<string> {
|
||||
return withBackendFallback(
|
||||
async () => {
|
||||
const { data } = await http.get<BackendProcessDefinition>(
|
||||
'/process-definitions/release',
|
||||
)
|
||||
return data.xml
|
||||
},
|
||||
() => releaseProcessXml,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
async function withBackendFallback<T>(
|
||||
request: () => Promise<T>,
|
||||
fallback: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (import.meta.env.VITE_DEVOPS_USE_MOCK === 'true') {
|
||||
lastDataSource = 'mock'
|
||||
await wait()
|
||||
return fallback()
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await request()
|
||||
lastDataSource = 'backend'
|
||||
return result
|
||||
} catch {
|
||||
lastDataSource = 'mock'
|
||||
await wait()
|
||||
return fallback()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<BackendHealth> {
|
||||
const { data } = await http.get<BackendHealth>('/health')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<BackendProject[]> {
|
||||
const { data } = await http.get<BackendProject[]>('/projects')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchRuns(): Promise<BackendDeployRun[]> {
|
||||
const { data } = await http.get<BackendDeployRun[]>('/deploy-runs')
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
||||
const { data } = await http.get<IntegrationConfigStatus>('/settings/integration-config')
|
||||
return sanitizeIntegrationConfigStatus(data)
|
||||
}
|
||||
|
||||
async function fetchIntegrationConfigStatusWithFallback(): Promise<IntegrationConfigStatus> {
|
||||
try {
|
||||
return await fetchIntegrationConfigStatus()
|
||||
} catch {
|
||||
return mockIntegrationConfigStatus
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationStatuses(health: BackendHealth): IntegrationStatus[] {
|
||||
return [
|
||||
toIntegrationStatus('jenkins', 'Jenkins', health),
|
||||
toIntegrationStatus('gitea', 'Gitea', health),
|
||||
toIntegrationStatus('wecom', '通知平台', health),
|
||||
toIntegrationStatus('llm', 'LLM Agent', health),
|
||||
]
|
||||
}
|
||||
|
||||
function toIntegrationStatus(
|
||||
key: IntegrationStatus['key'],
|
||||
name: string,
|
||||
health: BackendHealth,
|
||||
): IntegrationStatus {
|
||||
const dependency = health.dependencies[key] ?? { status: 'unavailable' as const }
|
||||
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
state: toHealthState(dependency.status),
|
||||
summary: dependency.message || healthMessage(dependency.status),
|
||||
checkedAt: formatDateTime(health.checkedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeIntegrationConfigStatus(
|
||||
payload: IntegrationConfigStatus,
|
||||
): IntegrationConfigStatus {
|
||||
const integrations = Array.isArray(payload?.integrations) ? payload.integrations : []
|
||||
|
||||
return {
|
||||
checkedAt:
|
||||
typeof payload?.checkedAt === 'string'
|
||||
? payload.checkedAt
|
||||
: new Date().toISOString(),
|
||||
integrations: integrations
|
||||
.filter((item) => integrationConfigKeys.includes(item.key))
|
||||
.map((item) => {
|
||||
const required = sanitizeVariableNames(item.required)
|
||||
const configured = sanitizeVariableNames(item.configured)
|
||||
const missing = sanitizeVariableNames(item.missing)
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
name: typeof item.name === 'string' && item.name ? item.name : item.key,
|
||||
status: sanitizeIntegrationConfigState(item.status),
|
||||
required,
|
||||
configured,
|
||||
missing,
|
||||
optional: sanitizeVariableNames(item.optional),
|
||||
note: sanitizeIntegrationNote(item.note),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeVariableNames(values?: string[]): string[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
.map((value) =>
|
||||
/^[A-Z][A-Z0-9_]*$/.test(value) ? value : redactedVariableName,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeIntegrationConfigState(
|
||||
state: IntegrationConfigState,
|
||||
): IntegrationConfigState {
|
||||
return state === 'configured' || state === 'partial' || state === 'missing'
|
||||
? state
|
||||
: 'missing'
|
||||
}
|
||||
|
||||
function sanitizeIntegrationNote(note?: string): string | undefined {
|
||||
if (!note) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return note
|
||||
.replace(/https?:\/\/\S+/g, '[已隐藏地址]')
|
||||
.replace(/key=[^&\s]+/g, 'key=[已隐藏]')
|
||||
}
|
||||
|
||||
function toProjectConfigs(backendProjects: BackendProject[]): ProjectConfig[] {
|
||||
return backendProjects.map((project) => {
|
||||
const mockProject = mockProjects.find((item) => item.key === project.key)
|
||||
const repo = parseRepository(project.repositoryUrl, project.key)
|
||||
|
||||
return {
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
repository: project.repositoryUrl,
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
serviceType: mockProject?.serviceType ?? '服务',
|
||||
defaultBranch: project.defaultBranch,
|
||||
tagPolicy: mockProject?.tagPolicy ?? '生产发布优先选择 v* tag',
|
||||
environments: project.environments.map((environment) => {
|
||||
const mockEnvironment = mockProject?.environments.find(
|
||||
(item) => item.name === environment.name,
|
||||
)
|
||||
|
||||
return {
|
||||
name: environment.name,
|
||||
label: environment.displayName,
|
||||
branchPolicy:
|
||||
mockEnvironment?.branchPolicy ??
|
||||
(environment.name === 'production' ? 'v* tag' : project.defaultBranch),
|
||||
status: mockEnvironment?.status ?? 'idle',
|
||||
version: mockEnvironment?.version ?? '-',
|
||||
lastRunId: mockEnvironment?.lastRunId ?? '-',
|
||||
lastReleasedAt: mockEnvironment?.lastReleasedAt ?? '-',
|
||||
lastActor: mockEnvironment?.lastActor ?? '-',
|
||||
jenkinsJob: environment.jenkinsJobPath,
|
||||
wecomRobot: mockEnvironment?.wecomRobot ?? '后端通知配置',
|
||||
}
|
||||
}),
|
||||
integrations: mockProject?.integrations ?? {
|
||||
jenkins: 'warning',
|
||||
gitea: 'warning',
|
||||
wecom: 'warning',
|
||||
llm: 'warning',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toReleaseRun(run: BackendDeployRun, projectList: ProjectConfig[]): ReleaseRun {
|
||||
const projectKey = run.projectKey || 'unknown-project'
|
||||
const project = projectList.find((item) => item.key === projectKey)
|
||||
const ref = run.ref || '-'
|
||||
const trigger = run.trigger ? triggerLabel(run.trigger) : '-'
|
||||
const startedAt = run.startedAt ?? run.createdAt
|
||||
|
||||
return {
|
||||
id: run.id || `${projectKey}-${ref}-${run.createdAt ?? Date.now()}`,
|
||||
projectKey,
|
||||
projectName: project?.name ?? projectKey,
|
||||
environment: toEnvironmentName(run.environment),
|
||||
ref,
|
||||
status: toReleaseStatus(run.status),
|
||||
actor: run.operator || '-',
|
||||
startedAt: formatDateTime(startedAt),
|
||||
finishedAt: formatDateTime(run.finishedAt),
|
||||
duration: formatDuration(startedAt, run.finishedAt, run.status),
|
||||
note: trigger,
|
||||
jenkinsQueueId: formatJenkinsQueueId(run.jenkinsQueueId),
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber,
|
||||
jenkinsBuildUrl: run.jenkinsBuildUrl,
|
||||
jenkinsSummary: formatJenkinsBuild(run),
|
||||
commitSummary: ref === '-' ? trigger : `${ref} · ${trigger}`,
|
||||
steps: (Array.isArray(run.steps) ? run.steps : [])
|
||||
.slice()
|
||||
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
|
||||
.map(toPipelineStep),
|
||||
}
|
||||
}
|
||||
|
||||
function toJenkinsSyncSummary(
|
||||
summary: BackendJenkinsSyncSummary,
|
||||
projectList: ProjectConfig[],
|
||||
): JenkinsSyncSummary {
|
||||
return {
|
||||
checkedAt: summary.checkedAt,
|
||||
totalCandidates: summary.totalCandidates,
|
||||
syncedRuns: (Array.isArray(summary.syncedRuns) ? summary.syncedRuns : []).map((run) =>
|
||||
toReleaseRun(run, projectList),
|
||||
),
|
||||
failedRuns: Array.isArray(summary.failedRuns) ? summary.failedRuns : [],
|
||||
}
|
||||
}
|
||||
|
||||
function toPipelineStep(step: BackendDeployRunStep): PipelineStep {
|
||||
const bpmnNodeId = step.bpmnNodeId || step.id || 'unknown-step'
|
||||
const name = step.name || bpmnNodeId
|
||||
|
||||
return {
|
||||
id: step.id || bpmnNodeId,
|
||||
bpmnNodeId,
|
||||
name,
|
||||
owner: stepOwner(bpmnNodeId),
|
||||
status: toStepStatus(step.status),
|
||||
duration: formatDuration(step.startedAt, step.finishedAt, step.status),
|
||||
summary: step.errorSummary || step.message || name,
|
||||
startedAt: formatDateTime(step.startedAt),
|
||||
finishedAt: formatDateTime(step.finishedAt),
|
||||
message: step.message,
|
||||
errorSummary: step.errorSummary,
|
||||
logExcerpt: step.logExcerpt,
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackRun(
|
||||
payload: CreateDeployRunPayload,
|
||||
projectList: ProjectConfig[],
|
||||
status: ReleaseStatus,
|
||||
): ReleaseRun {
|
||||
const project = projectList.find((item) => item.key === payload.projectKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
return {
|
||||
id: payload.idempotencyKey || `local-${Date.now()}`,
|
||||
projectKey: payload.projectKey,
|
||||
projectName: project?.name ?? payload.projectKey,
|
||||
environment: payload.environment,
|
||||
ref: payload.ref,
|
||||
status,
|
||||
actor: payload.operator,
|
||||
startedAt: formatDateTime(now),
|
||||
duration: status === 'queued' ? '等待 Jenkins queue' : '-',
|
||||
note: payload.remark || '本地降级发布单',
|
||||
jenkinsSummary: '等待 Jenkins 返回',
|
||||
jenkinsBuildUrl: '',
|
||||
commitSummary: '本地降级:后端不可用时保留操作上下文',
|
||||
steps: mockRuns[0].steps.map((step) => ({
|
||||
...step,
|
||||
status: step.bpmnNodeId === 'start-release' ? 'success' : 'pending',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackAgentInvocation(
|
||||
payload: CreateAgentInvocationPayload,
|
||||
): AgentInvocation {
|
||||
return {
|
||||
id: `local-agent-${Date.now()}`,
|
||||
runId: payload.runId,
|
||||
projectKey: payload.projectKey,
|
||||
type: payload.type,
|
||||
status: 'success',
|
||||
promptSummary: payload.promptSummary,
|
||||
resultMarkdown: fallbackAgentResult(payload.type),
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackRefs(projectKey: string, projectList: ProjectConfig[]): ProjectRefs {
|
||||
const project = projectList.find((item) => item.key === projectKey)
|
||||
const defaultBranch = project?.defaultBranch ?? 'develop'
|
||||
|
||||
return {
|
||||
source: 'mock',
|
||||
branches: Array.from(new Set([defaultBranch, 'develop', 'master']))
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ name })),
|
||||
tags: [
|
||||
{ name: 'v2026.06.05-envsplit.7' },
|
||||
{ name: 'v2026.06.11-api.1' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function toHealthState(status: BackendHealthStatus): HealthState {
|
||||
if (status === 'ok') {
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
if (status === 'not_configured') {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function toReleaseStatus(status?: BackendRunStatus): ReleaseStatus {
|
||||
if (status === 'pending') {
|
||||
return 'queued'
|
||||
}
|
||||
|
||||
if (
|
||||
status === 'queued' ||
|
||||
status === 'running' ||
|
||||
status === 'success' ||
|
||||
status === 'failed' ||
|
||||
status === 'canceled'
|
||||
) {
|
||||
return status
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function toStepStatus(status?: BackendRunStatus): StepStatus {
|
||||
if (status === 'pending') {
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
if (
|
||||
status === 'queued' ||
|
||||
status === 'running' ||
|
||||
status === 'success' ||
|
||||
status === 'failed' ||
|
||||
status === 'canceled'
|
||||
) {
|
||||
return status
|
||||
}
|
||||
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function stepOwner(nodeId: string): PipelineStep['owner'] {
|
||||
if (nodeId.includes('gitea')) {
|
||||
return 'Gitea'
|
||||
}
|
||||
|
||||
if (nodeId.includes('jenkins')) {
|
||||
return 'Jenkins'
|
||||
}
|
||||
|
||||
if (nodeId.includes('health')) {
|
||||
return 'HealthCheck'
|
||||
}
|
||||
|
||||
if (nodeId.includes('wecom') || nodeId.includes('notify')) {
|
||||
return 'Notification'
|
||||
}
|
||||
|
||||
return 'Platform'
|
||||
}
|
||||
|
||||
function healthMessage(status: BackendHealthStatus): string {
|
||||
if (status === 'ok') {
|
||||
return '后端检查通过'
|
||||
}
|
||||
|
||||
if (status === 'not_configured') {
|
||||
return '本地未配置,等待接入真实密钥'
|
||||
}
|
||||
|
||||
return '后端检查不可用'
|
||||
}
|
||||
|
||||
function triggerLabel(trigger: BackendDeployRun['trigger']): string {
|
||||
if (trigger === 'gitea_webhook') {
|
||||
return 'Gitea 自动触发'
|
||||
}
|
||||
|
||||
if (trigger === 'retry') {
|
||||
return '失败后重试'
|
||||
}
|
||||
|
||||
return '人工触发'
|
||||
}
|
||||
|
||||
function formatJenkinsBuild(run: BackendDeployRun): string {
|
||||
if (run.jenkinsBuildNumber !== undefined && run.jenkinsBuildNumber !== null) {
|
||||
return `Jenkins build #${run.jenkinsBuildNumber}`
|
||||
}
|
||||
|
||||
if (run.jenkinsQueueId) {
|
||||
return `Jenkins queue ${run.jenkinsQueueId}`
|
||||
}
|
||||
|
||||
return '等待 Jenkins 返回'
|
||||
}
|
||||
|
||||
function formatJenkinsQueueId(queueId?: string | number): string | undefined {
|
||||
if (queueId === undefined || queueId === null || queueId === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return String(queueId)
|
||||
}
|
||||
|
||||
function toEnvironmentName(environment?: EnvironmentName): EnvironmentName {
|
||||
return environment === 'production' ? 'production' : 'test'
|
||||
}
|
||||
|
||||
function parseRepository(repositoryUrl: string, fallbackRepo: string) {
|
||||
try {
|
||||
const pathname = new URL(repositoryUrl).pathname.replace(/\.git$/, '')
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
const repo = parts.at(-1) ?? fallbackRepo
|
||||
const owner = parts.at(-2) ?? 'my-project'
|
||||
|
||||
return { owner, repo }
|
||||
} catch {
|
||||
return { owner: 'my-project', repo: fallbackRepo }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.format(date)
|
||||
.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
function formatDuration(
|
||||
startedAt?: string,
|
||||
finishedAt?: string,
|
||||
status?: BackendRunStatus,
|
||||
): string {
|
||||
if (!startedAt) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
if (!finishedAt && !['queued', 'running', 'pending'].includes(status ?? 'success')) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const started = new Date(startedAt)
|
||||
const finished = finishedAt ? new Date(finishedAt) : new Date()
|
||||
|
||||
if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const seconds = Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
||||
}
|
||||
|
||||
function toSystemSettings(
|
||||
health: BackendHealth,
|
||||
integrationConfig = mockIntegrationConfigStatus,
|
||||
): SystemSetting[] {
|
||||
const integrationByKey = new Map(
|
||||
integrationConfig.integrations.map((item) => [item.key, item]),
|
||||
)
|
||||
|
||||
return settings.map((item) => {
|
||||
if (item.key === 'api-contract') {
|
||||
return {
|
||||
...item,
|
||||
status: toHealthState(health.status),
|
||||
contract: '已优先读取 devops-platform-api 的统一 envelope;后端不可用时降级到本地 mock。',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.key === 'agent') {
|
||||
return {
|
||||
...item,
|
||||
status: toHealthStateFromConfig(integrationByKey.get('llm')?.status),
|
||||
}
|
||||
}
|
||||
|
||||
if (item.key === 'notification') {
|
||||
const notificationConfig = integrationByKey.get('notification') ?? integrationByKey.get('wecom')
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: toHealthStateFromConfig(notificationConfig?.status),
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function toHealthStateFromConfig(state?: IntegrationConfigState): HealthState {
|
||||
if (state === 'configured') {
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
if (state === 'partial') {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function fallbackAgentResult(type: AgentPurpose): string {
|
||||
const mode: Record<AgentPurpose, AgentMode> = {
|
||||
'release-risk': 'risk',
|
||||
'failure-diagnosis': 'diagnosis',
|
||||
'runbook-qa': 'runbook',
|
||||
'release-note': 'releaseNotes',
|
||||
'incident-review': 'diagnosis',
|
||||
}
|
||||
const card = agentCards.find((item) => item.mode === mode[type])
|
||||
|
||||
return [
|
||||
`${card?.title ?? 'Agent'} 本地降级结果`,
|
||||
'后端或 LLM 未配置时,前端只保留 DevOps 场景输入,不保存任何模型密钥。',
|
||||
].join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* DevOps 平台统一 HTTP 实例,只识别后端 envelope,不承接任何第三方密钥。
|
||||
*/
|
||||
export const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 12_000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = unwrapApiPayload(response.data)
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
const message =
|
||||
error.response?.data?.error?.message ||
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'请求失败,请稍后重试'
|
||||
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
)
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapApiPayload(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) {
|
||||
return payload
|
||||
}
|
||||
|
||||
if (isApiEnvelope(payload)) {
|
||||
const envelope = payload
|
||||
|
||||
if (envelope.success) {
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
throw new Error(envelope.error?.message || '请求失败,请稍后重试')
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 1 && 'data' in payload) {
|
||||
return payload.data
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
|
||||
return isRecord(value) && typeof value.success === 'boolean'
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
import type {
|
||||
AgentCard,
|
||||
CreateDeployRunPayload,
|
||||
IntegrationStatus,
|
||||
IntegrationConfigStatus,
|
||||
ProjectConfig,
|
||||
ProjectRefs,
|
||||
JenkinsSyncSummary,
|
||||
ReleaseRun,
|
||||
SystemSetting,
|
||||
} from '../types/devops'
|
||||
|
||||
/**
|
||||
* 平台运行态聚合后端和 mock 降级数据,页面只消费统一后的展示模型。
|
||||
*/
|
||||
export const usePlatformStore = defineStore('platform', () => {
|
||||
const loading = ref(false)
|
||||
const dataSource = ref<'backend' | 'mock'>('mock')
|
||||
const projects = ref<ProjectConfig[]>([])
|
||||
const runs = ref<ReleaseRun[]>([])
|
||||
const integrations = ref<IntegrationStatus[]>([])
|
||||
const agentCards = ref<AgentCard[]>([])
|
||||
const settings = ref<SystemSetting[]>([])
|
||||
const integrationConfig = ref<IntegrationConfigStatus>({
|
||||
checkedAt: '',
|
||||
integrations: [],
|
||||
})
|
||||
const releaseProcessXml = ref('')
|
||||
const projectRefs = ref<Record<string, ProjectRefs>>({})
|
||||
|
||||
const failedRuns = computed(() => runs.value.filter((run) => run.status === 'failed'))
|
||||
const runningRuns = computed(() =>
|
||||
runs.value.filter((run) => run.status === 'queued' || run.status === 'running'),
|
||||
)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const summary = await devopsApi.getDashboard()
|
||||
projects.value = summary.projects
|
||||
runs.value = summary.runs
|
||||
integrations.value = summary.integrations
|
||||
agentCards.value = summary.agentCards
|
||||
settings.value = summary.settings
|
||||
integrationConfig.value = summary.integrationConfig
|
||||
releaseProcessXml.value = await devopsApi.getReleaseProcessXml()
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeployRun(payload: CreateDeployRunPayload) {
|
||||
const run = await devopsApi.createDeployRun(payload, projects.value)
|
||||
upsertRun(run)
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return run
|
||||
}
|
||||
|
||||
async function cancelDeployRun(run: ReleaseRun) {
|
||||
const canceled = await devopsApi.cancelDeployRun(run.id, run)
|
||||
upsertRun(canceled)
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return canceled
|
||||
}
|
||||
|
||||
async function retryDeployRun(run: ReleaseRun) {
|
||||
const retried = await devopsApi.retryDeployRun(run.id, run)
|
||||
upsertRun(retried)
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return retried
|
||||
}
|
||||
|
||||
async function syncJenkinsRun(run: ReleaseRun) {
|
||||
const synced = await devopsApi.syncJenkinsRun(run.id, run, projects.value)
|
||||
upsertRun(synced)
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return synced
|
||||
}
|
||||
|
||||
async function syncJenkinsRuns(): Promise<JenkinsSyncSummary> {
|
||||
const result = await devopsApi.syncJenkinsRuns(projects.value)
|
||||
result.syncedRuns.forEach(upsertRun)
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadProjectRefs(projectKey: string) {
|
||||
const refs = await devopsApi.getProjectRefs(projectKey, projects.value)
|
||||
projectRefs.value = {
|
||||
...projectRefs.value,
|
||||
[projectKey]: refs,
|
||||
}
|
||||
dataSource.value = devopsApi.getLastDataSource()
|
||||
return refs
|
||||
}
|
||||
|
||||
function upsertRun(run: ReleaseRun) {
|
||||
runs.value = [run, ...runs.value.filter((item) => item.id !== run.id)]
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
dataSource,
|
||||
projects,
|
||||
runs,
|
||||
integrations,
|
||||
agentCards,
|
||||
settings,
|
||||
integrationConfig,
|
||||
releaseProcessXml,
|
||||
projectRefs,
|
||||
failedRuns,
|
||||
runningRuns,
|
||||
loadAll,
|
||||
createDeployRun,
|
||||
cancelDeployRun,
|
||||
retryDeployRun,
|
||||
syncJenkinsRun,
|
||||
syncJenkinsRuns,
|
||||
loadProjectRefs,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
const operator = ref('ops-admin')
|
||||
const role = ref<'admin' | 'operator' | 'viewer'>('admin')
|
||||
const token = ref('mock-session')
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value))
|
||||
|
||||
function login(name: string) {
|
||||
operator.value = name || 'ops-admin'
|
||||
token.value = 'mock-session'
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
operator,
|
||||
role,
|
||||
token,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
@import 'element-plus/dist/index.css';
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
color: #172033;
|
||||
background: #f5f7fb;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 8px 0 0;
|
||||
color: #697386;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
color: #182033;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
color: #748093;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #748093;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.three-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.list-row strong {
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-row small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.el-card,
|
||||
.el-table,
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-button,
|
||||
.el-tag {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: #f7f9fc;
|
||||
color: #4b5565;
|
||||
}
|
||||
|
||||
.danger-note {
|
||||
border: 1px solid #ffd9d9;
|
||||
background: #fff7f7;
|
||||
color: #a63232;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.status-grid,
|
||||
.three-column,
|
||||
.section-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.three-column,
|
||||
.section-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* DevOps 运维平台前端展示模型,所有第三方集成密钥只允许以变量名状态呈现。
|
||||
*/
|
||||
export type HealthState = 'healthy' | 'warning' | 'error' | 'offline'
|
||||
|
||||
export type EnvironmentName = 'test' | 'production'
|
||||
|
||||
export type ReleaseStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
| 'idle'
|
||||
|
||||
export type StepStatus =
|
||||
| 'pending'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
| 'canceled'
|
||||
|
||||
export type AgentMode = 'risk' | 'diagnosis' | 'runbook' | 'releaseNotes'
|
||||
|
||||
export type AgentPurpose =
|
||||
| 'release-risk'
|
||||
| 'failure-diagnosis'
|
||||
| 'runbook-qa'
|
||||
| 'release-note'
|
||||
| 'incident-review'
|
||||
|
||||
export type IntegrationConfigKey =
|
||||
| 'jenkins'
|
||||
| 'gitea'
|
||||
| 'notification'
|
||||
| 'wecom'
|
||||
| 'llm'
|
||||
| 'database'
|
||||
| 'redis'
|
||||
| 'secrets'
|
||||
|
||||
export type IntegrationConfigState = 'configured' | 'partial' | 'missing'
|
||||
|
||||
/**
|
||||
* 后端安全配置检查结果;数组字段只承载变量名,不承载任何密钥值或 URL 原文。
|
||||
*/
|
||||
export interface IntegrationConfigItem {
|
||||
key: IntegrationConfigKey
|
||||
name: string
|
||||
status: IntegrationConfigState
|
||||
required: string[]
|
||||
configured: string[]
|
||||
missing: string[]
|
||||
optional?: string[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* /settings/integration-config 的前端模型,已由 Axios envelope 解包。
|
||||
*/
|
||||
export interface IntegrationConfigStatus {
|
||||
checkedAt: string
|
||||
integrations: IntegrationConfigItem[]
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
key: 'jenkins' | 'gitea' | 'wecom' | 'llm'
|
||||
name: string
|
||||
state: HealthState
|
||||
summary: string
|
||||
checkedAt: string
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
export interface EnvironmentStatus {
|
||||
name: EnvironmentName
|
||||
label: string
|
||||
branchPolicy: string
|
||||
status: ReleaseStatus
|
||||
version: string
|
||||
lastRunId: string
|
||||
lastReleasedAt: string
|
||||
lastActor: string
|
||||
jenkinsJob: string
|
||||
wecomRobot: string
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
key: string
|
||||
name: string
|
||||
repository: string
|
||||
owner: string
|
||||
repo: string
|
||||
serviceType: string
|
||||
defaultBranch: string
|
||||
tagPolicy: string
|
||||
environments: EnvironmentStatus[]
|
||||
integrations: {
|
||||
jenkins: HealthState
|
||||
gitea: HealthState
|
||||
wecom: HealthState
|
||||
llm: HealthState
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectRefs {
|
||||
source: 'gitea' | 'fallback' | 'mock'
|
||||
branches: Array<{
|
||||
name: string
|
||||
commitSha?: string
|
||||
}>
|
||||
tags: Array<{
|
||||
name: string
|
||||
commitSha?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string
|
||||
bpmnNodeId: string
|
||||
name: string
|
||||
owner: 'Gitea' | 'Jenkins' | 'HealthCheck' | 'Notification' | 'Platform'
|
||||
status: StepStatus
|
||||
duration: string
|
||||
summary: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
message?: string
|
||||
errorSummary?: string
|
||||
logExcerpt?: string
|
||||
}
|
||||
|
||||
export interface ReleaseRun {
|
||||
id: string
|
||||
projectKey: string
|
||||
projectName: string
|
||||
environment: EnvironmentName
|
||||
ref: string
|
||||
status: ReleaseStatus
|
||||
actor: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
duration: string
|
||||
note: string
|
||||
jenkinsQueueId?: string
|
||||
jenkinsBuildNumber?: number
|
||||
jenkinsBuildUrl?: string
|
||||
jenkinsSummary?: string
|
||||
commitSummary: string
|
||||
steps: PipelineStep[]
|
||||
}
|
||||
|
||||
export interface JenkinsSyncError {
|
||||
id: string
|
||||
projectKey: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface JenkinsSyncSummary {
|
||||
checkedAt: string
|
||||
totalCandidates: number
|
||||
syncedRuns: ReleaseRun[]
|
||||
failedRuns: JenkinsSyncError[]
|
||||
}
|
||||
|
||||
export interface AgentCard {
|
||||
mode: AgentMode
|
||||
title: string
|
||||
description: string
|
||||
inputScope: string[]
|
||||
outputPreview: string[]
|
||||
}
|
||||
|
||||
export interface SystemSetting {
|
||||
key: string
|
||||
name: string
|
||||
status: HealthState
|
||||
owner: string
|
||||
contract: string
|
||||
boundary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 首屏聚合数据,系统配置页通过 integrationConfig 展示真实安全配置状态。
|
||||
*/
|
||||
export interface DashboardSummary {
|
||||
integrations: IntegrationStatus[]
|
||||
projects: ProjectConfig[]
|
||||
runs: ReleaseRun[]
|
||||
agentCards: AgentCard[]
|
||||
settings: SystemSetting[]
|
||||
integrationConfig: IntegrationConfigStatus
|
||||
}
|
||||
|
||||
export interface CreateDeployRunPayload {
|
||||
projectKey: string
|
||||
environment: EnvironmentName
|
||||
ref: string
|
||||
operator: string
|
||||
remark?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface AgentInvocation {
|
||||
id: string
|
||||
runId?: string
|
||||
projectKey?: string
|
||||
type: AgentPurpose
|
||||
status: 'pending' | 'running' | 'success' | 'failed'
|
||||
promptSummary: string
|
||||
resultMarkdown?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateAgentInvocationPayload {
|
||||
type: AgentPurpose
|
||||
runId?: string
|
||||
projectKey?: string
|
||||
promptSummary: string
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref } from 'vue'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { AgentMode, AgentPurpose } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
const selectedMode = ref<AgentMode>('risk')
|
||||
const selectedProject = ref('access-manage')
|
||||
const selectedRun = ref('run-1050')
|
||||
const runbookQuestion = ref('测试环境 Jenkins 构建失败时应该先看哪些信息?')
|
||||
const generating = ref(false)
|
||||
const resultMarkdown = ref('')
|
||||
|
||||
const activeCard = computed(() => {
|
||||
return platform.agentCards.find((card) => card.mode === selectedMode.value)
|
||||
})
|
||||
|
||||
const preview = computed(() => {
|
||||
if (selectedMode.value === 'risk') {
|
||||
return ['检查生产 tag 是否来自 develop 合并记录', '确认 Jenkins prod job 参数模板已配置', '通知平台未配置时不阻断发布流程']
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
return ['失败节点:Jenkins 构建', '建议先查看 pnpm build 类型错误和 env 注入', '重试前确认依赖锁文件未被并行任务改写']
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'runbook') {
|
||||
return ['查看 run steps 中的失败节点和 build URL', '读取 progressiveText 的最后 200 行', '若为配置缺失,升级到后端维护密钥和 Job mapping']
|
||||
}
|
||||
|
||||
return ['发布对象、环境、tag 和操作者', '主要变更和验证范围', '回滚入口与通知对象']
|
||||
})
|
||||
|
||||
const purpose = computed<AgentPurpose>(() => {
|
||||
if (selectedMode.value === 'diagnosis') {
|
||||
return 'failure-diagnosis'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'runbook') {
|
||||
return 'runbook-qa'
|
||||
}
|
||||
|
||||
if (selectedMode.value === 'releaseNotes') {
|
||||
return 'release-note'
|
||||
}
|
||||
|
||||
return 'release-risk'
|
||||
})
|
||||
|
||||
async function generateAgentResult() {
|
||||
generating.value = true
|
||||
try {
|
||||
const invocation = await devopsApi.createAgentInvocation({
|
||||
type: purpose.value,
|
||||
runId: selectedMode.value === 'diagnosis' ? selectedRun.value : undefined,
|
||||
projectKey: selectedProject.value,
|
||||
promptSummary:
|
||||
selectedMode.value === 'runbook'
|
||||
? runbookQuestion.value
|
||||
: `${activeCard.value?.title ?? 'Agent'} · ${selectedProject.value}`,
|
||||
})
|
||||
resultMarkdown.value = invocation.resultMarkdown || 'Agent 未返回内容'
|
||||
ElMessage.success('Agent 结果已生成')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Agent 辅助</h1>
|
||||
<p>仅提供 DevOps 场景能力:发布风险、失败诊断、Runbook 问答和发布说明草稿。</p>
|
||||
</div>
|
||||
<ElTag type="warning" effect="plain" round>无泛聊天入口</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="three-column">
|
||||
<button
|
||||
v-for="card in platform.agentCards"
|
||||
:key="card.mode"
|
||||
class="agent-card"
|
||||
:class="{ active: selectedMode === card.mode }"
|
||||
type="button"
|
||||
@click="selectedMode = card.mode"
|
||||
>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<span>{{ card.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ activeCard?.title }}</h2>
|
||||
<span>输入被限制在运维对象内</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="项目">
|
||||
<ElSelect v-model="selectedProject">
|
||||
<ElOption
|
||||
v-for="project in platform.projects"
|
||||
:key="project.key"
|
||||
:label="project.name"
|
||||
:value="project.key"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="selectedMode === 'diagnosis'" label="失败 Run">
|
||||
<ElSelect v-model="selectedRun">
|
||||
<ElOption
|
||||
v-for="run in platform.failedRuns"
|
||||
:key="run.id"
|
||||
:label="`${run.id} · ${run.projectName} · ${run.ref}`"
|
||||
:value="run.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="selectedMode === 'runbook'" label="Runbook 问题">
|
||||
<ElInput v-model="runbookQuestion" type="textarea" :rows="3" maxlength="120" show-word-limit />
|
||||
<div class="field-hint">问题必须围绕发布、构建、通知、流程状态或回滚。</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="selectedMode === 'releaseNotes'" label="发布 tag">
|
||||
<ElSelect model-value="v2026.06.11-api.1">
|
||||
<ElOption label="v2026.06.11-api.1" value="v2026.06.11-api.1" />
|
||||
<ElOption label="v2026.06.05-envsplit.7" value="v2026.06.05-envsplit.7" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElButton type="primary" :loading="generating" @click="generateAgentResult">
|
||||
生成结果
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>输出预览</h2>
|
||||
<span>后端代理 LLM 后替换</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="scope-block">
|
||||
<strong>允许输入</strong>
|
||||
<ElTag v-for="scope in activeCard?.inputScope" :key="scope" type="info" effect="plain">
|
||||
{{ scope }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="scope-block">
|
||||
<strong>预期输出</strong>
|
||||
<ul>
|
||||
<li v-for="item in preview" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="resultMarkdown" class="result-block">
|
||||
<strong>生成结果</strong>
|
||||
<p>{{ resultMarkdown }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-card {
|
||||
min-height: 118px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-card.active {
|
||||
border-color: #1d5fd0;
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.agent-card strong,
|
||||
.agent-card span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-card strong {
|
||||
color: #172033;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.agent-card span {
|
||||
margin-top: 10px;
|
||||
color: #697386;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.scope-block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.scope-block + .scope-block {
|
||||
border-top: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.scope-block strong {
|
||||
width: 100%;
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #596578;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.result-block {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.result-block strong {
|
||||
display: block;
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-block p {
|
||||
margin: 8px 0 0;
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { Lock, Monitor } from '@element-plus/icons-vue'
|
||||
import { reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
|
||||
const form = reactive({
|
||||
operator: 'ops-admin',
|
||||
password: 'mock-password',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
session.login(form.operator)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview'
|
||||
void router.push(redirect)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-copy">
|
||||
<ElIcon><Monitor /></ElIcon>
|
||||
<h1>DevOps 运维平台</h1>
|
||||
<p>第一阶段使用 mock session,仅用于本地控制台联调。真实身份、RBAC 和审计由后端接入后替换。</p>
|
||||
</div>
|
||||
<ElForm class="login-form" label-position="top" @submit.prevent="submit">
|
||||
<ElFormItem label="操作员">
|
||||
<ElInput v-model="form.operator" size="large" autocomplete="username" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="口令">
|
||||
<ElInput
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElButton type="primary" size="large" :icon="Lock" native-type="submit">
|
||||
进入控制台
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
width: min(860px, 100%);
|
||||
grid-template-columns: 1fr 360px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 50px rgb(15 23 42 / 10%);
|
||||
}
|
||||
|
||||
.login-copy {
|
||||
padding: 44px;
|
||||
border-right: 1px solid #e6ebf3;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.login-copy .el-icon {
|
||||
color: #1d5fd0;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
margin: 18px 0 10px;
|
||||
color: #111827;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-copy p {
|
||||
max-width: 420px;
|
||||
color: #697386;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 44px 36px;
|
||||
}
|
||||
|
||||
.login-form .el-button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.login-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-copy {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e6ebf3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, Cpu, Failed, Finished } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
|
||||
const successCount = computed(() => platform.runs.filter((run) => run.status === 'success').length)
|
||||
const failedCount = computed(() => platform.failedRuns.length)
|
||||
const runningCount = computed(() => platform.runningRuns.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>运维总览</h1>
|
||||
<p>集中查看纳管项目环境、最近发布、Jenkins/Gitea/通知平台/LLM 当前状态。</p>
|
||||
</div>
|
||||
<ElButton type="primary" plain @click="platform.loadAll">刷新数据</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<MetricTile label="纳管项目" :value="platform.projects.length" caption="含 DevOps 平台自管理项目" :icon="Cpu" />
|
||||
<MetricTile label="运行中发布" :value="runningCount" caption="当前 Jenkins 或健康检查处理中" :icon="Finished" tone="warning" />
|
||||
<MetricTile label="成功记录" :value="successCount" caption="最近 mock run 成功数" :icon="CircleCheck" tone="success" />
|
||||
<MetricTile label="失败提醒" :value="failedCount" caption="需要诊断或重试的 run" :icon="Failed" tone="danger" />
|
||||
</div>
|
||||
|
||||
<div class="section-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>集成状态</h2>
|
||||
<span>{{ platform.dataSource === 'backend' ? '来自后端 health' : '后端不可用,当前使用 mock' }}</span>
|
||||
</div>
|
||||
<div class="panel-body compact-list">
|
||||
<div v-for="item in platform.integrations" :key="item.key" class="list-row">
|
||||
<span>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.summary }}</small>
|
||||
</span>
|
||||
<span class="integration-tail">
|
||||
<StatusPill :state="item.state" />
|
||||
<small>{{ item.latencyMs ? `${item.latencyMs}ms` : item.checkedAt }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>最近发布</h2>
|
||||
<span>按 startedAt 倒序</span>
|
||||
</div>
|
||||
<div class="panel-body compact-list">
|
||||
<div v-for="run in platform.runs" :key="run.id" class="list-row">
|
||||
<span>
|
||||
<strong>{{ run.projectName }} · {{ run.ref }}</strong>
|
||||
<small>{{ run.environment }} · {{ run.actor }} · {{ run.duration }}</small>
|
||||
</span>
|
||||
<StatusPill :state="run.status" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>项目环境</h2>
|
||||
<span>测试/生产状态和集成摘要</span>
|
||||
</div>
|
||||
<div class="project-grid panel-body">
|
||||
<article v-for="project in platform.projects" :key="project.key" class="project-card">
|
||||
<div class="project-head">
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<small>{{ project.serviceType }}</small>
|
||||
</div>
|
||||
<ElTag type="info" round>{{ project.defaultBranch }}</ElTag>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<div v-for="env in project.environments" :key="env.name" class="env-row">
|
||||
<span>
|
||||
<strong>{{ env.label }}</strong>
|
||||
<small>{{ env.version }} · {{ env.lastReleasedAt }}</small>
|
||||
</span>
|
||||
<StatusPill :state="env.status" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.integration-tail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 14px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.project-head,
|
||||
.env-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-head strong,
|
||||
.env-row strong {
|
||||
display: block;
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-head small,
|
||||
.env-row small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.env-row {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>项目配置</h1>
|
||||
<p>第一版展示项目、环境、Jenkins Job、Gitea 仓库和通知通道绑定关系。</p>
|
||||
</div>
|
||||
<ElButton type="primary" plain disabled>新增项目</ElButton>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>项目清单</h2>
|
||||
<span>{{ platform.dataSource === 'backend' ? '由后端 projects 接口返回' : '当前使用本地 mock 配置' }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ElTable :data="platform.projects" row-key="key">
|
||||
<ElTableColumn type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="env-config">
|
||||
<div v-for="env in row.environments" :key="env.name" class="env-config-card">
|
||||
<div class="env-config-head">
|
||||
<strong>{{ env.label }}</strong>
|
||||
<StatusPill :state="env.status" />
|
||||
</div>
|
||||
<dl>
|
||||
<dt>分支策略</dt>
|
||||
<dd>{{ env.branchPolicy }}</dd>
|
||||
<dt>Jenkins Job</dt>
|
||||
<dd class="mono">{{ env.jenkinsJob }}</dd>
|
||||
<dt>通知通道</dt>
|
||||
<dd>{{ env.wecomRobot }}</dd>
|
||||
<dt>最近发布</dt>
|
||||
<dd>{{ env.lastRunId }} · {{ env.version }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="name" label="项目" min-width="150" />
|
||||
<ElTableColumn prop="serviceType" label="类型" min-width="130" />
|
||||
<ElTableColumn prop="repository" label="Gitea 仓库" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ row.repository }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="defaultBranch" label="默认分支" width="120" />
|
||||
<ElTableColumn prop="tagPolicy" label="Tag 策略" min-width="220" />
|
||||
<ElTableColumn label="集成状态" width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="status-tags">
|
||||
<StatusPill :state="row.integrations.jenkins" text="Jenkins" />
|
||||
<StatusPill :state="row.integrations.gitea" text="Gitea" />
|
||||
<StatusPill :state="row.integrations.wecom" text="通知" />
|
||||
<StatusPill :state="row.integrations.llm" text="LLM" />
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.env-config {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 14px 46px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.env-config-card {
|
||||
padding: 14px;
|
||||
border: 1px solid #e1e8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.env-config-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
color: #596578;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #7b8798;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.env-config {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
||||
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { EnvironmentName, ReleaseRun, ReleaseStatus } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
|
||||
const form = reactive({
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test' as EnvironmentName,
|
||||
ref: 'develop',
|
||||
note: '测试环境部署验证',
|
||||
confirmed: false,
|
||||
})
|
||||
|
||||
const localStatus = ref<ReleaseStatus>('idle')
|
||||
const activeRun = ref<ReleaseRun | null>(null)
|
||||
const submitting = ref(false)
|
||||
const refsLoading = ref(false)
|
||||
|
||||
const selectedProject = computed(() => {
|
||||
return platform.projects.find((project) => project.key === form.projectKey)
|
||||
})
|
||||
|
||||
const currentRefs = computed(() => platform.projectRefs[form.projectKey])
|
||||
|
||||
const environmentOptions = computed(() => {
|
||||
const environments = selectedProject.value?.environments ?? []
|
||||
|
||||
if (!environments.length) {
|
||||
return [
|
||||
{ label: '测试环境', value: 'test' },
|
||||
{ label: '生产环境', value: 'production' },
|
||||
]
|
||||
}
|
||||
|
||||
return environments.map((environment) => ({
|
||||
label: environment.label,
|
||||
value: environment.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedEnvironment = computed(() => {
|
||||
return selectedProject.value?.environments.find((environment) => environment.name === form.environment)
|
||||
})
|
||||
|
||||
const useBranchRefs = computed(() => {
|
||||
return selectedEnvironment.value?.branchPolicy === 'master'
|
||||
})
|
||||
|
||||
const refOptions = computed(() => {
|
||||
const refs = currentRefs.value
|
||||
|
||||
if (form.environment === 'production' && !useBranchRefs.value) {
|
||||
return refs?.tags.map((tag) => tag.name) ?? ['v2026.06.05-envsplit.7']
|
||||
}
|
||||
|
||||
return refs?.branches.map((branch) => branch.name) ?? [selectedProject.value?.defaultBranch ?? 'develop']
|
||||
})
|
||||
|
||||
const refSourceText = computed(() => {
|
||||
const source = currentRefs.value?.source
|
||||
|
||||
if (source === 'gitea') {
|
||||
return 'Gitea 实时 refs'
|
||||
}
|
||||
|
||||
if (source === 'fallback') {
|
||||
return '后端 fallback refs'
|
||||
}
|
||||
|
||||
return '本地 mock refs'
|
||||
})
|
||||
|
||||
const canTrigger = computed(() => {
|
||||
if (!form.projectKey || !form.environment || !form.ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
return form.environment === 'production' ? form.confirmed : true
|
||||
})
|
||||
|
||||
const shownRun = computed(() => {
|
||||
return activeRun.value || platform.runs[0]
|
||||
})
|
||||
|
||||
const canSyncJenkins = computed(() => {
|
||||
return shownRun.value?.status === 'queued' || shownRun.value?.status === 'running'
|
||||
})
|
||||
|
||||
async function triggerRelease() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const run = await platform.createDeployRun({
|
||||
projectKey: form.projectKey,
|
||||
environment: form.environment,
|
||||
ref: form.ref,
|
||||
operator: 'ops-admin',
|
||||
remark: form.note,
|
||||
idempotencyKey: `${form.projectKey}:${form.environment}:${form.ref}:${Date.now()}`,
|
||||
})
|
||||
activeRun.value = run
|
||||
localStatus.value = run.status
|
||||
ElMessage.success(platform.dataSource === 'backend' ? '发布单已提交到后端' : '后端不可用,已生成本地发布单')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRelease() {
|
||||
if (!shownRun.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const run = await platform.cancelDeployRun(shownRun.value)
|
||||
activeRun.value = run
|
||||
localStatus.value = run.status
|
||||
ElMessage.success('发布单已取消')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRelease() {
|
||||
if (!shownRun.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const run = await platform.retryDeployRun(shownRun.value)
|
||||
activeRun.value = run
|
||||
localStatus.value = run.status
|
||||
ElMessage.success(platform.dataSource === 'backend' ? '已提交重试发布单' : '后端不可用,已生成本地重试单')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncJenkinsRun() {
|
||||
if (!shownRun.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const run = await platform.syncJenkinsRun(shownRun.value)
|
||||
activeRun.value = run
|
||||
localStatus.value = run.status
|
||||
ElMessage.success(platform.dataSource === 'backend' ? '已同步 Jenkins 状态' : '后端不可用,保留本地状态')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectRefs() {
|
||||
if (!form.projectKey) {
|
||||
return
|
||||
}
|
||||
|
||||
refsLoading.value = true
|
||||
try {
|
||||
await platform.loadProjectRefs(form.projectKey)
|
||||
} finally {
|
||||
refsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function useFailedRun() {
|
||||
const failed = platform.failedRuns[0]
|
||||
if (!failed) {
|
||||
return
|
||||
}
|
||||
|
||||
form.projectKey = failed.projectKey
|
||||
form.environment = failed.environment
|
||||
form.ref = failed.ref
|
||||
form.note = `重试 ${failed.id}`
|
||||
form.confirmed = failed.environment !== 'production'
|
||||
localStatus.value = 'failed'
|
||||
activeRun.value = failed
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.projectKey, platform.projects.length],
|
||||
() => {
|
||||
const firstEnvironment = selectedProject.value?.environments[0]
|
||||
|
||||
if (firstEnvironment && !selectedProject.value?.environments.some((env) => env.name === form.environment)) {
|
||||
form.environment = firstEnvironment.name
|
||||
}
|
||||
void loadProjectRefs()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(refOptions, (options) => {
|
||||
if (options.length > 0 && !options.includes(form.ref)) {
|
||||
form.ref = options[0]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>发布中心</h1>
|
||||
<p>选择项目、环境和 ref/tag,填写备注后触发发布;生产环境必须完成二次确认。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<ElButton :icon="RefreshRight" @click="useFailedRun">载入最近失败</ElButton>
|
||||
<StatusPill :state="localStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>发布参数</h2>
|
||||
<span>{{ platform.dataSource === 'backend' ? '通过后端创建发布单' : '后端不可用时使用本地降级' }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ElForm label-position="top">
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="项目">
|
||||
<ElSelect v-model="form.projectKey" filterable>
|
||||
<ElOption
|
||||
v-for="project in platform.projects"
|
||||
:key="project.key"
|
||||
:label="project.name"
|
||||
:value="project.key"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="环境">
|
||||
<ElSegmented
|
||||
v-model="form.environment"
|
||||
:options="environmentOptions"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="ref / tag">
|
||||
<ElSelect
|
||||
v-model="form.ref"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
:loading="refsLoading"
|
||||
>
|
||||
<ElOption v-for="item in refOptions" :key="item" :label="item" :value="item" />
|
||||
</ElSelect>
|
||||
<div class="field-hint">
|
||||
{{ form.environment === 'production' ? selectedProject?.tagPolicy : selectedProject?.defaultBranch }}
|
||||
· {{ refSourceText }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="备注">
|
||||
<ElInput v-model="form.note" type="textarea" :rows="3" maxlength="120" show-word-limit />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
<ElAlert
|
||||
v-if="form.environment === 'production'"
|
||||
class="danger-note"
|
||||
title="生产发布需要二次确认:已确认 tag、回滚方案、通知对象和业务窗口。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElCheckbox v-model="form.confirmed" class="confirm-line">
|
||||
我确认本次发布参数、目标环境和通知范围无误
|
||||
</ElCheckbox>
|
||||
<div class="action-row">
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:disabled="!canTrigger || submitting"
|
||||
:loading="submitting"
|
||||
@click="triggerRelease"
|
||||
>
|
||||
触发发布
|
||||
</ElButton>
|
||||
<ElButton :icon="VideoPause" :disabled="localStatus !== 'queued' || submitting" @click="cancelRelease">
|
||||
取消
|
||||
</ElButton>
|
||||
<ElButton :icon="CircleClose" :disabled="localStatus !== 'failed' || submitting" @click="retryRelease">
|
||||
重试
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>当前发布单</h2>
|
||||
<div class="toolbar compact-toolbar">
|
||||
<ElButton
|
||||
size="small"
|
||||
:icon="RefreshRight"
|
||||
:disabled="!canSyncJenkins || submitting"
|
||||
:loading="submitting && canSyncJenkins"
|
||||
@click="syncJenkinsRun"
|
||||
>
|
||||
同步 Jenkins
|
||||
</ElButton>
|
||||
<StatusPill :state="shownRun?.status || 'idle'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" v-if="shownRun">
|
||||
<RunExecutionDetails :run="shownRun" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>BPMN 流程状态</h2>
|
||||
<span>step.bpmnNodeId 与 BPMN XML 节点 id 对齐后高亮</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<BpmnStatusViewer
|
||||
v-if="platform.releaseProcessXml && shownRun"
|
||||
:xml="platform.releaseProcessXml"
|
||||
:steps="shownRun.steps"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.confirm-line {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref } from 'vue'
|
||||
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
|
||||
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { ReleaseRun } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
const selectedRunId = ref(platform.runs[0]?.id || '')
|
||||
const syncingAll = ref(false)
|
||||
|
||||
const selectedRun = computed<ReleaseRun | undefined>(() => {
|
||||
return platform.runs.find((run) => run.id === selectedRunId.value) || platform.runs[0]
|
||||
})
|
||||
|
||||
const canSyncSelectedRun = computed(() => {
|
||||
return canSyncRun(selectedRun.value)
|
||||
})
|
||||
|
||||
const canSyncAnyRun = computed(() => platform.runs.some((run) => canSyncRun(run)))
|
||||
|
||||
function canSyncRun(run?: ReleaseRun): boolean {
|
||||
return (
|
||||
Boolean(run?.jenkinsQueueId || run?.jenkinsBuildNumber) &&
|
||||
(run?.status === 'queued' || run?.status === 'running')
|
||||
)
|
||||
}
|
||||
|
||||
async function syncSelectedRun() {
|
||||
if (!selectedRun.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const run = await platform.syncJenkinsRun(selectedRun.value)
|
||||
selectedRunId.value = run.id
|
||||
ElMessage.success(platform.dataSource === 'backend' ? '已同步 Jenkins 状态' : '后端不可用,保留本地状态')
|
||||
}
|
||||
|
||||
async function syncAllJenkinsRuns() {
|
||||
syncingAll.value = true
|
||||
try {
|
||||
const result = await platform.syncJenkinsRuns()
|
||||
if (result.failedRuns.length > 0) {
|
||||
ElMessage.warning(
|
||||
`已同步 ${result.syncedRuns.length} 个 run,${result.failedRuns.length} 个同步失败`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(
|
||||
result.totalCandidates > 0
|
||||
? `已同步 ${result.syncedRuns.length} 个 Jenkins run`
|
||||
: '当前没有需要同步的 Jenkins run',
|
||||
)
|
||||
} finally {
|
||||
syncingAll.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>运行记录</h1>
|
||||
<p>查看发布 run、Jenkins 构建链接、耗时、失败节点和流程轨迹。</p>
|
||||
</div>
|
||||
<ElButton
|
||||
:icon="RefreshRight"
|
||||
:loading="syncingAll"
|
||||
:disabled="!canSyncAnyRun"
|
||||
@click="syncAllJenkinsRuns"
|
||||
>
|
||||
同步全部 Jenkins
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Run 列表</h2>
|
||||
<span>点击行切换下方流程图</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ElTable
|
||||
:data="platform.runs"
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
@row-click="(row: ReleaseRun) => (selectedRunId = row.id)"
|
||||
>
|
||||
<ElTableColumn prop="id" label="Run ID" width="110" />
|
||||
<ElTableColumn prop="projectName" label="项目" min-width="140" />
|
||||
<ElTableColumn prop="environment" label="环境" width="120" />
|
||||
<ElTableColumn prop="ref" label="ref/tag" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ row.ref }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<StatusPill :state="row.status" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="actor" label="操作者" width="130" />
|
||||
<ElTableColumn prop="duration" label="耗时" width="100" />
|
||||
<ElTableColumn prop="note" label="备注" min-width="220" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" v-if="selectedRun">
|
||||
<div class="panel-header">
|
||||
<h2>{{ selectedRun.id }} 执行详情</h2>
|
||||
<div class="toolbar compact-toolbar">
|
||||
<ElButton
|
||||
size="small"
|
||||
:icon="RefreshRight"
|
||||
:disabled="!canSyncSelectedRun"
|
||||
@click="syncSelectedRun"
|
||||
>
|
||||
同步 Jenkins
|
||||
</ElButton>
|
||||
<StatusPill :state="selectedRun.status" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<RunExecutionDetails :run="selectedRun" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" v-if="selectedRun">
|
||||
<div class="panel-header">
|
||||
<h2>{{ selectedRun.id }} 流程轨迹</h2>
|
||||
<StatusPill :state="selectedRun.status" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<BpmnStatusViewer :xml="platform.releaseProcessXml" :steps="selectedRun.steps" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { IntegrationConfigState } from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
|
||||
const configStatusMeta: Record<
|
||||
IntegrationConfigState,
|
||||
{ label: string; type: 'success' | 'warning' | 'danger' }
|
||||
> = {
|
||||
configured: { label: '已配置', type: 'success' },
|
||||
partial: { label: '部分配置', type: 'warning' },
|
||||
missing: { label: '未配置', type: 'danger' },
|
||||
}
|
||||
|
||||
function configStatusLabel(status: IntegrationConfigState): string {
|
||||
return configStatusMeta[status]?.label ?? '未配置'
|
||||
}
|
||||
|
||||
function configStatusType(
|
||||
status: IntegrationConfigState,
|
||||
): 'success' | 'warning' | 'danger' {
|
||||
return configStatusMeta[status]?.type ?? 'danger'
|
||||
}
|
||||
|
||||
function formatCheckedAt(value: string): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>系统配置</h1>
|
||||
<p>展示后端集成配置状态、API 契约来源、BPMN 映射、Agent 边界和通知维护状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>后端集成配置状态</h2>
|
||||
<span>检查时间:{{ formatCheckedAt(platform.integrationConfig.checkedAt) }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="secret-boundary">
|
||||
这里只展示环境变量名和配置状态;Jenkins token、Gitea token、通知 webhook、LLM key、数据库连接串不会进入浏览器。
|
||||
</div>
|
||||
<div class="config-list">
|
||||
<article
|
||||
v-for="item in platform.integrationConfig.integrations"
|
||||
:key="item.key"
|
||||
class="config-row"
|
||||
>
|
||||
<div class="config-row-main">
|
||||
<div>
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p v-if="item.note">{{ item.note }}</p>
|
||||
</div>
|
||||
<ElTag :type="configStatusType(item.status)" effect="light" round>
|
||||
{{ configStatusLabel(item.status) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="variable-grid">
|
||||
<div class="variable-group">
|
||||
<strong>必需变量</strong>
|
||||
<div class="variable-tags">
|
||||
<ElTag v-for="name in item.required" :key="name" class="mono" effect="plain">
|
||||
{{ name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-group">
|
||||
<strong>已识别</strong>
|
||||
<div class="variable-tags">
|
||||
<ElTag
|
||||
v-for="name in item.configured"
|
||||
:key="name"
|
||||
class="mono"
|
||||
type="success"
|
||||
effect="plain"
|
||||
>
|
||||
{{ name }}
|
||||
</ElTag>
|
||||
<span v-if="item.configured.length === 0" class="empty-value">暂无</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-group">
|
||||
<strong>缺失</strong>
|
||||
<div class="variable-tags">
|
||||
<ElTag
|
||||
v-for="name in item.missing"
|
||||
:key="name"
|
||||
class="mono"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>
|
||||
{{ name }}
|
||||
</ElTag>
|
||||
<span v-if="item.missing.length === 0" class="empty-value">无</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-group">
|
||||
<strong>可选变量</strong>
|
||||
<div class="variable-tags">
|
||||
<ElTag
|
||||
v-for="name in item.optional ?? []"
|
||||
:key="name"
|
||||
class="mono"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
{{ name }}
|
||||
</ElTag>
|
||||
<span v-if="!item.optional?.length" class="empty-value">暂无</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section-grid">
|
||||
<section v-for="item in platform.settings" :key="item.key" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ item.name }}</h2>
|
||||
<StatusPill :state="item.status" />
|
||||
</div>
|
||||
<div class="panel-body setting-card">
|
||||
<dl>
|
||||
<dt>责任方</dt>
|
||||
<dd>{{ item.owner }}</dd>
|
||||
<dt>契约</dt>
|
||||
<dd>{{ item.contract }}</dd>
|
||||
<dt>边界</dt>
|
||||
<dd>{{ item.boundary }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>前端环境变量</h2>
|
||||
<span>第一阶段仅保留 API 地址</span>
|
||||
</div>
|
||||
<div class="panel-body compact-list">
|
||||
<div class="list-row">
|
||||
<span>
|
||||
<strong class="mono">VITE_API_BASE_URL</strong>
|
||||
<small>联调时指向后端网关;未配置时默认 /api。</small>
|
||||
</span>
|
||||
<ElTag type="info" round>{{ platform.dataSource === 'backend' ? '后端已联通' : '/api 或 mock 降级' }}</ElTag>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
<span>
|
||||
<strong>密钥策略</strong>
|
||||
<small>Jenkins token、Gitea token、通知 webhook、LLM key 均不得进入前端。</small>
|
||||
</span>
|
||||
<ElTag type="success" round>前端隔离</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.setting-card dt {
|
||||
color: #7b8798;
|
||||
}
|
||||
|
||||
.setting-card dd {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.secret-boundary {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #25508f;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
padding: 14px;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.config-row-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-row-main h3 {
|
||||
margin: 0;
|
||||
color: #182033;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.config-row-main p {
|
||||
margin: 6px 0 0;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.variable-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.variable-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variable-group strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #4b5565;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.variable-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.empty-value {
|
||||
color: #9aa4b2;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.variable-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.config-row-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.variable-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 4301,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user