功能更新:新增Bug管理模块,完善用户角色分配,优化项目设置

This commit is contained in:
qiaoxinjiu
2026-05-07 19:43:05 +08:00
parent 916248483c
commit f8211673ee
44 changed files with 10410 additions and 437 deletions

View File

@@ -47,9 +47,12 @@
<el-form-item label="前置条件">
<el-input v-model="form.preconditions" type="textarea" :rows="3"></el-input>
</el-form-item>
<el-form-item label="步骤(JSON)" prop="steps">
<el-form-item label="步骤">
<el-input v-model="stepsText" type="textarea" :rows="10"></el-input>
</el-form-item>
<el-form-item label="预期结果">
<el-input v-model="expectedResultText" type="textarea" :rows="3" placeholder="请输入预期结果"></el-input>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="form.priority">
<el-option label="P0" :value="0"></el-option>
@@ -121,14 +124,15 @@ export default {
status: 1,
isAuto: 0
},
stepsText: '[]',
stepsText: '',
expectedResultText: '',
tagsText: '',
rules: {
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
moduleId: [{ required: true, message: '请选择模块', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
steps: [{ required: true, message: '请输入步骤', trigger: 'change' }]
// 步骤为文本输入,不做 JSON 校验;是否为空在 submitForm 里校验
}
}
},
@@ -201,7 +205,8 @@ export default {
status: data.status || 1,
isAuto: data.isAuto !== undefined ? data.isAuto : (data.is_auto !== undefined ? data.is_auto : 0)
})
this.stepsText = JSON.stringify(this.form.steps || [], null, 2)
this.stepsText = this.formatStepsToText(this.form.steps || [])
this.expectedResultText = data.expectedResults || data.expected_results || this.extractExpectedResultFromSteps(this.form.steps || [])
this.tagsText = Array.isArray(this.form.tags) ? this.form.tags.join(',') : (this.form.tags || '')
if (this.form.projectId) {
this.loadModuleOptions()
@@ -209,11 +214,11 @@ export default {
})
},
submitForm() {
let steps = []
try {
steps = JSON.parse(this.stepsText || '[]')
} catch (e) {
this.$message({ type: 'error', message: '步骤 JSON 格式错误' })
const expectedResult = (this.expectedResultText || '').trim()
const stepsText = String(this.stepsText || '').trim()
if (!stepsText) {
this.$message({ type: 'error', message: '请输入步骤' })
return
}
const tags = this.tagsText ? this.tagsText.split(',').map(item => item.trim()).filter(Boolean) : []
@@ -223,7 +228,8 @@ export default {
caseKey: this.form.caseKey,
title: this.form.title,
preconditions: this.form.preconditions,
steps,
steps: stepsText,
expectedResults: expectedResult,
priority: this.form.priority,
caseType: this.form.caseType,
tags,
@@ -248,8 +254,35 @@ export default {
return result
}, {})
},
formatStepsToText(steps) {
if (typeof steps === 'string') return steps
if (!Array.isArray(steps)) return ''
return steps
.map(item => {
if (item === null || item === undefined) return ''
if (typeof item === 'string') return item
return item.action || item.step || item.description || item.text || item.content || ''
})
.filter(Boolean)
.join('\n')
},
extractExpectedResultFromSteps(steps) {
if (!Array.isArray(steps) || steps.length === 0) return ''
const first = steps[0]
if (first && typeof first === 'object') {
return first.expected || first.expected_result || first.expectedResult || ''
}
return ''
},
backList() {
this.$router.push({ path: '/test-platform/case', query: { productId: this.form.productId || undefined, projectId: this.form.projectId || undefined } })
this.$router.push({
path: '/test-platform/case',
query: {
productId: this.form.productId || undefined,
projectId: this.form.projectId || undefined,
tab: 'cases'
}
})
}
},
created() {

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,7 @@ export default {
this.saving = true
createBuilder(this.projectId, Object.assign({}, this.form, { definition, input_schema })).then(() => {
this.$message({ type: 'success', message: '造数器保存成功' })
this.$router.push({ path: '/test-platform/data-factory/builders', query: { projectId: this.projectId } })
this.$router.push({ path: '/data-tools/factory/builders', query: { projectId: this.projectId } })
}).finally(() => {
this.saving = false
})

View File

@@ -72,13 +72,13 @@ export default {
})
},
goEditor(row) {
this.$router.push({ path: '/test-platform/data-factory/editor', query: { projectId: this.projectId, builderId: row && row.id } })
this.$router.push({ path: '/data-tools/factory/editor', query: { projectId: this.projectId, builderId: row && row.id } })
},
goTasks() {
this.$router.push({ path: '/test-platform/data-factory/tasks', query: { projectId: this.projectId } })
this.$router.push({ path: '/data-tools/factory/task', query: { projectId: this.projectId } })
},
goMock() {
this.$router.push({ path: '/test-platform/data-factory/mock', query: { projectId: this.projectId } })
this.$router.push({ path: '/data-tools/factory/mock', query: { projectId: this.projectId } })
},
execute(row) {
executeBuilder(this.projectId, row.id, { params: { count: 1 }, async: true }).then(() => {

View File

@@ -1,26 +1,100 @@
<template>
<div class="page-wrap">
<page-section title="计划构建">
<el-form :model="form" label-width="120px" size="small">
<el-form-item label="项目ID">
<el-input v-model="projectId" style="width: 200px;"></el-input>
<page-section :title="isEditMode ? '编辑计划' : '计划构建'">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small">
<el-form-item label="产品名称" prop="productId">
<el-select
v-model="form.productId"
filterable
clearable
placeholder="请选择产品"
style="width: 360px;"
@change="handleProductChange"
@focus="loadProductOptions">
<el-option
v-for="item in productOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="计划名称">
<el-form-item label="项目名称" prop="projectId">
<el-select
v-model="form.projectId"
filterable
clearable
placeholder="请选择项目"
style="width: 360px;"
:disabled="!form.productId"
@change="handleProjectChange">
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="计划名称" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="版本">
<el-form-item label="版本" prop="version">
<el-input v-model="form.version"></el-input>
</el-form-item>
<el-form-item label="负责人ID">
<el-input v-model="form.owner_id"></el-input>
<el-form-item label="负责人" prop="owner_id">
<el-select
v-model="form.owner_id"
filterable
clearable
placeholder="请选择负责人"
style="width: 360px;"
:disabled="!form.projectId">
<el-option
v-for="item in ownerOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="环境ID">
<el-input v-model="form.environment_id"></el-input>
<el-form-item label="环境名称" prop="environment_id">
<el-select
v-model="form.environment_id"
filterable
clearable
placeholder="请选择环境"
style="width: 360px;"
:disabled="!form.projectId">
<el-option
v-for="item in environmentOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="开始时间" prop="start_time">
<el-date-picker
v-model="form.start_time"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择开始时间"
style="width: 360px;">
</el-date-picker>
</el-form-item>
<el-form-item label="结束时间" prop="end_time">
<el-date-picker
v-model="form.end_time"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择结束时间"
style="width: 360px;">
</el-date-picker>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="4"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="goBack">返回</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存计划</el-button>
</el-form-item>
</el-form>
@@ -30,34 +104,270 @@
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { createPlan } from '@/api/planApi'
import { createPlan, getPlanDetail, updatePlan } from '@/api/planApi'
import { getProductList } from '@/api/productApi'
import { getProjectDetail, getProjectEnvironments, getProjectList, getProjectMembers } from '@/api/projectApi'
export default {
name: 'PlanBuilder',
components: { PageSection },
components: {PageSection},
data() {
return {
saving: false,
projectId: this.$route.query.projectId || 1,
planId: this.$route.query.planId || '',
productOptions: [],
projectOptions: [],
ownerOptions: [],
environmentOptions: [],
form: {
productId: '',
projectId: this.$route.query.projectId ? Number(this.$route.query.projectId) : '',
name: '',
version: '',
owner_id: '',
environment_id: '',
start_time: '',
end_time: '',
description: ''
},
rules: {
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
name: [{ required: true, message: '请输入计划名称', trigger: 'blur' }],
version: [{ required: true, message: '请输入版本', trigger: 'blur' }],
owner_id: [{ required: true, message: '请选择负责人', trigger: 'change' }],
environment_id: [{ required: true, message: '请选择环境', trigger: 'change' }],
start_time: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
end_time: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
}
}
},
methods: {
submitForm() {
this.saving = true
createPlan(this.projectId, this.form).then(() => {
this.$message({ type: 'success', message: '计划创建成功' })
this.$router.push({ path: '/test-platform/plans', query: { projectId: this.projectId } })
}).finally(() => {
this.saving = false
})
computed: {
isEditMode() {
return !!this.planId
}
},
methods: {
loadProductOptions() {
if (this.productOptions && this.productOptions.length > 0) {
return Promise.resolve()
}
return getProductList({ pageNo: 1, pageSize: 1000, status: 1 }).then(res => {
const data = res && res.data ? res.data : res || {}
this.productOptions = data.items || data.list || data.data || []
}).catch(() => {
this.productOptions = []
})
},
loadProjectOptionsByProduct(productId) {
if (!productId) {
this.projectOptions = []
return Promise.resolve()
}
return getProjectList({ pageNo: 1, pageSize: 1000, status: 1, productId }).then(res => {
const data = res && res.data ? res.data : res || {}
this.projectOptions = data.items || data.list || data.data || []
}).catch(() => {
this.projectOptions = []
})
},
loadProjectMeta(projectId) {
if (!projectId) {
this.ownerOptions = []
this.environmentOptions = []
return Promise.resolve()
}
const memberReq = getProjectMembers(projectId, { pageNo: 1, pageSize: 1000 }).then(res => {
const data = (res && res.data) || res || {}
const list = data.items || data.list || data.data || data || []
this.ownerOptions = (Array.isArray(list) ? list : []).map(item => ({
id: item.user_id || item.userId || item.id,
name:
item.real_name ||
item.realName ||
item.username ||
item.name ||
item.user_name ||
`用户${item.user_id || item.id}`
})).filter(item => item.id !== undefined && item.id !== null && item.id !== '')
}).catch(() => {
this.ownerOptions = []
})
const envReq = getProjectEnvironments(projectId, { pageNo: 1, pageSize: 1000 }).then(res => {
const data = (res && res.data) || res || {}
const list = data.items || data.list || data.data || data || []
this.environmentOptions = (Array.isArray(list) ? list : []).map(item => ({
id: item.id,
name: item.name
}))
}).catch(() => {
this.environmentOptions = []
})
return Promise.all([memberReq, envReq])
},
handleProductChange() {
this.form.projectId = ''
this.form.owner_id = ''
this.form.environment_id = ''
this.projectOptions = []
this.ownerOptions = []
this.environmentOptions = []
this.loadProjectOptionsByProduct(this.form.productId)
},
handleProjectChange() {
this.form.owner_id = ''
this.form.environment_id = ''
this.ownerOptions = []
this.environmentOptions = []
this.loadProjectMeta(this.form.projectId)
},
goBack() {
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.form.productId || undefined,
projectId: this.form.projectId || undefined
}
})
},
submitForm() {
this.$refs.formRef.validate(valid => {
if (!valid) {
return
}
const projectId = this.form.projectId
const payload = {
name: this.form.name,
version: this.form.version,
owner_id: this.form.owner_id,
environment_id: this.form.environment_id,
start_time: this.form.start_time,
end_time: this.form.end_time,
description: this.form.description
}
this.saving = true
const request = this.isEditMode
? updatePlan(projectId, this.planId, payload)
: createPlan(projectId, payload)
request.then(() => {
this.$message({ type: 'success', message: this.isEditMode ? '计划更新成功' : '计划创建成功' })
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.form.productId || undefined,
projectId: projectId || undefined
}
})
}).finally(() => {
this.saving = false
})
})
},
/** 与 PlanList 列表行一致,兼容详情接口多种时间字段名 */
pickPlanDetailStartRaw(plan) {
if (!plan) return ''
return (
plan.start_date ||
plan.startDate ||
plan.start_time ||
plan.startTime ||
plan.begin_time ||
plan.beginTime ||
plan.planned_start_time ||
plan.plannedStartTime ||
''
)
},
pickPlanDetailEndRaw(plan) {
if (!plan) return ''
return (
plan.end_date ||
plan.endDate ||
plan.end_time ||
plan.endTime ||
plan.finish_time ||
plan.finishTime ||
plan.planned_end_time ||
plan.plannedEndTime ||
''
)
},
/** 将接口返回的时间戳 / ISO 串等转为 date-picker 的 value-format 字符串 */
toDatePickerValue(value) {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim()))) {
const raw = Number(value)
if (Number.isNaN(raw) || raw <= 0) return ''
const ms = raw < 1000000000000 ? raw * 1000 : raw
const d = new Date(ms)
if (Number.isNaN(d.getTime())) return ''
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const s = String(value).trim()
if (!s) return ''
let normalized = s
if (s.includes('T')) {
normalized = s.replace('T', ' ').replace(/\.\d+/, '').replace(/Z$/i, '').trim()
}
if (normalized.length >= 19) {
return normalized.slice(0, 19)
}
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return `${normalized} 00:00:00`
}
const parsed = new Date(s)
if (!Number.isNaN(parsed.getTime())) {
const pad = n => String(n).padStart(2, '0')
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`
}
return normalized
},
unwrapPlanDetailPayload(res) {
const raw = (res && res.data) || res || {}
const inner = raw.plan || raw.detail
if (inner && typeof inner === 'object') {
return Object.assign({}, raw, inner)
}
return raw
},
loadPlanDetail() {
if (!this.isEditMode || !this.form.projectId) {
return Promise.resolve()
}
return getPlanDetail(this.form.projectId, this.planId).then(res => {
const data = this.unwrapPlanDetailPayload(res)
this.form.name = data.name || ''
this.form.version = data.version || ''
this.form.owner_id = data.owner_id || data.ownerId || ''
this.form.environment_id = data.environment_id || data.environmentId || ''
this.form.start_time = this.toDatePickerValue(this.pickPlanDetailStartRaw(data))
this.form.end_time = this.toDatePickerValue(this.pickPlanDetailEndRaw(data))
this.form.description = data.description || ''
}).catch(() => {})
},
initByRouteProject() {
if (!this.form.projectId) {
return Promise.resolve()
}
return getProjectDetail(this.form.projectId).then(res => {
const data = res && res.data ? res.data : res || {}
const productId = data.productId || data.product_id || ''
if (productId) {
this.form.productId = productId
return this.loadProjectOptionsByProduct(productId)
}
}).catch(() => {})
}
},
created() {
this.loadProductOptions().then(() => this.initByRouteProject()).finally(() => {
if (this.form.projectId) {
this.loadProjectMeta(this.form.projectId).then(() => {
this.loadPlanDetail()
})
}
})
}
}
</script>

View File

@@ -0,0 +1,268 @@
<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="planName" disabled style="width: 220px;"></el-input>
</el-form-item>
</el-form>
<div class="query-toolbar">
<el-form :inline="true" :model="queryForm" size="small" class="query-toolbar-form" @submit.native.prevent>
<el-form-item label="用例名称">
<el-input v-model="queryForm.keyword" clearable style="width: 180px;"></el-input>
</el-form-item>
<el-form-item label="模块名称">
<el-input v-model="queryForm.moduleName" clearable style="width: 160px;"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchCases">查询</el-button>
</el-form-item>
</el-form>
<div class="query-toolbar-actions">
<el-button size="small" @click="goBack">返回</el-button>
<el-button type="primary" size="small" :loading="submitting" @click="submitAssociate">关联用例</el-button>
</div>
</div>
<el-table
ref="caseTable"
v-loading="loading"
:data="tableData"
border
style="margin-top: 12px;"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="52"></el-table-column>
<el-table-column label="模块名称" min-width="160">
<template slot-scope="scope">{{ scope.row.module_name || '-' }}</template>
</el-table-column>
<el-table-column prop="case_key" label="用例编号" min-width="130"></el-table-column>
<el-table-column prop="title" label="用例名称" min-width="220"></el-table-column>
<el-table-column label="优先级" width="90">
<template slot-scope="scope">{{ formatPriority(scope.row.priority) }}</template>
</el-table-column>
<el-table-column label="是否已关联计划" width="130">
<template slot-scope="scope">
<el-tag size="mini" :type="isCaseAssociated(scope.row.id) ? 'success' : 'info'">
{{ isCaseAssociated(scope.row.id) ? '已关联' : '未关联' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
:current-page="pageNo"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</page-section>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getCaseList } from '@/api/caseApi'
import { addPlanCases, getPlanCaseList, getPlanDetail } from '@/api/planApi'
export default {
name: 'PlanCaseAdd',
components: { PageSection },
data() {
return {
loading: false,
submitting: false,
pageNo: 1,
pageSize: 20,
total: 0,
tableData: [],
selectedRows: [],
associatedCaseIdMap: {},
queryForm: {
keyword: '',
moduleName: ''
},
ownerId: this.$route.query.ownerId || ''
}
},
computed: {
projectId() {
return this.$route.query.projectId || ''
},
planId() {
return this.$route.query.planId || ''
},
productName() {
return this.$route.query.productName || ''
},
projectName() {
return this.$route.query.projectName || ''
},
planName() {
return this.$route.query.planName || ''
}
},
methods: {
loadAssociatedCaseIds() {
if (!this.projectId || !this.planId) {
this.associatedCaseIdMap = {}
return Promise.resolve()
}
return getPlanCaseList(this.projectId, this.planId, {
pageNo: 1,
pageSize: 2000
}).then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.associatedCaseIdMap = (Array.isArray(list) ? list : []).reduce((map, item) => {
const caseId = item.case_id || item.caseId || (item.case && item.case.id) || (item.case && item.case.case_id)
if (caseId !== undefined && caseId !== null && caseId !== '') {
map[caseId] = true
}
return map
}, {})
}).catch(() => {
this.associatedCaseIdMap = {}
})
},
fetchCases() {
if (!this.projectId) {
this.tableData = []
this.total = 0
return
}
this.loading = true
const params = {
keyword: this.queryForm.keyword || undefined,
module_name: this.queryForm.moduleName || undefined,
status: 4,
pageNo: this.pageNo,
pageSize: this.pageSize
}
Promise.all([this.loadAssociatedCaseIds(), getCaseList(this.projectId, params)]).then(([, res]) => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.tableData = Array.isArray(list) ? list : []
this.total = Number(data.total || this.tableData.length || 0)
this.$nextTick(() => {
this.$refs.caseTable && this.$refs.caseTable.clearSelection()
})
this.selectedRows = []
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
isCaseAssociated(caseId) {
return !!this.associatedCaseIdMap[caseId]
},
loadPlanOwner() {
if (!this.projectId || !this.planId || this.ownerId) {
return Promise.resolve()
}
return getPlanDetail(this.projectId, this.planId).then(res => {
const data = (res && res.data) || res || {}
this.ownerId = data.owner_id || data.ownerId || ''
}).catch(() => {})
},
handleSelectionChange(rows) {
this.selectedRows = rows || []
},
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchCases()
},
handleCurrentChange(page) {
this.pageNo = page
this.fetchCases()
},
formatPriority(value) {
const map = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
return map[value] || value
},
submitAssociate() {
if (!this.planId) {
this.$message.warning('缺少计划ID')
return
}
if (!this.ownerId) {
this.$message.warning('缺少负责人信息,无法关联')
return
}
const caseIds = this.selectedRows.map(item => item.id).filter(Boolean)
if (caseIds.length === 0) {
this.$message.warning('请先选择要关联的用例')
return
}
this.submitting = true
addPlanCases(this.projectId, this.planId, {
planId: Number(this.planId),
caseIds,
assigneeId: Number(this.ownerId),
roundNo: 1
}).then(() => {
this.$message.success('关联用例成功')
this.goBack()
}).finally(() => {
this.submitting = false
})
},
goBack() {
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.$route.query.productId || undefined,
projectId: this.projectId || undefined
}
})
}
},
created() {
this.loadPlanOwner().finally(() => {
this.fetchCases()
})
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
}
.query-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
}
.query-toolbar-form {
flex: 1;
min-width: 0;
}
.query-toolbar-actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
padding-top: 4px;
}
</style>

View File

@@ -1,108 +1,245 @@
<template>
<div class="page-wrap">
<page-section title="计划执行">
<el-form :inline="true" size="small" @submit.native.prevent>
<el-form-item label="项目ID">
<el-input v-model="projectId" style="width: 120px;"></el-input>
</el-form-item>
<el-form-item label="计划ID">
<el-input v-model="planId" style="width: 120px;"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchDetail">刷新</el-button>
</el-form-item>
</el-form>
<key-value-descriptions :items="summaryItems"></key-value-descriptions>
<el-divider></el-divider>
<el-form :model="executeForm" label-width="120px" size="small">
<el-form-item label="计划用例ID">
<el-input v-model="executeForm.planCaseId"></el-input>
</el-form-item>
<el-form-item label="执行状态">
<el-select v-model="executeForm.status">
<el-option label="通过" :value="1"></el-option>
<el-option label="失败" :value="2"></el-option>
<el-option label="阻塞" :value="3"></el-option>
</el-select>
</el-form-item>
<el-form-item label="实际结果">
<el-input v-model="executeForm.actual_result" type="textarea" :rows="4"></el-input>
</el-form-item>
<el-form-item label="缺陷链接">
<el-input v-model="defectLinksText" placeholder="多个缺陷号用逗号分隔"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submitExecute">提交执行</el-button>
</el-form-item>
</el-form>
<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: 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="fetchPlanCases">刷新</el-button>
</el-form-item>
</el-form>
<div class="filter-toolbar-actions">
<el-button size="small" @click="goBack">返回</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="planCaseTableData"
border
style="margin-top: 12px;">
<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 prop="actualResult" label="执行结果" min-width="180"></el-table-column>
<el-table-column label="执行状态" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="formatExecuteStatusTag(scope.row.status)">{{ scope.row.statusLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template slot-scope="scope">
<el-button type="text" @click="openExecuteDialog(scope.row)">执行</el-button>
</template>
</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="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
<el-dialog
title=""
:visible.sync="executeDialogVisible"
width="760px">
<div class="detail-title">{{ selectedPlanCase ? (selectedPlanCase.caseTitle || selectedPlanCase.title || selectedPlanCase.caseKey || selectedPlanCase.caseId) : '' }}</div>
<div class="detail-section">
<div class="detail-section-title">前置条件</div>
<div class="detail-text">{{ caseDetail.preconditions || '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-section-title">执行步骤</div>
<div class="detail-text">{{ formatSteps(caseDetail.steps) || '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-section-title">预期结果</div>
<div class="detail-text">{{ caseDetail.expected_results || caseDetail.expectedResults || '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-section-title">执行结果</div>
<el-input
v-model="executeResultText"
type="textarea"
:rows="3"
placeholder="失败或阻塞时必填">
</el-input>
</div>
<span slot="footer">
<el-button @click="executeDialogVisible = false">取消</el-button>
<el-button type="success" :loading="submitting" @click="submitExecute(1)">通过</el-button>
<el-button type="danger" :loading="submitting" @click="submitExecute(2)">失败</el-button>
<el-button type="warning" :loading="submitting" @click="submitExecute(3)">阻塞</el-button>
</span>
</el-dialog>
</page-section>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import KeyValueDescriptions from '@/components/TestPlatform/common/KeyValueDescriptions'
import { executePlanCase, getPlanDetail } from '@/api/planApi'
import { getCaseDetail } from '@/api/caseApi'
import { executePlanCase, getPlanCaseList } from '@/api/planApi'
export default {
name: 'PlanExecute',
components: { PageSection, KeyValueDescriptions },
components: { PageSection },
data() {
return {
projectId: this.$route.query.projectId || 1,
projectId: this.$route.query.projectId || '',
planId: this.$route.query.planId || '',
detail: {},
loading: false,
planCaseTableData: [],
total: 0,
selectedPlanCase: null,
caseDetail: {},
executeDialogVisible: false,
submitting: false,
defectLinksText: '',
executeForm: {
planCaseId: '',
status: 1,
actual_result: ''
}
executeResultText: '',
pageNo: 1,
pageSize: 10
}
},
computed: {
summaryItems() {
return [
{ label: '计划ID', value: this.detail.id },
{ label: '计划名称', value: this.detail.name },
{ label: '总用例数', value: this.detail.total_cases },
{ label: '已完成', value: this.detail.completed },
{ label: '通过率', value: this.detail.pass_rate }
]
productName() {
return this.$route.query.productName || ''
},
projectName() {
return this.$route.query.projectName || ''
},
planNameDisplay() {
return this.$route.query.planName || ''
}
},
methods: {
fetchDetail() {
if (!this.planId) {
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchPlanCases()
},
handleCurrentChange(page) {
this.pageNo = page
this.fetchPlanCases()
},
fetchPlanCases() {
if (!this.planId || !this.projectId) {
this.planCaseTableData = []
this.total = 0
this.selectedPlanCase = null
this.caseDetail = {}
return
}
getPlanDetail(this.projectId, this.planId).then(res => {
this.detail = (res && res.data) || res || {}
this.loading = true
getPlanCaseList(this.projectId, this.planId, { pageNo: this.pageNo, pageSize: this.pageSize })
.then(listRes => {
const data = (listRes && listRes.data) || listRes || {}
const list = data.list || data.items || []
this.total = Number(data.total != null ? data.total : (Array.isArray(list) ? list.length : 0))
this.planCaseTableData = (Array.isArray(list) ? list : []).map(item => ({
planCaseId: item.id,
caseId: item.case_id || item.caseId,
status: item.status,
statusLabel: this.formatExecuteStatus(item.status),
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 || ''
}))
})
.catch(() => {
this.planCaseTableData = []
this.total = 0
})
.finally(() => {
this.loading = false
})
},
openExecuteDialog(row) {
if (!row) return
this.selectedPlanCase = row
this.executeResultText = ''
if (!row.caseId) {
this.caseDetail = {}
this.executeDialogVisible = true
return
}
getCaseDetail(this.projectId, row.caseId).then(res => {
const data = (res && res.data) || res || {}
this.caseDetail = data
this.executeDialogVisible = true
}).catch(() => {
this.detail = {}
this.caseDetail = {}
this.executeDialogVisible = true
})
},
submitExecute() {
if (!this.executeForm.planCaseId) {
this.$message({ type: 'warning', message: '请输入计划用例ID' })
submitExecute(status) {
if (!this.selectedPlanCase || !this.selectedPlanCase.planCaseId) {
this.$message({ type: 'warning', message: '请先选择要执行的用例' })
return
}
if ((status === 2 || status === 3) && !String(this.executeResultText || '').trim()) {
this.$message({ type: 'warning', message: '失败或阻塞时请填写执行结果' })
return
}
this.submitting = true
executePlanCase(this.projectId, this.planId, this.executeForm.planCaseId, {
status: this.executeForm.status,
actual_result: this.executeForm.actual_result,
defect_links: this.defectLinksText ? this.defectLinksText.split(',').map(item => item.trim()).filter(Boolean) : [],
executePlanCase(this.projectId, this.planId, this.selectedPlanCase.planCaseId, {
status,
actualResult: this.executeResultText,
defectLinks: [],
attachments: []
}).then(() => {
this.$message({ type: 'success', message: '执行结果已提交' })
this.executeDialogVisible = false
this.fetchPlanCases()
}).finally(() => {
this.submitting = false
})
},
formatSteps(steps) {
if (!steps) return ''
if (typeof steps === 'string') return steps
if (Array.isArray(steps)) {
return steps.map(item => {
if (typeof item === 'string') return item
return item.action || item.step || item.text || item.content || ''
}).filter(Boolean).join('\n')
}
return String(steps)
},
formatExecuteStatus(status) {
const map = { 0: '待执行', 1: '通过', 2: '失败', 3: '阻塞' }
return map[status] || status
},
formatExecuteStatusTag(status) {
const map = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' }
return map[status] || 'info'
},
goBack() {
this.$router.push({
path: '/test-platform/plan',
query: {
productId: this.$route.query.productId || undefined,
projectId: this.projectId || undefined
}
})
}
},
created() {
this.fetchDetail()
this.fetchPlanCases()
}
}
</script>
@@ -111,4 +248,54 @@ export default {
.page-wrap {
padding: 20px;
}
.filter-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.filter-toolbar-form {
flex: 1;
min-width: 0;
}
.filter-toolbar-actions {
flex-shrink: 0;
padding-top: 4px;
}
.detail-panel {
margin-top: 16px;
}
.detail-title {
margin-bottom: 8px;
font-weight: 600;
color: #303133;
font-size: 18px;
line-height: 1.4;
}
.detail-text {
white-space: pre-wrap;
color: #606266;
line-height: 1.5;
}
.detail-section {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 10px;
}
.detail-section-title {
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
</style>

View File

@@ -4,37 +4,165 @@
<template slot="extra">
<el-button type="primary" size="small" @click="goBuilder()">新建计划</el-button>
</template>
<el-form :inline="true" :model="queryForm" size="small" @submit.native.prevent>
<el-form-item label="项目ID">
<el-input v-model="projectId" style="width: 120px;"></el-input>
<el-form :inline="true" size="small" @submit.native.prevent>
<el-form-item label="产品名称">
<el-select
v-model="selectedProductId"
filterable
clearable
placeholder="请选择产品"
style="width: 220px;"
@change="handleProductChange"
@focus="loadProductOptions">
<el-option
v-for="item in productOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="queryForm.keyword" clearable></el-input>
<el-form-item label="项目名称">
<el-select
v-model="selectedProjectId"
filterable
clearable
placeholder="请选择项目"
style="width: 240px;"
:disabled="!selectedProductId"
@change="handleProjectChange">
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
</el-form>
<el-form :inline="true" :model="queryForm" size="small" style="margin-top: 8px;" @submit.native.prevent>
<el-form-item label="计划名称">
<el-input v-model="queryForm.planName" clearable style="width: 180px;"></el-input>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" clearable>
<el-select v-model="queryForm.status" clearable style="width: 120px;">
<el-option label="草稿" :value="0"></el-option>
<el-option label="进行中" :value="1"></el-option>
<el-option label="已完成" :value="2"></el-option>
<el-option label="已归档" :value="3"></el-option>
<el-option label="已通过" :value="4"></el-option>
</el-select>
</el-form-item>
<el-form-item label="版本">
<el-input v-model="queryForm.version" clearable style="width: 140px;"></el-input>
</el-form-item>
<el-form-item label="负责人">
<el-select
v-model="queryForm.owner"
filterable
clearable
placeholder="请选择负责人"
style="width: 180px;"
:disabled="!selectedProjectId">
<el-option v-for="u in ownerMemberOptions" :key="'plan-owner-' + u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchList">查询</el-button>
<el-button type="primary" :disabled="!selectedProjectId" @click="fetchList">查询</el-button>
</el-form-item>
<el-form-item>
<el-button size="small" @click="resetQuery">重置</el-button>
</el-form-item>
</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 prop="owner_id" label="负责人" width="120"></el-table-column>
<el-table-column prop="status" label="状态" width="100"></el-table-column>
<el-table-column label="操作" width="260">
<el-table-column label="开始时间" min-width="170">
<template slot-scope="scope">{{ formatDateTime(getStartTimeValue(scope.row)) }}</template>
</el-table-column>
<el-table-column label="结束时间" min-width="170">
<template slot-scope="scope">{{ formatDateTime(getEndTimeValue(scope.row)) }}</template>
</el-table-column>
<el-table-column label="负责人" width="140">
<template slot-scope="scope">{{ formatOwner(scope.row) }}</template>
</el-table-column>
<el-table-column label="环境" width="160">
<template slot-scope="scope">{{ formatEnvironment(scope.row) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template slot-scope="scope">{{ formatStatus(scope.row.status) }}</template>
</el-table-column>
<el-table-column label="操作" width="600">
<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 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>
</template>
</el-table-column>
</el-table>
<el-dialog
title="发送机器人消息"
:visible.sync="hookSendDialogVisible"
width="600px"
append-to-body
@close="resetHookSendForm">
<el-form ref="hookSendFormRef" :model="hookSendForm" label-width="108px" size="small">
<el-form-item label="计划">
<el-input :value="hookSendPlanName" disabled />
</el-form-item>
<el-form-item label="消息标题">
<el-input :value="hookSendPreviewTitle" disabled />
</el-form-item>
<el-form-item label="消息正文" prop="content">
<el-input
v-model.trim="hookSendForm.content"
type="textarea"
:rows="5"
maxlength="4000"
show-word-limit
placeholder="默认含待执行说明与计划执行链接,可自行修改" />
</el-form-item>
<el-form-item label="消息类型">
<el-select
v-model="hookSendForm.hookType"
clearable
placeholder="不选则加载全部类型下的配置"
style="width: 100%;"
@change="onHookSendTypeChange">
<el-option label="飞书" :value="1" />
<el-option label="钉钉" :value="2" />
<el-option label="企微" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="Webhook 配置">
<el-select
v-model="hookSendForm.hookId"
filterable
clearable
:loading="hookSendHookListLoading"
placeholder="选择当前项目、当前类型下的配置;不选则按类型发全部(未选类型则发全部 Hook"
style="width: 100%;">
<el-option
v-for="h in hookSendHookOptions"
:key="'hook-opt-' + h.id"
:label="formatHookSendOptionLabel(h)"
:value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="@ 真实姓名" prop="realName">
<el-input v-model.trim="hookSendForm.realName" maxlength="64" placeholder="默认计划负责人,可改" />
</el-form-item>
</el-form>
<div v-if="hookSendResultLines.length" class="hook-send-result">
<div class="hook-send-result-title">发送结果</div>
<div v-for="(line, idx) in hookSendResultLines" :key="'hr-' + idx" class="hook-send-result-line">{{ line }}</div>
</div>
<span slot="footer">
<el-button size="small" @click="hookSendDialogVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="hookSendSubmitting" @click="submitHookSend">发送</el-button>
</span>
</el-dialog>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
:current-page="pageNo"
@@ -53,48 +181,688 @@
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getPlanList } from '@/api/planApi'
import { getProductList } from '@/api/productApi'
import {
getProjectDetail,
getProjectEnvironments,
getProjectHookList,
getProjectList,
getProjectMembers,
sendProjectHookMessage
} from '@/api/projectApi'
import {
readLastProductProjectCache,
saveLastProductProjectCache,
pickIdFromOptions
} from '@/utils/lastProductProjectCache'
export default {
name: 'PlanList',
components: { PageSection },
computed: {
hookSendPreviewTitle() {
const row = this.hookSendPlanRow
if (!row) return ''
return (row.name || '').trim() || this.hookSendPlanName || '测试计划'
}
},
data() {
return {
loading: false,
projectId: this.$route.query.projectId || 1,
projectId: this.$route.query.projectId || '',
selectedProductId: '',
selectedProjectId: this.$route.query.projectId ? Number(this.$route.query.projectId) : '',
productOptions: [],
projectOptions: [],
ownerMap: {},
/** 负责人筛选下拉label 优先 real_name与 ownerMap 同源) */
ownerMemberOptions: [],
environmentMap: {},
queryForm: {
keyword: '',
status: ''
planName: '',
status: '',
version: '',
owner: ''
},
pageNo: 1,
pageSize: 10,
total: 0,
tableData: []
tableData: [],
hookSendDialogVisible: false,
hookSendSubmitting: false,
hookSendPlanName: '',
/** 当前要推送机器人消息的计划行 */
hookSendPlanRow: null,
hookSendHookOptions: [],
hookSendHookListLoading: false,
hookSendForm: {
hookType: '',
hookId: '',
content: '',
realName: ''
},
hookSendResultLines: []
}
},
beforeRouteLeave(to, from, next) {
this.savePageCache()
next()
},
methods: {
getCacheKey() {
return 'test-platform-plan-list-cache'
},
savePageCache() {
const cache = {
projectId: this.projectId,
selectedProductId: this.selectedProductId,
selectedProjectId: this.selectedProjectId,
productOptions: this.productOptions,
projectOptions: this.projectOptions,
ownerMap: this.ownerMap,
ownerMemberOptions: this.ownerMemberOptions,
environmentMap: this.environmentMap,
queryForm: this.queryForm,
pageNo: this.pageNo,
pageSize: this.pageSize,
total: this.total,
tableData: this.tableData
}
window.sessionStorage.setItem(this.getCacheKey(), JSON.stringify(cache))
},
restorePageCache() {
const raw = window.sessionStorage.getItem(this.getCacheKey())
if (!raw) return false
try {
const cache = JSON.parse(raw)
this.projectId = cache.projectId || ''
this.selectedProductId = cache.selectedProductId || ''
this.selectedProjectId = cache.selectedProjectId || ''
this.productOptions = cache.productOptions || []
this.projectOptions = cache.projectOptions || []
this.ownerMap = cache.ownerMap || {}
this.ownerMemberOptions = Array.isArray(cache.ownerMemberOptions) ? cache.ownerMemberOptions : []
if (!this.ownerMemberOptions.length && this.ownerMap && Object.keys(this.ownerMap).length) {
this.ownerMemberOptions = Object.keys(this.ownerMap).map(k => ({
id: /^\d+$/.test(String(k)) ? Number(k) : k,
name: this.ownerMap[k]
}))
}
this.environmentMap = cache.environmentMap || {}
this.queryForm = cache.queryForm || {
planName: '',
status: '',
version: '',
owner: ''
}
this.syncOwnerFilterWithMemberOptions()
this.pageNo = Number(cache.pageNo || 1)
this.pageSize = Number(cache.pageSize || 10)
this.total = Number(cache.total || 0)
this.tableData = Array.isArray(cache.tableData) ? cache.tableData : []
return true
} catch (e) {
return false
}
},
loadProductOptions() {
if (this.productOptions && this.productOptions.length > 0) {
return Promise.resolve()
}
return getProductList({ pageNo: 1, pageSize: 1000, status: 1 }).then(res => {
const data = res && res.data ? res.data : res || {}
this.productOptions = data.items || data.list || data.data || []
}).catch(() => {
this.productOptions = []
})
},
loadProjectOptionsByProduct(productId) {
if (!productId) {
this.projectOptions = []
return Promise.resolve()
}
return getProjectList({ pageNo: 1, pageSize: 1000, status: 1, productId }).then(res => {
const data = res && res.data ? res.data : res || {}
this.projectOptions = data.items || data.list || data.data || []
}).catch(() => {
this.projectOptions = []
})
},
handleProductChange(val) {
this.selectedProjectId = ''
this.projectId = ''
this.queryForm.owner = ''
this.ownerMap = {}
this.ownerMemberOptions = []
this.tableData = []
this.total = 0
this.loadProjectOptionsByProduct(val)
},
handleProjectChange(val) {
this.selectedProjectId = val || ''
this.projectId = val || ''
this.pageNo = 1
this.queryForm.owner = ''
this.ownerMap = {}
this.ownerMemberOptions = []
this.environmentMap = {}
if (!val) {
this.tableData = []
this.total = 0
return
}
saveLastProductProjectCache(this.selectedProductId, val)
this.loadProjectMetaMaps(val).finally(() => {
this.fetchList()
})
},
restoreSharedProductProjectCache() {
const cached = readLastProductProjectCache()
const q = this.$route.query || {}
const fromPlanSelf = q.planOwnerSelf === '1' || q.planOwnerSelf === 'true'
let pid = cached && cached.productId
let projId = cached && cached.projectId
if (fromPlanSelf) {
if (q.productId !== undefined && q.productId !== null && String(q.productId).trim() !== '') {
pid = q.productId
}
if (q.projectId !== undefined && q.projectId !== null && String(q.projectId).trim() !== '') {
projId = q.projectId
}
}
if (pid === '' || pid === undefined || pid === null || projId === '' || projId === undefined || projId === null) {
return Promise.resolve()
}
const hasProduct = (this.productOptions || []).some(p => String(p.id) === String(pid))
if (!hasProduct) return Promise.resolve()
this.selectedProductId = pickIdFromOptions(this.productOptions, pid)
return this.loadProjectOptionsByProduct(this.selectedProductId).then(() => {
const hasProject = (this.projectOptions || []).some(p => String(p.id) === String(projId))
if (!hasProject) return
const picked = pickIdFromOptions(this.projectOptions, projId)
this.selectedProjectId = picked
this.projectId = picked
})
},
mergeCurrentUserIntoOwnerMemberOptionsIfNeeded() {
const u = this.$store.state.currentUser
if (!u || u.id == null || u.id === '') return
const id = u.id
if ((this.ownerMemberOptions || []).some(m => String(m.id) === String(id))) return
const name = u.realName || u.username || '当前用户'
this.ownerMemberOptions = [{ id, name }, ...(this.ownerMemberOptions || [])]
this.ownerMap = Object.assign({}, this.ownerMap, { [id]: name })
},
applyPlanOwnerSelfFromRoute() {
const q = this.$route.query || {}
if (q.planOwnerSelf !== '1' && q.planOwnerSelf !== 'true') return
const u = this.$store.state.currentUser
const uid = u && u.id != null && u.id !== '' ? u.id : null
if (uid == null) {
this.$message.warning('请先登录')
return
}
if (!this.selectedProjectId) {
this.$message.warning('请先选择项目,或从首页在已选过产品/项目时进入')
return
}
this.mergeCurrentUserIntoOwnerMemberOptionsIfNeeded()
this.queryForm.owner = uid
this.pageNo = 1
},
/** 负责人筛选值为成员 id若不在当前项目成员列表中则清空避免缓存里旧姓名或无效 id */
syncOwnerFilterWithMemberOptions() {
const cur = this.queryForm.owner
if (cur === '' || cur === undefined || cur === null) return
const idSet = new Set(this.ownerMemberOptions.map(u => String(u.id)))
if (!idSet.has(String(cur))) {
this.queryForm.owner = ''
}
},
loadProjectMetaMaps(projectId) {
if (!projectId) {
this.ownerMap = {}
this.ownerMemberOptions = []
this.environmentMap = {}
return Promise.resolve()
}
const memberReq = getProjectMembers(projectId, { pageNo: 1, pageSize: 1000 }).then(res => {
const data = (res && res.data) || res || {}
const list = data.items || data.list || data.data || data || []
const arr = Array.isArray(list) ? list : []
this.ownerMemberOptions = arr
.map(item => {
const id = item.user_id || item.userId || item.id
const name =
item.real_name ||
item.realName ||
item.username ||
item.name ||
item.user_name ||
(id !== undefined && id !== null ? String(id) : '')
return { id, name }
})
.filter(u => u.id !== undefined && u.id !== null && u.id !== '')
this.ownerMap = this.ownerMemberOptions.reduce((map, u) => {
map[u.id] = u.name || String(u.id)
return map
}, {})
this.syncOwnerFilterWithMemberOptions()
}).catch(() => {
this.ownerMap = {}
this.ownerMemberOptions = []
})
const envReq = getProjectEnvironments(projectId, { pageNo: 1, pageSize: 1000 }).then(res => {
const data = (res && res.data) || res || {}
const list = data.items || data.list || data.data || data || []
this.environmentMap = (Array.isArray(list) ? list : []).reduce((map, item) => {
if (item.id !== undefined && item.id !== null && item.id !== '') {
map[item.id] = item.name || String(item.id)
}
return map
}, {})
}).catch(() => {
this.environmentMap = {}
})
return Promise.all([memberReq, envReq])
},
fetchList() {
if (!this.projectId) {
this.tableData = []
this.total = 0
return
}
this.loading = true
getPlanList(this.projectId, this.queryForm).then(res => {
const params = this.cleanParams({
planName: this.queryForm.planName,
keyword: this.queryForm.planName,
status: this.queryForm.status,
version: this.queryForm.version,
owner: this.queryForm.owner,
owner_id: this.queryForm.owner,
pageNo: this.pageNo,
pageSize: this.pageSize
})
getPlanList(this.projectId, params).then(res => {
const data = (res && res.data) || res || {}
this.tableData = data.items || data.list || []
this.total = Number(data.total || this.tableData.length || 0)
this.savePageCache()
}).catch(() => {
this.tableData = []
this.total = 0
this.savePageCache()
}).finally(() => {
this.loading = false
})
},
cleanParams(params) {
return Object.keys(params).reduce((result, key) => {
if (params[key] !== '' && params[key] !== undefined && params[key] !== null) {
result[key] = params[key]
}
return result
}, {})
},
resetQuery() {
this.queryForm = {
planName: '',
status: '',
version: '',
owner: ''
}
this.pageNo = 1
this.fetchList()
},
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchList()
},
handleCurrentChange(page) {
this.pageNo = page
this.fetchList()
},
goBuilder() {
this.$router.push({ path: '/test-platform/plans/builder', query: { projectId: this.projectId } })
this.$router.push({ path: '/test-platform/plan/builder', query: { projectId: this.projectId } })
},
goEdit(row) {
this.$router.push({
path: '/test-platform/plan/builder',
query: {
projectId: this.projectId,
planId: row.id
}
})
},
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))
this.$router.push({
path: '/test-platform/plan/case/add',
query: {
productId: this.selectedProductId || undefined,
productName: (product && product.name) || '',
projectId: this.projectId || undefined,
projectName: (project && project.name) || '',
planId: row.id,
planName: row.name || '',
ownerId: row.owner_id || row.ownerId || undefined
}
})
},
goExecute(row) {
this.$router.push({ path: '/test-platform/plans/execute', query: { projectId: this.projectId, planId: row.id } })
const project = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
const product = (this.productOptions || []).find(item => String(item.id) === String(this.selectedProductId))
this.$router.push({
path: '/test-platform/plan/execute',
query: {
productId: this.selectedProductId || undefined,
productName: (product && product.name) || '',
projectId: this.projectId,
projectName: (project && project.name) || '',
planId: row.id,
planName: row.name || ''
}
})
},
openHookSendDialog(row) {
if (!this.projectId) {
this.$message.warning('请先选择项目')
return
}
const name = (row && row.name) || ''
this.hookSendPlanName = name || `计划 #${row && row.id != null ? row.id : ''}`
this.hookSendPlanRow = row || null
const defaultContent = this.buildPlanExecuteMessageBody(row)
const defaultReal = this.getPlanOwnerRealName(row)
this.hookSendForm = {
hookType: '',
hookId: '',
content: defaultContent,
realName: defaultReal
}
this.hookSendHookOptions = []
this.hookSendResultLines = []
this.hookSendDialogVisible = true
this.loadHookSendOptions()
this.$nextTick(() => {
if (this.$refs.hookSendFormRef) {
this.$refs.hookSendFormRef.clearValidate()
}
})
},
resetHookSendForm() {
this.hookSendSubmitting = false
this.hookSendPlanName = ''
this.hookSendPlanRow = null
this.hookSendHookOptions = []
this.hookSendForm = {
hookType: '',
hookId: '',
content: '',
realName: ''
}
this.hookSendResultLines = []
this.$nextTick(() => {
if (this.$refs.hookSendFormRef) {
this.$refs.hookSendFormRef.resetFields()
}
})
},
/** 与「执行」入口一致的计划执行页链接(绝对地址,便于 IM 中点击) */
buildPlanExecuteUrl(row) {
if (!row || row.id == null) 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 loc = this.$router.resolve({
path: '/test-platform/plan/execute',
query: {
productId: this.selectedProductId || undefined,
productName: (product && product.name) || '',
projectId: this.projectId,
projectName: (project && project.name) || '',
planId: row.id,
planName: row.name || ''
}
})
const href = (loc && loc.href) || ''
if (!href) return ''
if (/^https?:\/\//i.test(href)) return href
const origin = typeof window !== 'undefined' && window.location ? window.location.origin : ''
return origin + (href.charAt(0) === '/' ? href : `/${href}`)
},
buildPlanExecuteMessageBody(row) {
const url = this.buildPlanExecuteUrl(row)
if (!url) return '你有一条测试计划待执行:'
return `你有一条测试计划待执行:\n${url}`
},
/** 计划负责人展示名(优先真实姓名类字段,用于 @ */
getPlanOwnerRealName(row) {
if (!row) return ''
const oid = row.owner_id != null ? row.owner_id : row.ownerId
const fromMap = oid != null && oid !== '' ? this.ownerMap[oid] : ''
const s = (
row.owner_real_name ||
row.ownerRealName ||
row.owner_name ||
row.ownerName ||
fromMap ||
''
).trim()
return s
},
onHookSendTypeChange() {
this.hookSendForm.hookId = ''
this.loadHookSendOptions()
},
loadHookSendOptions() {
const pid = Number(this.projectId)
if (!pid || Number.isNaN(pid)) {
this.hookSendHookOptions = []
return
}
const params = { projectId: pid, pageNo: 1, pageSize: 500 }
const ht = this.hookSendForm.hookType
if (ht === 1 || ht === 2 || ht === 3) {
params.hookType = ht
}
this.hookSendHookListLoading = true
getProjectHookList(params)
.then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.hookSendHookOptions = Array.isArray(list) ? list : []
})
.catch(() => {
this.hookSendHookOptions = []
})
.finally(() => {
this.hookSendHookListLoading = false
})
},
formatHookSendOptionLabel(item) {
if (!item) return ''
const typeName = item.hook_type_name || this.hookTypeSendLabel(item.hook_type != null ? item.hook_type : item.hookType)
const desc = (item.description || '').trim()
const url = String(item.webhook_url || item.webhookUrl || '').trim()
const shortUrl = url.length > 42 ? `${url.slice(0, 42)}` : url
if (desc) return `${desc}${typeName} #${item.id}`
if (shortUrl) return `${shortUrl}${typeName} #${item.id}`
return `${typeName} #${item.id}`
},
hookTypeSendLabel(type) {
const map = { 1: '飞书', 2: '钉钉', 3: '企微' }
return map[Number(type)] || String(type || '-')
},
submitHookSend() {
const row = this.hookSendPlanRow
if (!row || row.id == null) {
this.$message.warning('缺少计划信息')
return
}
const pid = Number(this.projectId)
if (!pid || Number.isNaN(pid)) {
this.$message.warning('项目 ID 无效')
return
}
const title = (this.hookSendPreviewTitle || '').trim() || '测试计划'
const content = (this.hookSendForm.content || '').trim()
if (!content) {
this.$message.warning('消息正文不能为空')
return
}
if (content === '你有一条测试计划待执行:') {
this.$message.warning('无法生成计划执行链接,请检查项目与计划')
return
}
const payload = {
projectId: pid,
title,
content
}
const rn = (this.hookSendForm.realName || '').trim()
if (rn) payload.realName = rn
const hid = this.hookSendForm.hookId
if (hid !== '' && hid !== null && hid !== undefined) {
const n = Number(hid)
if (!Number.isNaN(n)) {
payload.hookId = n
const found = (this.hookSendHookOptions || []).find(h => String(h.id) === String(hid))
const fht = found && (found.hook_type != null ? found.hook_type : found.hookType)
if (fht === 1 || fht === 2 || fht === 3) {
payload.hookType = fht
}
}
} else {
const ht = this.hookSendForm.hookType
if (ht === 1 || ht === 2 || ht === 3) {
payload.hookType = ht
}
}
this.hookSendSubmitting = true
this.hookSendResultLines = []
sendProjectHookMessage(payload)
.then(res => {
const msg = (res && res.message) || ''
if (res && res.code === 20000) {
const list = res.data
const lines = []
if (Array.isArray(list)) {
list.forEach(item => {
const hid = item.hook_id != null ? item.hook_id : item.hookId
const htype = item.hook_type != null ? item.hook_type : item.hookType
const ok = item.success === true || item.success === 1
lines.push(
`Hook #${hid}${this.hookTypeSendLabel(htype)}${ok ? '成功' : '失败'}`
)
})
}
this.hookSendResultLines = lines
this.$message.success(msg || '已提交发送')
return
}
this.$message.error(msg || '发送失败')
})
.catch(() => {})
.finally(() => {
this.hookSendSubmitting = false
})
},
runAutoCases() {
this.$message.info('执行自动化用例功能开发中')
},
goProgress(row) {
this.$router.push({ path: '/test-platform/plans/progress', query: { projectId: this.projectId, planId: row.id } })
const project = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
const product = (this.productOptions || []).find(item => String(item.id) === String(this.selectedProductId))
this.$router.push({
path: '/test-platform/plan/progress',
query: {
productId: this.selectedProductId || undefined,
productName: (product && product.name) || '',
projectId: this.projectId || undefined,
projectName: (project && project.name) || '',
planId: row.id,
planName: row.name || ''
}
})
},
formatStatus(value) {
const map = { 0: '草稿', 1: '进行中', 2: '已完成', 3: '已归档', 4: '已通过' }
return map[value] || value
},
formatOwner(row) {
const ownerId = row.owner_id || row.ownerId
return row.owner_name || row.ownerName || row.username || row.owner || this.ownerMap[ownerId] || ownerId || '-'
},
formatEnvironment(row) {
const envId = row.environment_id || row.environmentId
return row.environment_name || row.environmentName || row.env_name || row.environment || this.environmentMap[envId] || envId || '-'
},
getStartTimeValue(row) {
if (!row) return ''
return row.start_date || row.startDate || row.start_time || row.startTime || row.begin_time || row.beginTime || row.planned_start_time || row.plannedStartTime || ''
},
getEndTimeValue(row) {
if (!row) return ''
return row.end_date || row.endDate || row.end_time || row.endTime || row.finish_time || row.finishTime || row.planned_end_time || row.plannedEndTime || ''
},
formatDateTime(value) {
if (!value) return '-'
if (typeof value === 'number' || /^\d+$/.test(String(value))) {
const raw = Number(value)
if (!Number.isNaN(raw) && raw > 0) {
const ms = raw < 1000000000000 ? raw * 1000 : raw
const date = new Date(ms)
if (!Number.isNaN(date.getTime())) {
const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
}
}
return String(value).replace('T', ' ').slice(0, 19)
}
},
created() {
this.fetchList()
const planOwnerSelf = this.$route.query.planOwnerSelf === '1' || this.$route.query.planOwnerSelf === 'true'
if (planOwnerSelf) {
try {
window.sessionStorage.removeItem(this.getCacheKey())
} catch (e) {}
}
// 若存在缓存(通常是从二级页返回),直接恢复,不自动刷新数据
if (this.restorePageCache()) {
if (planOwnerSelf && this.selectedProjectId) {
this.$nextTick(() => {
this.loadProjectMetaMaps(this.selectedProjectId).finally(() => {
this.applyPlanOwnerSelfFromRoute()
this.fetchList()
})
})
}
return
}
this.loadProductOptions().then(() => {
if (this.selectedProjectId) {
return getProjectDetail(this.selectedProjectId).then(res => {
const data = res && res.data ? res.data : res || {}
const productId = data.productId || data.product_id || ''
if (productId) {
this.selectedProductId = productId
return this.loadProjectOptionsByProduct(productId)
}
}).catch(() => {})
}
return this.restoreSharedProductProjectCache()
}).finally(() => {
if (this.selectedProjectId) {
this.projectId = this.selectedProjectId
this.loadProjectMetaMaps(this.selectedProjectId).finally(() => {
this.applyPlanOwnerSelfFromRoute()
this.fetchList()
})
}
})
}
}
</script>
@@ -103,4 +871,23 @@ export default {
.page-wrap {
padding: 20px;
}
.hook-send-result {
margin-top: 12px;
padding: 10px 12px;
background: #f5f7fa;
border-radius: 4px;
font-size: 13px;
color: #606266;
}
.hook-send-result-title {
font-weight: 600;
margin-bottom: 6px;
color: #303133;
}
.hook-send-result-line {
line-height: 1.6;
}
</style>

View File

@@ -1,67 +1,293 @@
<template>
<div class="page-wrap">
<page-section title="计划进度">
<el-form :inline="true" size="small">
<el-form-item label="项目ID">
<el-input v-model="projectId" style="width: 120px;"></el-input>
<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="计划ID">
<el-input v-model="planId" style="width: 120px;"></el-input>
<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="fetchProgress">刷新</el-button>
<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">
<el-col :span="8">
<page-section title="轮次汇总">
<json-viewer :value="progress.round_summary || []"></json-viewer>
</page-section>
<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="8">
<page-section title="人员负载">
<json-viewer :value="progress.assignee_load || []"></json-viewer>
</page-section>
<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="8">
<page-section title="每日趋势">
<json-viewer :value="progress.daily_trend || []"></json-viewer>
</page-section>
<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="状态" 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 JsonViewer from '@/components/TestPlatform/common/JsonViewer'
import { getPlanProgress } from '@/api/planApi'
import { getPlanCaseList, getPlanProgress } from '@/api/planApi'
export default {
name: 'PlanProgress',
components: { PageSection, JsonViewer },
components: { PageSection },
data() {
return {
projectId: this.$route.query.projectId || 1,
loading: false,
projectId: this.$route.query.projectId || '',
planId: this.$route.query.planId || '',
progress: {}
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: {
fetchProgress() {
if (!this.planId) {
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
}
getPlanProgress(this.projectId, this.planId).then(res => {
this.progress = (res && res.data) || res || {}
}).catch(() => {
this.progress = {}
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 || ''
}))
})
.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.fetchProgress()
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>
@@ -69,5 +295,99 @@ export default {
<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>

View File

@@ -148,7 +148,7 @@ export default {
this.fetchList()
},
goSettings(row) {
this.$router.push({ path: '/test-platform/projects/settings', query: { projectId: row.id } })
this.$router.push({ path: '/test-platform/project/setting', query: { projectId: row.id } })
},
fetchProductOptions() {
return getProductList({

View File

@@ -1,15 +1,19 @@
<template>
<div class="page-wrap">
<page-section title="项目设置">
<el-tabs value="members">
<template slot="extra">
<el-button size="small" @click="goBackToList">返回</el-button>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="项目成员" name="members">
<div class="toolbar-wrap">
<el-button type="primary" size="small" @click="openMemberDialog">新增成员</el-button>
</div>
<el-table :data="members" border>
<el-table-column prop="user_id" label="用户ID"></el-table-column>
<el-table-column prop="role" label="角色"></el-table-column>
<el-table-column prop="joined_at" label="加入时间"></el-table-column>
<el-table-column prop="project_name" label="项目名称"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role_name" label="角色"></el-table-column>
<el-table-column prop="joined_time" label="加入时间"></el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
@@ -47,16 +51,84 @@
</el-pagination>
</div>
</el-tab-pane>
<el-tab-pane label="Hook 配置" name="hooks">
<div class="toolbar-wrap hook-toolbar">
<div class="hook-toolbar-left">
<el-select
v-model="hookTypeFilter"
clearable
placeholder="Hook 类型"
size="small"
style="width: 140px;"
@change="onHookTypeFilterChange">
<el-option label="飞书" :value="1" />
<el-option label="钉钉" :value="2" />
<el-option label="企微" :value="3" />
</el-select>
</div>
<el-button type="primary" size="small" @click="openHookDialog('create')">新增 Hook</el-button>
</div>
<el-table v-loading="hookLoading" :data="hooks" border>
<el-table-column prop="hook_type_name" label="类型" width="100">
<template slot-scope="scope">{{ scope.row.hook_type_name || hookTypeLabel(scope.row.hook_type) }}</template>
</el-table-column>
<el-table-column label="Webhook" min-width="220" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.webhook_url || scope.row.webhookUrl || '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template slot-scope="scope">
<el-tag size="mini" :type="(scope.row.enabled === 1 || scope.row.enabled === true) ? 'success' : 'info'">
{{ (scope.row.enabled === 1 || scope.row.enabled === true) ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template slot-scope="scope">{{ scope.row.created_time || scope.row.createdTime || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="openHookDialog('edit', scope.row)">编辑</el-button>
<el-button type="text" size="small" style="color: #f56c6c;" @click="handleHookDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
:current-page="hookPageNo"
:page-size="hookPageSize"
:page-sizes="[10, 20, 50, 100]"
:total="hookTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleHookSizeChange"
@current-change="handleHookCurrentChange">
</el-pagination>
</div>
</el-tab-pane>
</el-tabs>
</page-section>
<el-dialog title="新增成员" :visible.sync="memberDialogVisible" width="520px" @close="resetMemberForm">
<el-form ref="memberForm" :model="memberForm" :rules="memberRules" label-width="94px" size="small">
<el-form-item label="用户ID" prop="user_id">
<el-input v-model.trim="memberForm.user_id" maxlength="64" placeholder="请输入用户ID"></el-input>
</el-form-item>
<el-form-item label="角色" prop="role">
<el-input v-model.trim="memberForm.role" maxlength="64" placeholder="请输入角色"></el-input>
<el-form-item label="选择用户" prop="user_ids">
<el-select
v-model="memberForm.user_ids"
multiple
filterable
placeholder="请选择用户"
style="width: 100%;"
@focus="loadUserOptions">
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.username + (item.real_name ? ' (' + item.real_name + ')' : '')"
:value="item.id">
</el-option>
<el-option v-if="userHasMore" disabled style="text-align: center;">
<span v-if="userLoading">加载中...</span>
<span v-else @click.stop="loadMoreUsers">加载更多</span>
</el-option>
</el-select>
</el-form-item>
</el-form>
<span slot="footer">
@@ -79,17 +151,62 @@
<el-button type="primary" size="small" :loading="environmentSubmitting" @click="submitEnvironment">确定</el-button>
</span>
</el-dialog>
<el-dialog
:title="hookDialogMode === 'create' ? '新增 Hook' : '编辑 Hook'"
:visible.sync="hookDialogVisible"
width="560px"
@close="resetHookForm">
<el-form ref="hookFormRef" :model="hookForm" :rules="hookRules" label-width="100px" size="small">
<el-form-item label="Hook 类型" prop="hookType">
<el-select v-model="hookForm.hookType" placeholder="请选择" style="width: 100%;" :disabled="hookDialogMode === 'edit'">
<el-option label="飞书" :value="1" />
<el-option label="钉钉" :value="2" />
<el-option label="企微" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="Webhook" prop="webhookUrl">
<el-input v-model.trim="hookForm.webhookUrl" type="textarea" :rows="2" placeholder="Webhook 地址" />
</el-form-item>
<el-form-item label="签名密钥" prop="secret">
<el-input v-model.trim="hookForm.secret" show-password placeholder="可选,飞书/钉钉等签名校验用" />
</el-form-item>
<el-form-item label="启用" prop="enabled">
<el-switch v-model="hookForm.enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model.trim="hookForm.description" maxlength="200" show-word-limit placeholder="说明用途" />
</el-form-item>
<el-form-item label="扩展配置" prop="configText">
<el-input v-model.trim="hookForm.configText" type="textarea" :rows="4" placeholder='JSON默认 {}' />
</el-form-item>
</el-form>
<span slot="footer">
<el-button size="small" @click="hookDialogVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="hookSubmitting" @click="submitHook">确定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import JsonViewer from '@/components/TestPlatform/common/JsonViewer'
import { createEnvironment, createProjectMember, getProjectEnvironments, getProjectMembers } from '@/api/projectApi'
import {
createEnvironment,
createProjectMember,
createProjectHook,
deleteProjectHook,
getProjectEnvironments,
getProjectHookDetail,
getProjectHookList,
getProjectMembers,
updateProjectHook
} from '@/api/projectApi'
import { getUserList } from '@/api/rbacApi'
const getDefaultMemberForm = () => ({
user_id: '',
role: ''
user_ids: []
})
const getDefaultEnvironmentForm = () => ({
@@ -97,11 +214,22 @@ const getDefaultEnvironmentForm = () => ({
variablesText: '{}'
})
const getDefaultHookForm = () => ({
hookId: null,
hookType: 1,
webhookUrl: '',
secret: '',
enabled: 1,
description: '',
configText: '{}'
})
export default {
name: 'ProjectSettings',
components: { PageSection, JsonViewer },
data() {
return {
activeTab: 'members',
memberPageNo: 1,
memberPageSize: 10,
memberTotal: 0,
@@ -117,19 +245,240 @@ export default {
memberForm: getDefaultMemberForm(),
environmentForm: getDefaultEnvironmentForm(),
memberRules: {
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
role: [{ required: true, message: '请输入角色', trigger: 'blur' }]
user_ids: [{ required: true, message: '请选择用户', trigger: 'change' }]
},
environmentRules: {
name: [{ required: true, message: '请输入环境名称', trigger: 'blur' }],
variablesText: [{ required: true, message: '请输入变量JSON', trigger: 'blur' }]
},
userOptions: [],
userLoading: false,
userPageNo: 1,
userPageSize: 10,
userTotal: 0,
userHasMore: false,
hooks: [],
hookPageNo: 1,
hookPageSize: 10,
hookTotal: 0,
hookLoading: false,
hookTypeFilter: '',
hookDialogVisible: false,
hookDialogMode: 'create',
hookSubmitting: false,
hookForm: getDefaultHookForm(),
hookRules: {
hookType: [{ required: true, message: '请选择类型', trigger: 'change' }],
webhookUrl: [{ required: true, message: '请输入 Webhook 地址', trigger: 'blur' }],
configText: [
{
validator: (rule, value, callback) => {
const s = (value || '').trim()
if (!s) {
callback()
return
}
try {
JSON.parse(s)
callback()
} catch (e) {
callback(new Error('扩展配置须为合法 JSON'))
}
},
trigger: 'blur'
}
]
}
}
},
methods: {
goBackToList() {
this.$router.push({ path: '/test-platform/project' })
},
getProjectId() {
return this.$route.query.projectId || 1
},
hookTypeLabel(type) {
const map = { 1: '飞书', 2: '钉钉', 3: '企微' }
return map[Number(type)] || type || '-'
},
onHookTypeFilterChange() {
this.hookPageNo = 1
this.fetchHooks()
},
fetchHooks() {
const projectId = this.getProjectId()
this.hookLoading = true
const params = {
projectId,
pageNo: this.hookPageNo,
pageSize: this.hookPageSize
}
if (this.hookTypeFilter !== '' && this.hookTypeFilter !== null && this.hookTypeFilter !== undefined) {
params.hookType = this.hookTypeFilter
}
getProjectHookList(params)
.then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.hooks = Array.isArray(list) ? list : []
this.hookTotal = Number(data.total != null ? data.total : this.hooks.length)
})
.catch(() => {
this.hooks = []
this.hookTotal = 0
})
.finally(() => {
this.hookLoading = false
})
},
openHookDialog(mode, row) {
this.hookDialogMode = mode
if (mode === 'create') {
this.hookForm = getDefaultHookForm()
this.hookDialogVisible = true
this.$nextTick(() => {
if (this.$refs.hookFormRef) this.$refs.hookFormRef.clearValidate()
})
return
}
const id = row && (row.id != null ? row.id : row.hookId)
if (id == null) {
this.$message.warning('缺少 Hook ID')
return
}
getProjectHookDetail(id)
.then(res => {
const d = (res && res.data) || res || {}
const cfg = d.config
let configText = '{}'
if (cfg != null && typeof cfg === 'object') {
try {
configText = JSON.stringify(cfg, null, 0)
} catch (e) {
configText = '{}'
}
} else if (typeof cfg === 'string' && cfg.trim()) {
configText = cfg.trim()
}
this.hookForm = {
hookId: d.id,
hookType: d.hook_type != null ? d.hook_type : d.hookType,
webhookUrl: d.webhook_url || d.webhookUrl || '',
secret: d.secret != null ? String(d.secret) : '',
enabled: d.enabled === 0 || d.enabled === false ? 0 : 1,
description: d.description || '',
configText
}
this.hookDialogVisible = true
this.$nextTick(() => {
if (this.$refs.hookFormRef) this.$refs.hookFormRef.clearValidate()
})
})
.catch(() => {})
},
resetHookForm() {
this.hookForm = getDefaultHookForm()
this.hookSubmitting = false
this.$nextTick(() => {
if (this.$refs.hookFormRef) this.$refs.hookFormRef.resetFields()
})
},
submitHook() {
this.$refs.hookFormRef.validate(valid => {
if (!valid) return
let config = {}
const ct = (this.hookForm.configText || '').trim()
if (ct) {
try {
config = JSON.parse(ct)
} catch (e) {
this.$message.error('扩展配置 JSON 无效')
return
}
}
this.hookSubmitting = true
const done = () => {
this.hookDialogVisible = false
this.hookPageNo = 1
this.fetchHooks()
}
if (this.hookDialogMode === 'create') {
createProjectHook({
projectId: Number(this.getProjectId()),
hookType: this.hookForm.hookType,
webhookUrl: this.hookForm.webhookUrl,
secret: this.hookForm.secret || undefined,
enabled: this.hookForm.enabled,
description: this.hookForm.description || undefined,
config
})
.then(res => {
if (res && res.code === 20000) {
this.$message.success((res && res.message) || '创建成功')
done()
} else {
this.$message.error((res && res.message) || '创建失败')
}
})
.finally(() => {
this.hookSubmitting = false
})
return
}
const payload = {
hookId: this.hookForm.hookId,
hookType: this.hookForm.hookType,
webhookUrl: this.hookForm.webhookUrl,
enabled: this.hookForm.enabled,
description: this.hookForm.description || undefined,
config
}
if (String(this.hookForm.secret || '').trim() !== '') {
payload.secret = this.hookForm.secret
}
updateProjectHook(payload)
.then(res => {
if (res && res.code === 20000) {
this.$message.success((res && res.message) || '更新成功')
done()
} else {
this.$message.error((res && res.message) || '更新失败')
}
})
.finally(() => {
this.hookSubmitting = false
})
})
},
handleHookDelete(row) {
const id = row && (row.id != null ? row.id : row.hookId)
if (id == null) {
this.$message.warning('缺少 Hook ID')
return
}
this.$confirm('确认删除该 Hook 配置?', '提示', { type: 'warning' })
.then(() => deleteProjectHook({ hookId: id }))
.then(res => {
if (res && res.code === 20000) {
this.$message.success((res && res.message) || '已删除')
this.hookPageNo = 1
this.fetchHooks()
} else {
this.$message.error((res && res.message) || '删除失败')
}
})
.catch(() => {})
},
handleHookSizeChange(val) {
this.hookPageSize = val
this.hookPageNo = 1
this.fetchHooks()
},
handleHookCurrentChange(val) {
this.hookPageNo = val
this.fetchHooks()
},
fetchData() {
const projectId = this.getProjectId()
getProjectMembers(projectId, {
@@ -157,6 +506,9 @@ export default {
},
openMemberDialog() {
this.memberDialogVisible = true
this.userOptions = []
this.userPageNo = 1
this.userHasMore = false
this.$nextTick(() => {
this.memberForm = getDefaultMemberForm()
if (this.$refs.memberForm) {
@@ -167,19 +519,59 @@ export default {
resetMemberForm() {
this.memberForm = getDefaultMemberForm()
this.memberSubmitting = false
this.userOptions = []
this.userPageNo = 1
this.userHasMore = false
this.$nextTick(() => {
if (this.$refs.memberForm) {
this.$refs.memberForm.resetFields()
}
})
},
loadMoreUsers() {
if (this.userHasMore && !this.userLoading) {
this.userPageNo++
this.loadUserOptions()
}
},
loadUserOptions() {
this.userLoading = true
getUserList({
pageNo: this.userPageNo,
pageSize: this.userPageSize,
keyword: '',
status: 1
}).then(res => {
const data = res && res.data ? res.data : res || {}
const list = data.list || data.items || data.data || []
this.userTotal = data.total || data.totalCount || 0
if (this.userPageNo === 1) {
this.userOptions = list
} else {
this.userOptions = [...this.userOptions, ...list]
}
this.userHasMore = this.userOptions.length < this.userTotal
}).catch(() => {
this.userOptions = []
this.userHasMore = false
}).finally(() => {
this.userLoading = false
})
},
submitMember() {
this.$refs.memberForm.validate(valid => {
if (!valid) {
return
}
if (!this.memberForm.user_ids || this.memberForm.user_ids.length === 0) {
this.$message.error('请选择用户')
return
}
this.memberSubmitting = true
createProjectMember(Object.assign({ project_id: this.getProjectId() }, this.memberForm)).then(res => {
createProjectMember({
project_id: this.getProjectId(),
user_ids: this.memberForm.user_ids
}).then(res => {
const message = (res && res.message) || ''
if (res && res.code === 20000) {
this.$message.success(message || '成员新增成功')
@@ -265,6 +657,7 @@ export default {
},
created() {
this.fetchData()
this.fetchHooks()
}
}
</script>
@@ -278,4 +671,16 @@ export default {
margin-bottom: 16px;
text-align: right;
}
.hook-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.hook-toolbar-left {
text-align: left;
}
</style>

View File

@@ -4,24 +4,76 @@
<template slot="extra">
<el-button type="primary" size="small" :loading="generating" @click="handleGenerate">生成报告</el-button>
</template>
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="项目ID">
<el-input v-model="projectId" style="width: 120px;"></el-input>
<el-form :inline="true" :model="queryForm" size="small" @submit.native.prevent>
<el-form-item label="产品名称">
<el-select
v-model="selectedProductId"
filterable
clearable
placeholder="请选择产品"
style="width: 220px;"
@change="handleProductChange"
@focus="loadProductOptions">
<el-option
v-for="item in productOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="计划ID">
<el-input v-model="queryForm.plan_id" style="width: 120px;"></el-input>
<el-form-item label="项目名称">
<el-select
v-model="selectedProjectId"
filterable
clearable
placeholder="请选择项目"
style="width: 240px;"
:disabled="!selectedProductId"
@change="handleProjectChange">
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="计划名称">
<el-select
v-model="queryForm.plan_id"
filterable
clearable
placeholder="请选择计划"
style="width: 260px;"
:disabled="!selectedProjectId"
@focus="loadPlanOptions">
<el-option
v-for="item in planOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchList">查询</el-button>
<el-button type="primary" :disabled="!selectedProjectId" @click="fetchList">查询</el-button>
</el-form-item>
<el-form-item>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</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="report_type" label="类型" width="120"></el-table-column>
<el-table-column prop="generated_at" label="生成时间" min-width="160"></el-table-column>
<el-table-column label="计划名称" min-width="180">
<template slot-scope="scope">{{ formatPlanName(scope.row) }}</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template slot-scope="scope">{{ formatReportType(scope.row.report_type || scope.row.type) }}</template>
</el-table-column>
<el-table-column label="生成时间" min-width="180">
<template slot-scope="scope">{{ formatDateTime(scope.row.generated_time || scope.row.generated_at || scope.row.created_at || scope.row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" @click="goViewer(scope.row)">查看</el-button>
<el-button type="text" @click="goViewer(scope.row)">查看链接</el-button>
</template>
</el-table-column>
</el-table>
@@ -42,7 +94,15 @@
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getPlanList } from '@/api/planApi'
import { getProductList } from '@/api/productApi'
import { getProjectDetail, getProjectList } from '@/api/projectApi'
import { generateReport, getReportList } from '@/api/reportApi'
import {
readLastProductProjectCache,
saveLastProductProjectCache,
pickIdFromOptions
} from '@/utils/lastProductProjectCache'
export default {
name: 'ReportList',
@@ -51,48 +111,276 @@ export default {
return {
loading: false,
generating: false,
projectId: this.$route.query.projectId || 1,
projectId: this.$route.query.projectId || '',
selectedProductId: '',
selectedProjectId: this.$route.query.projectId ? Number(this.$route.query.projectId) : '',
productOptions: [],
projectOptions: [],
planOptions: [],
queryForm: {
plan_id: this.$route.query.planId || ''
},
tableData: []
tableData: [],
pageNo: 1,
pageSize: 10,
total: 0
}
},
beforeRouteLeave(to, from, next) {
this.savePageCache()
next()
},
methods: {
getCacheKey() {
return 'test-platform-report-list-cache'
},
savePageCache() {
const cache = {
projectId: this.projectId,
selectedProductId: this.selectedProductId,
selectedProjectId: this.selectedProjectId,
productOptions: this.productOptions,
projectOptions: this.projectOptions,
planOptions: this.planOptions,
queryForm: this.queryForm,
tableData: this.tableData,
pageNo: this.pageNo,
pageSize: this.pageSize,
total: this.total
}
window.sessionStorage.setItem(this.getCacheKey(), JSON.stringify(cache))
},
restorePageCache() {
const raw = window.sessionStorage.getItem(this.getCacheKey())
if (!raw) return false
try {
const cache = JSON.parse(raw)
this.projectId = cache.projectId || ''
this.selectedProductId = cache.selectedProductId || ''
this.selectedProjectId = cache.selectedProjectId || ''
this.productOptions = cache.productOptions || []
this.projectOptions = cache.projectOptions || []
this.planOptions = cache.planOptions || []
this.queryForm = cache.queryForm || { plan_id: '' }
this.tableData = cache.tableData || []
this.pageNo = Number(cache.pageNo || 1)
this.pageSize = Number(cache.pageSize || 10)
this.total = Number(cache.total || 0)
return true
} catch (e) {
return false
}
},
loadProductOptions() {
if (this.productOptions && this.productOptions.length > 0) {
return Promise.resolve()
}
return getProductList({ pageNo: 1, pageSize: 1000, status: 1 }).then(res => {
const data = (res && res.data) || res || {}
this.productOptions = data.items || data.list || data.data || []
}).catch(() => {
this.productOptions = []
})
},
loadProjectOptionsByProduct(productId) {
if (!productId) {
this.projectOptions = []
return Promise.resolve()
}
return getProjectList({ pageNo: 1, pageSize: 1000, status: 1, productId }).then(res => {
const data = (res && res.data) || res || {}
this.projectOptions = data.items || data.list || data.data || []
}).catch(() => {
this.projectOptions = []
})
},
loadPlanOptions() {
if (!this.selectedProjectId) {
this.planOptions = []
return Promise.resolve()
}
return getPlanList(this.selectedProjectId, { pageNo: 1, pageSize: 1000 }).then(res => {
const data = (res && res.data) || res || {}
this.planOptions = data.items || data.list || data.data || []
}).catch(() => {
this.planOptions = []
})
},
handleProductChange(val) {
this.selectedProjectId = ''
this.projectId = ''
this.queryForm.plan_id = ''
this.projectOptions = []
this.planOptions = []
this.tableData = []
this.total = 0
this.loadProjectOptionsByProduct(val)
},
handleProjectChange(val) {
this.selectedProjectId = val || ''
this.projectId = val || ''
this.queryForm.plan_id = ''
this.planOptions = []
this.pageNo = 1
if (!val) {
this.tableData = []
this.total = 0
return
}
saveLastProductProjectCache(this.selectedProductId, val)
this.loadPlanOptions().finally(() => {
this.fetchList()
})
},
restoreSharedProductProjectCache() {
const cached = readLastProductProjectCache()
if (!cached) return Promise.resolve()
const { productId: pid, projectId: projId } = cached
if (pid === '' || pid === undefined || pid === null || projId === '' || projId === undefined || projId === null) {
return Promise.resolve()
}
const hasProduct = (this.productOptions || []).some(p => String(p.id) === String(pid))
if (!hasProduct) return Promise.resolve()
this.selectedProductId = pickIdFromOptions(this.productOptions, pid)
return this.loadProjectOptionsByProduct(this.selectedProductId).then(() => {
const hasProject = (this.projectOptions || []).some(p => String(p.id) === String(projId))
if (!hasProject) return
const picked = pickIdFromOptions(this.projectOptions, projId)
this.selectedProjectId = picked
this.projectId = picked
})
},
fetchList() {
if (!this.projectId) {
this.tableData = []
this.total = 0
return
}
this.loading = true
getReportList(this.projectId, Object.assign({}, this.queryForm, {
getReportList(Object.assign({}, this.queryForm, {
product_id: this.selectedProductId || undefined,
project_id: this.projectId || undefined,
plan_id: this.queryForm.plan_id || undefined,
pageNo: this.pageNo,
pageSize: this.pageSize
})).then(res => {
const data = (res && res.data) || res || {}
this.tableData = data.items || data.list || data.data || []
this.total = data.total || data.totalCount || this.tableData.length
this.savePageCache()
}).catch(() => {
this.tableData = []
this.total = 0
this.savePageCache()
}).finally(() => {
this.loading = false
})
},
handleGenerate() {
if (!this.queryForm.plan_id) {
this.$message({ type: 'warning', message: '请先输入计划ID' })
this.$message({ type: 'warning', message: '请先选择计划名称' })
return
}
if (!this.projectId) {
this.$message({ type: 'warning', message: '请先选择项目名称' })
return
}
this.generating = true
generateReport(this.projectId, { plan_id: this.queryForm.plan_id }).then(() => {
generateReport({
planId: this.queryForm.plan_id,
plan_id: this.queryForm.plan_id
}).then(() => {
this.$message({ type: 'success', message: '报告生成任务已提交' })
this.fetchList()
}).finally(() => {
this.generating = false
})
},
goViewer(row) {
this.$router.push({ path: '/test-platform/reports/viewer', query: { projectId: this.projectId, reportId: row.id } })
const type = String(row.report_type || row.type || '').toLowerCase()
const isAuto = type.indexOf('auto') > -1 || type.indexOf('automation') > -1 || type.indexOf('自动') > -1 || Number(row.report_type || row.type) === 2
if (isAuto) {
const externalUrl = row.external_url || row.externalUrl || row.report_url || row.reportUrl || 'https://example.com/automation-report'
window.open(externalUrl, '_blank')
this.$message.info('自动化报告外部链接暂未实现,先跳转占位链接')
return
}
this.$router.push({
path: '/test-platform/report/viewer',
query: {
projectId: this.projectId,
reportId: row.id
}
})
},
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchList()
},
handleCurrentChange(page) {
this.pageNo = page
this.fetchList()
},
resetQuery() {
this.queryForm.plan_id = ''
this.pageNo = 1
this.fetchList()
},
formatDateTime(value) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 19)
},
formatPlanName(row) {
if (!row) return '-'
const directName = row.plan_name || row.planName || row.name || ''
if (directName) return directName
const planId = row.plan_id || row.planId
if (!planId) return '-'
const matched = (this.planOptions || []).find(item => String(item.id) === String(planId))
return (matched && matched.name) || String(planId)
},
formatReportType(value) {
const map = {
manual: '手工',
auto: '自动化',
automation: '自动化',
1: '手工',
2: '自动化'
}
return map[value] || value || '-'
},
initByRouteProject() {
if (!this.selectedProjectId) return Promise.resolve()
return getProjectDetail(this.selectedProjectId).then(res => {
const data = (res && res.data) || res || {}
const productId = data.productId || data.product_id || ''
if (productId) {
this.selectedProductId = productId
return this.loadProjectOptionsByProduct(productId)
}
}).catch(() => {})
}
},
created() {
this.fetchList()
if (this.restorePageCache()) {
return
}
this.loadProductOptions()
.then(() => {
if (this.selectedProjectId) {
return this.initByRouteProject()
}
return this.restoreSharedProductProjectCache()
})
.finally(() => {
if (this.selectedProjectId) {
this.projectId = this.selectedProjectId
this.loadPlanOptions().finally(() => {
this.fetchList()
})
}
})
}
}
</script>

View File

@@ -1,6 +1,9 @@
<template>
<div class="page-wrap">
<page-section title="报告查看">
<div style="margin-bottom: 12px;">
<el-button size="small" @click="goBack">返回</el-button>
</div>
<el-alert
v-if="!report.content"
title="当前无可展示内容,待后端返回 report.content HTML"
@@ -35,11 +38,19 @@ export default {
if (!this.reportId) {
return
}
getReportDetail(this.projectId, this.reportId).then(res => {
getReportDetail(this.reportId, this.projectId).then(res => {
this.report = (res && res.data) || res || {}
}).catch(() => {
this.report = {}
})
},
goBack() {
this.$router.push({
path: '/test-platform/report',
query: {
projectId: this.projectId || undefined
}
})
}
},
created() {