Files
devops-platform-web/src/components/RunExecutionDetails.vue
T
2026-06-12 02:37:08 +08:00

395 lines
8.9 KiB
Vue

<script setup lang="ts">
/**
* 发布执行详情聚合 run 元数据、步骤摘要和按需读取的 Jenkins build 日志。
*/
import { RefreshRight, Tickets } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { devopsApi } from '../services/devopsApi'
import type { JenkinsBuildLog, ReleaseRun } from '../types/devops'
import StatusPill from './StatusPill.vue'
const props = defineProps<{
run: ReleaseRun
}>()
const logVisible = ref(false)
const logLoading = ref(false)
const logText = ref('')
const logMeta = ref<JenkinsBuildLog | null>(null)
const notableSteps = computed(() =>
props.run.steps.filter(
(step) =>
step.status === 'failed' ||
step.status === 'skipped' ||
step.errorSummary ||
step.message ||
step.logExcerpt,
),
)
const canReadJenkinsLog = computed(
() => props.run.jenkinsBuildNumber !== undefined && props.run.jenkinsBuildNumber !== null,
)
const logTitle = computed(() => {
const buildNumber = props.run.jenkinsBuildNumber
return buildNumber ? `Jenkins 日志 #${buildNumber}` : 'Jenkins 日志'
})
function noteLabel(status: string) {
return status === 'failed' ? '失败摘要' : '执行提示'
}
function displayText(value?: string | number) {
if (value === undefined || value === null || value === '' || value === '-') {
return '未返回'
}
return String(value)
}
function finishText() {
if (props.run.finishedAt && props.run.finishedAt !== '-') {
return props.run.finishedAt
}
if (props.run.status === 'queued' || props.run.status === 'running') {
return '未结束'
}
return '未返回'
}
function buildText() {
if (props.run.jenkinsBuildNumber !== undefined && props.run.jenkinsBuildNumber !== null) {
return `#${props.run.jenkinsBuildNumber}`
}
return '未返回'
}
function resetJenkinsLog() {
logVisible.value = false
logLoading.value = false
logText.value = ''
logMeta.value = null
}
async function openJenkinsLog() {
if (!canReadJenkinsLog.value) {
ElMessage.warning('当前发布单尚未关联 Jenkins build')
return
}
logVisible.value = true
if (!logText.value) {
await loadJenkinsLog(0)
}
}
async function reloadJenkinsLog() {
await loadJenkinsLog(0)
}
async function loadMoreJenkinsLog() {
await loadJenkinsLog(logMeta.value?.nextStart ?? 0)
}
async function loadJenkinsLog(start: number) {
logLoading.value = true
try {
const log = await devopsApi.getJenkinsLog(props.run.id, start)
logMeta.value = log
logText.value = start === 0 ? log.text : `${logText.value}${log.text}`
} finally {
logLoading.value = false
}
}
watch(() => props.run.id, resetJenkinsLog)
</script>
<template>
<div class="run-execution-details">
<dl class="detail-grid">
<dt>Run ID</dt>
<dd class="mono">{{ displayText(run.id) }}</dd>
<dt>项目</dt>
<dd>{{ displayText(run.projectName) }}</dd>
<dt>环境</dt>
<dd>{{ displayText(run.environment) }}</dd>
<dt>ref/tag</dt>
<dd class="mono">{{ displayText(run.ref) }}</dd>
<dt>操作者</dt>
<dd>{{ displayText(run.actor) }}</dd>
<dt>状态</dt>
<dd><StatusPill :state="run.status" /></dd>
<dt>开始时间</dt>
<dd>{{ displayText(run.startedAt) }}</dd>
<dt>结束时间</dt>
<dd>{{ finishText() }}</dd>
<dt>Jenkins Queue</dt>
<dd class="mono">{{ displayText(run.jenkinsQueueId) }}</dd>
<dt>Jenkins Build</dt>
<dd class="mono">{{ buildText() }}</dd>
<dt>Jenkins 日志</dt>
<dd>
<ElButton
size="small"
:icon="Tickets"
:disabled="!canReadJenkinsLog"
:loading="logLoading"
@click="openJenkinsLog"
>
查看日志
</ElButton>
</dd>
<dt>Jenkins 状态</dt>
<dd>{{ displayText(run.jenkinsSummary) }}</dd>
<dt>Build URL</dt>
<dd>
<a v-if="run.jenkinsBuildUrl" :href="run.jenkinsBuildUrl" target="_blank" rel="noreferrer">
{{ run.jenkinsBuildUrl }}
</a>
<span v-else>未返回</span>
</dd>
<dt>Commit</dt>
<dd>{{ displayText(run.commitSummary) }}</dd>
<dt>备注</dt>
<dd>{{ displayText(run.note) }}</dd>
</dl>
<div class="failure-block">
<div class="failure-header">
<strong>执行提示 / 日志摘要</strong>
<span>{{ notableSteps.length ? `${notableSteps.length} 个节点` : '未返回异常或提示' }}</span>
</div>
<div v-if="notableSteps.length" class="failure-list">
<article
v-for="step in notableSteps"
:key="step.id"
class="failure-item"
:class="{ danger: step.status === 'failed' }"
>
<div class="failure-title">
<strong>{{ step.name }}</strong>
<StatusPill :state="step.status" />
</div>
<p v-if="step.errorSummary">
<span>{{ noteLabel(step.status) }}</span>
{{ step.errorSummary }}
</p>
<p v-if="step.message">
<span>执行消息</span>
{{ step.message }}
</p>
<pre v-if="step.logExcerpt">{{ step.logExcerpt }}</pre>
<p v-if="!step.errorSummary && !step.message && !step.logExcerpt">
<span>节点摘要</span>
{{ step.summary }}
</p>
</article>
</div>
<div v-else class="empty-line">后端尚未返回异常摘要执行提示或日志摘要</div>
</div>
<ElDrawer v-model="logVisible" :title="logTitle" size="min(760px, 92vw)" append-to-body>
<div class="jenkins-log-drawer">
<div class="jenkins-log-toolbar">
<span>
{{ logMeta ? `${logMeta.jobPath} · start ${logMeta.start} -> ${logMeta.nextStart}` : '等待读取' }}
</span>
<div class="log-actions">
<ElButton size="small" :icon="RefreshRight" :loading="logLoading" @click="reloadJenkinsLog">
重新读取
</ElButton>
<ElButton
v-if="logMeta?.hasMore"
size="small"
:loading="logLoading"
@click="loadMoreJenkinsLog"
>
继续加载
</ElButton>
</div>
</div>
<pre v-if="logText" class="jenkins-log-output">{{ logText }}</pre>
<div v-else class="empty-line">Jenkins 暂无日志</div>
</div>
</ElDrawer>
</div>
</template>
<style scoped>
.run-execution-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
font-size: 13px;
}
.detail-grid dt {
color: #7b8798;
}
.detail-grid dd {
margin: 0;
min-width: 0;
color: #172033;
overflow-wrap: anywhere;
}
.detail-grid a {
color: #1d5fd0;
}
.detail-grid :deep(.el-button) {
max-width: 100%;
}
.failure-block {
border-top: 1px solid #edf1f7;
padding-top: 14px;
}
.failure-header,
.failure-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.failure-header strong,
.failure-title strong {
color: #172033;
font-size: 13px;
}
.failure-header span {
color: #7b8798;
font-size: 12px;
}
.failure-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.failure-item {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fbfcff;
}
.failure-item.danger {
border-color: #f0d5d5;
background: #fffafa;
}
.failure-item p {
margin: 8px 0 0;
color: #4b5565;
font-size: 13px;
line-height: 1.6;
}
.failure-item p span {
display: inline-block;
margin-right: 8px;
color: #62572c;
font-weight: 600;
}
.failure-item.danger p span {
color: #9f2f2f;
}
.failure-item pre {
max-height: 180px;
margin: 10px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #172033;
color: #eef4ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.empty-line {
margin-top: 10px;
color: #7b8798;
font-size: 13px;
}
.jenkins-log-drawer {
display: flex;
min-height: 100%;
flex-direction: column;
gap: 12px;
}
.jenkins-log-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #5f6b7a;
font-size: 12px;
}
.log-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.jenkins-log-output {
flex: 1;
min-height: 420px;
margin: 0;
padding: 12px;
overflow: auto;
border-radius: 6px;
background: #111827;
color: #e5edf8;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.detail-grid {
grid-template-columns: 1fr;
gap: 4px 0;
}
.detail-grid dd + dt {
margin-top: 8px;
}
.jenkins-log-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>