feat(plan): 计划自动化执行、结果列表与鉴权请求优化 #2

Merged
qiaoxinjiu merged 1 commits from 2026-04-29-nrc4 into master 2026-05-11 14:29:28 +08:00
14 changed files with 1587 additions and 48 deletions
Showing only changes of commit 6e9673f7dd - Show all commits

55
src/api/automationApi.js Normal file
View File

@@ -0,0 +1,55 @@
import request from '@/utils/request'
/** POST /automation/plan/run — 文档字段为 camelCase */
export function runAutomationPlan(data) {
return request({
url: '/automation/plan/run',
method: 'post',
data: data || {}
})
}
/** POST /automation/case/run */
export function runAutomationCase(data) {
return request({
url: '/automation/case/run',
method: 'post',
data: data || {}
})
}
/** GET /automation/execution/list */
export function getAutomationExecutionList(params) {
return request({
url: '/automation/execution/list',
method: 'get',
params: params || {}
})
}
/** GET /automation/execution/detail */
export function getAutomationExecutionDetail(executionId) {
return request({
url: '/automation/execution/detail',
method: 'get',
params: { executionId }
})
}
/** GET /automation/execution/case/list */
export function getAutomationExecutionCaseList(params) {
return request({
url: '/automation/execution/case/list',
method: 'get',
params: params || {}
})
}
/** POST /automation/execution/poll — body 可选 { executionId },不传则轮询所有待执行任务 */
export function postAutomationExecutionPoll(data) {
return request({
url: '/automation/execution/poll',
method: 'post',
data: data || {}
})
}

View File

@@ -24,13 +24,13 @@ export function updatePlan(projectId, planId, data) {
})
}
export function deletePlan(projectId, planId) {
/** POST /plan/deletebody 传 planId */
export function deletePlan(planId) {
return request({
url: '/plan/delete',
method: 'post',
data: {
project_id: projectId,
id: planId
planId
}
})
}

View File

@@ -264,6 +264,7 @@ export default {
if (command === 'logout') {
localStorage.removeItem('authUser')
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
localStorage.removeItem('userMenus')
this.$store.commit('ClearCurrentUser')
this.$message.success('已退出登录')

View File

@@ -0,0 +1,379 @@
<template>
<div class="page-wrap">
<page-section title="自动化执行结果">
<div class="filter-toolbar">
<el-form :inline="true" size="small" class="filter-toolbar-form" @submit.native.prevent>
<el-form-item label="产品">
<el-input :value="productName" disabled style="width: 200px;" />
</el-form-item>
<el-form-item label="项目">
<el-input :value="projectName" disabled style="width: 200px;" />
</el-form-item>
<el-form-item label="计划">
<el-input :value="planNameDisplay" disabled style="width: 220px;" />
</el-form-item>
</el-form>
<div class="filter-toolbar-actions">
<el-button size="small" @click="goBackToRun">返回执行</el-button>
<el-button size="small" @click="goPlanList">返回计划</el-button>
</div>
</div>
<el-table v-loading="loading" :data="rows" border style="margin-top: 12px;">
<el-table-column label="执行单号" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.execution_no || scope.row.executionNo || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="mainStatusTag(scope.row.status)">{{ mainStatusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="环境" width="100">
<template slot-scope="scope">{{ scope.row.env_code || scope.row.envCode || '-' }}</template>
</el-table-column>
<el-table-column label="总数" width="72">
<template slot-scope="scope">{{ scope.row.total_count != null ? scope.row.total_count : scope.row.totalCount }}</template>
</el-table-column>
<el-table-column label="通过" width="72">
<template slot-scope="scope">{{ scope.row.passed_count != null ? scope.row.passed_count : scope.row.passedCount }}</template>
</el-table-column>
<el-table-column label="失败" width="72">
<template slot-scope="scope">{{ scope.row.failed_count != null ? scope.row.failed_count : scope.row.failedCount }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template slot-scope="scope">{{ scope.row.created_time || scope.row.createdTime || '-' }}</template>
</el-table-column>
<el-table-column label="执行详情" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="goToExecutionDetail(scope.row)">查看详情</el-button>
</template>
</el-table-column>
<el-table-column label="报告" width="100" align="center">
<template slot-scope="scope">
<el-link
v-if="getReportUrl(scope.row)"
:href="getReportUrl(scope.row)"
target="_blank"
type="primary">
打开
</el-link>
<span v-else-if="isReportLoading(scope.row)" class="report-loading">
<i class="el-icon-loading"></i>
</span>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pager"
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="pageNo"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange" />
</page-section>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getAutomationExecutionList, postAutomationExecutionPoll } from '@/api/automationApi'
const MAIN_STATUS_LABELS = {
0: '待触发',
1: '触发中',
2: '排队中',
3: '执行中',
4: '成功',
5: '失败',
6: '已取消',
7: '触发失败',
8: '回调异常'
}
/** 终态:一般不再有报告 URL 更新 */
const TERMINAL = new Set([4, 5, 6, 7, 8])
/** 无报告也可停止加载 */
const TERMINAL_NO_REPORT_EXPECTED = new Set([6, 7, 8])
const POLL_MS = 5000
export default {
name: 'PlanAutomationExecutionList',
components: { PageSection },
data() {
return {
projectId: this.$route.query.projectId || '',
planId: this.$route.query.planId || '',
loading: false,
rows: [],
pageNo: 1,
pageSize: 20,
total: 0,
pollTimer: null,
/** executionId -> 本轮 poll 请求中 */
pollInflight: {}
}
},
computed: {
productName() {
return this.$route.query.productName || ''
},
projectName() {
return this.$route.query.projectName || ''
},
planNameDisplay() {
return this.$route.query.planName || ''
}
},
watch: {
'$route.query': {
handler() {
this.projectId = this.$route.query.projectId || ''
this.planId = this.$route.query.planId || ''
this.pageNo = 1
this.stopPoll()
this.fetchList()
},
deep: true
}
},
created() {
this.fetchList()
},
beforeDestroy() {
this.stopPoll()
},
methods: {
getReportUrl(row) {
if (!row) return ''
const u = row.report_url || row.reportUrl
return u && String(u).trim() ? String(u).trim() : ''
},
rowExecutionId(row) {
if (!row) return null
const id = row.id != null ? row.id : row.execution_id || row.executionId
return id != null && id !== '' ? Number(id) : null
},
rowStatusNum(row) {
const s = row && row.status
const n = Number(s)
return Number.isFinite(n) ? n : -1
},
/** 仍可能通过 poll 拿到报告地址 */
needsReportPoll(row) {
if (this.getReportUrl(row)) return false
const st = this.rowStatusNum(row)
if (TERMINAL_NO_REPORT_EXPECTED.has(st)) return false
return true
},
/** 轮询未停且该行仍可能出报告时,报告列持续显示加载(含两次 poll 间隔) */
isReportLoading(row) {
if (this.getReportUrl(row)) return false
if (!this.needsReportPoll(row)) return false
return this.pollTimer != null || !!this.pollInflight[this.rowExecutionId(row)]
},
mainStatusLabel(s) {
return MAIN_STATUS_LABELS[s] != null ? MAIN_STATUS_LABELS[s] : s == null ? '-' : String(s)
},
mainStatusTag(s) {
const map = { 0: 'info', 1: 'warning', 2: 'warning', 3: 'primary', 4: 'success', 5: 'danger', 6: 'info', 7: 'danger', 8: 'danger' }
return map[s] || 'info'
},
mergePollIntoRow(executionId, d) {
if (!d || typeof d !== 'object') return
const idx = this.rows.findIndex(r => this.rowExecutionId(r) === executionId)
if (idx < 0) return
const row = this.rows[idx]
const next = Object.assign({}, row)
const pick = (snake, camel) => {
if (d[snake] !== undefined && d[snake] !== null && d[snake] !== '') next[snake] = d[snake]
if (d[camel] !== undefined && d[camel] !== null && d[camel] !== '') next[camel] = d[camel]
}
if (d.status !== undefined && d.status !== null) next.status = d.status
pick('report_url', 'reportUrl')
pick('console_url', 'consoleUrl')
pick('jenkins_build_url', 'jenkinsBuildUrl')
pick('jenkins_build_number', 'jenkinsBuildNumber')
pick('end_time', 'endTime')
if (d.duration_seconds != null || d.durationSeconds != null) {
next.duration_seconds = d.duration_seconds != null ? d.duration_seconds : d.durationSeconds
}
this.$set(this.rows, idx, next)
},
pollOne(executionId) {
if (executionId == null || Number.isNaN(executionId)) return Promise.resolve()
this.$set(this.pollInflight, executionId, true)
return postAutomationExecutionPoll({ executionId })
.then(res => {
if (!res || res.code !== 20000) return
const d = res.data
if (!d || typeof d !== 'object') return
const rid = d.id != null ? Number(d.id) : executionId
this.mergePollIntoRow(rid, d)
})
.catch(() => {})
.finally(() => {
this.$delete(this.pollInflight, executionId)
})
},
runPollTick() {
const targets = this.rows
.map(r => this.rowExecutionId(r))
.filter(id => id != null && !Number.isNaN(id))
.filter(id => {
const row = this.rows.find(x => this.rowExecutionId(x) === id)
return row && this.needsReportPoll(row)
})
if (!targets.length) {
this.stopPoll()
return
}
targets.forEach(id => {
if (this.pollInflight[id]) return
this.pollOne(id)
})
},
startPoll() {
this.stopPoll()
this.runPollTick()
this.pollTimer = window.setInterval(() => this.runPollTick(), POLL_MS)
},
stopPoll() {
if (this.pollTimer != null) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
},
fetchList() {
const pid = this.planId
const projId = this.projectId
if (!pid || !projId) {
this.rows = []
this.total = 0
this.stopPoll()
return
}
this.stopPoll()
this.loading = true
getAutomationExecutionList({
planId: Number(pid),
projectId: Number(projId),
pageNo: this.pageNo,
pageSize: this.pageSize
})
.then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.rows = Array.isArray(list) ? list.map(x => Object.assign({}, x)) : []
this.total = Number(data.total != null ? data.total : this.rows.length)
this.$nextTick(() => {
const any = this.rows.some(r => this.needsReportPoll(r))
if (any) this.startPoll()
})
})
.catch(() => {
this.rows = []
this.total = 0
})
.finally(() => {
this.loading = false
})
},
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchList()
},
handlePageChange(page) {
this.pageNo = page
this.fetchList()
},
automationRunQuery() {
const q = this.$route.query || {}
return {
productId: q.productId || undefined,
productName: q.productName || '',
projectId: this.projectId || undefined,
projectName: q.projectName || '',
planId: this.planId || undefined,
planName: q.planName || '',
environmentId: q.environmentId || undefined,
jenkinsUrl: q.jenkinsUrl || undefined
}
},
goBackToRun() {
this.stopPoll()
this.$router.push({
path: '/test-platform/plan/automation',
query: this.automationRunQuery()
})
},
/** 进入与执行后相同的计划自动化执行详情页(主单 + 执行明细) */
goToExecutionDetail(row) {
const id = this.rowExecutionId(row)
if (id == null || Number.isNaN(id)) {
this.$message.warning('缺少执行单 ID')
return
}
this.stopPoll()
this.$router.push({
path: '/test-platform/plan/automation',
query: Object.assign({}, this.automationRunQuery(), { executionId: id })
})
},
goPlanList() {
this.stopPoll()
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.$route.query.productId || undefined,
projectId: this.projectId || undefined
}
})
}
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
}
.filter-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.filter-toolbar-form {
flex: 1;
min-width: 0;
}
.filter-toolbar-actions {
flex-shrink: 0;
padding-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pager {
margin-top: 16px;
text-align: right;
}
.report-loading {
color: #409eff;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,842 @@
<template>
<div class="page-wrap">
<page-section title="计划自动化执行">
<div class="filter-toolbar">
<el-form :inline="true" size="small" class="filter-toolbar-form" @submit.native.prevent>
<el-form-item label="产品">
<el-input :value="productName" disabled style="width: 200px;" />
</el-form-item>
<el-form-item label="项目">
<el-input :value="projectName" disabled style="width: 200px;" />
</el-form-item>
<el-form-item label="计划">
<div class="plan-title-row">
<el-input :value="planNameDisplay" disabled class="plan-name-input" />
<template v-if="planJenkinsUrl">
<span class="plan-jenkins-sep">·</span>
<el-link
:href="planJenkinsUrl"
:title="planJenkinsUrl"
target="_blank"
type="primary"
class="plan-jenkins-link">
自动化执行 Jenkins
</el-link>
</template>
</div>
</el-form-item>
</el-form>
<div class="filter-toolbar-actions">
<el-button size="small" @click="goExecutionResultList">执行结果列表</el-button>
<el-button size="small" @click="goBack">返回</el-button>
</div>
</div>
<el-card v-if="!executionId" class="run-card" shadow="never">
<el-form ref="runFormRef" :model="runForm" :rules="runRules" label-width="108px" size="small">
<el-form-item label="执行环境" prop="envCode">
<el-select v-model="runForm.envCode" placeholder="请选择环境编码" filterable style="width: 280px;">
<el-option
v-for="opt in envOptions"
:key="'env-' + opt.value"
:label="opt.label"
:value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="执行模式">
<el-radio-group v-model="runForm.runMode">
<el-radio :label="1">串行</el-radio>
<el-radio :label="2">并行</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="计划轮次">
<el-input-number v-model="runForm.roundNo" :min="1" :step="1" controls-position="right" placeholder="可选" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="runForm.remark" maxlength="200" show-word-limit style="width: 420px;" placeholder="可选" />
</el-form-item>
</el-form>
<div class="table-toolbar">
<span class="hint">勾选后点击执行已选用例将传 caseIds翻页会保留已勾选项不勾选可执行计划内全部自动化用例</span>
<el-button size="small" :loading="casesLoading" @click="refreshPlanCaseList">刷新用例列表</el-button>
</div>
<el-table
ref="caseTable"
v-loading="casesLoading"
:data="planCaseRows"
border
row-key="planCaseId"
@selection-change="onSelectionChange">
<el-table-column type="selection" width="48" :reserve-selection="true" />
<el-table-column prop="caseKey" label="用例编号" min-width="120" />
<el-table-column prop="caseTitle" label="用例名称" min-width="200" />
<el-table-column label="自动化" width="88">
<template slot-scope="scope">{{ formatAuto(scope.row) }}</template>
</el-table-column>
</el-table>
<el-pagination
class="plan-case-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="planCasePageNo"
:page-sizes="[10, 20, 50, 100]"
:page-size="planCasePageSize"
:total="planCaseTotal"
@size-change="handlePlanCaseSizeChange"
@current-change="handlePlanCaseCurrentChange" />
<div class="run-actions">
<el-button type="primary" :loading="runSubmitting" @click="submitRun(true)">执行计划内全部自动化用例</el-button>
<el-button type="success" :loading="runSubmitting" :disabled="!selectedRows.length" @click="submitRun(false)">
执行已选用例{{ selectedRows.length }}
</el-button>
</div>
</el-card>
<template v-else>
<el-card class="exec-card" shadow="never">
<div class="exec-head">
<div>
<span class="exec-no">{{ executionSummary.execution_no || executionSummary.executionNo || '-' }}</span>
<el-tag size="small" class="exec-tag" :type="mainStatusTag(executionSummary.status)">
{{ mainStatusLabel(executionSummary.status) }}
</el-tag>
</div>
<div class="exec-actions">
<el-button size="small" :loading="detailLoading" @click="refreshExecution">刷新</el-button>
<el-button size="small" @click="resetRunAnother">再跑一轮</el-button>
</div>
</div>
<div class="exec-desc">
<el-row :gutter="12" type="flex" class="exec-desc-row">
<el-col :span="12">
<div class="exec-desc-item"><span class="exec-desc-label">环境</span>{{ executionSummary.env_code || executionSummary.envCode || '-' }}</div>
</el-col>
<el-col :span="12">
<div class="exec-desc-item"><span class="exec-desc-label">模式</span>{{ (executionSummary.run_mode || executionSummary.runMode) === 2 ? '并行' : '串行' }}</div>
</el-col>
</el-row>
<el-row :gutter="12" type="flex" class="exec-desc-row">
<el-col :span="12">
<div class="exec-desc-item"><span class="exec-desc-label">总数</span>{{ pickCount(executionSummary, 'total_count', 'totalCount') }}</div>
</el-col>
<el-col :span="12">
<div class="exec-desc-item">
<span class="exec-desc-label">通过 / 失败</span>
{{ pickCount(executionSummary, 'passed_count', 'passedCount') }} /
{{ pickCount(executionSummary, 'failed_count', 'failedCount') }}
</div>
</el-col>
</el-row>
<div class="exec-desc-item exec-desc-block">
<span class="exec-desc-label">Jenkins</span>
<el-link v-if="jenkinsLink" :href="jenkinsLink" target="_blank" type="primary">{{ jenkinsLink }}</el-link>
<span v-else>-</span>
</div>
<div class="exec-desc-item exec-desc-block">
<span class="exec-desc-label">控制台</span>
<el-link v-if="consoleLink" :href="consoleLink" target="_blank" type="primary">打开</el-link>
<span v-else>-</span>
</div>
<div class="exec-desc-item exec-desc-block">
<span class="exec-desc-label">报告</span>
<el-link v-if="reportLink" :href="reportLink" target="_blank" type="primary">打开</el-link>
<span v-else>-</span>
</div>
</div>
<div v-if="polling" class="poll-hint">执行中 5 秒自动刷新状态</div>
</el-card>
<el-card class="case-list-card" shadow="never">
<div slot="header" class="card-header">执行明细</div>
<el-table v-loading="caseListLoading" :data="executionCaseRows" border max-height="420">
<el-table-column prop="run_order" label="#" width="56">
<template slot-scope="scope">{{ scope.row.run_order != null ? scope.row.run_order : scope.row.runOrder }}</template>
</el-table-column>
<el-table-column prop="case_key" label="编号" min-width="100">
<template slot-scope="scope">{{ scope.row.case_key || scope.row.caseKey }}</template>
</el-table-column>
<el-table-column prop="case_title" label="标题" min-width="160">
<template slot-scope="scope">{{ scope.row.case_title || scope.row.caseTitle }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="caseStatusTag(scope.row.status)">{{ caseStatusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="result_message" label="结果" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.result_message || scope.row.resultMessage || '-' }}</template>
</el-table-column>
<el-table-column label="耗时(s)" width="88">
<template slot-scope="scope">{{ scope.row.duration_seconds != null ? scope.row.duration_seconds : scope.row.durationSeconds }}</template>
</el-table-column>
</el-table>
<el-pagination
class="case-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[20, 50, 100, 200]"
:page-size="casePageSize"
:current-page="casePageNo"
:total="caseListTotal"
@size-change="handleCaseListSizeChange"
@current-change="handleCasePageChange" />
</el-card>
</template>
</page-section>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getPlanCaseList } from '@/api/planApi'
import { getProjectEnvironments } from '@/api/projectApi'
import {
getAutomationExecutionCaseList,
getAutomationExecutionDetail,
postAutomationExecutionPoll,
runAutomationPlan
} from '@/api/automationApi'
const MAIN_STATUS_LABELS = {
0: '待触发',
1: '触发中',
2: '排队中',
3: '执行中',
4: '成功',
5: '失败',
6: '已取消',
7: '触发失败',
8: '回调异常'
}
const CASE_STATUS_LABELS = {
0: '待执行',
1: '执行中',
2: '通过',
3: '失败',
4: '阻塞',
5: '跳过',
6: '未找到',
7: '已取消'
}
/** 主单终态:文档 4~8 */
const MAIN_TERMINAL = new Set([4, 5, 6, 7, 8])
function pickEnvCode(item) {
if (!item) return ''
const c = item.code || item.env_code || item.envCode
if (c != null && String(c).trim() !== '') return String(c).trim()
const name = (item.name || '').trim()
if (name) return name
if (item.id != null) return String(item.id)
return ''
}
export default {
name: 'PlanAutomationRun',
components: { PageSection },
data() {
return {
projectId: this.$route.query.projectId || '',
planId: this.$route.query.planId || '',
routeEnvId: this.$route.query.environmentId || '',
casesLoading: false,
planCaseRows: [],
planCasePageNo: 1,
planCasePageSize: 10,
planCaseTotal: 0,
selectedRows: [],
envOptions: [],
runForm: {
envCode: '',
runMode: 1,
roundNo: null,
remark: ''
},
runRules: {
envCode: [{ required: true, message: '请选择执行环境', trigger: 'change' }]
},
runSubmitting: false,
executionId: null,
executionSummary: {},
executionCaseRows: [],
caseListLoading: false,
caseListTotal: 0,
casePageNo: 1,
casePageSize: 50,
detailLoading: false,
pollTimer: null,
polling: false
}
},
computed: {
productName() {
return this.$route.query.productName || ''
},
projectName() {
return this.$route.query.projectName || ''
},
planNameDisplay() {
return this.$route.query.planName || ''
},
/** 与计划列表入口一致,由路由 query 传入(计划级 jenkins_url */
planJenkinsUrl() {
const q = this.$route.query || {}
const raw = q.jenkinsUrl != null && String(q.jenkinsUrl).trim() !== '' ? q.jenkinsUrl : q.jenkins_url
if (raw == null || String(raw).trim() === '') return ''
return String(raw).trim()
},
jenkinsLink() {
const e = this.executionSummary
return e.jenkins_build_url || e.jenkinsBuildUrl || ''
},
consoleLink() {
const e = this.executionSummary
return e.console_url || e.consoleUrl || ''
},
reportLink() {
const e = this.executionSummary
return e.report_url || e.reportUrl || ''
}
},
watch: {
'$route.query': {
handler() {
this.projectId = this.$route.query.projectId || ''
this.planId = this.$route.query.planId || ''
this.routeEnvId = this.$route.query.environmentId || ''
this.applyRouteExecutionContext()
},
deep: true
}
},
created() {
this.bootstrap()
},
beforeDestroy() {
this.stopPolling()
},
methods: {
/** 兼容下划线 / 驼峰0 为有效计数Vue2 模板不支持 ?? */
pickCount(row, snakeKey, camelKey) {
const o = row || {}
const a = o[snakeKey]
if (a != null && a !== '') return a
const b = o[camelKey]
if (b != null && b !== '') return b
return '-'
},
parseRouteExecutionId() {
const q = this.$route.query || {}
const raw = q.executionId != null && q.executionId !== '' ? q.executionId : q.execution_id
if (raw === '' || raw === undefined || raw === null) return null
const n = Number(raw)
return Number.isNaN(n) ? null : n
},
/** 与路由同步:带 executionId 则进入执行详情;去掉则回到选例执行 */
applyRouteExecutionContext() {
const exId = this.parseRouteExecutionId()
if (exId != null) {
if (this.executionId !== exId) {
this.stopPolling()
this.executionId = exId
this.loadEnvironments().then(() => this.applyDefaultEnvFromRoute())
return this.refreshExecution().then(() => this.startPollingIfNeeded())
}
return Promise.resolve()
}
if (this.executionId != null) {
this.resetRunAnother()
}
return Promise.resolve()
},
syncRouteExecutionId(id) {
if (id == null) return
const q = Object.assign({}, this.$route.query || {}, { executionId: id })
this.$router.replace({ path: '/test-platform/plan/automation', query: q }).catch(() => {})
},
bootstrap() {
const exId = this.parseRouteExecutionId()
if (exId != null) {
this.executionId = exId
this.loadEnvironments().then(() => {
this.applyDefaultEnvFromRoute()
})
return this.refreshExecution().then(() => this.startPollingIfNeeded())
}
this.executionId = null
return this.loadEnvironments().then(() => {
this.applyDefaultEnvFromRoute()
this.fetchPlanCases()
})
},
loadEnvironments() {
const pid = Number(this.projectId)
if (!pid || Number.isNaN(pid)) {
this.envOptions = []
return Promise.resolve()
}
return getProjectEnvironments(pid, { pageNo: 1, pageSize: 1000 })
.then(res => {
const data = (res && res.data) || res || {}
const list = data.items || data.list || data.data || []
this.envOptions = (Array.isArray(list) ? list : []).map(item => {
const value = pickEnvCode(item)
const labelName = (item.name || value || '').trim() || value
return {
value,
label: value && labelName !== value ? `${labelName}${value}` : labelName,
raw: item
}
}).filter(o => o.value)
})
.catch(() => {
this.envOptions = []
})
},
applyDefaultEnvFromRoute() {
const eid = this.routeEnvId
if (!eid || !this.envOptions.length) return
const found = this.envOptions.find(
o => o.raw && String(o.raw.id) === String(eid)
)
if (found && found.value) {
this.runForm.envCode = found.value
}
},
fetchPlanCases() {
if (!this.planId || !this.projectId) {
this.planCaseRows = []
this.planCaseTotal = 0
return
}
this.casesLoading = true
getPlanCaseList(this.projectId, this.planId, {
pageNo: this.planCasePageNo,
pageSize: this.planCasePageSize
})
.then(listRes => {
const data = (listRes && listRes.data) || listRes || {}
const list = data.list || data.items || []
this.planCaseTotal = Number(data.total != null ? data.total : (Array.isArray(list) ? list.length : 0))
this.planCaseRows = (Array.isArray(list) ? list : []).map(item => ({
planCaseId: item.id,
caseId: item.case_id != null ? item.case_id : item.caseId,
caseKey: item.case_key || item.caseKey || '',
caseTitle: item.case_title || item.caseTitle || item.title || '',
isAuto: item.is_auto != null ? item.is_auto : item.isAuto
}))
})
.catch(() => {
this.planCaseRows = []
this.planCaseTotal = 0
})
.finally(() => {
this.casesLoading = false
})
},
refreshPlanCaseList() {
this.planCasePageNo = 1
this.fetchPlanCases()
},
handlePlanCaseSizeChange(size) {
this.planCasePageSize = size
this.planCasePageNo = 1
this.fetchPlanCases()
},
handlePlanCaseCurrentChange(page) {
this.planCasePageNo = page
this.fetchPlanCases()
},
onSelectionChange(rows) {
this.selectedRows = rows || []
},
formatAuto(row) {
const v = row && row.isAuto
if (v === 1 || v === true || v === '1') return '是'
if (v === 0 || v === false || v === '0') return '否'
return '-'
},
submitRun(runAll) {
if (!this.$refs.runFormRef) return
this.$refs.runFormRef.validate(valid => {
if (!valid) return
const planId = Number(this.planId)
if (!planId || Number.isNaN(planId)) {
this.$message.warning('计划 ID 无效')
return
}
if (!runAll && (!this.selectedRows || !this.selectedRows.length)) {
this.$message.warning('请先勾选要执行的用例')
return
}
const payload = {
planId,
envCode: this.runForm.envCode,
runMode: this.runForm.runMode || 1
}
const rn = this.runForm.roundNo
if (rn != null && rn !== '' && !Number.isNaN(Number(rn))) {
payload.roundNo = Number(rn)
}
if (this.runForm.remark) {
payload.remark = this.runForm.remark
}
if (!runAll) {
const ids = this.selectedRows.map(r => Number(r.caseId)).filter(id => !Number.isNaN(id))
if (!ids.length) {
this.$message.warning('勾选用例缺少 caseId')
return
}
payload.caseIds = ids
}
this.runSubmitting = true
runAutomationPlan(payload)
.then(res => {
const body = (res && res.data) || res || {}
const id = body.id
if (id == null) {
this.$message.error('未返回 execution id')
return
}
this.$message.success((res && res.msg) || (res && res.message) || '已提交自动化执行')
this.executionId = id
this.executionSummary = body
this.casePageNo = 1
this.refreshExecution().then(() => {
this.startPollingIfNeeded()
this.syncRouteExecutionId(id)
})
})
.catch(() => {})
.finally(() => {
this.runSubmitting = false
})
})
},
refreshExecution() {
if (!this.executionId) return Promise.resolve()
this.detailLoading = true
const d1 = getAutomationExecutionDetail(this.executionId)
.then(res => {
const data = (res && res.data) || res || {}
this.executionSummary = data && typeof data === 'object' ? data : {}
})
.catch(() => {})
const d2 = this.loadExecutionCasePage()
return Promise.all([d1, d2]).finally(() => {
this.detailLoading = false
})
},
loadExecutionCasePage() {
if (!this.executionId) return Promise.resolve()
this.caseListLoading = true
return getAutomationExecutionCaseList({
executionId: this.executionId,
pageNo: this.casePageNo,
pageSize: this.casePageSize
})
.then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.executionCaseRows = Array.isArray(list) ? list : []
this.caseListTotal = Number(data.total != null ? data.total : this.executionCaseRows.length)
})
.catch(() => {
this.executionCaseRows = []
this.caseListTotal = 0
})
.finally(() => {
this.caseListLoading = false
})
},
handleCasePageChange(p) {
this.casePageNo = p
this.loadExecutionCasePage()
},
handleCaseListSizeChange(size) {
this.casePageSize = size
this.casePageNo = 1
this.loadExecutionCasePage()
},
mainStatusLabel(s) {
return MAIN_STATUS_LABELS[s] != null ? MAIN_STATUS_LABELS[s] : s == null ? '-' : String(s)
},
mainStatusTag(s) {
const map = { 0: 'info', 1: 'warning', 2: 'warning', 3: 'primary', 4: 'success', 5: 'danger', 6: 'info', 7: 'danger', 8: 'danger' }
return map[s] || 'info'
},
caseStatusLabel(s) {
return CASE_STATUS_LABELS[s] != null ? CASE_STATUS_LABELS[s] : s == null ? '-' : String(s)
},
caseStatusTag(s) {
const map = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'warning', 5: 'info', 6: 'info', 7: 'info' }
return map[s] || 'info'
},
isTerminalMainStatus(s) {
return s != null && MAIN_TERMINAL.has(Number(s))
},
/** 与执行结果列表页 poll 一致:合并返回字段到主单摘要 */
mergePollIntoExecutionSummary(d) {
if (!d || typeof d !== 'object') return
const next = Object.assign({}, this.executionSummary)
if (d.status !== undefined && d.status !== null) next.status = d.status
const setUrlPair = (snake, camel) => {
if (d[snake] != null && String(d[snake]).trim() !== '') next[snake] = d[snake]
if (d[camel] != null && String(d[camel]).trim() !== '') next[camel] = d[camel]
}
setUrlPair('report_url', 'reportUrl')
setUrlPair('console_url', 'consoleUrl')
setUrlPair('jenkins_build_url', 'jenkinsBuildUrl')
if (d.jenkins_build_number != null || d.jenkinsBuildNumber != null) {
const n = d.jenkins_build_number != null ? d.jenkins_build_number : d.jenkinsBuildNumber
next.jenkins_build_number = n
next.jenkinsBuildNumber = n
}
if (d.end_time != null && String(d.end_time).trim() !== '') next.end_time = d.end_time
if (d.endTime != null && String(d.endTime).trim() !== '') next.endTime = d.endTime
if (d.duration_seconds != null || d.durationSeconds != null) {
const sec = d.duration_seconds != null ? d.duration_seconds : d.durationSeconds
next.duration_seconds = sec
next.durationSeconds = sec
}
if (d.id != null) next.id = d.id
this.executionSummary = next
},
/** 仅轮询 poll 更新主单;不轮询执行明细列表(与执行结果列表页一致) */
startPollingIfNeeded() {
this.stopPolling()
const s = this.executionSummary && this.executionSummary.status
if (this.isTerminalMainStatus(s)) {
this.polling = false
return
}
if (!this.executionId) {
this.polling = false
return
}
this.polling = true
const tick = () => {
postAutomationExecutionPoll({ executionId: this.executionId })
.then(res => {
if (!res || res.code !== 20000) return
const d = res.data
if (!d || typeof d !== 'object') return
this.mergePollIntoExecutionSummary(d)
if (this.isTerminalMainStatus(this.executionSummary.status)) {
this.stopPolling()
this.loadExecutionCasePage()
}
})
.catch(() => {})
}
tick()
this.pollTimer = window.setInterval(tick, 5000)
},
stopPolling() {
if (this.pollTimer != null) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
this.polling = false
},
resetRunAnother() {
this.stopPolling()
this.executionId = null
this.executionSummary = {}
this.executionCaseRows = []
this.caseListTotal = 0
this.casePageNo = 1
this.planCasePageNo = 1
if (this.$refs.caseTable) {
this.$refs.caseTable.clearSelection()
}
this.selectedRows = []
const q = Object.assign({}, this.$route.query || {})
delete q.executionId
delete q.execution_id
this.$router.replace({ path: '/test-platform/plan/automation', query: q }).catch(() => {})
this.fetchPlanCases()
},
goExecutionResultList() {
const q = this.$route.query || {}
this.$router.push({
path: '/test-platform/plan/automation/executions',
query: {
productId: q.productId || undefined,
productName: q.productName || '',
projectId: this.projectId || undefined,
projectName: q.projectName || '',
planId: this.planId || undefined,
planName: this.planNameDisplay || q.planName || '',
environmentId: q.environmentId || undefined,
jenkinsUrl: q.jenkinsUrl || undefined
}
})
},
goBack() {
this.stopPolling()
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.$route.query.productId || undefined,
projectId: this.projectId || undefined
}
})
}
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
}
.filter-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.filter-toolbar-form {
flex: 1;
min-width: 0;
}
.plan-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
max-width: 560px;
}
.plan-name-input {
width: 220px;
flex-shrink: 0;
}
.plan-jenkins-sep {
color: #c0c4cc;
user-select: none;
}
.plan-jenkins-link {
flex-shrink: 0;
}
.filter-toolbar-actions {
flex-shrink: 0;
padding-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.run-card,
.exec-card,
.case-list-card {
margin-top: 12px;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12px 0 8px;
gap: 12px;
}
.hint {
font-size: 12px;
color: #909399;
}
.run-actions {
margin-top: 16px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.exec-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.exec-no {
font-weight: 600;
margin-right: 8px;
}
.exec-tag {
vertical-align: middle;
}
.exec-actions {
display: flex;
gap: 8px;
}
.exec-desc {
margin-top: 8px;
padding: 12px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
}
.exec-desc-row {
margin-bottom: 8px;
}
.exec-desc-item {
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.exec-desc-block {
margin-top: 6px;
}
.exec-desc-label {
display: inline-block;
min-width: 88px;
margin-right: 8px;
color: #909399;
font-weight: 500;
}
.poll-hint {
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.card-header {
font-weight: 600;
}
.plan-case-pager {
margin-top: 12px;
text-align: right;
}
.case-pager {
margin-top: 12px;
text-align: right;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="page-wrap">
<page-section :title="isEditMode ? '编辑计划' : '计划构建'">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small">
<el-form ref="formRef" :model="form" :rules="rules" label-width="200px" size="small">
<el-form-item label="产品名称" prop="productId">
<el-select
v-model="form.productId"
@@ -70,6 +70,12 @@
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="是否自动化测试计划" prop="isAuto">
<el-select v-model.number="form.isAuto" placeholder="请选择" style="width: 200px;">
<el-option :value="0" label="否" />
<el-option :value="1" label="是" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="start_time">
<el-date-picker
v-model="form.start_time"
@@ -93,6 +99,14 @@
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="4"></el-input>
</el-form-item>
<el-form-item label="自动化执行 Jenkins URL">
<el-input
v-model.trim="form.jenkins_url"
maxlength="512"
show-word-limit
placeholder="选填,自动化触发时使用的 Jenkins 地址"
style="width: 480px;" />
</el-form-item>
<el-form-item>
<el-button @click="goBack">返回</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存计划</el-button>
@@ -128,7 +142,10 @@ export default {
environment_id: '',
start_time: '',
end_time: '',
description: ''
description: '',
jenkins_url: '',
/** 是否自动化测试计划0 否1 是,提交字段名 isAuto */
isAuto: 0
},
rules: {
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
@@ -245,6 +262,8 @@ export default {
end_time: this.form.end_time,
description: this.form.description
}
payload.jenkins_url = (this.form.jenkins_url || '').trim()
payload.isAuto = this.form.isAuto === 1 ? 1 : 0
this.saving = true
const request = this.isEditMode
? updatePlan(projectId, this.planId, payload)
@@ -344,6 +363,10 @@ export default {
this.form.start_time = this.toDatePickerValue(this.pickPlanDetailStartRaw(data))
this.form.end_time = this.toDatePickerValue(this.pickPlanDetailEndRaw(data))
this.form.description = data.description || ''
this.form.jenkins_url = data.jenkins_url || data.jenkinsUrl || ''
const autoRaw = data.isAuto !== undefined && data.isAuto !== null ? data.isAuto : data.is_auto
this.form.isAuto =
autoRaw === true || autoRaw === 1 || autoRaw === '1' ? 1 : 0
}).catch(() => {})
},
initByRouteProject() {

View File

@@ -89,6 +89,8 @@ export default {
tableData: [],
selectedRows: [],
associatedCaseIdMap: {},
/** 自动化测试计划is_auto=1用例列表只按是否实现自动化过滤不按评审通过状态过滤 */
planIsAutomation: false,
queryForm: {
keyword: '',
moduleName: ''
@@ -146,10 +148,14 @@ export default {
const params = {
keyword: this.queryForm.keyword || undefined,
module_name: this.queryForm.moduleName || undefined,
status: 4,
pageNo: this.pageNo,
pageSize: this.pageSize
}
if (this.planIsAutomation) {
params.isAuto = 1
} else {
params.status = 4
}
Promise.all([this.loadAssociatedCaseIds(), getCaseList(this.projectId, params)]).then(([, res]) => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
@@ -169,13 +175,20 @@ export default {
isCaseAssociated(caseId) {
return !!this.associatedCaseIdMap[caseId]
},
loadPlanOwner() {
if (!this.projectId || !this.planId || this.ownerId) {
/** 拉计划详情:负责人 + 是否自动化测试计划(关联用例列表筛选依赖) */
loadPlanContext() {
if (!this.projectId || !this.planId) {
return Promise.resolve()
}
return getPlanDetail(this.projectId, this.planId).then(res => {
const data = (res && res.data) || res || {}
const raw = (res && res.data) || res || {}
const inner = raw.plan || raw.detail
const data = inner && typeof inner === 'object' ? Object.assign({}, raw, inner) : raw
if (!this.ownerId) {
this.ownerId = data.owner_id || data.ownerId || ''
}
const autoRaw = data.isAuto !== undefined && data.isAuto !== null ? data.isAuto : data.is_auto
this.planIsAutomation = autoRaw === 1 || autoRaw === true || autoRaw === '1'
}).catch(() => {})
},
handleSelectionChange(rows) {
@@ -232,7 +245,7 @@ export default {
}
},
created() {
this.loadPlanOwner().finally(() => {
this.loadPlanContext().finally(() => {
this.fetchCases()
})
}

View File

@@ -28,6 +28,12 @@
<el-table-column prop="planCaseId" label="计划用例ID" width="120"></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 prop="actualResult" label="执行结果" min-width="180"></el-table-column>
<el-table-column label="执行状态" width="110">
<template slot-scope="scope">
@@ -157,7 +163,8 @@ export default {
actualResult: item.actual_result || item.actualResult || '',
caseKey: item.case_key || item.caseKey || '',
caseTitle: item.case_title || item.caseTitle || item.title || '',
title: item.title || item.case_title || item.caseTitle || ''
title: item.title || item.case_title || item.caseTitle || '',
jenkinsUrl: item.jenkins_url || item.jenkinsUrl || ''
}))
})
.catch(() => {

View File

@@ -74,7 +74,9 @@
</el-form>
<el-table v-loading="loading" :data="tableData" border style="margin-top: 16px;">
<el-table-column prop="name" label="计划名称" min-width="180"></el-table-column>
<el-table-column prop="version" label="版本" width="120"></el-table-column>
<el-table-column label="是否自动化测试" width="140">
<template slot-scope="scope">{{ formatPlanIsAuto(scope.row) }}</template>
</el-table-column>
<el-table-column label="开始时间" min-width="170">
<template slot-scope="scope">{{ formatDateTime(getStartTimeValue(scope.row)) }}</template>
</el-table-column>
@@ -90,19 +92,20 @@
<el-table-column label="状态" width="110">
<template slot-scope="scope">{{ formatStatus(scope.row.status) }}</template>
</el-table-column>
<el-table-column label="操作" width="600">
<el-table-column label="操作" width="660">
<template slot-scope="scope">
<el-button type="text" @click="goEdit(scope.row)">编辑</el-button>
<el-button type="text" @click="goAssociateCases(scope.row)">关联用例</el-button>
<el-button type="text" @click="goExecute(scope.row)">执行</el-button>
<el-button v-if="!isPlanAutomation(scope.row)" type="text" @click="goExecute(scope.row)">执行</el-button>
<el-button type="text" @click="goProgress(scope.row)">进度</el-button>
<el-button type="text" @click="openHookSendDialog(scope.row)">机器人消息</el-button>
<el-button type="text" @click="runAutoCases(scope.row)">执行自动化用例</el-button>
<el-button type="text" @click="openHookSendDialog(scope.row)">发送消息</el-button>
<el-button v-if="isPlanAutomation(scope.row)" type="text" @click="runAutoCases(scope.row)">执行自动化用例</el-button>
<el-button type="text" class="danger-text" @click="confirmDeletePlan(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="发送机器人消息"
title="发送消息"
:visible.sync="hookSendDialogVisible"
width="600px"
append-to-body
@@ -180,7 +183,7 @@
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getPlanList } from '@/api/planApi'
import { deletePlan, getPlanList } from '@/api/planApi'
import { getProductList } from '@/api/productApi'
import {
getProjectDetail,
@@ -535,6 +538,28 @@ export default {
}
})
},
confirmDeletePlan(row) {
if (!row || row.id == null) {
this.$message.warning('缺少计划信息')
return
}
if (!this.projectId) {
this.$message.warning('请先选择项目')
return
}
const name = (row.name || '').trim() || `计划 #${row.id}`
this.$confirm(`确定删除计划「${name}」吗?删除后不可恢复。`, '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => deletePlan(row.id))
.then(() => {
this.$message.success('删除成功')
this.fetchList()
})
.catch(() => {})
},
goAssociateCases(row) {
const project = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
const product = (this.productOptions || []).find(item => String(item.id) === String(this.selectedProductId))
@@ -769,8 +794,31 @@ export default {
this.hookSendSubmitting = false
})
},
runAutoCases() {
this.$message.info('执行自动化用例功能开发中')
runAutoCases(row) {
if (!row || row.id == null) {
this.$message.warning('缺少计划信息')
return
}
if (!this.projectId) {
this.$message.warning('请先选择项目')
return
}
const project = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
const product = (this.productOptions || []).find(item => String(item.id) === String(this.selectedProductId))
const jenkinsUrl = (row.jenkins_url || row.jenkinsUrl || '').trim()
this.$router.push({
path: '/test-platform/plan/automation',
query: {
productId: this.selectedProductId || undefined,
productName: (product && product.name) || '',
projectId: this.projectId,
projectName: (project && project.name) || '',
planId: row.id,
planName: row.name || '',
environmentId: row.environment_id != null ? row.environment_id : row.environmentId,
jenkinsUrl: jenkinsUrl || undefined
}
})
},
goProgress(row) {
const project = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
@@ -787,6 +835,19 @@ export default {
}
})
},
formatPlanIsAuto(row) {
if (!row) return '-'
const v = row.isAuto !== undefined && row.isAuto !== null ? row.isAuto : row.is_auto
if (v === 1 || v === true || v === '1') return '是'
if (v === 0 || v === false || v === '0') return '否'
return '-'
},
/** 列表 is_auto === 1自动化测试计划只展示「执行自动化用例」 */
isPlanAutomation(row) {
if (!row) return false
const v = row.isAuto !== undefined && row.isAuto !== null ? row.isAuto : row.is_auto
return v === 1 || v === true || v === '1'
},
formatStatus(value) {
const map = { 0: '草稿', 1: '进行中', 2: '已完成', 3: '已归档', 4: '已通过' }
return map[value] || value
@@ -872,6 +933,14 @@ export default {
padding: 20px;
}
.danger-text {
color: #f56c6c;
}
.danger-text:hover {
color: #f78989;
}
.hook-send-result {
margin-top: 12px;
padding: 10px 12px;

View File

@@ -84,6 +84,12 @@
</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>
@@ -224,7 +230,8 @@ export default {
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 || ''
actualResult: item.actual_result || item.actualResult || '',
jenkinsUrl: item.jenkins_url || item.jenkinsUrl || ''
}))
})
.catch(() => {

View File

@@ -92,6 +92,12 @@ export default {
} else {
localStorage.removeItem('accessToken')
}
const rt = data.refresh_token || data.refreshToken
if (rt) {
localStorage.setItem('refreshToken', rt)
} else {
localStorage.removeItem('refreshToken')
}
this.$store.commit('SetCurrentUser', user)
this.$store.commit('SetRole', user.roleIds)
this.$store.commit('SetUserMenus', [])

View File

@@ -138,6 +138,20 @@ export default new Router({
Manage: (resolve) => require(['@/components/TestPlatform/Plan/PlanExecute'], resolve)
}
},
{
path: '/test-platform/plan/automation',
name: 'PlanAutomationRun',
components: {
Manage: (resolve) => require(['@/components/TestPlatform/Plan/PlanAutomationRun'], resolve)
}
},
{
path: '/test-platform/plan/automation/executions',
name: 'PlanAutomationExecutionList',
components: {
Manage: (resolve) => require(['@/components/TestPlatform/Plan/PlanAutomationExecutionList'], resolve)
}
},
{
path: '/test-platform/plan/progress',
name: 'PlanProgress',

58
src/utils/authToken.js Normal file
View File

@@ -0,0 +1,58 @@
import axios from 'axios'
/** 与 request 实例一致,避免走带拦截器的 axios 造成循环 */
const REFRESH_URL = '/it/api/auth/refresh'
let inflightRefresh = null
/**
* 静默续期POST /auth/refresh仅当业务接口返回 code 451 时由 request 响应拦截器调用)
* body 优先 refreshToken否则传 accessToken成功 code=20000 且 data.token
* @returns {Promise<boolean>}
*/
export function tryRefreshAccessToken() {
if (inflightRefresh) {
return inflightRefresh
}
const refreshToken = localStorage.getItem('refreshToken')
const accessToken = localStorage.getItem('accessToken')
if (!refreshToken && !accessToken) {
return Promise.resolve(false)
}
inflightRefresh = axios({
method: 'post',
url: REFRESH_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
...(accessToken ? { accessToken } : {})
},
data: refreshToken ? { refreshToken } : accessToken ? { accessToken } : {}
})
.then(res => {
const body = res && res.data
if (!body || body.code !== 20000) {
return false
}
const d = body.data || {}
const token = d.token || body.token
if (token) {
localStorage.setItem('accessToken', token)
}
const rt = d.refresh_token || d.refreshToken
if (rt) {
localStorage.setItem('refreshToken', rt)
}
return !!token
})
.catch(() => false)
.finally(() => {
inflightRefresh = null
})
return inflightRefresh
}
export function clearTokenStorage() {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
}

View File

@@ -1,15 +1,65 @@
import axios from 'axios'
import { Message } from 'element-ui';
import { Message } from 'element-ui'
import router from '../router/index'
const service = axios.create({
// baseURL: 'http://10.250.0.252:5010', // api 的 base_url
// baseURL: '', // api 的 base_url
baseURL: '/it/api', // api 的 base_url
import store from '@/vuex/store'
import { clearTokenStorage, tryRefreshAccessToken } from './authToken'
timeout: 90000 // request timeout
const service = axios.create({
baseURL: '/it/api',
timeout: 90000
})
// 请求拦截 设置统一header
function pushLoginExpired(message) {
clearTokenStorage()
localStorage.removeItem('authUser')
localStorage.removeItem('userMenus')
store.commit('ClearCurrentUser')
router.push({ name: 'login' })
Message.error(message || '登录已失效,请重新登录')
}
function apiCode(data) {
if (!data || data.code === undefined || data.code === null) return null
const n = Number(data.code)
return Number.isFinite(n) ? n : null
}
function isMissingToken(data) {
return apiCode(data) === 40001
}
/** 仅 451token 无效或已过期,走静默续期并重试一次 */
function isTokenExpiredRefreshable(data) {
return apiCode(data) === 451
}
function isForbiddenApi(data) {
return apiCode(data) === 40003
}
/** @param {{ config: object, data?: object }} ctx */
function handleTokenExpiredRefreshAndRetry(ctx) {
const cfg = (ctx && ctx.config) || {}
const pdata = (ctx && ctx.data) || {}
if (cfg.__retriedTokenRefresh) {
pushLoginExpired(pdata.message || 'token无效或已过期')
return Promise.reject(new Error('token无效或已过期'))
}
return tryRefreshAccessToken().then(ok => {
if (!ok) {
pushLoginExpired(pdata.message || 'token无效或已过期')
return Promise.reject(new Error('token无效或已过期'))
}
const nextCfg = Object.assign({}, cfg, { __retriedTokenRefresh: true })
nextCfg.headers = Object.assign({}, nextCfg.headers || {})
const newAt = localStorage.getItem('accessToken')
if (newAt) {
nextCfg.headers.accessToken = newAt
}
return service.request(nextCfg)
})
}
service.interceptors.request.use(
config => {
const accessToken = localStorage.getItem('accessToken')
@@ -18,36 +68,51 @@ service.interceptors.request.use(
}
return config
},
error => {
return Promise.reject(error)
}
error => Promise.reject(error)
)
// 响应拦截 401 token过期处理
service.interceptors.response.use(
response => {
const data = response && response.data ? response.data : {}
// 兼容后端返回结构:{ success, code, message, data }
if (data && data.code === 500) {
Message.error('服务异常')
return Promise.reject(new Error(data.message || '服务异常'))
} else if (data && data.code === 451) {
router.push({ name: 'login' })
Message.error(data.message || '登录已失效,请重新登录')
return Promise.reject(new Error(data.message || '登录已失效'))
} else if (data && data.success === false) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
} else if (data && data.code !== undefined && data.code !== 20000) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
} else {
return response.data
}
if (data && isMissingToken(data)) {
pushLoginExpired(data.message || '缺少token')
return Promise.reject(new Error(data.message || '缺少token'))
}
if (data && isTokenExpiredRefreshable(data)) {
return handleTokenExpiredRefreshAndRetry({ config: response.config, data })
}
if (data && isForbiddenApi(data)) {
Message.error(data.message || '无权限访问该接口!')
return Promise.reject(new Error(data.message || '无权限访问该接口'))
}
if (data && data.success === false) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
}
if (data && data.code !== undefined && data.code !== 20000) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
}
return response.data
},
error => {
// 非 2xx 时会进入这里(如 40009/40012后端通常会带 JSON body
const data = error && error.response && error.response.data ? error.response.data : null
const res = error && error.response
const data = res && res.data ? res.data : null
if (data && isMissingToken(data) && error.config) {
pushLoginExpired(data.message || '缺少token')
return Promise.reject(new Error(data.message || '缺少token'))
}
if (data && isTokenExpiredRefreshable(data) && error.config && !error.config.__retriedTokenRefresh) {
return handleTokenExpiredRefreshAndRetry({ config: error.config, data })
}
if (data && isForbiddenApi(data)) {
Message.error(data.message || '无权限访问该接口!')
return Promise.reject(error)
}
if (data && typeof data === 'object') {
if (data.success === false) {
Message.error(data.message || '请求失败')