Files
effekt-interface-frontend/src/components/TestPlatform/Plan/PlanProgress.vue
qiaoxinjiu 6e9673f7dd feat(plan): 计划自动化执行、结果列表与鉴权请求优化
- 新增 automationApi、PlanAutomationRun、PlanAutomationExecutionList
- 计划构建/列表/执行/进度等接入自动化与路由
- request 与 authToken 处理 token 刷新与错误码

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 14:27:55 +08:00

401 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrap">
<page-section title="计划进度">
<el-form :inline="true" size="small" @submit.native.prevent>
<el-form-item label="产品名称">
<el-input :value="productName" disabled style="width: 220px;"></el-input>
</el-form-item>
<el-form-item label="项目名称">
<el-input :value="projectName" disabled style="width: 220px;"></el-input>
</el-form-item>
<el-form-item label="计划名称">
<el-input :value="planNameDisplay" disabled style="width: 240px;"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchProgressBoard">刷新看板</el-button>
</el-form-item>
<el-form-item>
<el-button @click="goBack">返回</el-button>
</el-form-item>
</el-form>
<el-row :gutter="16" class="metric-row">
<el-col :span="6">
<div class="metric-card">
<div class="metric-label">总用例数</div>
<div class="metric-value">{{ summary.total }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="metric-card">
<div class="metric-label">待执行</div>
<div class="metric-value warning">{{ summary.pending }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="metric-card">
<div class="metric-label">已执行</div>
<div class="metric-value success">{{ summary.executed }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="metric-card">
<div class="metric-label">完成率</div>
<div class="metric-value primary">{{ summary.progressPercent }}%</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<div class="board-card">
<div class="board-title">执行完成度</div>
<div class="dashboard-wrap">
<el-progress
type="dashboard"
:percentage="summary.progressPercent"
:color="progressColor">
</el-progress>
<div class="dashboard-desc">已执行 {{ summary.executed }} / {{ summary.total }}</div>
</div>
</div>
</el-col>
<el-col :span="16">
<div class="board-card">
<div class="board-title">状态分布</div>
<div v-for="item in statusBarData" :key="item.key" class="status-bar-row">
<div class="status-label">
<el-tag size="mini" :type="item.tag">{{ item.label }}</el-tag>
</div>
<div class="status-bar-track">
<div class="status-bar-fill" :style="{ width: item.percent + '%', background: item.color }"></div>
</div>
<div class="status-value">{{ item.count }}{{ item.percent }}%</div>
</div>
</div>
</el-col>
</el-row>
<div class="board-card" style="margin-top: 16px;">
<div class="board-title">用例状态看板明细</div>
<el-table v-loading="loading" :data="displayRows" border style="margin-top: 10px;">
<el-table-column label="模块" min-width="160">
<template slot-scope="scope">{{ formatModuleName(scope.row) }}</template>
</el-table-column>
<el-table-column prop="caseKey" label="用例编号" min-width="120"></el-table-column>
<el-table-column prop="caseTitle" label="用例名称" min-width="220"></el-table-column>
<el-table-column label="自动化执行 Jenkins URL" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
<el-link v-if="scope.row.jenkinsUrl" :href="scope.row.jenkinsUrl" target="_blank" type="primary">打开</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template slot-scope="scope">
<el-tag size="mini" :type="statusTagType(scope.row.statusCode)">{{ statusLabel(scope.row.statusCode) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="actualResult" label="执行结果" min-width="180"></el-table-column>
</el-table>
<el-pagination
style="margin-top: 12px; text-align: right;"
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="pageNo"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="caseListTotal"
@size-change="handleCaseListSizeChange"
@current-change="handleCaseListPageChange">
</el-pagination>
</div>
</page-section>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getPlanCaseList, getPlanProgress } from '@/api/planApi'
export default {
name: 'PlanProgress',
components: { PageSection },
data() {
return {
loading: false,
projectId: this.$route.query.projectId || '',
planId: this.$route.query.planId || '',
progress: {},
planCaseTableData: [],
caseListTotal: 0,
pageNo: 1,
pageSize: 10
}
},
computed: {
productName() {
return this.$route.query.productName || ''
},
projectName() {
return this.$route.query.projectName || ''
},
planNameDisplay() {
return this.$route.query.planName || ''
},
summary() {
const statusCount = this.statusCountMap
const progressTotal = this.toNumber(this.progress.total_cases || this.progress.total || 0)
const total = progressTotal || this.caseListTotal || 0
const pending = this.toNumber(statusCount[0])
const executed = Math.max(0, total - pending)
const progressPercent = total > 0 ? Math.round((executed / total) * 100) : 0
return { total, pending, executed, progressPercent }
},
/** 顶部看板统计以 getPlanProgress 为准,不能按当前页用例行数聚合 */
statusCountMap() {
const map = {}
const fromProgress = this.progress.status_count || this.progress.statusCount || {}
if (fromProgress && typeof fromProgress === 'object' && Object.keys(fromProgress).length > 0) {
Object.keys(fromProgress).forEach(key => {
map[this.toNumber(key)] = this.toNumber(fromProgress[key])
})
return map
}
this.planCaseTableData.forEach(item => {
const key = this.toNumber(item.statusCode)
map[key] = (map[key] || 0) + 1
})
return map
},
statusBarData() {
const total = this.summary.total || 1
const statuses = [
{ key: 0, label: '待执行', tag: 'info', color: '#909399' },
{ key: 1, label: '通过', tag: 'success', color: '#67c23a' },
{ key: 2, label: '失败', tag: 'danger', color: '#f56c6c' },
{ key: 3, label: '阻塞', tag: 'warning', color: '#e6a23c' }
]
return statuses.map(item => {
const count = this.toNumber(this.statusCountMap[item.key] || 0)
const percent = Math.round((count / total) * 100)
return Object.assign({}, item, { count, percent })
})
},
progressColor() {
const value = this.summary.progressPercent
if (value >= 80) return '#67c23a'
if (value >= 50) return '#409eff'
if (value >= 30) return '#e6a23c'
return '#f56c6c'
},
displayRows() {
return this.planCaseTableData
}
},
methods: {
handleCaseListSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchProgressBoard()
},
handleCaseListPageChange(page) {
this.pageNo = page
this.fetchProgressBoard()
},
fetchProgressBoard() {
if (!this.planId || !this.projectId) {
this.progress = {}
this.planCaseTableData = []
this.caseListTotal = 0
return
}
this.loading = true
Promise.all([
getPlanProgress(this.projectId, this.planId),
getPlanCaseList(this.projectId, this.planId, { pageNo: this.pageNo, pageSize: this.pageSize })
])
.then(([progressRes, listRes]) => {
this.progress = (progressRes && progressRes.data) || progressRes || {}
const listData = (listRes && listRes.data) || listRes || {}
const list = listData.list || listData.items || listData.data || []
this.caseListTotal = Number(
listData.total != null ? listData.total : Array.isArray(list) ? list.length : 0
)
this.planCaseTableData = (Array.isArray(list) ? list : []).map(item => ({
id: item.id,
caseId: item.case_id || item.caseId,
moduleName: item.module_name || item.moduleName || '',
moduleId: item.module_id || item.moduleId || '',
caseKey: item.case_key || item.caseKey || '',
caseTitle: item.case_title || item.caseTitle || item.title || '',
title: item.title || item.case_title || item.caseTitle || '',
statusCode: this.toNumber(item.status),
actualResult: item.actual_result || item.actualResult || '',
jenkinsUrl: item.jenkins_url || item.jenkinsUrl || ''
}))
})
.catch(() => {
this.progress = {}
this.planCaseTableData = []
this.caseListTotal = 0
})
.finally(() => {
this.loading = false
})
},
toNumber(value) {
const num = Number(value)
return Number.isFinite(num) ? num : 0
},
statusLabel(status) {
const map = { 0: '待执行', 1: '通过', 2: '失败', 3: '阻塞' }
return map[this.toNumber(status)] || '未知'
},
statusTagType(status) {
const map = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
return map[this.toNumber(status)] || 'info'
},
formatModuleName(row) {
if (!row) return '-'
if (row.moduleName) return row.moduleName
return '-'
},
goBack() {
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.$route.query.productId || undefined,
projectId: this.projectId || undefined
}
})
},
normalizeRouteQuery() {
if (!this.projectId && this.$route.query.project_id) {
this.projectId = this.$route.query.project_id
}
if (!this.planId && this.$route.query.id) {
this.planId = this.$route.query.id
}
}
},
created() {
this.normalizeRouteQuery()
this.fetchProgressBoard()
},
watch: {
'$route.query.planId'(val) {
if (val !== undefined) {
this.planId = val
this.fetchProgressBoard()
}
},
'$route.query.projectId'(val) {
if (val !== undefined) {
this.projectId = val
this.fetchProgressBoard()
}
}
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
background: #f7f8fa;
}
.metric-row {
margin-bottom: 16px;
}
.metric-card {
background: linear-gradient(135deg, #ffffff 0%, #f7fbff 100%);
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px 16px;
min-height: 84px;
box-shadow: 0 4px 10px rgba(31, 45, 61, 0.06);
}
.metric-label {
color: #909399;
font-size: 13px;
}
.metric-value {
margin-top: 8px;
color: #303133;
font-size: 28px;
font-weight: 600;
line-height: 1;
}
.metric-value.success {
color: #67c23a;
}
.metric-value.warning {
color: #e6a23c;
}
.metric-value.primary {
color: #409eff;
}
.board-card {
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
padding: 16px;
box-shadow: 0 6px 14px rgba(31, 45, 61, 0.08);
}
.board-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.dashboard-wrap {
margin-top: 10px;
text-align: center;
}
.dashboard-desc {
color: #606266;
margin-top: 8px;
}
.status-bar-row {
display: flex;
align-items: center;
margin-top: 14px;
}
.status-label {
width: 72px;
}
.status-bar-track {
flex: 1;
height: 10px;
border-radius: 10px;
background: #f1f2f4;
overflow: hidden;
}
.status-bar-fill {
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
}
.status-value {
width: 110px;
text-align: right;
color: #606266;
font-size: 12px;
}
</style>