feat: 展示集成变量说明与流程详情
This commit is contained in:
@@ -8,7 +8,7 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
|
||||
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并按 queued/running/success/failed/skipped/canceled 状态高亮节点。
|
||||
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。
|
||||
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
|
||||
|
||||
## 技术栈
|
||||
@@ -41,6 +41,8 @@ pnpm build
|
||||
|
||||
接口入口为 `src/services/devopsApi.ts`,只调用后端统一 envelope 响应;后端不可用时页面应暴露真实接口错误,不伪造项目、运行记录或 refs。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。
|
||||
|
||||
通知平台配置方案见 [docs/notification-platform-config.md](docs/notification-platform-config.md),包含企业微信、飞书和通用 webhook 的后端变量、安全边界与验证步骤。`/settings` 页面支持展示后端返回的变量说明字段,用于 hover 查看每个变量含义。
|
||||
|
||||
浏览器端不得保存或直连以下密钥和服务:
|
||||
|
||||
- Jenkins token
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# DevOps 通知平台配置方案
|
||||
|
||||
更新时间:2026-06-11
|
||||
|
||||
## 当前结论
|
||||
|
||||
通知平台不是前端能力,web 端只展示后端返回的配置状态、变量名和变量说明。真实 webhook、token、签名密钥只能存在于 `devops-platform-api` 的服务端环境变量、服务器 secret 或后续加密存储中。
|
||||
|
||||
当前后端通知 provider 已按以下变量预留:
|
||||
|
||||
| Provider | 适用平台 | 必需变量 | Webhook 变量 | 当前策略 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `wecom` | 企业微信 | `NOTIFICATION_PROVIDER=wecom` | `WECOM_WEBHOOK_URL` | 发送 markdown 发布通知 |
|
||||
| `feishu` | 飞书 | `NOTIFICATION_PROVIDER=feishu` | `FEISHU_WEBHOOK_URL` | 发送 text 发布通知 |
|
||||
| `generic` | 内部通知网关 | `NOTIFICATION_PROVIDER=generic` | `NOTIFICATION_WEBHOOK_URL` | 发送结构化 JSON |
|
||||
|
||||
未配置 provider 或 webhook 时,发布流程不应被阻断;通知节点应显示 skipped/failed,并把脱敏摘要写入审计日志。
|
||||
|
||||
## 后端环境变量
|
||||
|
||||
```env
|
||||
DEVOPS_PUBLIC_URL=https://devops.mrzhan.top
|
||||
|
||||
# 任选其一:wecom / feishu / generic
|
||||
NOTIFICATION_PROVIDER=wecom
|
||||
|
||||
# 企业微信机器人
|
||||
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-real-key
|
||||
|
||||
# 飞书自定义机器人
|
||||
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-with-real-token
|
||||
|
||||
# 通用内部 webhook
|
||||
NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret
|
||||
```
|
||||
|
||||
安全要求:
|
||||
|
||||
| 要求 | 说明 |
|
||||
| --- | --- |
|
||||
| 不进前端 | web 端只拿变量名、状态和说明,不接收真实 URL |
|
||||
| 不进 Git | `.env`、服务器 secret、后续 `secrets` 表均不得提交真实 webhook |
|
||||
| 日志脱敏 | 任何异常日志、审计摘要和 Agent 上下文都不能包含 webhook 原文 |
|
||||
| 不阻断发布 | 通知失败只影响通知节点和审计,不回滚 Jenkins 构建结果 |
|
||||
|
||||
## 企业微信配置
|
||||
|
||||
官方参考:`https://developer.work.weixin.qq.com/document/path/91770`
|
||||
|
||||
1. 在企业微信群中添加群机器人。
|
||||
2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL`。
|
||||
3. 设置 `NOTIFICATION_PROVIDER=wecom`。
|
||||
4. 重启 `devops-platform-api`。
|
||||
5. 访问 `/settings`,确认“通知平台”从未配置变成已配置或部分配置。
|
||||
6. 创建一次测试发布单,检查流程中的通知节点和群内消息。
|
||||
|
||||
本地验证命令:
|
||||
|
||||
```bash
|
||||
curl -X POST "$WECOM_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"msgtype":"markdown","markdown":{"content":"# DevOps 通知测试\n\n> 项目:devops-platform-web\n> 环境:production\n> 状态:success"}}'
|
||||
```
|
||||
|
||||
## 飞书配置
|
||||
|
||||
官方参考:`https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot`
|
||||
|
||||
1. 在飞书目标群添加“自定义机器人”。
|
||||
2. 复制 webhook,保存到服务端环境变量 `FEISHU_WEBHOOK_URL`。
|
||||
3. 建议开启安全设置:关键词、IP 白名单或签名校验。
|
||||
4. 设置 `NOTIFICATION_PROVIDER=feishu`。
|
||||
5. 重启 `devops-platform-api`。
|
||||
6. 创建测试发布单,检查通知节点和飞书群消息。
|
||||
|
||||
基础 text 消息验证:
|
||||
|
||||
```bash
|
||||
curl -X POST "$FEISHU_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"msg_type":"text","content":{"text":"DevOps 通知测试:devops-platform-web production success"}}'
|
||||
```
|
||||
|
||||
如果开启飞书签名校验,需要后端按飞书规则计算 timestamp/sign,再放入请求 body;不要在前端计算或保存签名密钥。
|
||||
|
||||
## 通用 webhook 配置
|
||||
|
||||
适合接内部通知网关或消息中转服务:
|
||||
|
||||
```env
|
||||
NOTIFICATION_PROVIDER=generic
|
||||
NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret
|
||||
```
|
||||
|
||||
建议后端投递结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "deploy.succeeded",
|
||||
"title": "DevOps 发布成功",
|
||||
"run": {
|
||||
"id": "run_xxx",
|
||||
"projectKey": "devops-platform-web",
|
||||
"environment": "production",
|
||||
"ref": "master",
|
||||
"status": "success",
|
||||
"operator": "ops-admin"
|
||||
},
|
||||
"publicUrl": "https://devops.mrzhan.top"
|
||||
}
|
||||
```
|
||||
|
||||
## Settings 变量说明契约
|
||||
|
||||
`/settings` 页面已按后端返回字段展示 hover 说明。后端推荐在 `GET /settings/integration-config` 中为每个变量补充 `variableHelp`:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "notification",
|
||||
"name": "通知平台",
|
||||
"status": "partial",
|
||||
"required": ["NOTIFICATION_PROVIDER", "WECOM_WEBHOOK_URL"],
|
||||
"configured": ["NOTIFICATION_PROVIDER"],
|
||||
"missing": ["WECOM_WEBHOOK_URL"],
|
||||
"optional": ["FEISHU_WEBHOOK_URL", "NOTIFICATION_WEBHOOK_URL", "DEVOPS_PUBLIC_URL"],
|
||||
"variableHelp": {
|
||||
"NOTIFICATION_PROVIDER": {
|
||||
"description": "通知 provider,可选 wecom、feishu、generic。",
|
||||
"example": "wecom",
|
||||
"docUrl": "https://developer.work.weixin.qq.com/document/path/91770"
|
||||
},
|
||||
"WECOM_WEBHOOK_URL": {
|
||||
"description": "企业微信群机器人的服务端 webhook,只能保存在后端。",
|
||||
"docUrl": "https://developer.work.weixin.qq.com/document/path/91770"
|
||||
},
|
||||
"FEISHU_WEBHOOK_URL": {
|
||||
"description": "飞书自定义机器人的服务端 webhook,只能保存在后端。",
|
||||
"docUrl": "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot"
|
||||
},
|
||||
"NOTIFICATION_WEBHOOK_URL": {
|
||||
"description": "内部通知网关或通用 webhook 的服务端地址。"
|
||||
},
|
||||
"DEVOPS_PUBLIC_URL": {
|
||||
"description": "通知消息里跳转回 DevOps 平台的公开访问地址。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端兼容字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `variableHelp` | 推荐格式,支持 description、example、docUrl |
|
||||
| `variableDescriptions` | 兼容 `变量名 -> 说明字符串` 的简单格式 |
|
||||
| `variables[]` | 兼容 `{ name, description, example, docUrl }` 数组格式 |
|
||||
|
||||
## 上线检查
|
||||
|
||||
| 检查项 | 通过标准 |
|
||||
| --- | --- |
|
||||
| `/settings` | 只展示变量名、配置状态和后端说明,不展示真实 webhook |
|
||||
| 发布流程 | 通知未配置时节点 skipped,不阻断成功发布 |
|
||||
| 审计日志 | 记录通知投递结果,摘要已脱敏 |
|
||||
| Agent | 只能解释通知 skipped/failed,不输出或猜测 webhook |
|
||||
| 服务器 | 真实 webhook 仅在 `.env`、secret manager 或加密存储中存在 |
|
||||
@@ -12,9 +12,19 @@ const props = defineProps<{
|
||||
steps: PipelineStep[]
|
||||
}>()
|
||||
|
||||
type StatusMeta = {
|
||||
label: string
|
||||
short: string
|
||||
className: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const canvasEl = ref<HTMLDivElement>()
|
||||
const selectedNodeId = ref('')
|
||||
const viewer = shallowRef<InstanceType<typeof BpmnViewer>>()
|
||||
let eventBusBound = false
|
||||
let overlayIds: Array<string | number> = []
|
||||
let markedNodeIds = new Set<string>()
|
||||
|
||||
const markerClasses: Record<StepStatus, string> = {
|
||||
pending: 'bpmn-step-pending',
|
||||
@@ -26,18 +36,103 @@ const markerClasses: Record<StepStatus, string> = {
|
||||
canceled: 'bpmn-step-canceled',
|
||||
}
|
||||
|
||||
function findStep(nodeId: string) {
|
||||
return props.steps.find((step) => step.bpmnNodeId === nodeId)
|
||||
const statusMeta: Record<StepStatus, StatusMeta> = {
|
||||
pending: {
|
||||
label: '等待中',
|
||||
short: '等',
|
||||
className: 'pending',
|
||||
description: '前置节点尚未完成,流程还没有执行到这里。',
|
||||
},
|
||||
queued: {
|
||||
label: '排队中',
|
||||
short: '排',
|
||||
className: 'queued',
|
||||
description: '任务已经提交,正在等待 Jenkins 或后端调度。',
|
||||
},
|
||||
running: {
|
||||
label: '运行中',
|
||||
short: '行',
|
||||
className: 'running',
|
||||
description: '当前节点正在执行,状态会随后端同步刷新。',
|
||||
},
|
||||
success: {
|
||||
label: '成功',
|
||||
short: '成',
|
||||
className: 'success',
|
||||
description: '节点已经完成且没有阻断后续流程。',
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
short: '败',
|
||||
className: 'failed',
|
||||
description: '节点执行失败,需要查看失败摘要和日志片段。',
|
||||
},
|
||||
skipped: {
|
||||
label: '跳过',
|
||||
short: '跳',
|
||||
className: 'skipped',
|
||||
description: '节点被后端判定为可跳过,通常不阻断发布。',
|
||||
},
|
||||
canceled: {
|
||||
label: '取消',
|
||||
short: '取',
|
||||
className: 'canceled',
|
||||
description: '流程被人工取消或由后端终止。',
|
||||
},
|
||||
}
|
||||
|
||||
const statusLegend = Object.values(statusMeta)
|
||||
const orderedSteps = computed(() => props.steps)
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const active = orderedSteps.value.find((step) =>
|
||||
['running', 'queued', 'failed'].includes(step.status),
|
||||
)
|
||||
|
||||
if (active) {
|
||||
return active
|
||||
}
|
||||
|
||||
return (
|
||||
orderedSteps.value
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((step) => ['success', 'skipped', 'canceled'].includes(step.status)) ??
|
||||
orderedSteps.value[0]
|
||||
)
|
||||
})
|
||||
|
||||
const selectedStep = computed(() => {
|
||||
if (!selectedNodeId.value) {
|
||||
return undefined
|
||||
return currentStep.value
|
||||
}
|
||||
|
||||
return findStep(selectedNodeId.value)
|
||||
return findStep(selectedNodeId.value) ?? currentStep.value
|
||||
})
|
||||
|
||||
const stepStats = computed(() => {
|
||||
const total = orderedSteps.value.length
|
||||
const done = orderedSteps.value.filter((step) =>
|
||||
['success', 'failed', 'skipped', 'canceled'].includes(step.status),
|
||||
).length
|
||||
const running = orderedSteps.value.filter((step) => step.status === 'running').length
|
||||
const failed = orderedSteps.value.filter((step) => step.status === 'failed').length
|
||||
|
||||
return { total, done, running, failed }
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (stepStats.value.total === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round((stepStats.value.done / stepStats.value.total) * 100)
|
||||
})
|
||||
|
||||
function findStep(nodeId: string) {
|
||||
return props.steps.find((step) => step.bpmnNodeId === nodeId)
|
||||
}
|
||||
|
||||
async function renderDiagram() {
|
||||
if (!canvasEl.value || !props.xml) {
|
||||
return
|
||||
@@ -52,12 +147,17 @@ async function renderDiagram() {
|
||||
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')
|
||||
bindEventBus()
|
||||
applyMarkers()
|
||||
}
|
||||
|
||||
function bindEventBus() {
|
||||
if (!viewer.value || eventBusBound) {
|
||||
return
|
||||
}
|
||||
|
||||
const eventBus = viewer.value.get('eventBus') as {
|
||||
on: (event: string, callback: (event: { element?: { id: string } }) => void) => void
|
||||
@@ -65,10 +165,12 @@ async function renderDiagram() {
|
||||
|
||||
eventBus.on('element.click', (event) => {
|
||||
const nodeId = event.element?.id
|
||||
|
||||
if (nodeId && findStep(nodeId)) {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
})
|
||||
eventBusBound = true
|
||||
}
|
||||
|
||||
function applyMarkers() {
|
||||
@@ -80,17 +182,90 @@ function applyMarkers() {
|
||||
addMarker: (id: string, marker: string) => void
|
||||
removeMarker: (id: string, marker: string) => void
|
||||
}
|
||||
const elementRegistry = viewer.value.get('elementRegistry') as {
|
||||
get: (id: string) => unknown
|
||||
}
|
||||
|
||||
markedNodeIds.forEach((nodeId) => {
|
||||
Object.values(markerClasses).forEach((marker) => {
|
||||
canvas.removeMarker(nodeId, marker)
|
||||
})
|
||||
canvas.removeMarker(nodeId, 'bpmn-step-selected')
|
||||
})
|
||||
markedNodeIds = new Set()
|
||||
clearOverlays()
|
||||
|
||||
props.steps.forEach((step) => {
|
||||
Object.values(markerClasses).forEach((marker) => {
|
||||
canvas.removeMarker(step.bpmnNodeId, marker)
|
||||
})
|
||||
if (!elementRegistry.get(step.bpmnNodeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
canvas.addMarker(step.bpmnNodeId, markerClasses[step.status] ?? markerClasses.pending)
|
||||
|
||||
if (selectedStep.value?.bpmnNodeId === step.bpmnNodeId) {
|
||||
canvas.addMarker(step.bpmnNodeId, 'bpmn-step-selected')
|
||||
}
|
||||
|
||||
markedNodeIds.add(step.bpmnNodeId)
|
||||
addStepOverlay(step)
|
||||
})
|
||||
}
|
||||
|
||||
function clearOverlays() {
|
||||
if (!viewer.value || overlayIds.length === 0) {
|
||||
overlayIds = []
|
||||
return
|
||||
}
|
||||
|
||||
const overlays = viewer.value.get('overlays') as {
|
||||
remove: (id: string | number) => void
|
||||
}
|
||||
|
||||
overlayIds.forEach((id) => overlays.remove(id))
|
||||
overlayIds = []
|
||||
}
|
||||
|
||||
function addStepOverlay(step: PipelineStep) {
|
||||
if (!viewer.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlays = viewer.value.get('overlays') as {
|
||||
add: (
|
||||
elementId: string,
|
||||
type: string,
|
||||
options: { position: { top?: number; right?: number }; html: HTMLElement },
|
||||
) => string | number
|
||||
}
|
||||
const meta = statusMeta[step.status]
|
||||
const badge = document.createElement('button')
|
||||
badge.type = 'button'
|
||||
badge.className = `bpmn-step-badge is-${meta.className}`
|
||||
badge.textContent = meta.short
|
||||
badge.title = `${step.name}:${meta.label}`
|
||||
badge.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
selectedNodeId.value = step.bpmnNodeId
|
||||
})
|
||||
|
||||
overlayIds.push(
|
||||
overlays.add(step.bpmnNodeId, 'step-status', {
|
||||
position: { top: -12, right: -12 },
|
||||
html: badge,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function statusClass(status: StepStatus): string {
|
||||
return statusMeta[status]?.className ?? 'pending'
|
||||
}
|
||||
|
||||
function isSelectedStep(step: PipelineStep): boolean {
|
||||
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.xml, props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|')],
|
||||
() => props.xml,
|
||||
async () => {
|
||||
await nextTick()
|
||||
await renderDiagram()
|
||||
@@ -100,41 +275,100 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|'),
|
||||
() => {
|
||||
applyMarkers()
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedNodeId, () => {
|
||||
applyMarkers()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOverlays()
|
||||
viewer.value?.destroy()
|
||||
viewer.value = undefined
|
||||
eventBusBound = false
|
||||
})
|
||||
</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 class="flow-main">
|
||||
<div class="flow-toolbar">
|
||||
<div>
|
||||
<strong>发布执行链路</strong>
|
||||
<span>当前:{{ selectedStep?.name || '等待后端步骤' }}</span>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<div class="legend">
|
||||
<span v-for="meta in statusLegend" :key="meta.className">
|
||||
<i class="dot" :class="meta.className" />{{ meta.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="canvasEl" class="bpmn-canvas" />
|
||||
</div>
|
||||
|
||||
<aside class="bpmn-side">
|
||||
<div class="flow-stats">
|
||||
<div>
|
||||
<small>流程进度</small>
|
||||
<strong>{{ progressPercent }}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>节点</small>
|
||||
<strong>{{ stepStats.done }}/{{ stepStats.total }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>运行</small>
|
||||
<strong>{{ stepStats.running }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>失败</small>
|
||||
<strong>{{ stepStats.failed }}</strong>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${progressPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list">
|
||||
<button
|
||||
v-for="step in steps"
|
||||
v-for="(step, index) in orderedSteps"
|
||||
:key="step.id"
|
||||
class="step-row"
|
||||
:class="{ active: selectedNodeId === step.bpmnNodeId }"
|
||||
class="timeline-step"
|
||||
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
|
||||
type="button"
|
||||
@click="selectedNodeId = step.bpmnNodeId"
|
||||
>
|
||||
<span>
|
||||
<i class="timeline-index">{{ index + 1 }}</i>
|
||||
<span class="timeline-text">
|
||||
<strong>{{ step.name }}</strong>
|
||||
<small>{{ step.owner }} · {{ step.duration }}</small>
|
||||
<small>{{ step.owner }} · {{ statusMeta[step.status].description }}</small>
|
||||
</span>
|
||||
<StatusPill :state="step.status" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedStep" class="node-detail">
|
||||
<div class="node-detail-head">
|
||||
<span>
|
||||
<strong>{{ selectedStep.name }}</strong>
|
||||
<small>{{ selectedStep.owner }} · {{ selectedStep.bpmnNodeId }}</small>
|
||||
</span>
|
||||
<StatusPill :state="selectedStep.status" />
|
||||
</div>
|
||||
<p>{{ selectedStep.summary }}</p>
|
||||
<dl>
|
||||
<dt>耗时</dt>
|
||||
<dd>{{ selectedStep.duration }}</dd>
|
||||
<dt>开始</dt>
|
||||
<dd>{{ selectedStep.startedAt || '-' }}</dd>
|
||||
<dt>结束</dt>
|
||||
<dd>{{ selectedStep.finishedAt || '-' }}</dd>
|
||||
</dl>
|
||||
<p v-if="selectedStep.message">
|
||||
<span>执行消息</span>
|
||||
{{ selectedStep.message }}
|
||||
@@ -145,28 +379,65 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
<pre v-if="selectedStep.logExcerpt">{{ selectedStep.logExcerpt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bpmn-viewer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 14px;
|
||||
min-height: 360px;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 16px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.bpmn-canvas {
|
||||
min-height: 360px;
|
||||
.flow-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.flow-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.flow-toolbar strong,
|
||||
.flow-toolbar span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-toolbar strong {
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flow-toolbar > div > span {
|
||||
margin-top: 4px;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bpmn-canvas {
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(#f6f8fb 1px, transparent 1px),
|
||||
linear-gradient(90deg, #f6f8fb 1px, transparent 1px),
|
||||
#ffffff;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.bpmn-side {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -175,10 +446,6 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
}
|
||||
@@ -195,33 +462,88 @@ onBeforeUnmount(() => {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.success {
|
||||
.dot.success,
|
||||
.timeline-step.success .timeline-index {
|
||||
background: #18a058;
|
||||
}
|
||||
|
||||
.running {
|
||||
.dot.running,
|
||||
.timeline-step.running .timeline-index {
|
||||
background: #1d5fd0;
|
||||
}
|
||||
|
||||
.queued {
|
||||
.dot.queued,
|
||||
.timeline-step.queued .timeline-index {
|
||||
background: #b7791f;
|
||||
}
|
||||
|
||||
.failed {
|
||||
.dot.failed,
|
||||
.timeline-step.failed .timeline-index {
|
||||
background: #d03050;
|
||||
}
|
||||
|
||||
.pending {
|
||||
.dot.pending,
|
||||
.dot.skipped,
|
||||
.dot.canceled,
|
||||
.timeline-step.pending .timeline-index,
|
||||
.timeline-step.skipped .timeline-index,
|
||||
.timeline-step.canceled .timeline-index {
|
||||
background: #9aa4b2;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
.flow-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e3e8f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.flow-stats small,
|
||||
.flow-stats strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-stats small {
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.flow-stats strong {
|
||||
margin-top: 4px;
|
||||
color: #172033;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
grid-column: 1 / -1;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e8eef7;
|
||||
}
|
||||
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #1d5fd0;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
@@ -230,25 +552,49 @@ onBeforeUnmount(() => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.step-row.active {
|
||||
.timeline-step.active {
|
||||
border-color: #1d5fd0;
|
||||
background: #f3f7ff;
|
||||
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
|
||||
}
|
||||
|
||||
.step-row strong,
|
||||
.step-row small {
|
||||
.timeline-index {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.timeline-step strong,
|
||||
.timeline-step small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.step-row strong {
|
||||
.timeline-step strong {
|
||||
overflow: hidden;
|
||||
color: #182033;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-row small {
|
||||
.timeline-step small {
|
||||
margin-top: 3px;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.node-detail {
|
||||
@@ -258,11 +604,29 @@ onBeforeUnmount(() => {
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.node-detail strong {
|
||||
.node-detail-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-detail-head strong,
|
||||
.node-detail-head small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-detail-head strong {
|
||||
color: #172033;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-detail-head small {
|
||||
margin-top: 4px;
|
||||
color: #748093;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-detail p {
|
||||
margin: 6px 0 0;
|
||||
color: #596578;
|
||||
@@ -277,6 +641,29 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-detail dl {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
margin: 10px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-detail dt {
|
||||
color: #7b8798;
|
||||
}
|
||||
|
||||
.node-detail dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #172033;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-detail pre {
|
||||
max-height: 160px;
|
||||
margin: 10px 0 0;
|
||||
@@ -290,6 +677,47 @@ onBeforeUnmount(() => {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge) {
|
||||
display: inline-flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 8px 18px rgb(15 23 42 / 16%);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge.is-success) {
|
||||
background: #18a058;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge.is-queued) {
|
||||
background: #b7791f;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge.is-running) {
|
||||
background: #1d5fd0;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge.is-failed) {
|
||||
background: #d03050;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-badge.is-pending),
|
||||
:global(.bpmn-step-badge.is-skipped),
|
||||
:global(.bpmn-step-badge.is-canceled) {
|
||||
background: #8b96a8;
|
||||
}
|
||||
|
||||
:global(.bpmn-step-selected .djs-visual > :first-child) {
|
||||
filter: drop-shadow(0 8px 12px rgb(29 95 208 / 22%));
|
||||
}
|
||||
|
||||
:global(.bpmn-step-success .djs-visual > :first-child) {
|
||||
stroke: #18a058 !important;
|
||||
stroke-width: 3px !important;
|
||||
@@ -322,5 +750,9 @@ onBeforeUnmount(() => {
|
||||
.bpmn-viewer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+180
-10
@@ -13,6 +13,7 @@ import type {
|
||||
IntegrationConfigKey,
|
||||
IntegrationConfigState,
|
||||
IntegrationConfigStatus,
|
||||
IntegrationVariableHelp,
|
||||
IntegrationStatus,
|
||||
JenkinsSyncSummary,
|
||||
PipelineStep,
|
||||
@@ -28,6 +29,13 @@ import { http } from './http'
|
||||
type DataSource = 'backend'
|
||||
type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable'
|
||||
type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled'
|
||||
type BackendVariableHelpValue =
|
||||
| string
|
||||
| {
|
||||
description?: unknown
|
||||
example?: unknown
|
||||
docUrl?: unknown
|
||||
}
|
||||
|
||||
interface BackendHealth {
|
||||
status: BackendHealthStatus
|
||||
@@ -99,6 +107,31 @@ interface BackendProcessDefinition {
|
||||
nodeIds: string[]
|
||||
}
|
||||
|
||||
interface BackendIntegrationConfigItem {
|
||||
key: IntegrationConfigKey
|
||||
name: string
|
||||
status: IntegrationConfigState
|
||||
required?: string[]
|
||||
configured?: string[]
|
||||
missing?: string[]
|
||||
optional?: string[]
|
||||
note?: string
|
||||
variableHelp?: Record<string, BackendVariableHelpValue>
|
||||
variableDescriptions?: Record<string, unknown>
|
||||
variables?: Array<{
|
||||
name?: unknown
|
||||
key?: unknown
|
||||
description?: unknown
|
||||
example?: unknown
|
||||
docUrl?: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
interface BackendIntegrationConfigStatus {
|
||||
checkedAt?: string
|
||||
integrations?: BackendIntegrationConfigItem[]
|
||||
}
|
||||
|
||||
let lastDataSource: DataSource = 'backend'
|
||||
const integrationConfigKeys: IntegrationConfigKey[] = [
|
||||
'jenkins',
|
||||
@@ -283,6 +316,14 @@ export const devopsApi = {
|
||||
return fetchIntegrationConfigStatus()
|
||||
},
|
||||
|
||||
async getReleaseProcessXml(): Promise<string> {
|
||||
const { data } = await http.get<BackendProcessDefinition>(
|
||||
'/process-definitions/release',
|
||||
)
|
||||
lastDataSource = 'backend'
|
||||
return data.xml
|
||||
},
|
||||
|
||||
async createAgentInvocation(
|
||||
payload: CreateAgentInvocationPayload,
|
||||
): Promise<AgentInvocation> {
|
||||
@@ -300,13 +341,6 @@ export const devopsApi = {
|
||||
return toSystemSettings(health, integrationConfig)
|
||||
},
|
||||
|
||||
async getReleaseProcessXml(): Promise<string> {
|
||||
const { data } = await http.get<BackendProcessDefinition>(
|
||||
'/process-definitions/release',
|
||||
)
|
||||
lastDataSource = 'backend'
|
||||
return data.xml
|
||||
},
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<BackendHealth> {
|
||||
@@ -325,7 +359,9 @@ async function fetchRuns(): Promise<BackendDeployRun[]> {
|
||||
}
|
||||
|
||||
async function fetchIntegrationConfigStatus(): Promise<IntegrationConfigStatus> {
|
||||
const { data } = await http.get<IntegrationConfigStatus>('/settings/integration-config')
|
||||
const { data } = await http.get<BackendIntegrationConfigStatus>(
|
||||
'/settings/integration-config',
|
||||
)
|
||||
return sanitizeIntegrationConfigStatus(data)
|
||||
}
|
||||
|
||||
@@ -358,7 +394,7 @@ function toIntegrationStatus(
|
||||
}
|
||||
|
||||
function sanitizeIntegrationConfigStatus(
|
||||
payload: IntegrationConfigStatus,
|
||||
payload: BackendIntegrationConfigStatus,
|
||||
): IntegrationConfigStatus {
|
||||
const integrations = Array.isArray(payload?.integrations) ? payload.integrations : []
|
||||
|
||||
@@ -373,6 +409,8 @@ function sanitizeIntegrationConfigStatus(
|
||||
const required = sanitizeVariableNames(item.required)
|
||||
const configured = sanitizeVariableNames(item.configured)
|
||||
const missing = sanitizeVariableNames(item.missing)
|
||||
const optional = sanitizeVariableNames(item.optional)
|
||||
const variableHelp = sanitizeVariableHelp(item)
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
@@ -381,8 +419,9 @@ function sanitizeIntegrationConfigStatus(
|
||||
required,
|
||||
configured,
|
||||
missing,
|
||||
optional: sanitizeVariableNames(item.optional),
|
||||
optional,
|
||||
note: sanitizeIntegrationNote(item.note),
|
||||
...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
@@ -423,6 +462,137 @@ function sanitizeIntegrationNote(note?: string): string | undefined {
|
||||
.replace(/key=[^&\s]+/g, 'key=[已隐藏]')
|
||||
}
|
||||
|
||||
function sanitizeVariableHelp(
|
||||
item: BackendIntegrationConfigItem,
|
||||
): Record<string, IntegrationVariableHelp> {
|
||||
const help: Record<string, IntegrationVariableHelp> = {}
|
||||
|
||||
collectVariableHelpMap(help, item.variableHelp)
|
||||
collectVariableDescriptionMap(help, item.variableDescriptions)
|
||||
collectVariableHelpArray(help, item.variables)
|
||||
|
||||
return help
|
||||
}
|
||||
|
||||
function collectVariableHelpMap(
|
||||
target: Record<string, IntegrationVariableHelp>,
|
||||
value?: Record<string, BackendVariableHelpValue>,
|
||||
) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([rawName, rawHelp]) => {
|
||||
const name = sanitizeVariableName(rawName)
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
const help =
|
||||
typeof rawHelp === 'string'
|
||||
? sanitizeVariableHelpValue({ description: rawHelp })
|
||||
: sanitizeVariableHelpValue(rawHelp)
|
||||
|
||||
if (help) {
|
||||
target[name] = help
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function collectVariableDescriptionMap(
|
||||
target: Record<string, IntegrationVariableHelp>,
|
||||
value?: Record<string, unknown>,
|
||||
) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([rawName, rawDescription]) => {
|
||||
const name = sanitizeVariableName(rawName)
|
||||
const description = sanitizeVariableDescription(rawDescription)
|
||||
|
||||
if (name && description) {
|
||||
target[name] = { description }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function collectVariableHelpArray(
|
||||
target: Record<string, IntegrationVariableHelp>,
|
||||
value?: BackendIntegrationConfigItem['variables'],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
value.forEach((item) => {
|
||||
const name = sanitizeVariableName(item.name ?? item.key)
|
||||
const help = sanitizeVariableHelpValue(item)
|
||||
|
||||
if (name && help) {
|
||||
target[name] = help
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeVariableHelpValue(
|
||||
value?: {
|
||||
description?: unknown
|
||||
example?: unknown
|
||||
docUrl?: unknown
|
||||
},
|
||||
): IntegrationVariableHelp | undefined {
|
||||
const description = sanitizeVariableDescription(value?.description)
|
||||
|
||||
if (!description) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
...(typeof value?.example === 'string' && value.example.trim()
|
||||
? { example: sanitizeIntegrationNote(value.example.trim()) ?? value.example.trim() }
|
||||
: {}),
|
||||
...(typeof value?.docUrl === 'string' && isSafeDocUrl(value.docUrl)
|
||||
? { docUrl: value.docUrl.trim() }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeVariableDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return sanitizeIntegrationNote(value.trim())
|
||||
}
|
||||
|
||||
function sanitizeVariableName(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const name = value.trim()
|
||||
return /^[A-Z][A-Z0-9_]*$/.test(name) ? name : undefined
|
||||
}
|
||||
|
||||
function isSafeDocUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const normalized = url.toString().toLowerCase()
|
||||
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
!url.searchParams.has('key') &&
|
||||
!normalized.includes('/cgi-bin/webhook/') &&
|
||||
!normalized.includes('/open-apis/bot/') &&
|
||||
!normalized.includes('/webhook/')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function safeText(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.trim() ? value : fallback
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export type IntegrationConfigKey =
|
||||
|
||||
export type IntegrationConfigState = 'configured' | 'partial' | 'missing'
|
||||
|
||||
export interface IntegrationVariableHelp {
|
||||
description: string
|
||||
example?: string
|
||||
docUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端安全配置检查结果;数组字段只承载变量名,不承载任何密钥值或 URL 原文。
|
||||
*/
|
||||
@@ -55,6 +61,7 @@ export interface IntegrationConfigItem {
|
||||
missing: string[]
|
||||
optional?: string[]
|
||||
note?: string
|
||||
variableHelp?: Record<string, IntegrationVariableHelp>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+141
-67
@@ -1,11 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import AuditJsonCell from '../components/AuditJsonCell.vue'
|
||||
import StatusPill from '../components/StatusPill.vue'
|
||||
import { usePlatformStore } from '../stores/platform'
|
||||
import type { AuditLogEntry } from '../types/devops'
|
||||
import type { IntegrationConfigState } from '../types/devops'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
IntegrationConfigItem,
|
||||
IntegrationConfigState,
|
||||
} from '../types/devops'
|
||||
|
||||
const platform = usePlatformStore()
|
||||
type VariableTagType = 'success' | 'warning' | 'danger' | 'info'
|
||||
type VariableGroup = {
|
||||
key: string
|
||||
label: string
|
||||
names: string[]
|
||||
emptyText: string
|
||||
tagType?: VariableTagType
|
||||
}
|
||||
|
||||
const configStatusMeta: Record<
|
||||
IntegrationConfigState,
|
||||
@@ -56,14 +68,66 @@ function auditResource(record: AuditLogEntry): string {
|
||||
: record.resourceType
|
||||
}
|
||||
|
||||
function auditDigest(record: AuditLogEntry): string {
|
||||
const payload = record.after ?? record.parameterDigest ?? record.before
|
||||
function auditPayload(record: AuditLogEntry): unknown {
|
||||
return record.after ?? record.parameterDigest ?? record.before ?? null
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return '-'
|
||||
function variableGroups(item: IntegrationConfigItem): VariableGroup[] {
|
||||
return [
|
||||
{
|
||||
key: 'required',
|
||||
label: '必需变量',
|
||||
names: item.required,
|
||||
emptyText: '暂无',
|
||||
},
|
||||
{
|
||||
key: 'configured',
|
||||
label: '已识别',
|
||||
names: item.configured,
|
||||
emptyText: '暂无',
|
||||
tagType: 'success',
|
||||
},
|
||||
{
|
||||
key: 'missing',
|
||||
label: '缺失',
|
||||
names: item.missing,
|
||||
emptyText: '无',
|
||||
tagType: 'danger',
|
||||
},
|
||||
{
|
||||
key: 'optional',
|
||||
label: '可选变量',
|
||||
names: item.optional ?? [],
|
||||
emptyText: '暂无',
|
||||
tagType: 'info',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function variableHelp(item: IntegrationConfigItem, name: string) {
|
||||
return item.variableHelp?.[name]
|
||||
}
|
||||
|
||||
function variableDescription(
|
||||
item: IntegrationConfigItem,
|
||||
name: string,
|
||||
group: VariableGroup,
|
||||
): string {
|
||||
const help = variableHelp(item, name)
|
||||
|
||||
if (help?.description) {
|
||||
return help.description
|
||||
}
|
||||
|
||||
return JSON.stringify(payload)
|
||||
if (item.note) {
|
||||
return item.note
|
||||
}
|
||||
|
||||
return `后端暂未返回 ${item.name} ${group.label} ${name} 的变量说明;请在 /settings/integration-config 响应中补充 variableHelp。`
|
||||
}
|
||||
|
||||
function variableSourceLabel(item: IntegrationConfigItem, name: string): string {
|
||||
return variableHelp(item, name)?.description ? '后端返回' : '等待后端说明'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -72,7 +136,7 @@ function auditDigest(record: AuditLogEntry): string {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>系统配置</h1>
|
||||
<p>展示后端集成配置状态、API 契约来源、BPMN 映射、Agent 边界和通知维护状态。</p>
|
||||
<p>展示后端集成配置状态、API 契约来源、流程映射、Agent 边界和通知维护状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,57 +166,45 @@ function auditDigest(record: AuditLogEntry): string {
|
||||
</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"
|
||||
<div
|
||||
v-for="group in variableGroups(item)"
|
||||
:key="group.key"
|
||||
class="variable-group"
|
||||
>
|
||||
{{ name }}
|
||||
</ElTag>
|
||||
<span v-if="item.configured.length === 0" class="empty-value">暂无</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-group">
|
||||
<strong>缺失</strong>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<div class="variable-tags">
|
||||
<ElTag
|
||||
v-for="name in item.missing"
|
||||
:key="name"
|
||||
class="mono"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
<ElTooltip
|
||||
v-for="name in group.names"
|
||||
:key="`${group.key}-${name}`"
|
||||
effect="light"
|
||||
placement="top"
|
||||
:show-after="160"
|
||||
>
|
||||
<template #content>
|
||||
<div class="variable-tooltip">
|
||||
<strong class="mono">{{ name }}</strong>
|
||||
<p>{{ variableDescription(item, name, group) }}</p>
|
||||
<small>{{ group.label }} · {{ variableSourceLabel(item, name) }}</small>
|
||||
<small v-if="variableHelp(item, name)?.example">
|
||||
示例:{{ variableHelp(item, name)?.example }}
|
||||
</small>
|
||||
<a
|
||||
v-if="variableHelp(item, name)?.docUrl"
|
||||
:href="variableHelp(item, name)?.docUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看官方文档
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<ElTag class="mono variable-tag" :type="group.tagType" 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>
|
||||
</ElTooltip>
|
||||
<span v-if="group.names.length === 0" class="empty-value">
|
||||
{{ group.emptyText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,9 +257,9 @@ function auditDigest(record: AuditLogEntry): string {
|
||||
{{ auditActor(row) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="脱敏摘要" min-width="280">
|
||||
<ElTableColumn label="脱敏摘要" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<span class="audit-digest">{{ auditDigest(row) }}</span>
|
||||
<AuditJsonCell :value="auditPayload(row)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
@@ -334,23 +386,45 @@ function auditDigest(record: AuditLogEntry): string {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.variable-tag {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.variable-tooltip {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.variable-tooltip strong,
|
||||
.variable-tooltip small,
|
||||
.variable-tooltip a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.variable-tooltip p {
|
||||
margin: 6px 0;
|
||||
color: #4b5565;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.variable-tooltip small {
|
||||
margin-top: 4px;
|
||||
color: #7b8798;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.variable-tooltip a {
|
||||
margin-top: 6px;
|
||||
color: #1d5fd0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-value {
|
||||
color: #9aa4b2;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.audit-digest {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #4b5565;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.variable-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user