feat: 优化发布流程状态展示
This commit is contained in:
@@ -7,9 +7,9 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
|
||||
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
||||
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
||||
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 发布中心包含项目、环境、ref/tag、备注、生产确认提示、触发、取消、重试和 Jenkins 状态同步入口。
|
||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
||||
- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。
|
||||
- BPMN 状态区使用后端流程 XML 和 run steps 作为状态来源,左侧展示节点输出或 Jenkins 构建日志,右侧粘性展示进度统计、节点时间线和详情面板。
|
||||
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
|
||||
|
||||
## 技术栈
|
||||
|
||||
+520
-266
@@ -1,15 +1,15 @@
|
||||
<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 { RefreshRight, Tickets } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { devopsApi } from '../services/devopsApi'
|
||||
import type { JenkinsBuildLog, PipelineStep, ReleaseRun, StepStatus } from '../types/devops'
|
||||
import StatusPill from './StatusPill.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
xml: string
|
||||
steps: PipelineStep[]
|
||||
run?: ReleaseRun
|
||||
}>()
|
||||
|
||||
type StatusMeta = {
|
||||
@@ -19,22 +19,19 @@ type StatusMeta = {
|
||||
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>()
|
||||
type OutputMode = 'step' | 'jenkins'
|
||||
const maxRenderedLogLines = 520
|
||||
|
||||
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',
|
||||
}
|
||||
const selectedNodeId = ref('')
|
||||
const terminalOutputEl = ref<HTMLElement>()
|
||||
const outputMode = ref<OutputMode>('step')
|
||||
const logLoading = ref(false)
|
||||
const logText = ref('')
|
||||
const foldedLogLines = ref(0)
|
||||
const logError = ref('')
|
||||
const logMeta = ref<JenkinsBuildLog | null>(null)
|
||||
const autoTail = ref(true)
|
||||
let logTimer: ReturnType<typeof window.setTimeout> | undefined
|
||||
|
||||
const statusMeta: Record<StepStatus, StatusMeta> = {
|
||||
pending: {
|
||||
@@ -83,6 +80,10 @@ const statusMeta: Record<StepStatus, StatusMeta> = {
|
||||
|
||||
const statusLegend = Object.values(statusMeta)
|
||||
const orderedSteps = computed(() => props.steps)
|
||||
const outputOptions = computed(() => [
|
||||
{ label: '节点输出', value: 'step' },
|
||||
{ label: 'Jenkins 日志', value: 'jenkins', disabled: !canRequestJenkinsLog.value },
|
||||
])
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const active = orderedSteps.value.find((step) =>
|
||||
@@ -129,133 +130,117 @@ const progressPercent = computed(() => {
|
||||
return Math.round((stepStats.value.done / stepStats.value.total) * 100)
|
||||
})
|
||||
|
||||
const canRequestJenkinsLog = computed(() => Boolean(props.run?.id))
|
||||
|
||||
const hasJenkinsBuildNumber = computed(
|
||||
() => props.run?.jenkinsBuildNumber !== undefined && props.run?.jenkinsBuildNumber !== null,
|
||||
)
|
||||
|
||||
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
|
||||
? `Jenkins Build #${props.run?.jenkinsBuildNumber}`
|
||||
: 'Jenkins 输出日志'
|
||||
}
|
||||
|
||||
return selectedStep.value?.name || '等待后端步骤'
|
||||
})
|
||||
|
||||
const terminalSubtitle = computed(() => {
|
||||
if (outputMode.value === 'jenkins') {
|
||||
if (logMeta.value) {
|
||||
return `${logMeta.value.jobPath} · ${logMeta.value.start} -> ${logMeta.value.nextStart}`
|
||||
}
|
||||
|
||||
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(' · ')
|
||||
}
|
||||
|
||||
if (!selectedStep.value) {
|
||||
return processSourceText.value
|
||||
}
|
||||
|
||||
return `${selectedStep.value.owner} · ${selectedStep.value.bpmnNodeId}`
|
||||
})
|
||||
|
||||
const logRefreshStateText = computed(() => {
|
||||
if (logLoading.value) {
|
||||
return '刷新中'
|
||||
}
|
||||
|
||||
if (outputMode.value === 'jenkins' && autoTail.value && isActiveRun.value) {
|
||||
return '跟随中'
|
||||
}
|
||||
|
||||
return '局部刷新'
|
||||
})
|
||||
|
||||
const stepOutputText = computed(() => {
|
||||
const step = selectedStep.value
|
||||
|
||||
if (!step) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return [
|
||||
`# ${step.name}`,
|
||||
`owner=${step.owner}`,
|
||||
`status=${statusMeta[step.status].label}`,
|
||||
`bpmnNodeId=${step.bpmnNodeId}`,
|
||||
`duration=${step.duration}`,
|
||||
step.startedAt ? `startedAt=${step.startedAt}` : '',
|
||||
step.finishedAt ? `finishedAt=${step.finishedAt}` : '',
|
||||
'',
|
||||
step.errorSummary ? `[error]\n${step.errorSummary}` : '',
|
||||
step.message ? `[message]\n${step.message}` : '',
|
||||
step.logExcerpt ? `[log]\n${step.logExcerpt}` : '',
|
||||
!step.errorSummary && !step.message && !step.logExcerpt ? step.summary : '',
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n')
|
||||
})
|
||||
|
||||
const terminalText = computed(() => {
|
||||
if (outputMode.value !== 'jenkins') {
|
||||
return stepOutputText.value
|
||||
}
|
||||
|
||||
if (!foldedLogLines.value) {
|
||||
return logText.value
|
||||
}
|
||||
|
||||
return `... 已折叠前 ${foldedLogLines.value} 行日志,仅展示最近 ${maxRenderedLogLines} 行 ...\n${logText.value}`
|
||||
})
|
||||
|
||||
const terminalEmptyTitle = computed(() => {
|
||||
if (outputMode.value === 'jenkins') {
|
||||
return canRequestJenkinsLog.value ? 'Jenkins 暂无日志内容' : '等待发布单'
|
||||
}
|
||||
|
||||
return '暂无节点输出'
|
||||
})
|
||||
|
||||
const terminalEmptyHint = computed(() => {
|
||||
if (outputMode.value === 'jenkins') {
|
||||
return canRequestJenkinsLog.value
|
||||
? '正在通过后端读取 Jenkins progressive log;如果这里为空,需要后端返回对应 run 的构建日志。'
|
||||
: '触发发布后,这里会跟随当前 run 读取 Jenkins 构建日志。'
|
||||
}
|
||||
|
||||
return '点击右侧节点查看该节点的执行消息、失败摘要或日志片段。'
|
||||
})
|
||||
|
||||
function findStep(nodeId: string) {
|
||||
return props.steps.find((step) => step.bpmnNodeId === nodeId)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
eventBus.on('element.click', (event) => {
|
||||
const nodeId = event.element?.id
|
||||
|
||||
if (nodeId && findStep(nodeId)) {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
})
|
||||
eventBusBound = true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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) => {
|
||||
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'
|
||||
}
|
||||
@@ -264,52 +249,240 @@ function isSelectedStep(step: PipelineStep): boolean {
|
||||
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.xml,
|
||||
async () => {
|
||||
await nextTick()
|
||||
await renderDiagram()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
function selectStep(step: PipelineStep) {
|
||||
selectedNodeId.value = step.bpmnNodeId
|
||||
|
||||
if (step.owner === 'Jenkins') {
|
||||
outputMode.value = 'jenkins'
|
||||
void reloadJenkinsLog()
|
||||
return
|
||||
}
|
||||
|
||||
outputMode.value = 'step'
|
||||
}
|
||||
|
||||
function clearLogTimer() {
|
||||
if (logTimer !== undefined) {
|
||||
window.clearTimeout(logTimer)
|
||||
logTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function resetJenkinsLog() {
|
||||
clearLogTimer()
|
||||
logLoading.value = false
|
||||
logText.value = ''
|
||||
foldedLogLines.value = 0
|
||||
logError.value = ''
|
||||
logMeta.value = null
|
||||
}
|
||||
|
||||
async function reloadJenkinsLog() {
|
||||
await loadJenkinsLog(0, 'replace')
|
||||
}
|
||||
|
||||
async function loadMoreJenkinsLog() {
|
||||
await loadJenkinsLog(logMeta.value?.nextStart ?? 0, 'append')
|
||||
}
|
||||
|
||||
async function loadJenkinsLog(start: number, mode: 'replace' | 'append' | 'poll') {
|
||||
clearLogTimer()
|
||||
|
||||
if (!props.run) {
|
||||
if (mode !== 'poll') {
|
||||
ElMessage.warning('当前没有可读取的发布单')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const runId = props.run.id
|
||||
logLoading.value = mode !== 'poll' || !logText.value
|
||||
logError.value = ''
|
||||
|
||||
try {
|
||||
const log = await devopsApi.getJenkinsLog(runId, start)
|
||||
|
||||
if (runId !== props.run?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
logMeta.value = log
|
||||
updateLogText(log.text, mode)
|
||||
scrollLogToBottom()
|
||||
} catch (error) {
|
||||
logError.value = error instanceof Error ? error.message : 'Jenkins 日志读取失败'
|
||||
if (mode !== 'poll') {
|
||||
ElMessage.error(logError.value)
|
||||
}
|
||||
} finally {
|
||||
if (runId === props.run?.id) {
|
||||
logLoading.value = false
|
||||
scheduleLogRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateLogText(nextText: string, mode: 'replace' | 'append' | 'poll') {
|
||||
const mergedText = mode === 'replace' ? nextText : `${logText.value}${nextText}`
|
||||
const lines = mergedText.split('\n')
|
||||
|
||||
if (mode === 'replace') {
|
||||
foldedLogLines.value = 0
|
||||
}
|
||||
|
||||
if (lines.length <= maxRenderedLogLines) {
|
||||
logText.value = mergedText
|
||||
return
|
||||
}
|
||||
|
||||
const removed = lines.length - maxRenderedLogLines
|
||||
foldedLogLines.value += removed
|
||||
logText.value = lines.slice(removed).join('\n')
|
||||
}
|
||||
|
||||
async function scrollLogToBottom() {
|
||||
if (!autoTail.value || outputMode.value !== 'jenkins') {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const element = terminalOutputEl.value
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.scrollTo({
|
||||
top: element.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleLogRefresh() {
|
||||
clearLogTimer()
|
||||
|
||||
if (!autoTail.value || outputMode.value !== 'jenkins' || !canRequestJenkinsLog.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isActiveRun.value && !logMeta.value?.hasMore) {
|
||||
return
|
||||
}
|
||||
|
||||
logTimer = window.setTimeout(() => {
|
||||
void loadJenkinsLog(logMeta.value?.nextStart ?? 0, logText.value ? 'poll' : 'replace')
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|'),
|
||||
() => `${props.run?.id ?? 'none'}:${props.run?.jenkinsBuildNumber ?? 'none'}`,
|
||||
() => {
|
||||
applyMarkers()
|
||||
resetJenkinsLog()
|
||||
|
||||
if (hasJenkinsBuildNumber.value) {
|
||||
outputMode.value = 'jenkins'
|
||||
void loadJenkinsLog(0, 'replace')
|
||||
} else if (outputMode.value === 'jenkins') {
|
||||
void loadJenkinsLog(0, 'replace')
|
||||
} else if (selectedStep.value?.owner !== 'Jenkins') {
|
||||
outputMode.value = 'step'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.run?.status,
|
||||
() => {
|
||||
scheduleLogRefresh()
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedNodeId, () => {
|
||||
applyMarkers()
|
||||
watch(
|
||||
() => `${props.run?.id ?? 'none'}:${currentStep.value?.id ?? 'none'}:${currentStep.value?.status ?? 'none'}`,
|
||||
() => {
|
||||
if (selectedNodeId.value || currentStep.value?.owner !== 'Jenkins') {
|
||||
return
|
||||
}
|
||||
|
||||
outputMode.value = 'jenkins'
|
||||
|
||||
if (!logText.value && !logLoading.value) {
|
||||
void loadJenkinsLog(0, 'replace')
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(outputMode, () => {
|
||||
if (outputMode.value === 'jenkins' && !logText.value && !logLoading.value) {
|
||||
void loadJenkinsLog(0, 'replace')
|
||||
}
|
||||
})
|
||||
|
||||
watch(autoTail, () => {
|
||||
scheduleLogRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOverlays()
|
||||
viewer.value?.destroy()
|
||||
viewer.value = undefined
|
||||
eventBusBound = false
|
||||
clearLogTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bpmn-viewer">
|
||||
<div class="flow-main">
|
||||
<div class="flow-toolbar">
|
||||
<section class="execution-output">
|
||||
<div class="execution-toolbar">
|
||||
<div>
|
||||
<strong>发布执行链路</strong>
|
||||
<span>当前:{{ selectedStep?.name || '等待后端步骤' }}</span>
|
||||
<strong>{{ terminalTitle }}</strong>
|
||||
<span>{{ terminalSubtitle }}</span>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span v-for="meta in statusLegend" :key="meta.className">
|
||||
<i class="dot" :class="meta.className" />{{ meta.label }}
|
||||
<div class="execution-actions">
|
||||
<ElSegmented v-model="outputMode" size="small" :options="outputOptions" />
|
||||
<span
|
||||
v-if="outputMode === 'jenkins'"
|
||||
class="refresh-state"
|
||||
:class="{ loading: logLoading }"
|
||||
>
|
||||
{{ logRefreshStateText }}
|
||||
</span>
|
||||
<ElSwitch
|
||||
v-if="outputMode === 'jenkins'"
|
||||
v-model="autoTail"
|
||||
size="small"
|
||||
inline-prompt
|
||||
active-text="跟随"
|
||||
inactive-text="暂停"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="outputMode === 'jenkins'"
|
||||
size="small"
|
||||
:icon="RefreshRight"
|
||||
:disabled="!canRequestJenkinsLog"
|
||||
:loading="logLoading"
|
||||
@click="reloadJenkinsLog"
|
||||
>
|
||||
重读
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="outputMode === 'jenkins' && logMeta?.hasMore"
|
||||
size="small"
|
||||
:loading="logLoading"
|
||||
@click="loadMoreJenkinsLog"
|
||||
>
|
||||
继续加载
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="canvasEl" class="bpmn-canvas" />
|
||||
</div>
|
||||
|
||||
<pre v-if="terminalText" ref="terminalOutputEl" class="terminal-output">{{ terminalText }}</pre>
|
||||
<div v-else class="terminal-empty">
|
||||
<ElIcon><Tickets /></ElIcon>
|
||||
<strong>{{ terminalEmptyTitle }}</strong>
|
||||
<span>{{ terminalEmptyHint }}</span>
|
||||
</div>
|
||||
<div v-if="logError && outputMode === 'jenkins'" class="log-error">{{ logError }}</div>
|
||||
</section>
|
||||
|
||||
<aside class="bpmn-side">
|
||||
<div class="flow-stats">
|
||||
@@ -334,6 +507,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span v-for="meta in statusLegend" :key="meta.className">
|
||||
<i class="dot" :class="meta.className" />{{ meta.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list">
|
||||
<button
|
||||
v-for="(step, index) in orderedSteps"
|
||||
@@ -341,7 +520,7 @@ onBeforeUnmount(() => {
|
||||
class="timeline-step"
|
||||
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
|
||||
type="button"
|
||||
@click="selectedNodeId = step.bpmnNodeId"
|
||||
@click="selectStep(step)"
|
||||
>
|
||||
<i class="timeline-index">{{ index + 1 }}</i>
|
||||
<span class="timeline-text">
|
||||
@@ -386,60 +565,166 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.bpmn-viewer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 16px;
|
||||
min-height: 420px;
|
||||
height: clamp(430px, 58vh, 560px);
|
||||
min-height: 0;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.flow-main {
|
||||
.execution-output {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border: 1px solid #dfe6f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
background: #0f172a;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.flow-toolbar {
|
||||
.execution-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
background: #fbfcff;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
background: #111827;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.flow-toolbar strong,
|
||||
.flow-toolbar span {
|
||||
.execution-toolbar strong,
|
||||
.execution-toolbar span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-toolbar strong {
|
||||
color: #172033;
|
||||
.execution-toolbar strong {
|
||||
color: #f8fafc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flow-toolbar > div > span {
|
||||
.execution-toolbar > div > span {
|
||||
margin-top: 4px;
|
||||
color: #748093;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.execution-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-state {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgb(148 163 184 / 30%);
|
||||
border-radius: 999px;
|
||||
background: rgb(15 23 42 / 55%);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refresh-state::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #38bdf8;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.refresh-state.loading::before {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.execution-actions :deep(.el-segmented) {
|
||||
--el-segmented-bg-color: #1f2937;
|
||||
--el-segmented-item-selected-bg-color: #334155;
|
||||
--el-segmented-item-selected-color: #ffffff;
|
||||
--el-segmented-item-hover-color: #ffffff;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
background:
|
||||
linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
|
||||
#0f172a;
|
||||
background-size: 100% 24px;
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.terminal-empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 28px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-empty .el-icon {
|
||||
color: #38bdf8;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.terminal-empty strong {
|
||||
color: #e5edf8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.terminal-empty span {
|
||||
max-width: 420px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #7f1d1d;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
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 {
|
||||
position: sticky;
|
||||
top: 86px;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 2px;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.legend {
|
||||
@@ -550,6 +835,11 @@ onBeforeUnmount(() => {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.timeline-step.active {
|
||||
@@ -558,6 +848,10 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
|
||||
}
|
||||
|
||||
.timeline-step:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.timeline-index {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -677,82 +971,42 @@ 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;
|
||||
}
|
||||
|
||||
: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;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.flow-toolbar {
|
||||
.execution-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.execution-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.execution-output {
|
||||
height: min(520px, 70vh);
|
||||
}
|
||||
|
||||
.bpmn-side {
|
||||
position: static;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+118
-32
@@ -19,7 +19,6 @@ const form = reactive({
|
||||
environment: 'test' as EnvironmentName,
|
||||
ref: '',
|
||||
note: '发布验证',
|
||||
confirmed: false,
|
||||
})
|
||||
|
||||
const localStatus = ref<ReleaseStatus>('idle')
|
||||
@@ -141,7 +140,7 @@ const canTrigger = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
return form.environment === 'production' ? form.confirmed : true
|
||||
return true
|
||||
})
|
||||
|
||||
const shownRun = computed(() => {
|
||||
@@ -180,7 +179,7 @@ async function triggerRelease() {
|
||||
productionConfirmation:
|
||||
form.environment === 'production'
|
||||
? {
|
||||
confirmed: form.confirmed,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date().toISOString(),
|
||||
summary: '已确认 ref、回滚方案、通知对象和业务窗口',
|
||||
}
|
||||
@@ -293,7 +292,6 @@ function useFailedRun() {
|
||||
form.environment = failed.environment
|
||||
form.ref = failed.ref
|
||||
form.note = `重试 ${failed.id}`
|
||||
form.confirmed = failed.environment !== 'production'
|
||||
localStatus.value = 'failed'
|
||||
activeRun.value = failed
|
||||
}
|
||||
@@ -357,7 +355,7 @@ watch(refOptions, (options) => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>发布中心</h1>
|
||||
<p>选择项目、环境和 ref/tag,填写备注后触发发布;生产环境必须完成二次确认。</p>
|
||||
<p>选择项目、环境和 ref/tag,填写备注后触发发布;生产环境会提交确认信息。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<ElButton :icon="RefreshRight" @click="useFailedRun">载入最近失败</ElButton>
|
||||
@@ -365,8 +363,8 @@ watch(refOptions, (options) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<section class="panel">
|
||||
<div class="release-layout">
|
||||
<section class="panel release-params-panel">
|
||||
<div class="panel-header">
|
||||
<h2>发布参数</h2>
|
||||
<span>通过后端创建发布单</span>
|
||||
@@ -387,7 +385,7 @@ watch(refOptions, (options) => {
|
||||
当前权限:{{ selectedProjectPermissionName }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="环境">
|
||||
<ElFormItem label="环境" class="environment-field">
|
||||
<ElSegmented
|
||||
v-model="form.environment"
|
||||
:options="environmentOptions"
|
||||
@@ -433,20 +431,17 @@ watch(refOptions, (options) => {
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="备注">
|
||||
<ElInput v-model="form.note" type="textarea" :rows="3" maxlength="120" show-word-limit />
|
||||
<ElFormItem label="备注" class="note-field">
|
||||
<ElInput v-model="form.note" type="textarea" :rows="2" maxlength="120" show-word-limit />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
<ElAlert
|
||||
v-if="form.environment === 'production'"
|
||||
class="danger-note"
|
||||
title="生产发布需要二次确认:已确认 tag、回滚方案、通知对象和业务窗口。"
|
||||
title="生产发布将提交确认信息,请确认 tag、回滚方案、通知对象和业务窗口。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElCheckbox v-model="form.confirmed" class="confirm-line">
|
||||
我确认本次发布参数、目标环境和通知范围无误
|
||||
</ElCheckbox>
|
||||
<div class="action-row">
|
||||
<ElButton
|
||||
type="primary"
|
||||
@@ -471,7 +466,7 @@ watch(refOptions, (options) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel current-run-panel">
|
||||
<div class="panel-header">
|
||||
<h2>当前发布单</h2>
|
||||
<div class="toolbar compact-toolbar">
|
||||
@@ -491,33 +486,106 @@ watch(refOptions, (options) => {
|
||||
<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>
|
||||
<section class="panel bpmn-status-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"
|
||||
:run="shownRun"
|
||||
:steps="shownRun.steps"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(560px, 1fr) minmax(320px, 400px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.release-params-panel,
|
||||
.current-run-panel,
|
||||
.bpmn-status-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bpmn-status-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.current-run-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.current-run-panel .panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: clamp(320px, 42vh, 520px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.bpmn-status-panel .panel-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.current-run-panel :deep(.run-execution-details) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.current-run-panel :deep(.detail-grid) {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 9px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.current-run-panel :deep(.detail-grid dd) {
|
||||
line-height: 1.45;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.current-run-panel :deep(.failure-block) {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.release-params-panel .panel-body {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 14px;
|
||||
gap: 8px 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.confirm-line {
|
||||
margin-top: 14px;
|
||||
.note-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.environment-field :deep(.el-segmented) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.environment-field :deep(.el-segmented__item) {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.release-params-panel :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@@ -602,8 +670,26 @@ watch(refOptions, (options) => {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.release-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.release-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bpmn-status-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.current-run-panel .panel-body {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -225,7 +225,11 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
||||
<StatusPill :state="selectedRun.status" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<BpmnStatusViewer :xml="platform.releaseProcessXml" :steps="selectedRun.steps" />
|
||||
<BpmnStatusViewer
|
||||
:xml="platform.releaseProcessXml"
|
||||
:run="selectedRun"
|
||||
:steps="selectedRun.steps"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user