diff --git a/src/api/automationApi.js b/src/api/automationApi.js new file mode 100644 index 0000000..b76c818 --- /dev/null +++ b/src/api/automationApi.js @@ -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 || {} + }) +} diff --git a/src/api/planApi.js b/src/api/planApi.js index 4db8b5e..0169f1c 100644 --- a/src/api/planApi.js +++ b/src/api/planApi.js @@ -24,13 +24,13 @@ export function updatePlan(projectId, planId, data) { }) } -export function deletePlan(projectId, planId) { +/** POST /plan/delete,body 传 planId */ +export function deletePlan(planId) { return request({ url: '/plan/delete', method: 'post', data: { - project_id: projectId, - id: planId + planId } }) } diff --git a/src/components/Home.vue b/src/components/Home.vue index 7392b08..daa1ad0 100644 --- a/src/components/Home.vue +++ b/src/components/Home.vue @@ -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('已退出登录') diff --git a/src/components/TestPlatform/Plan/PlanAutomationExecutionList.vue b/src/components/TestPlatform/Plan/PlanAutomationExecutionList.vue new file mode 100644 index 0000000..369c71a --- /dev/null +++ b/src/components/TestPlatform/Plan/PlanAutomationExecutionList.vue @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + + + + 返回执行 + 返回计划 + + + + + + {{ scope.row.execution_no || scope.row.executionNo || '-' }} + + + + {{ mainStatusLabel(scope.row.status) }} + + + + {{ scope.row.env_code || scope.row.envCode || '-' }} + + + {{ scope.row.total_count != null ? scope.row.total_count : scope.row.totalCount }} + + + {{ scope.row.passed_count != null ? scope.row.passed_count : scope.row.passedCount }} + + + {{ scope.row.failed_count != null ? scope.row.failed_count : scope.row.failedCount }} + + + {{ scope.row.created_time || scope.row.createdTime || '-' }} + + + + 查看详情 + + + + + + 打开 + + + + + - + + + + + + + + + + + + diff --git a/src/components/TestPlatform/Plan/PlanAutomationRun.vue b/src/components/TestPlatform/Plan/PlanAutomationRun.vue new file mode 100644 index 0000000..3894071 --- /dev/null +++ b/src/components/TestPlatform/Plan/PlanAutomationRun.vue @@ -0,0 +1,842 @@ + + + + + + + + + + + + + + + + · + + 自动化执行 Jenkins + + + + + + + 执行结果列表 + 返回 + + + + + + + + + + + + + 串行 + 并行 + + + + + + + + + + + + 勾选后点击「执行已选用例」将传 caseIds;翻页会保留已勾选项。不勾选可「执行计划内全部自动化用例」。 + 刷新用例列表 + + + + + + + + {{ formatAuto(scope.row) }} + + + + + + + 执行计划内全部自动化用例 + + 执行已选用例({{ selectedRows.length }}) + + + + + + + + + {{ executionSummary.execution_no || executionSummary.executionNo || '-' }} + + {{ mainStatusLabel(executionSummary.status) }} + + + + 刷新 + 再跑一轮 + + + + + + 环境{{ executionSummary.env_code || executionSummary.envCode || '-' }} + + + 模式{{ (executionSummary.run_mode || executionSummary.runMode) === 2 ? '并行' : '串行' }} + + + + + 总数{{ pickCount(executionSummary, 'total_count', 'totalCount') }} + + + + 通过 / 失败 + {{ pickCount(executionSummary, 'passed_count', 'passedCount') }} / + {{ pickCount(executionSummary, 'failed_count', 'failedCount') }} + + + + + Jenkins + {{ jenkinsLink }} + - + + + 控制台 + 打开 + - + + + 报告 + 打开 + - + + + 执行中,每 5 秒自动刷新状态… + + + + 执行明细 + + + {{ scope.row.run_order != null ? scope.row.run_order : scope.row.runOrder }} + + + {{ scope.row.case_key || scope.row.caseKey }} + + + {{ scope.row.case_title || scope.row.caseTitle }} + + + + {{ caseStatusLabel(scope.row.status) }} + + + + {{ scope.row.result_message || scope.row.resultMessage || '-' }} + + + {{ scope.row.duration_seconds != null ? scope.row.duration_seconds : scope.row.durationSeconds }} + + + + + + + + + + + + diff --git a/src/components/TestPlatform/Plan/PlanBuilder.vue b/src/components/TestPlatform/Plan/PlanBuilder.vue index 385e132..9473a92 100644 --- a/src/components/TestPlatform/Plan/PlanBuilder.vue +++ b/src/components/TestPlatform/Plan/PlanBuilder.vue @@ -1,7 +1,7 @@ - + + + + + + + + + + 返回 保存计划 @@ -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() { diff --git a/src/components/TestPlatform/Plan/PlanCaseAdd.vue b/src/components/TestPlatform/Plan/PlanCaseAdd.vue index cc318fd..148d5a0 100644 --- a/src/components/TestPlatform/Plan/PlanCaseAdd.vue +++ b/src/components/TestPlatform/Plan/PlanCaseAdd.vue @@ -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 || {} - this.ownerId = data.owner_id || data.ownerId || '' + 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() }) } diff --git a/src/components/TestPlatform/Plan/PlanExecute.vue b/src/components/TestPlatform/Plan/PlanExecute.vue index 0d68254..07f4420 100644 --- a/src/components/TestPlatform/Plan/PlanExecute.vue +++ b/src/components/TestPlatform/Plan/PlanExecute.vue @@ -28,6 +28,12 @@ + + + 打开 + - + + @@ -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(() => { diff --git a/src/components/TestPlatform/Plan/PlanList.vue b/src/components/TestPlatform/Plan/PlanList.vue index 4b9b8fe..0d4bf75 100644 --- a/src/components/TestPlatform/Plan/PlanList.vue +++ b/src/components/TestPlatform/Plan/PlanList.vue @@ -74,7 +74,9 @@ - + + {{ formatPlanIsAuto(scope.row) }} + {{ formatDateTime(getStartTimeValue(scope.row)) }} @@ -90,19 +92,20 @@ {{ formatStatus(scope.row.status) }} - + 编辑 关联用例 - 执行 + 执行 进度 - 机器人消息 - 执行自动化用例 + 发送消息 + 执行自动化用例 + 删除 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; diff --git a/src/components/TestPlatform/Plan/PlanProgress.vue b/src/components/TestPlatform/Plan/PlanProgress.vue index 30122fb..d548400 100644 --- a/src/components/TestPlatform/Plan/PlanProgress.vue +++ b/src/components/TestPlatform/Plan/PlanProgress.vue @@ -84,6 +84,12 @@ + + + 打开 + - + + {{ statusLabel(scope.row.statusCode) }} @@ -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(() => { diff --git a/src/components/User/Login.vue b/src/components/User/Login.vue index 26c11cc..3ffcd8e 100644 --- a/src/components/User/Login.vue +++ b/src/components/User/Login.vue @@ -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', []) diff --git a/src/router/index.js b/src/router/index.js index 351237b..16a7734 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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', diff --git a/src/utils/authToken.js b/src/utils/authToken.js new file mode 100644 index 0000000..93669fc --- /dev/null +++ b/src/utils/authToken.js @@ -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} + */ +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') +} diff --git a/src/utils/request.js b/src/utils/request.js index d026487..c18e24d 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -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 +} + +/** 仅 451:token 无效或已过期,走静默续期并重试一次 */ +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 || '请求失败')