feat: 完善发布运维前端体验

This commit is contained in:
湛兮
2026-06-12 17:53:41 +08:00
parent 3c964b68ed
commit 94649fe604
16 changed files with 428 additions and 277 deletions
+4 -4
View File
@@ -16,7 +16,7 @@
- 状态管理使用 Pinia。
- 路由使用 Vue Router。
- 请求封装使用 Axios,并集中在 `src/services/**`
- BPMN 展示使用 `bpmn-js`,不手写替代渲染器。
- 发布流程展示以后端 `run.steps` 为准,前端只展示步骤状态、节点详情和日志,不维护流程图 XML 渲染器。
- 不新增前端 mock 数据或接口失败降级;项目、发布、refs、流程和 Agent 调用统一走后端 API。
## API 与安全边界
@@ -25,9 +25,9 @@
- 前端不得直接调用 Jenkins、Gitea、企微、飞书或模型供应商;统一通过后端 API。
- API 契约以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 和后端 OpenAPI 为准。
## BPMN 与 Agent 边界
## 发布流程与 Agent 边界
- BPMN 组件只展示流程图、节点状态和节点详情,不推进状态机。
- `step.bpmnNodeId` 必须与 BPMN XML 节点 id 一致
- 发布步骤组件只展示后端步骤、节点状态和节点详情,不推进状态机。
- 后端返回的节点标识在前端统一映射为 `step.nodeId`,仅用于展示和定位步骤
- Agent 只能作为全局右侧抽屉工具存在,不作为独立侧边栏路由;能力只允许 DevOps 场景:发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。
- 禁止在前端新增泛聊天入口。
+5 -6
View File
@@ -1,6 +1,6 @@
# DevOps 运维平台前端
Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于管理本地项目和 DevOps 平台自身的发布状态、Jenkins/Gitea/通知平台/LLM 接入状态、BPMN 流程展示和 Agent 辅助能力。
Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于管理本地项目和 DevOps 平台自身的发布状态、Jenkins/Gitea/通知平台/LLM 接入状态、发布步骤轨迹和 Agent 辅助能力。
## 当前范围
@@ -12,7 +12,7 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
- 发布中心包含项目、环境、后端返回的 ref/tag、备注、发布前检查、生产发布回滚/窗口/通知确认、触发、取消、重试和 Jenkins 状态同步入口。
- 运行记录展示状态概览、项目/环境/状态/关键词筛选、Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要,并提供超级管理员导入 Jenkins 直接构建记录的入口。
- 系统配置页展示集成配置、Agent 密钥状态、通知 outbox 和审计日志概览,支持通知投递与审计记录筛选,用于定位配置缺失、通知失败和敏感操作。
- BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。
- 发布步骤轨迹区使用后端 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿,并通过后端 Agent API 创建调用记录。
## 技术栈
@@ -24,7 +24,6 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
- Pinia
- Vue Router
- Axios
- bpmn-js
## 本地启动
@@ -54,10 +53,10 @@ pnpm build
- 通知 webhook
- LLM API key/baseURL/model
## BPMN 边界
## 发布步骤边界
- 前端只负责 BPMN XML 展示和节点状态高亮
- `run.steps[].bpmnNodeId` 必须与 BPMN XML 节点 id 一致
- 前端只负责后端 `run.steps` 的状态、节点详情和日志展示,不渲染流程图 XML
- 后端返回的节点标识在前端统一映射为 `run.steps[].nodeId`,用于步骤选择、详情定位和日志联动
- 流程推进、失败停留、审计日志和状态机由后端负责。
## Agent 边界
+3 -3
View File
@@ -19,8 +19,8 @@
- `/overview`:总览,展示值班焦点、优先 Run 队列、项目发布健康、服务状态和项目环境快捷入口。
- `/projects`:项目配置,展示项目可发布性概览、发布态/权限筛选、Gitea 仓库、Jenkins Job、通知通道、环境策略和上下文跳转。
- `/members`:成员权限,仅超级管理员可见,展示账号/构建权限/安全状态概览,支持成员筛选,并维护普通成员、项目权限和密码重置消息。
- `/releases`:发布中心,提供发布参数、后端 ref/tag 选择、发布前检查、生产发布确认、触发、取消、重试和 BPMN 流程状态
- `/runs`:运行记录,展示 run 状态概览、筛选列表、执行详情、流程轨迹,并支持超级管理员导入 Jenkins 直接构建记录。
- `/releases`:发布中心,提供发布参数、后端 ref/tag 选择、发布前检查、生产发布确认、触发、取消、重试和发布步骤轨迹
- `/runs`:运行记录,展示 run 状态概览、筛选列表、执行详情、步骤轨迹,并支持超级管理员导入 Jenkins 直接构建记录。
- 顶部 Agent 按钮:打开全局右侧抽屉,只保留 DevOps 场景能力;`/agent` 仅重定向到总览,不作为独立侧边栏入口。
- `/settings`:系统配置,展示集成配置、Agent、通知 outbox 和审计日志概览,支持通知/审计筛选,并维护后端 Agent 模型配置。
@@ -37,7 +37,7 @@
- 发布中心触发按钮接入后端发布单创建接口。
- 运行记录接入后端 run/step 查询接口。
- Jenkins 直接构建不由前端直连 Jenkins;运行记录页调用后端 `/deploy-runs/import-jenkins` 扫描已纳管 job 并导入 run。
- BPMN XML 由后端流程定义接口返回
- 发布步骤轨迹以后端 run steps 为准,前端不再拉取或渲染流程图 XML
- Agent 面板调用后端 Agent proxy,前端只传 DevOps 场景参数。
- Agent 配置页调用后端 `/agent/config`,前端不持久化 key、baseURL 或 model。
- Agent 能力限定为发布风险摘要、失败诊断、Runbook 问答、发布说明草稿和事故复盘草稿。
-1
View File
@@ -12,7 +12,6 @@
"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",
-155
View File
@@ -14,9 +14,6 @@ importers:
axios:
specifier: ^1.17.0
version: 1.17.0
bpmn-js:
specifier: ^18.18.0
version: 18.18.0
element-plus:
specifier: ^2.14.1
version: 2.14.1(vue@3.5.38(typescript@6.0.3))
@@ -89,9 +86,6 @@ packages:
resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@bpmn-io/diagram-js-ui@0.2.4':
resolution: {integrity: sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==}
'@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
@@ -388,13 +382,6 @@ packages:
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
bpmn-js@18.18.0:
resolution: {integrity: sha512-3pTiqc5M+CRtXCvBjRBFEewqNKDnx0ytuCLucvMRfdhes/EuVr4memcsi6ql+q/1RUbycOL9MyfNcgrwSfnA1w==}
bpmn-moddle@10.0.0:
resolution: {integrity: sha512-vXePD5jkatcILmM3zwJG/m6IIHIghTGB7WvgcdEraEw8E8VdJHrTgrvBUhbzqaXJpnsGQz15QS936xeBY6l9aA==}
engines: {node: '>= 20.12'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -403,10 +390,6 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -444,22 +427,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
diagram-js-direct-editing@3.4.0:
resolution: {integrity: sha512-eXfd+nxJr5xt3YlCrs+E53mPi+gqBWRBkAXkR99ZPL7Pl0B3H5nkv/uiwx4Q4vJU/Sp8Jy3zVdhP/Y2c6loTjw==}
peerDependencies:
diagram-js: '*'
diagram-js@15.17.0:
resolution: {integrity: sha512-wOlnfzd7MmcUMwIpJiCiFScQ04hQMx4Ribir0ky4cfNgzwr7V1dhi7MdFuLyaMdseglMkBU7YYoqmzCDR8+JCw==}
didi@11.0.0:
resolution: {integrity: sha512-PzCfRzQttvFpVcYMbSF7h8EsWjeJpVjWH4qDhB5LkMi1ILvHq4Ob0vhM2wLFziPkbUBi+PAo7ODbe2sacR7nJQ==}
engines: {node: '>= 20.12'}
domify@3.0.0:
resolution: {integrity: sha512-bs2yO68JDFOm6rKv8f0EnrM2cENduhRkpqOtt/s5l5JBA/eqGBZCzLPmdYoHtJ6utgLGgcBajFsEQbl12pT0lQ==}
engines: {node: '>=20'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -552,20 +519,10 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
htm@3.1.1:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ids@3.0.2:
resolution: {integrity: sha512-t6YJP4mdC+GHF96Nbis/4FEANhP/8VWmYMvUuYpXvSdrhg5hpIVbq2XZlOA3UWTbtdwPCi0q7jEXOdHkAnqOnw==}
engines: {node: '>= 20.12'}
inherits-browser@0.1.0:
resolution: {integrity: sha512-CJHHvW3jQ6q7lzsXPpapLdMx5hDpSF3FSh45pwsj6bKxJJ8Nl8v43i5yXnr3BdfOimGHKyniewQtnAIp3vyJJw==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
@@ -689,27 +646,12 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
min-dash@5.0.0:
resolution: {integrity: sha512-EGuoBnVL7/Fnv2sqakpX5WGmZehZ3YMmLayT7sM8E9DRU74kkeyMg4Rik1lsOkR2GbFNeBca4/L+UfU6gF0Edw==}
min-dom@5.3.0:
resolution: {integrity: sha512-0w5FEBgPAyHhmFojW3zxd7we3D+m5XYS3E/06OyvxmbHJoiQVa4Nagj6RWvoAKYRw5xth6cP5TMePc5cR1M9hA==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
moddle-xml@12.0.0:
resolution: {integrity: sha512-NJc2+sCe4tvuGlaUBcoZcYf6j9f+z+qxHOyGm/LB3ZrlJXVPPHoBTg/KXgDRCufdBJhJ3AheFs3QU/abABNzRg==}
engines: {node: '>= 18'}
peerDependencies:
moddle: '>= 6.2.0'
moddle@8.1.0:
resolution: {integrity: sha512-dBddc1CNuZHgro8nQWwfPZ2BkyLWdnxoNpPu9d+XKPN96DAiiBOeBw527ft++ebDuFez5PMdaR3pgUgoOaUGrA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -724,16 +666,9 @@ packages:
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
object-refs@0.4.0:
resolution: {integrity: sha512-6kJqKWryKZmtte6QYvouas0/EIJKPI1/MMIuRsiBlNuhIMfqYTggzX2F1AJ2+cDs288xyi9GL7FyasHINR98BQ==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-intersection@4.1.0:
resolution: {integrity: sha512-urUP6WvhnxbHPdHYl6L7Yrc6+1ny6uOFKPCzPxTSUSYGHG0o94RmI7SvMMaScNAM5RtTf08bg4skc6/kjfne3A==}
engines: {node: '>= 14.20'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -769,9 +704,6 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
preact@10.29.2:
resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -791,10 +723,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
saxen@11.0.2:
resolution: {integrity: sha512-WDb4gqac8uiJzOdOdVpr9NWh9NrJMm7Brn5GX2Poj+mjE/QTXqYQENr8T/mom54dDDgbd3QjwTg23TRHYiWXRA==}
engines: {node: '>= 20.12'}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@@ -810,10 +738,6 @@ packages:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tiny-svg@4.1.4:
resolution: {integrity: sha512-cBaEACCbouYrQc9RG+eTXnPYosX1Ijqty/I6DdXovwDd89Pwu4jcmpOR7BuFEF9YCcd7/AWwasE0207WMK7hdw==}
engines: {node: '>= 20'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -966,11 +890,6 @@ snapshots:
'@babel/helper-string-parser': 8.0.0-rc.6
'@babel/helper-validator-identifier': 8.0.0-rc.6
'@bpmn-io/diagram-js-ui@0.2.4':
dependencies:
htm: 3.1.1
preact: 10.29.2
'@ctrl/tinycolor@4.2.0': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.38(typescript@6.0.3))':
@@ -1282,23 +1201,6 @@ snapshots:
birpc@2.9.0: {}
bpmn-js@18.18.0:
dependencies:
bpmn-moddle: 10.0.0
diagram-js: 15.17.0
diagram-js-direct-editing: 3.4.0(diagram-js@15.17.0)
ids: 3.0.2
inherits-browser: 0.1.0
min-dash: 5.0.0
min-dom: 5.3.0
tiny-svg: 4.1.4
bpmn-moddle@10.0.0:
dependencies:
min-dash: 5.0.0
moddle: 8.1.0
moddle-xml: 12.0.0(moddle@8.1.0)
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -1308,8 +1210,6 @@ snapshots:
dependencies:
readdirp: 5.0.0
clsx@2.1.1: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -1334,28 +1234,6 @@ snapshots:
detect-libc@2.1.2: {}
diagram-js-direct-editing@3.4.0(diagram-js@15.17.0):
dependencies:
diagram-js: 15.17.0
min-dash: 5.0.0
min-dom: 5.3.0
diagram-js@15.17.0:
dependencies:
'@bpmn-io/diagram-js-ui': 0.2.4
clsx: 2.1.1
didi: 11.0.0
inherits-browser: 0.1.0
min-dash: 5.0.0
min-dom: 5.3.0
object-refs: 0.4.0
path-intersection: 4.1.0
tiny-svg: 4.1.4
didi@11.0.0: {}
domify@3.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1453,8 +1331,6 @@ snapshots:
hookable@5.5.3: {}
htm@3.1.1: {}
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -1462,10 +1338,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ids@3.0.2: {}
inherits-browser@0.1.0: {}
is-what@5.5.0: {}
jsesc@3.1.0: {}
@@ -1555,13 +1427,6 @@ snapshots:
dependencies:
mime-db: 1.52.0
min-dash@5.0.0: {}
min-dom@5.3.0:
dependencies:
domify: 3.0.0
min-dash: 5.0.0
mitt@3.0.1: {}
mlly@1.8.2:
@@ -1571,16 +1436,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
moddle-xml@12.0.0(moddle@8.1.0):
dependencies:
min-dash: 5.0.0
moddle: 8.1.0
saxen: 11.0.2
moddle@8.1.0:
dependencies:
min-dash: 5.0.0
ms@2.1.3: {}
muggle-string@0.4.1: {}
@@ -1589,12 +1444,8 @@ snapshots:
normalize-wheel-es@1.2.0: {}
object-refs@0.4.0: {}
path-browserify@1.0.1: {}
path-intersection@4.1.0: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
@@ -1630,8 +1481,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.29.2: {}
proxy-from-env@2.1.0: {}
quansync@0.2.11: {}
@@ -1661,8 +1510,6 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
saxen@11.0.2: {}
scule@1.3.0: {}
source-map-js@1.2.1: {}
@@ -1673,8 +1520,6 @@ snapshots:
dependencies:
copy-anything: 4.0.5
tiny-svg@4.1.4: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@@ -7,7 +7,6 @@ import type { JenkinsBuildLog, PipelineStep, ReleaseRun, StepStatus } from '../t
import StatusPill from './StatusPill.vue'
const props = defineProps<{
xml: string
steps: PipelineStep[]
run?: ReleaseRun
}>()
@@ -22,7 +21,7 @@ type StatusMeta = {
type OutputMode = 'step' | 'jenkins'
const maxRenderedLogLines = 520
const selectedNodeId = ref('')
const selectedStepNodeId = ref('')
const terminalOutputEl = ref<HTMLElement>()
const outputMode = ref<OutputMode>('step')
const logLoading = ref(false)
@@ -104,11 +103,11 @@ const currentStep = computed(() => {
})
const selectedStep = computed(() => {
if (!selectedNodeId.value) {
if (!selectedStepNodeId.value) {
return currentStep.value
}
return findStep(selectedNodeId.value) ?? currentStep.value
return findStep(selectedStepNodeId.value) ?? currentStep.value
})
const stepStats = computed(() => {
@@ -140,8 +139,6 @@ const isActiveRun = computed(
() => props.run?.status === 'queued' || props.run?.status === 'running',
)
const processSourceText = computed(() => (props.xml ? 'BPMN XML 已加载' : '等待 BPMN XML'))
const terminalTitle = computed(() => {
if (outputMode.value === 'jenkins') {
return hasJenkinsBuildNumber.value
@@ -160,14 +157,14 @@ const terminalSubtitle = computed(() => {
const buildText = hasJenkinsBuildNumber.value ? `Build #${props.run?.jenkinsBuildNumber}` : ''
const queueText = props.run?.jenkinsQueueId ? `Queue ${props.run.jenkinsQueueId}` : ''
return [buildText, queueText, processSourceText.value].filter(Boolean).join(' · ')
return [buildText, queueText].filter(Boolean).join(' · ') || '等待 Jenkins 日志来源'
}
if (!selectedStep.value) {
return processSourceText.value
return '等待后端步骤'
}
return `${selectedStep.value.owner} · ${selectedStep.value.bpmnNodeId}`
return `${selectedStep.value.owner} · 节点 ${selectedStep.value.nodeId}`
})
const logRefreshStateText = computed(() => {
@@ -193,7 +190,7 @@ const stepOutputText = computed(() => {
`# ${step.name}`,
`owner=${step.owner}`,
`status=${statusMeta[step.status].label}`,
`bpmnNodeId=${step.bpmnNodeId}`,
`nodeId=${step.nodeId}`,
`duration=${step.duration}`,
step.startedAt ? `startedAt=${step.startedAt}` : '',
step.finishedAt ? `finishedAt=${step.finishedAt}` : '',
@@ -238,7 +235,7 @@ const terminalEmptyHint = computed(() => {
})
function findStep(nodeId: string) {
return props.steps.find((step) => step.bpmnNodeId === nodeId)
return props.steps.find((step) => step.nodeId === nodeId)
}
function statusClass(status: StepStatus): string {
@@ -246,11 +243,11 @@ function statusClass(status: StepStatus): string {
}
function isSelectedStep(step: PipelineStep): boolean {
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
return selectedStep.value?.nodeId === step.nodeId
}
function selectStep(step: PipelineStep) {
selectedNodeId.value = step.bpmnNodeId
selectedStepNodeId.value = step.nodeId
if (step.owner === 'Jenkins') {
outputMode.value = 'jenkins'
@@ -401,7 +398,7 @@ watch(
watch(
() => `${props.run?.id ?? 'none'}:${currentStep.value?.id ?? 'none'}:${currentStep.value?.status ?? 'none'}`,
() => {
if (selectedNodeId.value || currentStep.value?.owner !== 'Jenkins') {
if (selectedStepNodeId.value || currentStep.value?.owner !== 'Jenkins') {
return
}
@@ -430,7 +427,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="bpmn-viewer">
<div class="pipeline-viewer">
<section class="execution-output">
<div class="execution-toolbar">
<div>
@@ -484,7 +481,7 @@ onBeforeUnmount(() => {
<div v-if="logError && outputMode === 'jenkins'" class="log-error">{{ logError }}</div>
</section>
<aside class="bpmn-side">
<aside class="pipeline-side">
<div class="flow-stats">
<div>
<small>流程进度</small>
@@ -535,7 +532,7 @@ onBeforeUnmount(() => {
<div class="node-detail-head">
<span>
<strong>{{ selectedStep.name }}</strong>
<small>{{ selectedStep.owner }} · {{ selectedStep.bpmnNodeId }}</small>
<small>{{ selectedStep.owner }} · 节点 {{ selectedStep.nodeId }}</small>
</span>
<StatusPill :state="selectedStep.status" />
</div>
@@ -563,7 +560,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.bpmn-viewer {
.pipeline-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 16px;
@@ -712,7 +709,7 @@ onBeforeUnmount(() => {
font-size: 12px;
}
.bpmn-side {
.pipeline-side {
position: sticky;
top: 86px;
display: flex;
@@ -972,7 +969,7 @@ onBeforeUnmount(() => {
}
@media (max-width: 960px) {
.bpmn-viewer {
.pipeline-viewer {
grid-template-columns: 1fr;
height: auto;
}
@@ -989,7 +986,7 @@ onBeforeUnmount(() => {
height: min(520px, 70vh);
}
.bpmn-side {
.pipeline-side {
position: static;
height: auto;
max-height: none;
+73 -2
View File
@@ -1,8 +1,79 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import {
ElAlert,
ElAside,
ElButton,
ElCheckbox,
ElContainer,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElHeader,
ElIcon,
ElInput,
ElLink,
ElLoading,
ElMain,
ElMenu,
ElMenuItem,
ElOption,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElSegmented,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
ElTag,
ElTooltip,
} from 'element-plus'
import { createPinia } from 'pinia'
import router from './router'
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app')
const app = createApp(App)
for (const component of [
ElAlert,
ElAside,
ElButton,
ElCheckbox,
ElContainer,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElHeader,
ElIcon,
ElInput,
ElLink,
ElMain,
ElMenu,
ElMenuItem,
ElOption,
ElRadio,
ElRadioButton,
ElRadioGroup,
ElSegmented,
ElSelect,
ElSwitch,
ElTable,
ElTableColumn,
ElTag,
ElTooltip,
]) {
app.use(component)
}
app.use(ElLoading).use(createPinia()).use(router).mount('#app')
+12 -8
View File
@@ -1,13 +1,14 @@
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 MembersView from '../views/MembersView.vue'
import ReleaseCenterView from '../views/ReleaseCenterView.vue'
import RunsView from '../views/RunsView.vue'
import SettingsView from '../views/SettingsView.vue'
const MainLayout = () => import('../layouts/MainLayout.vue')
const LoginView = () => import('../views/LoginView.vue')
const OverviewView = () => import('../views/OverviewView.vue')
const ProjectsView = () => import('../views/ProjectsView.vue')
const MembersView = () => import('../views/MembersView.vue')
const ReleaseCenterView = () => import('../views/ReleaseCenterView.vue')
const RunsView = () => import('../views/RunsView.vue')
const SettingsView = () => import('../views/SettingsView.vue')
const routes: RouteRecordRaw[] = [
{
@@ -59,6 +60,9 @@ const routes: RouteRecordRaw[] = [
path: 'settings',
name: 'settings',
component: SettingsView,
meta: {
superAdminOnly: true,
},
},
],
},
+12 -26
View File
@@ -91,6 +91,7 @@ interface BackendProject {
interface BackendDeployRunStep {
id?: string
nodeId?: string
bpmnNodeId?: string
name?: string
status?: BackendRunStatus
@@ -151,13 +152,6 @@ interface BackendJenkinsImportSummary {
}>
}
interface BackendProcessDefinition {
key: 'release'
version: string
xml: string
nodeIds: string[]
}
interface BackendIntegrationConfigItem {
key: IntegrationConfigKey
name: string
@@ -231,7 +225,7 @@ const agentCards: AgentCard[] = [
mode: 'incidentReview',
title: '事故复盘草稿',
description: '基于失败 run、流程耗时、Jenkins 摘要和 Runbook 生成复盘草稿。',
inputScope: ['失败 run', 'BPMN 节点', '脱敏日志片段', 'Runbook'],
inputScope: ['失败 run', '失败节点', '脱敏日志片段', 'Runbook'],
outputPreview: ['时间线', '根因假设', '修复和预防项'],
},
]
@@ -246,12 +240,12 @@ const settings: SystemSetting[] = [
boundary: '前端不写死真实 token,不直接访问 Jenkins/Gitea/LLM。',
},
{
key: 'bpmn',
name: 'BPMN 流程定义',
key: 'pipeline',
name: '发布步骤流转',
status: 'healthy',
owner: '平台流程配置',
contract: 'step.bpmnNodeId 必须和 BPMN XML 节点 id 一致。',
boundary: '前端只展示流程和状态,不推进状态机。',
owner: '后端发布状态机',
contract: 'run.steps 由后端返回,前端只按节点顺序展示状态、耗时和日志摘要。',
boundary: '前端只展示步骤和日志,不推进状态机。',
},
{
key: 'agent',
@@ -539,14 +533,6 @@ export const devopsApi = {
return data
},
async getReleaseProcessXml(): Promise<string> {
const { data } = await http.get<BackendProcessDefinition>(
'/process-definitions/release',
)
lastDataSource = 'backend'
return data.xml
},
async createAgentInvocation(
payload: CreateAgentInvocationPayload,
): Promise<AgentInvocation> {
@@ -1153,14 +1139,14 @@ function sanitizeNotificationOutboxMessage(
}
function toPipelineStep(step: BackendDeployRunStep): PipelineStep {
const bpmnNodeId = step.bpmnNodeId || step.id || 'unknown-step'
const name = step.name || bpmnNodeId
const nodeId = step.nodeId || step.bpmnNodeId || step.id || 'unknown-step'
const name = step.name || nodeId
return {
id: step.id || bpmnNodeId,
bpmnNodeId,
id: step.id || nodeId,
nodeId,
name,
owner: stepOwner(bpmnNodeId),
owner: stepOwner(nodeId),
status: toStepStatus(step.status),
duration: formatDuration(step.startedAt, step.finishedAt, step.status),
summary: step.errorSummary || step.message || name,
+1 -6
View File
@@ -46,7 +46,6 @@ export const usePlatformStore = defineStore('platform', () => {
checkedAt: '',
integrations: [],
})
const releaseProcessXml = ref('')
const projectRefs = ref<Record<string, ProjectRefs>>({})
const failedRuns = computed(() => runs.value.filter((run) => run.status === 'failed'))
@@ -83,19 +82,16 @@ export const usePlatformStore = defineStore('platform', () => {
agentCards.value = summary.agentCards
settings.value = summary.settings
integrationConfig.value = summary.integrationConfig
const processXmlPromise = devopsApi.getReleaseProcessXml()
const auditLogsPromise = includeAdminData
? devopsApi.getAuditLogs()
: Promise.resolve<AuditLogEntry[]>([])
const notificationOutboxPromise = includeAdminData
? devopsApi.getNotificationOutbox()
: Promise.resolve<NotificationOutboxMessage[]>([])
const [processXml, recentAuditLogs, recentNotificationOutbox] = await Promise.all([
processXmlPromise,
const [recentAuditLogs, recentNotificationOutbox] = await Promise.all([
auditLogsPromise,
notificationOutboxPromise,
])
releaseProcessXml.value = processXml
auditLogs.value = recentAuditLogs
notificationOutbox.value = recentNotificationOutbox
applyProjectGiteaDiagnostics()
@@ -358,7 +354,6 @@ export const usePlatformStore = defineStore('platform', () => {
projectJenkinsDiagnostics,
projectGiteaDiagnostics,
integrationConfig,
releaseProcessXml,
projectRefs,
failedRuns,
runningRuns,
+1 -1
View File
@@ -353,7 +353,7 @@ export interface ProjectRefItem {
export interface PipelineStep {
id: string
bpmnNodeId: string
nodeId: string
name: string
owner: 'Gitea' | 'Jenkins' | 'HealthCheck' | 'Notification' | 'Platform'
status: StepStatus
+43
View File
@@ -273,6 +273,16 @@ async function updatePermissions() {
return
}
const buildCount = Object.values(editPermissions).filter((level) => level === 'build').length
const confirmed = await confirmMemberAction(
'确认保存项目权限',
`将保存 ${member.displayName} 的项目权限,其中 ${buildCount} 个项目具备构建权限。`,
)
if (!confirmed) {
return
}
const updated = await devopsApi.updateMemberPermissions(member.id, {
permissions: toPermissionPayload(editPermissions),
})
@@ -313,6 +323,16 @@ async function showTemporaryPassword(
async function toggleStatus(member: MemberSummary) {
const nextStatus = member.status === 'active' ? 'disabled' : 'active'
const actionText = nextStatus === 'active' ? '启用' : '禁用'
const confirmed = await confirmMemberAction(
`${actionText}成员`,
`确认${actionText}成员 ${member.displayName}${nextStatus === 'disabled' ? '禁用后该成员不能继续登录平台。' : ''}`,
)
if (!confirmed) {
return
}
const updated = await devopsApi.updateMemberStatus(member.id, {
status: nextStatus,
})
@@ -336,12 +356,35 @@ async function deleteMember(member: MemberSummary) {
}
async function markMessageHandled(message: PlatformMessageSummary) {
const confirmed = await confirmMemberAction(
'确认处理消息',
`将密码重置消息“${message.title}”标记为已处理。`,
)
if (!confirmed) {
return
}
await devopsApi.markMessageHandled(message.id)
await loadMessages()
await loadMembers()
ElMessage.success('消息已处理')
}
async function confirmMemberAction(title: string, message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认执行',
cancelButtonText: '取消',
type: 'warning',
autofocus: false,
})
return true
} catch {
return false
}
}
async function loadMembers() {
members.value = await devopsApi.getMembers()
}
+106 -26
View File
@@ -3,10 +3,10 @@
* 发布中心负责创建发布单、展示真实 Gitea ref 摘要并同步 Jenkins 状态。
*/
import { CircleClose, Promotion, RefreshRight, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
import PipelineStatusViewer from '../components/PipelineStatusViewer.vue'
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
import StatusPill from '../components/StatusPill.vue'
import { isDevopsApiError } from '../services/http'
@@ -333,8 +333,16 @@ const preflightSummary = computed(() => {
return '关键检查均已通过'
})
const contextRuns = computed(() => {
return platform.runs.filter((run) => matchesReleaseContext(run))
})
const shownRun = computed(() => {
return activeRun.value || platform.runs[0]
if (activeRun.value && matchesReleaseContext(activeRun.value)) {
return activeRun.value
}
return contextRuns.value[0]
})
const canSyncJenkins = computed(() => {
@@ -357,6 +365,20 @@ const canRetryShownRun = computed(
)
async function triggerRelease() {
if (!canTrigger.value) {
ElMessage.warning(triggerDisabledReason.value || '发布参数不完整')
return
}
const confirmed = await confirmReleaseAction(
form.environment === 'production' ? '确认触发生产发布' : '确认触发发布',
`即将发布 ${selectedProject.value?.name || form.projectKey} / ${form.environment} / ${form.ref}。提交后会创建后端发布单并进入 Jenkins 执行链路。`,
)
if (!confirmed) {
return
}
submitting.value = true
try {
const run = await platform.createDeployRun({
@@ -444,12 +466,22 @@ async function cancelRelease() {
return
}
const run = shownRun.value
const confirmed = await confirmReleaseAction(
'确认取消发布单',
`将取消 ${run.projectName} / ${run.environment} 的发布单 ${run.id}。该操作会写入审计日志。`,
)
if (!confirmed) {
return
}
submitting.value = true
const runId = shownRun.value.id
const runId = run.id
try {
const run = await platform.cancelDeployRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
const canceled = await platform.cancelDeployRun(run)
activeRun.value = canceled
localStatus.value = canceled.status
ElMessage.success('发布单已取消')
} catch (error) {
await handleReleaseActionError(error, '发布单取消失败', runId)
@@ -463,12 +495,22 @@ async function retryRelease() {
return
}
const run = shownRun.value
const confirmed = await confirmReleaseAction(
'确认重试发布单',
`将基于 ${run.projectName} / ${run.environment} 的发布单 ${run.id} 发起重试。`,
)
if (!confirmed) {
return
}
submitting.value = true
const runId = shownRun.value.id
const runId = run.id
try {
const run = await platform.retryDeployRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
const retried = await platform.retryDeployRun(run)
activeRun.value = retried
localStatus.value = retried.status
ElMessage.success('已提交重试发布单')
} catch (error) {
await handleReleaseActionError(error, '发布单重试失败', runId)
@@ -482,12 +524,22 @@ async function syncJenkinsRun() {
return
}
const run = shownRun.value
const confirmed = await confirmReleaseAction(
'确认同步 Jenkins 状态',
`将从后端同步发布单 ${run.id} 的 Jenkins queue/build 状态。`,
)
if (!confirmed) {
return
}
submitting.value = true
const runId = shownRun.value.id
const runId = run.id
try {
const run = await platform.syncJenkinsRun(shownRun.value)
activeRun.value = run
localStatus.value = run.status
const synced = await platform.syncJenkinsRun(run)
activeRun.value = synced
localStatus.value = synced.status
ElMessage.success('已同步 Jenkins 状态')
} catch (error) {
await handleReleaseActionError(error, 'Jenkins 状态同步失败', runId)
@@ -527,6 +579,24 @@ function queryProjectKey(): string {
return typeof route.query.projectKey === 'string' ? route.query.projectKey : ''
}
function matchesReleaseContext(run: ReleaseRun): boolean {
return run.projectKey === form.projectKey && run.environment === form.environment
}
async function confirmReleaseAction(title: string, message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认执行',
cancelButtonText: '取消',
type: 'warning',
autofocus: false,
})
return true
} catch {
return false
}
}
async function handleReleaseActionError(
error: unknown,
fallbackMessage: string,
@@ -536,7 +606,7 @@ async function handleReleaseActionError(
const refreshedRuns = await platform.refreshRuns()
const refreshedRun = runId
? refreshedRuns.find((run) => run.id === runId)
: refreshedRuns[0]
: refreshedRuns.find((run) => matchesReleaseContext(run))
if (refreshedRun) {
activeRun.value = refreshedRun
@@ -805,20 +875,23 @@ watch(
<div class="panel-body" v-if="shownRun">
<RunExecutionDetails :run="shownRun" />
</div>
<div v-else class="panel-body release-empty-state">
<ElEmpty description="当前项目和环境暂无发布单" />
</div>
</section>
<section class="panel bpmn-status-panel">
<section class="panel pipeline-status-panel">
<div class="panel-header">
<h2>BPMN 流程状态</h2>
<span>step.bpmnNodeId BPMN XML 节点 id 对齐后高亮</span>
<h2>发布步骤轨迹</h2>
<span>以后端 run steps 为准展示节点状态输出和 Jenkins 日志</span>
</div>
<div class="panel-body">
<BpmnStatusViewer
v-if="platform.releaseProcessXml && shownRun"
:xml="platform.releaseProcessXml"
<PipelineStatusViewer
v-if="shownRun"
:run="shownRun"
:steps="shownRun.steps"
/>
<ElEmpty v-else description="暂无可查看的发布步骤" />
</div>
</section>
</div>
@@ -835,11 +908,11 @@ watch(
.release-params-panel,
.current-run-panel,
.bpmn-status-panel {
.pipeline-status-panel {
min-width: 0;
}
.bpmn-status-panel {
.pipeline-status-panel {
grid-column: 1 / -1;
}
@@ -858,10 +931,17 @@ watch(
overscroll-behavior: contain;
}
.bpmn-status-panel .panel-body {
.pipeline-status-panel .panel-body {
min-width: 0;
}
.release-empty-state {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
}
.current-run-panel :deep(.run-execution-details) {
gap: 12px;
}
@@ -1152,7 +1232,7 @@ watch(
grid-template-columns: 1fr;
}
.bpmn-status-panel {
.pipeline-status-panel {
grid-column: auto;
}
+76 -16
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
/**
* 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤流转图
* 运行记录页展示历史发布单、Jenkins 同步入口和后端步骤轨迹
*/
import { CircleClose, Download, RefreshRight, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BpmnStatusViewer from '../components/BpmnStatusViewer.vue'
import PipelineStatusViewer from '../components/PipelineStatusViewer.vue'
import RunExecutionDetails from '../components/RunExecutionDetails.vue'
import StatusPill from '../components/StatusPill.vue'
import { isDevopsApiError } from '../services/http'
@@ -24,6 +24,7 @@ const session = useSessionStore()
const route = useRoute()
const selectedRunId = ref(platform.runs[0]?.id || '')
const syncingAll = ref(false)
const selectedActionSubmitting = ref(false)
const importingJenkins = ref(false)
const lastJenkinsImportSummary = ref<JenkinsImportSummary | null>(null)
const filterProjectKey = ref(queryProjectKey())
@@ -213,12 +214,25 @@ async function syncSelectedRun() {
return
}
const run = selectedRun.value
const confirmed = await confirmRunAction(
'确认同步 Jenkins 状态',
`将同步发布单 ${run.id} 的 Jenkins queue/build 状态。`,
)
if (!confirmed) {
return
}
selectedActionSubmitting.value = true
try {
const run = await platform.syncJenkinsRun(selectedRun.value)
selectedRunId.value = run.id
const synced = await platform.syncJenkinsRun(run)
selectedRunId.value = synced.id
ElMessage.success('已同步 Jenkins 状态')
} catch (error) {
await handleRunActionError(error, 'Jenkins 状态同步失败')
} finally {
selectedActionSubmitting.value = false
}
}
@@ -227,16 +241,38 @@ async function retrySelectedRun() {
return
}
const run = selectedRun.value
const confirmed = await confirmRunAction(
'确认重试发布单',
`将基于 ${run.projectName} / ${run.environment} 的发布单 ${run.id} 发起重试。`,
)
if (!confirmed) {
return
}
selectedActionSubmitting.value = true
try {
const run = await platform.retryDeployRun(selectedRun.value)
selectedRunId.value = run.id
const retried = await platform.retryDeployRun(run)
selectedRunId.value = retried.id
ElMessage.success('已提交重试发布单')
} catch (error) {
await handleRunActionError(error, '发布单重试失败')
} finally {
selectedActionSubmitting.value = false
}
}
async function syncAllJenkinsRuns() {
const confirmed = await confirmRunAction(
'确认批量同步 Jenkins',
'将同步所有当前可同步的 Jenkins run 状态。该操作可能产生多条后端请求和审计记录。',
)
if (!confirmed) {
return
}
syncingAll.value = true
try {
const result = await platform.syncJenkinsRuns()
@@ -260,6 +296,15 @@ async function syncAllJenkinsRuns() {
}
async function importJenkinsBuilds() {
const confirmed = await confirmRunAction(
'确认导入 Jenkins 直构',
'将扫描已纳管 Jenkins Job 并导入平台外直接触发的构建记录。',
)
if (!confirmed) {
return
}
importingJenkins.value = true
try {
const result = await platform.importJenkinsBuilds()
@@ -291,6 +336,20 @@ async function importJenkinsBuilds() {
}
}
async function confirmRunAction(title: string, message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认执行',
cancelButtonText: '取消',
type: 'warning',
autofocus: false,
})
return true
} catch {
return false
}
}
async function handleRunActionError(error: unknown, fallbackMessage: string) {
if (isDevopsApiError(error) && error.code === 'CONFLICT') {
await platform.refreshRuns()
@@ -367,13 +426,13 @@ function skippedReasonLabel(reason: string): string {
<div class="page-header">
<div>
<h1>运行记录</h1>
<p>查看发布 runJenkins 构建链接耗时失败节点和流程轨迹</p>
<p>查看发布 runJenkins 构建链接耗时失败节点和步骤轨迹</p>
</div>
<div class="page-actions">
<ElButton
:icon="Download"
:loading="importingJenkins"
:disabled="!canImportJenkinsBuilds"
:disabled="!canImportJenkinsBuilds || syncingAll"
@click="importJenkinsBuilds"
>
导入 Jenkins 直构
@@ -381,7 +440,7 @@ function skippedReasonLabel(reason: string): string {
<ElButton
:icon="RefreshRight"
:loading="syncingAll"
:disabled="!canSyncAnyRun"
:disabled="!canSyncAnyRun || importingJenkins"
@click="syncAllJenkinsRuns"
>
同步全部 Jenkins
@@ -531,7 +590,8 @@ function skippedReasonLabel(reason: string): string {
<ElButton
size="small"
:icon="RefreshRight"
:disabled="!canSyncSelectedRun"
:disabled="!canSyncSelectedRun || selectedActionSubmitting"
:loading="selectedActionSubmitting && canSyncSelectedRun"
@click="syncSelectedRun"
>
同步 Jenkins
@@ -539,7 +599,8 @@ function skippedReasonLabel(reason: string): string {
<ElButton
size="small"
:icon="CircleClose"
:disabled="!canRetrySelectedRun"
:disabled="!canRetrySelectedRun || selectedActionSubmitting"
:loading="selectedActionSubmitting && canRetrySelectedRun"
@click="retrySelectedRun"
>
重试
@@ -560,12 +621,11 @@ function skippedReasonLabel(reason: string): string {
<section class="panel" v-if="selectedRun">
<div class="panel-header">
<h2>{{ selectedRun.id }} 流程轨迹</h2>
<h2>{{ selectedRun.id }} 发布步骤轨迹</h2>
<StatusPill :state="selectedRun.status" />
</div>
<div class="panel-body">
<BpmnStatusViewer
:xml="platform.releaseProcessXml"
<PipelineStatusViewer
:run="selectedRun"
:steps="selectedRun.steps"
/>
@@ -578,7 +638,7 @@ function skippedReasonLabel(reason: string): string {
</div>
<div class="panel-body run-empty-state">
<strong>当前筛选没有可查看的 Run</strong>
<span>调整项目环境状态或搜索条件后再查看对应的执行详情与流程轨迹</span>
<span>调整项目环境状态或搜索条件后再查看对应的执行详情与步骤轨迹</span>
</div>
</section>
</div>
+49 -2
View File
@@ -3,7 +3,7 @@
* 系统配置页展示集成变量说明、配置边界和审计日志脱敏 JSON。
*/
import { Connection, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import AuditJsonCell from '../components/AuditJsonCell.vue'
import StatusPill from '../components/StatusPill.vue'
@@ -385,6 +385,15 @@ async function saveAgentConfig() {
return
}
const confirmed = await confirmSettingsAction(
'确认保存 Agent 模型配置',
'将把 Base URL、模型名称和可选 API Key 提交给后端保存;前端不会持久化密钥。',
)
if (!confirmed) {
return
}
agentConfigLoading.value = true
try {
@@ -410,6 +419,15 @@ async function testAgentConfig() {
return
}
const confirmed = await confirmSettingsAction(
'确认测试 Agent 连接',
'后端将使用已保存的模型配置发起一次真实连通性测试。',
)
if (!confirmed) {
return
}
agentConfigTesting.value = true
try {
@@ -432,6 +450,15 @@ async function refreshNotificationOutbox() {
}
async function retryNotificationOutbox(record: NotificationOutboxMessage) {
const confirmed = await confirmSettingsAction(
'确认重发通知',
`将重发通知 outbox ${record.id},渠道 ${record.channel},模板 ${record.template}`,
)
if (!confirmed) {
return
}
setRetryingOutbox(record.id, true)
try {
@@ -445,6 +472,20 @@ async function retryNotificationOutbox(record: NotificationOutboxMessage) {
}
}
async function confirmSettingsAction(title: string, message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
confirmButtonText: '确认执行',
cancelButtonText: '取消',
type: 'warning',
autofocus: false,
})
return true
} catch {
return false
}
}
function applyAgentConfig(config: AgentConfigSummary) {
agentConfig.value = config
agentConfigForm.baseURL = config.baseURL
@@ -607,12 +648,18 @@ function resetAuditFilters() {
/>
</ElFormItem>
<div class="toolbar">
<ElButton type="primary" :loading="agentConfigLoading" @click="saveAgentConfig">
<ElButton
type="primary"
:loading="agentConfigLoading"
:disabled="agentConfigTesting"
@click="saveAgentConfig"
>
保存配置
</ElButton>
<ElButton
:icon="Connection"
:loading="agentConfigTesting"
:disabled="agentConfigLoading"
@click="testAgentConfig"
>
测试连接
+25
View File
@@ -7,6 +7,31 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'element-plus-vendor',
test: /node_modules[\\/](?:\.pnpm[\\/].*?[\\/]node_modules[\\/])?(?:element-plus|@element-plus|@vueuse)[\\/]/,
priority: 30,
},
{
name: 'vue-vendor',
test: /node_modules[\\/](?:\.pnpm[\\/].*?[\\/]node_modules[\\/])?(?:vue|@vue|vue-router|pinia)[\\/]/,
priority: 20,
},
{
name: 'vendor',
test: /node_modules[\\/]/,
priority: 10,
},
],
},
},
},
},
server: {
port: 4301,
proxy: {