feat: 优化发布流程状态展示
This commit is contained in:
@@ -7,9 +7,9 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于
|
|||||||
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
- 登录、总览、项目配置、成员权限、发布中心、运行记录、系统配置,以及全局右侧 Agent 抽屉。
|
||||||
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
- 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。
|
||||||
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
- 成员权限页调用后端 `/members` 和 `/messages`,维护普通成员账号、项目权限、密码重置和平台消息。
|
||||||
- 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。
|
- 发布中心包含项目、环境、ref/tag、备注、生产确认提示、触发、取消、重试和 Jenkins 状态同步入口。
|
||||||
- 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。
|
- 运行记录展示 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 创建调用记录。
|
- Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
+520
-266
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'bpmn-js/dist/assets/bpmn-js.css'
|
import { RefreshRight, Tickets } from '@element-plus/icons-vue'
|
||||||
import 'bpmn-js/dist/assets/diagram-js.css'
|
import { ElMessage } from 'element-plus'
|
||||||
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
import { devopsApi } from '../services/devopsApi'
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
import type { JenkinsBuildLog, PipelineStep, ReleaseRun, StepStatus } from '../types/devops'
|
||||||
import type { PipelineStep, StepStatus } from '../types/devops'
|
|
||||||
import StatusPill from './StatusPill.vue'
|
import StatusPill from './StatusPill.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
xml: string
|
xml: string
|
||||||
steps: PipelineStep[]
|
steps: PipelineStep[]
|
||||||
|
run?: ReleaseRun
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type StatusMeta = {
|
type StatusMeta = {
|
||||||
@@ -19,22 +19,19 @@ type StatusMeta = {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvasEl = ref<HTMLDivElement>()
|
type OutputMode = 'step' | 'jenkins'
|
||||||
const selectedNodeId = ref('')
|
const maxRenderedLogLines = 520
|
||||||
const viewer = shallowRef<InstanceType<typeof BpmnViewer>>()
|
|
||||||
let eventBusBound = false
|
|
||||||
let overlayIds: Array<string | number> = []
|
|
||||||
let markedNodeIds = new Set<string>()
|
|
||||||
|
|
||||||
const markerClasses: Record<StepStatus, string> = {
|
const selectedNodeId = ref('')
|
||||||
pending: 'bpmn-step-pending',
|
const terminalOutputEl = ref<HTMLElement>()
|
||||||
queued: 'bpmn-step-queued',
|
const outputMode = ref<OutputMode>('step')
|
||||||
running: 'bpmn-step-running',
|
const logLoading = ref(false)
|
||||||
success: 'bpmn-step-success',
|
const logText = ref('')
|
||||||
failed: 'bpmn-step-failed',
|
const foldedLogLines = ref(0)
|
||||||
skipped: 'bpmn-step-skipped',
|
const logError = ref('')
|
||||||
canceled: 'bpmn-step-canceled',
|
const logMeta = ref<JenkinsBuildLog | null>(null)
|
||||||
}
|
const autoTail = ref(true)
|
||||||
|
let logTimer: ReturnType<typeof window.setTimeout> | undefined
|
||||||
|
|
||||||
const statusMeta: Record<StepStatus, StatusMeta> = {
|
const statusMeta: Record<StepStatus, StatusMeta> = {
|
||||||
pending: {
|
pending: {
|
||||||
@@ -83,6 +80,10 @@ const statusMeta: Record<StepStatus, StatusMeta> = {
|
|||||||
|
|
||||||
const statusLegend = Object.values(statusMeta)
|
const statusLegend = Object.values(statusMeta)
|
||||||
const orderedSteps = computed(() => props.steps)
|
const orderedSteps = computed(() => props.steps)
|
||||||
|
const outputOptions = computed(() => [
|
||||||
|
{ label: '节点输出', value: 'step' },
|
||||||
|
{ label: 'Jenkins 日志', value: 'jenkins', disabled: !canRequestJenkinsLog.value },
|
||||||
|
])
|
||||||
|
|
||||||
const currentStep = computed(() => {
|
const currentStep = computed(() => {
|
||||||
const active = orderedSteps.value.find((step) =>
|
const active = orderedSteps.value.find((step) =>
|
||||||
@@ -129,133 +130,117 @@ const progressPercent = computed(() => {
|
|||||||
return Math.round((stepStats.value.done / stepStats.value.total) * 100)
|
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) {
|
function findStep(nodeId: string) {
|
||||||
return props.steps.find((step) => step.bpmnNodeId === nodeId)
|
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 {
|
function statusClass(status: StepStatus): string {
|
||||||
return statusMeta[status]?.className ?? 'pending'
|
return statusMeta[status]?.className ?? 'pending'
|
||||||
}
|
}
|
||||||
@@ -264,52 +249,240 @@ function isSelectedStep(step: PipelineStep): boolean {
|
|||||||
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
|
return selectedStep.value?.bpmnNodeId === step.bpmnNodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
function selectStep(step: PipelineStep) {
|
||||||
() => props.xml,
|
selectedNodeId.value = step.bpmnNodeId
|
||||||
async () => {
|
|
||||||
await nextTick()
|
if (step.owner === 'Jenkins') {
|
||||||
await renderDiagram()
|
outputMode.value = 'jenkins'
|
||||||
},
|
void reloadJenkinsLog()
|
||||||
{
|
return
|
||||||
immediate: true,
|
}
|
||||||
},
|
|
||||||
)
|
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(
|
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, () => {
|
watch(
|
||||||
applyMarkers()
|
() => `${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(() => {
|
onBeforeUnmount(() => {
|
||||||
clearOverlays()
|
clearLogTimer()
|
||||||
viewer.value?.destroy()
|
|
||||||
viewer.value = undefined
|
|
||||||
eventBusBound = false
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bpmn-viewer">
|
<div class="bpmn-viewer">
|
||||||
<div class="flow-main">
|
<section class="execution-output">
|
||||||
<div class="flow-toolbar">
|
<div class="execution-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>发布执行链路</strong>
|
<strong>{{ terminalTitle }}</strong>
|
||||||
<span>当前:{{ selectedStep?.name || '等待后端步骤' }}</span>
|
<span>{{ terminalSubtitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<div class="execution-actions">
|
||||||
<span v-for="meta in statusLegend" :key="meta.className">
|
<ElSegmented v-model="outputMode" size="small" :options="outputOptions" />
|
||||||
<i class="dot" :class="meta.className" />{{ meta.label }}
|
<span
|
||||||
|
v-if="outputMode === 'jenkins'"
|
||||||
|
class="refresh-state"
|
||||||
|
:class="{ loading: logLoading }"
|
||||||
|
>
|
||||||
|
{{ logRefreshStateText }}
|
||||||
</span>
|
</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>
|
</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">
|
<aside class="bpmn-side">
|
||||||
<div class="flow-stats">
|
<div class="flow-stats">
|
||||||
@@ -334,6 +507,12 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="timeline-list">
|
||||||
<button
|
<button
|
||||||
v-for="(step, index) in orderedSteps"
|
v-for="(step, index) in orderedSteps"
|
||||||
@@ -341,7 +520,7 @@ onBeforeUnmount(() => {
|
|||||||
class="timeline-step"
|
class="timeline-step"
|
||||||
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
|
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectedNodeId = step.bpmnNodeId"
|
@click="selectStep(step)"
|
||||||
>
|
>
|
||||||
<i class="timeline-index">{{ index + 1 }}</i>
|
<i class="timeline-index">{{ index + 1 }}</i>
|
||||||
<span class="timeline-text">
|
<span class="timeline-text">
|
||||||
@@ -386,60 +565,166 @@ onBeforeUnmount(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.bpmn-viewer {
|
.bpmn-viewer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 360px;
|
grid-template-columns: minmax(0, 1fr) 330px;
|
||||||
gap: 16px;
|
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;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid #dfe6f0;
|
border: 1px solid #dfe6f0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #0f172a;
|
||||||
|
contain: layout paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-toolbar {
|
.execution-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 14px 16px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid #edf1f7;
|
border-bottom: 1px solid #1f2937;
|
||||||
background: #fbfcff;
|
background: #111827;
|
||||||
|
color: #dbeafe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-toolbar strong,
|
.execution-toolbar strong,
|
||||||
.flow-toolbar span {
|
.execution-toolbar span {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-toolbar strong {
|
.execution-toolbar strong {
|
||||||
color: #172033;
|
color: #f8fafc;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-toolbar > div > span {
|
.execution-toolbar > div > span {
|
||||||
margin-top: 4px;
|
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;
|
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 {
|
.bpmn-side {
|
||||||
|
position: sticky;
|
||||||
|
top: 86px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-right: 2px;
|
||||||
|
contain: layout paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
@@ -550,6 +835,11 @@ onBeforeUnmount(() => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
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 {
|
.timeline-step.active {
|
||||||
@@ -558,6 +848,10 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
|
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-step:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-index {
|
.timeline-index {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -677,82 +971,42 @@ onBeforeUnmount(() => {
|
|||||||
white-space: pre-wrap;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.bpmn-viewer {
|
.bpmn-viewer {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-toolbar {
|
.execution-toolbar {
|
||||||
flex-direction: column;
|
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>
|
</style>
|
||||||
|
|||||||
+118
-32
@@ -19,7 +19,6 @@ const form = reactive({
|
|||||||
environment: 'test' as EnvironmentName,
|
environment: 'test' as EnvironmentName,
|
||||||
ref: '',
|
ref: '',
|
||||||
note: '发布验证',
|
note: '发布验证',
|
||||||
confirmed: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const localStatus = ref<ReleaseStatus>('idle')
|
const localStatus = ref<ReleaseStatus>('idle')
|
||||||
@@ -141,7 +140,7 @@ const canTrigger = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return form.environment === 'production' ? form.confirmed : true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const shownRun = computed(() => {
|
const shownRun = computed(() => {
|
||||||
@@ -180,7 +179,7 @@ async function triggerRelease() {
|
|||||||
productionConfirmation:
|
productionConfirmation:
|
||||||
form.environment === 'production'
|
form.environment === 'production'
|
||||||
? {
|
? {
|
||||||
confirmed: form.confirmed,
|
confirmed: true,
|
||||||
confirmedAt: new Date().toISOString(),
|
confirmedAt: new Date().toISOString(),
|
||||||
summary: '已确认 ref、回滚方案、通知对象和业务窗口',
|
summary: '已确认 ref、回滚方案、通知对象和业务窗口',
|
||||||
}
|
}
|
||||||
@@ -293,7 +292,6 @@ function useFailedRun() {
|
|||||||
form.environment = failed.environment
|
form.environment = failed.environment
|
||||||
form.ref = failed.ref
|
form.ref = failed.ref
|
||||||
form.note = `重试 ${failed.id}`
|
form.note = `重试 ${failed.id}`
|
||||||
form.confirmed = failed.environment !== 'production'
|
|
||||||
localStatus.value = 'failed'
|
localStatus.value = 'failed'
|
||||||
activeRun.value = failed
|
activeRun.value = failed
|
||||||
}
|
}
|
||||||
@@ -357,7 +355,7 @@ watch(refOptions, (options) => {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>发布中心</h1>
|
<h1>发布中心</h1>
|
||||||
<p>选择项目、环境和 ref/tag,填写备注后触发发布;生产环境必须完成二次确认。</p>
|
<p>选择项目、环境和 ref/tag,填写备注后触发发布;生产环境会提交确认信息。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<ElButton :icon="RefreshRight" @click="useFailedRun">载入最近失败</ElButton>
|
<ElButton :icon="RefreshRight" @click="useFailedRun">载入最近失败</ElButton>
|
||||||
@@ -365,8 +363,8 @@ watch(refOptions, (options) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two-column">
|
<div class="release-layout">
|
||||||
<section class="panel">
|
<section class="panel release-params-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>发布参数</h2>
|
<h2>发布参数</h2>
|
||||||
<span>通过后端创建发布单</span>
|
<span>通过后端创建发布单</span>
|
||||||
@@ -387,7 +385,7 @@ watch(refOptions, (options) => {
|
|||||||
当前权限:{{ selectedProjectPermissionName }}
|
当前权限:{{ selectedProjectPermissionName }}
|
||||||
</div>
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="环境">
|
<ElFormItem label="环境" class="environment-field">
|
||||||
<ElSegmented
|
<ElSegmented
|
||||||
v-model="form.environment"
|
v-model="form.environment"
|
||||||
:options="environmentOptions"
|
:options="environmentOptions"
|
||||||
@@ -433,20 +431,17 @@ watch(refOptions, (options) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem label="备注">
|
<ElFormItem label="备注" class="note-field">
|
||||||
<ElInput v-model="form.note" type="textarea" :rows="3" maxlength="120" show-word-limit />
|
<ElInput v-model="form.note" type="textarea" :rows="2" maxlength="120" show-word-limit />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
<ElAlert
|
<ElAlert
|
||||||
v-if="form.environment === 'production'"
|
v-if="form.environment === 'production'"
|
||||||
class="danger-note"
|
class="danger-note"
|
||||||
title="生产发布需要二次确认:已确认 tag、回滚方案、通知对象和业务窗口。"
|
title="生产发布将提交确认信息,请确认 tag、回滚方案、通知对象和业务窗口。"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
/>
|
/>
|
||||||
<ElCheckbox v-model="form.confirmed" class="confirm-line">
|
|
||||||
我确认本次发布参数、目标环境和通知范围无误
|
|
||||||
</ElCheckbox>
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -471,7 +466,7 @@ watch(refOptions, (options) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel current-run-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>当前发布单</h2>
|
<h2>当前发布单</h2>
|
||||||
<div class="toolbar compact-toolbar">
|
<div class="toolbar compact-toolbar">
|
||||||
@@ -491,33 +486,106 @@ watch(refOptions, (options) => {
|
|||||||
<RunExecutionDetails :run="shownRun" />
|
<RunExecutionDetails :run="shownRun" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel bpmn-status-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>BPMN 流程状态</h2>
|
<h2>BPMN 流程状态</h2>
|
||||||
<span>step.bpmnNodeId 与 BPMN XML 节点 id 对齐后高亮</span>
|
<span>step.bpmnNodeId 与 BPMN XML 节点 id 对齐后高亮</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<BpmnStatusViewer
|
<BpmnStatusViewer
|
||||||
v-if="platform.releaseProcessXml && shownRun"
|
v-if="platform.releaseProcessXml && shownRun"
|
||||||
:xml="platform.releaseProcessXml"
|
:xml="platform.releaseProcessXml"
|
||||||
:steps="shownRun.steps"
|
:run="shownRun"
|
||||||
/>
|
:steps="shownRun.steps"
|
||||||
</div>
|
/>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px 14px;
|
gap: 8px 16px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-line {
|
.note-field {
|
||||||
margin-top: 14px;
|
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 {
|
.action-row {
|
||||||
@@ -602,8 +670,26 @@ watch(refOptions, (options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.release-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -225,7 +225,11 @@ async function handleRunActionError(error: unknown, fallbackMessage: string) {
|
|||||||
<StatusPill :state="selectedRun.status" />
|
<StatusPill :state="selectedRun.status" />
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<BpmnStatusViewer :xml="platform.releaseProcessXml" :steps="selectedRun.steps" />
|
<BpmnStatusViewer
|
||||||
|
:xml="platform.releaseProcessXml"
|
||||||
|
:run="selectedRun"
|
||||||
|
:steps="selectedRun.steps"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user