feat: 优化发布流程状态展示

This commit is contained in:
湛兮
2026-06-12 12:26:47 +08:00
parent 61e6c73f12
commit 0f92eb7b2b
4 changed files with 645 additions and 301 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+5 -1
View File
@@ -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>