Files
effekt-interface-frontend/src/components/TestPlatform/Case/CaseList.vue

1467 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrap">
<page-section title="用例管理">
<el-form :inline="true" size="small" @submit.native.prevent>
<el-form-item label="产品">
<el-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-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-tabs v-model="activeTab" style="margin-top: 8px;">
<el-tab-pane label="模块列表" name="modules">
<div class="toolbar-wrap">
<el-button type="primary" size="small" :disabled="!selectedProjectId" @click="openModuleCreate">新增模块</el-button>
</div>
<el-form :inline="true" :model="moduleSearchForm" size="small" style="margin-top: 8px;" @submit.native.prevent>
<el-form-item label="模块名称">
<el-input v-model="moduleSearchForm.keyword" clearable style="width: 180px;" @keyup.enter.native="handleModuleSearch"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!selectedProjectId" @click="handleModuleSearch">查询</el-button>
</el-form-item>
<el-form-item>
<el-button size="small" @click="resetModuleSearch">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="moduleLoading" :data="pagedModuleData" border style="margin-top: 8px;">
<el-table-column prop="id" label="模块ID" width="100"></el-table-column>
<el-table-column prop="name" label="模块名称" min-width="160"></el-table-column>
<el-table-column prop="parent_name" label="父模块名称" min-width="160"></el-table-column>
<el-table-column prop="sort_order" label="排序" width="90"></el-table-column>
<el-table-column prop="path" label="路径" min-width="180"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button type="text" @click="openModuleEdit(scope.row)">编辑</el-button>
<el-button type="text" style="color: #F56C6C;" @click="removeModule(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 16px; text-align: right;">
<el-pagination
:current-page="modulePageNo"
:page-size="modulePageSize"
:page-sizes="[10, 20, 50, 100]"
:total="moduleTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleModuleSizeChange"
@current-change="handleModuleCurrentChange">
</el-pagination>
</div>
</el-tab-pane>
<el-tab-pane label="用例列表" name="cases">
<el-form :inline="true" :model="queryForm" size="small" @submit.native.prevent>
<el-form-item label="用例标题">
<el-input v-model="queryForm.keyword" clearable style="width: 180px;" @keyup.enter.native="fetchList"></el-input>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="queryForm.priority" clearable style="width: 100px;">
<el-option label="P0" :value="0"></el-option>
<el-option label="P1" :value="1"></el-option>
<el-option label="P2" :value="2"></el-option>
<el-option label="P3" :value="3"></el-option>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="queryForm.tag" clearable style="width: 140px;" @keyup.enter.native="fetchList"></el-input>
</el-form-item>
<el-form-item label="创建人">
<el-input v-model="queryForm.creator" clearable style="width: 140px;" @keyup.enter.native="fetchList"></el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createdTimeRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
style="width: 260px;"
@change="handleCreatedTimeChange">
</el-date-picker>
</el-form-item>
<el-form-item class="more-filter-item">
<el-popover
v-model="moreFilterVisible"
placement="bottom-start"
width="560"
trigger="click">
<div class="more-filter-wrap">
<el-form :inline="true" :model="queryForm" size="small" @submit.native.prevent>
<el-form-item label="模块">
<el-select v-model="queryForm.moduleId" clearable filterable style="width: 180px;">
<el-option
v-for="item in flatModuleOptions"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryForm.caseType" clearable style="width: 180px;">
<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-select v-model="queryForm.status" clearable style="width: 180px;">
<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-select v-model="queryForm.isAuto" clearable style="width: 180px;">
<el-option label="未实现" :value="0"></el-option>
<el-option label="已实现" :value="1"></el-option>
</el-select>
</el-form-item>
</el-form>
<div class="more-filter-footer">
<el-button size="small" @click="moreFilterVisible = false">取消</el-button>
<el-button type="primary" size="small" :disabled="!selectedProjectId" @click="applyMoreFilters">搜索</el-button>
</div>
</div>
<el-button slot="reference" size="small">更多筛选</el-button>
</el-popover>
</el-form-item>
<el-form-item>
<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-item class="case-action-buttons-item">
<el-button type="primary" size="small" :disabled="!selectedProjectId" @click="goEditor()">新建用例</el-button>
<el-button size="small" :disabled="!selectedProjectId" @click="openCaseImportDialog">导入Excel</el-button>
<el-popover
v-model="columnSettingVisible"
placement="bottom-end"
width="300"
trigger="click">
<div class="column-setting-wrap">
<div class="column-setting-title">自定义列表展示字段</div>
<el-checkbox-group v-model="selectedCaseColumnKeys" @change="handleColumnSelectionChange">
<el-checkbox v-for="item in allCaseColumns" :key="item.key" :label="item.key">{{ item.label }}</el-checkbox>
</el-checkbox-group>
</div>
<el-button slot="reference" size="small">自定义列表展示字段</el-button>
</el-popover>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="tableData" border style="margin-top: 8px;">
<el-table-column
v-for="column in visibleCaseColumns"
:key="column.key"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
:width="column.width">
<template slot-scope="scope">
<template v-if="column.key === 'tags'">
<template v-if="Array.isArray(getCaseColumnValue(scope.row, 'tags'))">
<el-tag v-for="tag in getCaseColumnValue(scope.row, 'tags')" :key="tag" size="mini" style="margin-right: 4px;">{{ tag }}</el-tag>
</template>
<span v-else>{{ formatTags(getCaseColumnValue(scope.row, 'tags')) }}</span>
</template>
<template v-else>
{{ formatCaseColumnValue(column.key, getCaseColumnValue(scope.row, column.key)) }}
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button type="text" @click="goEditor(scope.row)">编辑</el-button>
<el-button type="text" style="color: #F56C6C;" @click="remove(scope.row)">删除</el-button>
</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>
</el-tab-pane>
<el-tab-pane label="用例脑图" name="case-mindmap">
<div class="toolbar-wrap">
<el-button size="small" :disabled="!selectedProjectId" @click="fetchCaseMindmapData">刷新结构</el-button>
<el-button size="small" :disabled="!selectedProjectId" @click="toggleMindmapCollapsed">{{ mindmapCollapsed ? '展开展示' : '合并展示' }}</el-button>
</div>
<div v-loading="caseMindmapLoading" class="mindmap-wrap">
<el-tree
v-if="caseMindmapTreeData && caseMindmapTreeData.length > 0"
ref="mindmapTreeRef"
:key="mindmapRenderKey"
:data="caseMindmapTreeData"
node-key="id"
:default-expand-all="!mindmapCollapsed"
:expand-on-click-node="false"
:props="{ children: 'children', label: 'name' }"
class="xmind-tree">
<div slot-scope="{ data }" class="mindmap-node-wrap">
<span class="mindmap-node" :class="{
'mindmap-node-project': data.nodeTypeLabel === '项目',
'mindmap-node-module': data.nodeTypeLabel === '模块',
'mindmap-node-case': data.nodeTypeLabel === '用例',
'mindmap-node-active': selectedMindmapCase && selectedMindmapCase.id === data.id
}" @click.stop="handleMindmapNodeClick(data)">
<span class="mindmap-node-title">{{ data.name }}</span>
<span class="mindmap-node-meta">
<el-tag size="mini" :type="data.nodeTypeLabel === '项目' ? 'success' : (data.nodeTypeLabel === '模块' ? 'warning' : 'info')">{{ data.nodeTypeLabel }}</el-tag>
<span v-if="data.creator" class="mindmap-meta-text">创建人{{ data.creator }}</span>
<span v-if="data.updatedTime" class="mindmap-meta-text">更新时间{{ data.updatedTime }}</span>
</span>
</span>
<div v-if="data.nodeTypeLabel === '用例' && selectedMindmapCase && selectedMindmapCase.id === data.id" class="mindmap-inline-detail">
<div class="mindmap-inline-detail-line"></div>
<div class="mindmap-inline-detail-card">
<div class="mindmap-inline-detail-header">
<div class="mindmap-inline-detail-title">用例详情</div>
<el-tag size="mini" :type="formatStatusTagType(data.status)">{{ formatStatus(data.status) || '未知状态' }}</el-tag>
</div>
<template v-if="mindmapCaseEditing">
<div class="mindmap-inline-detail-item">
<b>前置条件</b>
<el-input v-model="mindmapCaseEditForm.preconditions" type="textarea" :rows="2"></el-input>
</div>
<div class="mindmap-inline-detail-item">
<b>测试步骤</b>
<el-input v-model="mindmapCaseEditForm.steps" type="textarea" :rows="4"></el-input>
</div>
<div class="mindmap-inline-detail-item">
<b>预期结果</b>
<el-input v-model="mindmapCaseEditForm.expectedResults" type="textarea" :rows="2"></el-input>
</div>
<div class="mindmap-inline-actions">
<el-button size="mini" @click="cancelMindmapCaseEdit">取消</el-button>
<el-button type="primary" size="mini" :loading="mindmapCaseSaving" @click="saveMindmapCaseEdit">保存</el-button>
</div>
</template>
<template v-else>
<div class="mindmap-inline-detail-item"><b>前置条件</b>{{ data.preconditions || '无' }}</div>
<div class="mindmap-inline-detail-item"><b>测试步骤</b>{{ formatMindmapSteps(data.steps) || '无' }}</div>
<div class="mindmap-inline-detail-item"><b>预期结果</b>{{ data.expectedResults || '无' }}</div>
<div class="mindmap-inline-actions">
<el-button size="mini" :loading="mindmapCaseReviewing" @click="startMindmapCaseReview">评审</el-button>
<el-button type="primary" plain size="mini" @click="startMindmapCaseEdit">编辑</el-button>
</div>
</template>
</div>
</div>
</div>
</el-tree>
<div v-else class="mindmap-empty">暂无结构数据</div>
</div>
</el-tab-pane>
</el-tabs>
</page-section>
<el-dialog :title="moduleDialogMode === 'edit' ? '编辑模块' : '新增模块'" :visible.sync="moduleDialogVisible" width="520px" @close="resetModuleForm">
<el-form ref="moduleForm" :model="moduleForm" :rules="moduleRules" label-width="100px" size="small">
<el-form-item label="产品" prop="productId">
<el-select v-model="moduleForm.productId" filterable clearable placeholder="请选择产品" style="width: 100%;" @change="handleModuleProductChange" @focus="loadProductOptions">
<el-option v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="项目" prop="projectId">
<el-select v-model="moduleForm.projectId" filterable clearable placeholder="请选择项目" style="width: 100%;" :disabled="!moduleForm.productId" @change="handleModuleProjectChange">
<el-option v-for="item in moduleProjectOptions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="模块名称" prop="name">
<el-input v-model="moduleForm.name"></el-input>
</el-form-item>
<el-form-item label="父模块">
<el-select v-model="moduleForm.parentId" filterable placeholder="不选则为根模块" style="width: 100%;" :disabled="!moduleForm.projectId">
<el-option label="无(根模块)" value="0"></el-option>
<el-option v-for="item in moduleParentOptions" :key="item.id" :label="item.name" :value="String(item.id)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input v-model="moduleForm.sortOrder"></el-input>
</el-form-item>
<el-form-item label="路径">
<el-input v-model="moduleForm.path"></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="moduleDialogVisible = false">取消</el-button>
<el-button type="primary" size="small" :loading="moduleSubmitting" @click="submitModule">{{ moduleDialogMode === 'edit' ? '保存' : '确定' }}</el-button>
</span>
</el-dialog>
<el-dialog title="批量上传" :visible.sync="caseImportDialogVisible" width="780px" @close="resetImportDialog">
<div class="case-import-panel">
<div class="case-import-icon">X</div>
<div class="case-import-title">上传用例</div>
<div class="case-import-subtitle">仅支持 xlsxxls 文件系统将自动解析用例数据</div>
<div
class="case-import-dropzone"
@dragover.prevent
@drop.prevent="onImportFileDrop">
<div class="case-import-drop-text">
拖拽文件到此处<span class="link-text" @click="triggerImportFileSelect">点击选择</span>
</div>
<div class="case-import-file-tip">仅支持 .xlsx .xls 格式最大文件大小 20MB</div>
<div v-if="importFile" class="case-import-file-name">当前文件{{ importFile.name }}</div>
<input
ref="importFileInput"
class="hidden-file-input"
type="file"
accept=".xls,.xlsx"
@change="onImportFileChange">
</div>
<div class="case-import-actions">
<el-button type="text" @click="downloadImportTemplate">下载标准模板</el-button>
<el-button type="primary" :disabled="!importFile || !selectedProjectId" :loading="importSubmitting" @click="submitCaseImport">开始上传并解析</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import PageSection from '@/components/TestPlatform/common/PageSection'
import { createModule, deleteCase, deleteModule, downloadCaseImportTemplate, getCaseList, getModuleTree, importCaseExcel, updateCase, updateModule } from '@/api/caseApi'
import { getProductList } from '@/api/productApi'
import { getProjectDetail, getProjectList } from '@/api/projectApi'
import {
readLastProductProjectCache,
saveLastProductProjectCache,
pickIdFromOptions
} from '@/utils/lastProductProjectCache'
export default {
name: 'CaseList',
components: { PageSection },
data() {
const routeTab = this.$route.query.tab
return {
activeTab: routeTab === 'cases' ? 'cases' : 'modules',
loading: false,
moduleLoading: false,
moduleSubmitting: false,
moduleDialogVisible: false,
moduleDialogMode: 'create',
editingModuleId: '',
caseMindmapLoading: false,
caseMindmapTreeData: [],
mindmapCollapsed: false,
mindmapRenderKey: 0,
selectedMindmapCase: null,
mindmapCaseEditing: false,
mindmapCaseSaving: false,
mindmapCaseReviewing: false,
mindmapCaseEditForm: {
preconditions: '',
steps: '',
expectedResults: ''
},
projectId: this.$route.query.projectId || '',
selectedProductId: '',
selectedProjectId: this.$route.query.projectId ? Number(this.$route.query.projectId) : '',
productOptions: [],
projectOptions: [],
moduleProjectOptions: [],
moduleParentOptions: [],
moduleSearchForm: {
keyword: ''
},
modulePageNo: 1,
modulePageSize: 20,
pageNo: 1,
pageSize: 20,
total: 0,
moreFilterVisible: false,
columnSettingVisible: false,
caseImportDialogVisible: false,
importSubmitting: false,
createdTimeRange: [],
importFile: null,
queryForm: {
keyword: '',
priority: '',
creator: '',
createdStartTime: '',
createdEndTime: '',
moduleId: '',
caseType: '',
status: '',
isAuto: '',
tag: ''
},
allCaseColumns: [
{ key: 'title', label: '用例标题', prop: 'title', minWidth: 220 },
{ key: 'moduleName', label: '模块名称', prop: 'module_name', minWidth: 140 },
{ key: 'priority', label: '优先级', prop: 'priority', width: 90 },
{ key: 'tags', label: '标签', prop: 'tags', minWidth: 180 },
{ key: 'creator', label: '创建人', prop: 'creator', minWidth: 120 },
{ key: 'createdTime', label: '创建时间', prop: 'created_time', minWidth: 160 },
{ key: 'caseType', label: '类型', prop: 'case_type', width: 90 },
{ key: 'status', label: '状态', prop: 'status', width: 100 },
{ key: 'isAuto', label: '是否自动化', prop: 'is_auto', width: 110 },
{ key: 'caseKey', label: '用例编号', prop: 'case_key', minWidth: 120 },
{ key: 'projectName', label: '项目名称', prop: 'project_name', minWidth: 140 }
],
selectedCaseColumnKeys: ['title', 'moduleName', 'priority', 'tags', 'creator', 'createdTime'],
moduleQueryForm: {
projectId: this.$route.query.projectId || ''
},
moduleForm: {
productId: '',
projectId: this.$route.query.projectId || '',
name: '',
parentId: '0',
sortOrder: '0',
path: ''
},
moduleRules: {
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
name: [{ required: true, message: '请输入模块名称', trigger: 'blur' }]
},
tableData: [],
moduleData: []
}
},
computed: {
visibleCaseColumns() {
return this.allCaseColumns.filter(item => this.selectedCaseColumnKeys.includes(item.key))
},
filteredModuleData() {
const keyword = (this.moduleSearchForm.keyword || '').trim().toLowerCase()
if (!keyword) {
return this.moduleData
}
return (this.moduleData || []).filter(item => {
const source = [
item.id,
item.name,
item.parent_name,
item.path
].map(v => String(v || '').toLowerCase()).join(' ')
return source.includes(keyword)
})
},
moduleTotal() {
return (this.filteredModuleData || []).length
},
pagedModuleData() {
const start = (this.modulePageNo - 1) * this.modulePageSize
const end = start + this.modulePageSize
return (this.filteredModuleData || []).slice(start, end)
},
flatModuleOptions() {
const result = []
const walk = (list, prefix) => {
;(list || []).forEach(item => {
const name = prefix ? `${prefix} / ${item.name}` : item.name
result.push({
id: item.id,
name
})
const children = item.children || item.child_list || item.childList || []
if (Array.isArray(children) && children.length > 0) {
walk(children, name)
}
})
}
walk(this.moduleData, '')
return result
}
},
watch: {
activeTab(val) {
if (val === 'case-mindmap') {
this.fetchCaseMindmapData()
}
}
},
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 = []
})
},
loadModuleProjectOptionsByProduct(productId) {
if (!productId) {
this.moduleProjectOptions = []
return Promise.resolve()
}
return getProjectList({ pageNo: 1, pageSize: 1000, status: 1, productId }).then(res => {
const data = res && res.data ? res.data : res || {}
this.moduleProjectOptions = data.items || data.list || data.data || []
}).catch(() => {
this.moduleProjectOptions = []
})
},
loadModuleParentOptionsByProject(projectId) {
if (!projectId) {
this.moduleParentOptions = []
return Promise.resolve()
}
return getModuleTree({ projectId }).then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.moduleParentOptions = Array.isArray(list) ? list : []
}).catch(() => {
this.moduleParentOptions = []
})
},
handleProductChange(val) {
this.selectedProjectId = ''
this.projectId = ''
this.moduleQueryForm.projectId = ''
this.tableData = []
this.total = 0
this.moduleData = []
this.loadProjectOptionsByProduct(val)
},
handleProjectChange(val) {
this.projectId = val || ''
this.moduleQueryForm.projectId = val || ''
this.pageNo = 1
this.modulePageNo = 1
if (!val) {
this.tableData = []
this.total = 0
this.moduleData = []
return
}
saveLastProductProjectCache(this.selectedProductId, val)
this.fetchModuleList()
this.fetchList()
this.fetchCaseMindmapData()
},
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
this.moduleQueryForm.projectId = picked
})
},
fetchModuleList() {
if (!this.moduleQueryForm.projectId) {
this.moduleData = []
return
}
this.moduleLoading = true
getModuleTree(this.cleanParams(this.moduleQueryForm)).then(res => {
const data = (res && res.data) || res || {}
const list = data.list || data.items || []
this.moduleData = Array.isArray(list) ? list : []
this.modulePageNo = 1
}).catch(() => {
this.moduleData = []
}).finally(() => {
this.moduleLoading = false
})
},
fetchCaseMindmapData() {
if (!this.projectId) {
this.caseMindmapTreeData = []
this.selectedMindmapCase = null
this.mindmapCaseEditing = false
return
}
this.caseMindmapLoading = true
getModuleTree({
projectId: this.projectId,
parentId: 0,
pageNo: 1,
pageSize: 200
}).then(res => {
const data = (res && res.data) || res || {}
const rootModules = data.list || data.items || []
this.caseMindmapTreeData = this.buildCaseMindmapTree(Array.isArray(rootModules) ? rootModules : [])
this.mindmapRenderKey += 1
this.selectedMindmapCase = null
this.mindmapCaseEditing = false
}).catch(() => {
this.caseMindmapTreeData = []
this.selectedMindmapCase = null
this.mindmapCaseEditing = false
}).finally(() => {
this.caseMindmapLoading = false
})
},
toggleMindmapCollapsed() {
this.mindmapCollapsed = !this.mindmapCollapsed
this.mindmapRenderKey += 1
},
handleMindmapNodeClick(node) {
if (!node) {
return
}
if (node.nodeTypeLabel === '模块') {
this.expandMindmapModuleCases(node)
return
}
if (node.nodeTypeLabel === '用例') {
this.selectedMindmapCase = node
this.mindmapCaseEditing = false
}
},
expandMindmapModuleCases(moduleNode) {
if (!moduleNode || !moduleNode.rawId) {
return
}
const moduleId = moduleNode.rawId
if (moduleNode._childrenChecked && moduleNode._casesLoaded) {
return
}
if (moduleNode._childrenLoading || moduleNode._casesLoading) {
return
}
moduleNode._childrenLoading = true
getModuleTree({
projectId: this.projectId,
parentId: moduleId,
pageNo: 1,
pageSize: 200
}).then(res => {
const data = (res && res.data) || res || {}
const children = data.list || data.items || []
const childList = Array.isArray(children) ? children : []
moduleNode._childrenChecked = true
if (childList.length > 0) {
const childNodes = childList.map(item => this.buildMindmapModuleNode(item))
moduleNode.children = (moduleNode.children || []).concat(childNodes)
this.mindmapRenderKey += 1
return
}
moduleNode._casesLoading = true
const query = Object.assign({}, this.queryForm, {
moduleId,
created_by_name: this.queryForm.creator
})
delete query.creator
const params = this.cleanParams(Object.assign({}, query, {
pageNo: 1,
pageSize: 20
}))
return getCaseList(this.projectId, params).then(caseRes => {
const caseData = (caseRes && caseRes.data) || caseRes || {}
const list = caseData.list || caseData.items || []
const caseNodes = (Array.isArray(list) ? list : []).map(item => this.buildMindmapCaseNode(item))
moduleNode.children = (moduleNode.children || []).concat(caseNodes)
moduleNode._casesLoaded = true
this.mindmapRenderKey += 1
}).finally(() => {
moduleNode._casesLoading = false
})
}).finally(() => {
moduleNode._childrenLoading = false
})
},
startMindmapCaseEdit() {
if (!this.selectedMindmapCase) return
this.mindmapCaseEditForm = {
preconditions: this.selectedMindmapCase.preconditions || '',
steps: this.formatMindmapSteps(this.selectedMindmapCase.steps) || '',
expectedResults: this.selectedMindmapCase.expectedResults || ''
}
this.mindmapCaseEditing = true
},
cancelMindmapCaseEdit() {
this.mindmapCaseEditing = false
},
saveMindmapCaseEdit() {
if (!this.selectedMindmapCase || !this.selectedMindmapCase.rawId) {
return
}
const caseId = this.selectedMindmapCase.rawId
const payload = this.cleanParams({
preconditions: this.mindmapCaseEditForm.preconditions,
steps: this.mindmapCaseEditForm.steps,
expectedResults: this.mindmapCaseEditForm.expectedResults
})
this.mindmapCaseSaving = true
updateCase(this.projectId, caseId, payload).then(() => {
this.$message.success('保存成功')
this.selectedMindmapCase.preconditions = this.mindmapCaseEditForm.preconditions
this.selectedMindmapCase.steps = this.mindmapCaseEditForm.steps
this.selectedMindmapCase.expectedResults = this.mindmapCaseEditForm.expectedResults
this.mindmapCaseEditing = false
this.fetchList()
}).finally(() => {
this.mindmapCaseSaving = false
})
},
startMindmapCaseReview() {
if (!this.selectedMindmapCase || !this.selectedMindmapCase.rawId) {
return
}
const caseId = this.selectedMindmapCase.rawId
this.mindmapCaseReviewing = true
// 先进入评审中
updateCase(this.projectId, caseId, { status: 3 }).then(() => {
this.selectedMindmapCase.status = 3
return this.$confirm('是否评审通过?', '评审确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 确定后评审通过,状态置为 4
return updateCase(this.projectId, caseId, { status: 4 }).then(() => {
this.selectedMindmapCase.status = 4
this.$message.success('评审通过')
})
}).catch(() => {
// 取消仅关闭弹框,保持评审中
this.selectedMindmapCase.status = 3
})
}).finally(() => {
this.mindmapCaseReviewing = false
this.fetchList()
})
},
formatMindmapSteps(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)
},
formatStatusTagType(status) {
if (status === 1) return 'success'
if (status === 3) return 'warning'
if (status === 2) return 'info'
if (status === 4) return 'success'
return ''
},
buildCaseMindmapTree(rootModules) {
const moduleChildren = (rootModules || []).map(item => this.buildMindmapModuleNode(item))
const currentProject = (this.projectOptions || []).find(item => String(item.id) === String(this.projectId))
return [{
id: `project-${this.projectId}`,
rawId: this.projectId,
nodeTypeLabel: '项目',
name: (currentProject && currentProject.name) || `项目-${this.projectId}`,
creator: '',
updatedTime: '',
children: moduleChildren
}]
},
buildMindmapModuleNode(item) {
return {
id: `module-${item.id}`,
rawId: item.id,
nodeTypeLabel: '模块',
name: item.name || `模块-${item.id}`,
creator: '',
updatedTime: item.updated_time || item.updatedTime || '',
parentId: item.parent_id || item.parentId || 0,
_childrenChecked: false,
_childrenLoading: false,
_casesLoaded: false,
_casesLoading: false,
children: []
}
},
buildMindmapCaseNode(item) {
return {
id: `case-${item.id}`,
rawId: item.id,
nodeTypeLabel: '用例',
name: item.title || item.case_key || `用例-${item.id}`,
creator: item.created_by_name || '',
updatedTime: item.updated_time || item.updatedTime || '',
status: item.status,
preconditions: item.preconditions || '',
steps: item.steps || '',
expectedResults: item.expected_results || item.expectedResults || ''
}
},
handleModuleSearch() {
this.modulePageNo = 1
},
resetModuleSearch() {
this.moduleSearchForm.keyword = ''
this.modulePageNo = 1
},
handleModuleSizeChange(size) {
this.modulePageSize = size
this.modulePageNo = 1
},
handleModuleCurrentChange(page) {
this.modulePageNo = page
},
openModuleCreate() {
this.moduleDialogMode = 'create'
this.editingModuleId = ''
this.moduleDialogVisible = true
this.moduleForm.productId = this.selectedProductId || ''
this.moduleForm.projectId = this.selectedProjectId || this.moduleQueryForm.projectId || this.projectId || ''
this.moduleForm.parentId = '0'
this.loadModuleProjectOptionsByProduct(this.moduleForm.productId)
this.loadModuleParentOptionsByProject(this.moduleForm.projectId)
},
openModuleEdit(row) {
this.moduleDialogMode = 'edit'
this.editingModuleId = row.id
this.moduleDialogVisible = true
this.moduleForm.productId = this.selectedProductId || ''
this.moduleForm.projectId = row.project_id || row.projectId || this.selectedProjectId || this.moduleQueryForm.projectId || this.projectId || ''
this.moduleForm.name = row.name || ''
this.moduleForm.parentId = String(row.parent_id || row.parentId || 0)
this.moduleForm.sortOrder = String(row.sort_order || row.sortOrder || 0)
this.moduleForm.path = row.path || ''
this.loadModuleProjectOptionsByProduct(this.moduleForm.productId)
this.loadModuleParentOptionsByProject(this.moduleForm.projectId).then(() => {
this.moduleParentOptions = (this.moduleParentOptions || []).filter(item => item.id !== row.id)
})
},
handleModuleProductChange(val) {
this.moduleForm.projectId = ''
this.moduleForm.parentId = '0'
this.moduleParentOptions = []
this.loadModuleProjectOptionsByProduct(val)
},
handleModuleProjectChange(val) {
this.moduleForm.parentId = '0'
this.loadModuleParentOptionsByProject(val)
},
resetModuleForm() {
this.moduleDialogMode = 'create'
this.editingModuleId = ''
this.moduleForm = {
productId: this.selectedProductId || '',
projectId: this.selectedProjectId || this.moduleQueryForm.projectId || this.projectId || '',
name: '',
parentId: '0',
sortOrder: '0',
path: ''
}
this.loadModuleParentOptionsByProject(this.moduleForm.projectId)
this.$nextTick(() => {
this.$refs.moduleForm && this.$refs.moduleForm.clearValidate()
})
},
submitModule() {
this.$refs.moduleForm.validate(valid => {
if (!valid) {
return
}
this.moduleSubmitting = true
const payload = this.cleanParams({
projectId: this.moduleForm.projectId,
name: this.moduleForm.name,
parentId: this.moduleForm.parentId,
sortOrder: this.moduleForm.sortOrder,
path: this.moduleForm.path
})
const request = this.moduleDialogMode === 'edit'
? updateModule(Object.assign({ moduleId: this.editingModuleId }, payload))
: createModule(payload)
request.then(() => {
this.$message({ type: 'success', message: this.moduleDialogMode === 'edit' ? '保存成功' : '新增成功' })
this.moduleDialogVisible = false
if (this.selectedProjectId !== this.moduleForm.projectId) {
this.selectedProjectId = this.moduleForm.projectId
this.projectId = this.moduleForm.projectId
this.moduleQueryForm.projectId = this.moduleForm.projectId
}
this.fetchModuleList()
}).finally(() => {
this.moduleSubmitting = false
})
})
},
removeModule(row) {
this.$confirm('确认删除该模块吗?', '提示', { type: 'warning' }).then(() => {
deleteModule({ moduleId: row.id }).then(() => {
this.$message({ type: 'success', message: '删除成功' })
this.fetchModuleList()
})
}).catch(() => {})
},
fetchList() {
if (!this.projectId) {
this.tableData = []
this.total = 0
return
}
this.loading = true
const query = Object.assign({}, this.queryForm, {
// 后端创建人字段为 created_by_name
created_by_name: this.queryForm.creator
})
delete query.creator
const params = this.cleanParams(Object.assign({}, query, {
pageNo: this.pageNo,
pageSize: this.pageSize
}))
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)
}).catch(() => {
this.tableData = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
handleSizeChange(size) {
this.pageSize = size
this.pageNo = 1
this.fetchList()
},
handleCurrentChange(page) {
this.pageNo = page
this.fetchList()
},
handleCreatedTimeChange(value) {
const range = Array.isArray(value) ? value : []
this.queryForm.createdStartTime = range[0] || ''
this.queryForm.createdEndTime = range[1] || ''
},
applyMoreFilters() {
this.moreFilterVisible = false
this.pageNo = 1
this.fetchList()
if (this.activeTab === 'case-mindmap') {
this.fetchCaseMindmapData()
}
},
resetQuery() {
this.queryForm = {
keyword: '',
priority: '',
creator: '',
createdStartTime: '',
createdEndTime: '',
moduleId: '',
caseType: '',
status: '',
isAuto: '',
tag: ''
}
this.createdTimeRange = []
this.pageNo = 1
this.fetchList()
if (this.activeTab === 'case-mindmap') {
this.fetchCaseMindmapData()
}
},
handleColumnSelectionChange(value) {
if (value.length === 0) {
this.$message.warning('至少保留一个展示字段')
this.selectedCaseColumnKeys = ['title']
}
},
getCaseColumnValue(row, key) {
const map = {
title: row.title,
moduleName: row.module_name,
priority: row.priority,
tags: row.tags,
creator: row.created_by_name || row.creator || row.creator_name || row.create_user || row.createUser || row.created_by || '',
createdTime: row.created_time || row.create_time || row.createdTime || row.createTime || '',
caseType: row.case_type,
status: row.status,
isAuto: row.is_auto,
caseKey: row.case_key,
projectName: row.project_name
}
return map[key]
},
formatCaseColumnValue(key, value) {
if (key === 'priority') return this.formatPriority(value)
if (key === 'caseType') return this.formatCaseType(value)
if (key === 'status') return this.formatStatus(value)
if (key === 'isAuto') return this.formatIsAuto(value)
return value
},
openCaseImportDialog() {
this.caseImportDialogVisible = true
},
resetImportDialog() {
this.importFile = null
if (this.$refs.importFileInput) {
this.$refs.importFileInput.value = ''
}
},
triggerImportFileSelect() {
this.$refs.importFileInput && this.$refs.importFileInput.click()
},
onImportFileChange(event) {
const files = event && event.target && event.target.files ? event.target.files : []
const file = files[0]
this.setImportFile(file)
},
onImportFileDrop(event) {
const files = event && event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : []
const file = files[0]
this.setImportFile(file)
},
setImportFile(file) {
if (!file) {
return
}
const lowerName = String(file.name || '').toLowerCase()
const isExcel = lowerName.endsWith('.xls') || lowerName.endsWith('.xlsx')
if (!isExcel) {
this.$message.warning('仅支持 .xls 或 .xlsx 文件')
return
}
const maxSize = 20 * 1024 * 1024
if (file.size > maxSize) {
this.$message.warning('文件大小不能超过 20MB')
return
}
this.importFile = file
},
downloadImportTemplate() {
if (!this.selectedProjectId) {
this.$message.warning('请先选择项目')
return
}
downloadCaseImportTemplate(this.selectedProjectId).then(res => {
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '用例导入模板.xlsx'
link.click()
window.URL.revokeObjectURL(url)
})
},
submitCaseImport() {
if (!this.selectedProjectId) {
this.$message.warning('请先选择项目')
return
}
if (!this.importFile) {
this.$message.warning('请先选择导入文件')
return
}
this.importSubmitting = true
importCaseExcel(this.selectedProjectId, this.importFile).then(() => {
this.$message.success('导入并解析成功')
this.caseImportDialogVisible = false
this.fetchList()
}).finally(() => {
this.importSubmitting = false
})
},
goEditor(row) {
this.$router.push({
path: '/test-platform/case/editor',
query: {
productId: this.selectedProductId || undefined,
projectId: this.projectId,
caseId: row && row.id
}
})
},
goReview(row) {
this.$router.push({ path: '/test-platform/case/review', query: { projectId: this.projectId, caseId: row.id } })
},
remove(row) {
this.$confirm('确认删除该用例吗?', '提示', { type: 'warning' }).then(() => {
deleteCase(this.projectId, row.id).then(() => {
this.$message({ type: 'success', message: '删除成功' })
this.fetchList()
})
}).catch(() => {})
},
cleanParams(params) {
return Object.keys(params).reduce((result, key) => {
if (params[key] !== '' && params[key] !== undefined && params[key] !== null) {
result[key] = params[key]
}
return result
}, {})
},
formatPriority(value) {
const map = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
return map[value] || value
},
formatCaseType(value) {
const map = { 1: '功能', 2: '性能', 3: '安全', 4: '接口' }
return map[value] || value
},
formatStatus(value) {
const map = { 1: '正常', 2: '已废弃', 3: '评审中', 4: '评审通过' }
return map[value] || value
},
formatIsAuto(value) {
const map = { 0: '未实现', 1: '已实现' }
return map[value] || value
},
formatTags(tags) {
return Array.isArray(tags) ? tags.join('、') : (tags || '')
}
},
created() {
this.loadProductOptions().then(() => {
const routeProductId = this.$route.query.productId ? Number(this.$route.query.productId) : ''
if (routeProductId) {
this.selectedProductId = routeProductId
return this.loadProjectOptionsByProduct(routeProductId)
}
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.moduleQueryForm.projectId = this.selectedProjectId
this.fetchModuleList()
this.fetchList()
}
})
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
}
.toolbar-wrap {
text-align: right;
margin-top: 8px;
}
.case-action-buttons-item {
float: right;
margin-right: 0 !important;
}
.more-filter-wrap {
padding: 4px 0;
}
.more-filter-footer {
border-top: 1px solid #ebeef5;
text-align: right;
padding-top: 10px;
}
.column-setting-wrap {
max-height: 320px;
overflow-y: auto;
}
.column-setting-title {
color: #606266;
font-weight: 500;
margin-bottom: 8px;
}
.case-import-panel {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 28px 24px;
text-align: center;
}
.case-import-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: #fff;
background: #409eff;
font-weight: 600;
margin-bottom: 8px;
}
.case-import-title {
font-size: 28px;
color: #303133;
margin-bottom: 6px;
}
.case-import-subtitle {
color: #909399;
margin-bottom: 16px;
}
.case-import-dropzone {
border: 1px dashed #dcdfe6;
border-radius: 4px;
padding: 28px 20px;
margin: 0 auto;
max-width: 520px;
}
.case-import-drop-text {
color: #606266;
margin-bottom: 10px;
}
.link-text {
color: #409eff;
cursor: pointer;
}
.case-import-file-tip {
color: #909399;
}
.case-import-file-name {
margin-top: 8px;
color: #303133;
}
.case-import-actions {
margin-top: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.hidden-file-input {
display: none;
}
.mindmap-wrap {
margin-top: 8px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fafcff;
padding: 14px 12px;
min-height: 280px;
overflow: auto;
}
.xmind-tree {
min-width: 980px;
background: transparent;
}
.xmind-tree /deep/ .el-tree-node {
position: relative;
}
.xmind-tree /deep/ .el-tree-node__content {
height: auto;
padding: 8px 0;
}
.xmind-tree /deep/ .el-tree-node__children {
position: relative;
margin-left: 12px;
padding-left: 22px;
}
.xmind-tree /deep/ .el-tree-node__children:before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 10px;
border-left: 1px solid #b7d0e8;
}
.xmind-tree /deep/ .el-tree-node__content:before {
content: '';
position: absolute;
left: -14px;
top: 50%;
width: 14px;
border-top: 1px solid #8fb8de;
}
.xmind-tree /deep/ .el-tree > .el-tree-node > .el-tree-node__content:before {
display: none;
}
.mindmap-node {
display: inline-flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
max-width: 760px;
background: #fff;
border: 1px solid #dce6ff;
border-radius: 8px;
padding: 8px 10px;
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.08);
}
.mindmap-node-wrap {
display: inline-flex;
align-items: flex-start;
}
.mindmap-node-title {
color: #303133;
font-weight: 600;
line-height: 1.4;
}
.mindmap-node-project {
border-color: #67c23a;
box-shadow: 0 1px 6px rgba(103, 194, 58, 0.16);
}
.mindmap-node-module {
border-color: #e6a23c;
box-shadow: 0 1px 6px rgba(230, 162, 60, 0.14);
}
.mindmap-node-case {
border-color: #7fb3e3;
}
.mindmap-inline-detail {
display: inline-flex;
align-items: stretch;
margin-left: 14px;
}
.mindmap-inline-detail-line {
width: 18px;
margin-top: 22px;
border-top: 1px solid #8fb8de;
}
.mindmap-inline-detail-card {
width: 420px;
max-width: 620px;
background: #fff;
border: 1px solid #d9e8f6;
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 1px 6px rgba(64, 158, 255, 0.12);
}
.mindmap-inline-detail-title {
color: #303133;
font-weight: 600;
}
.mindmap-inline-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.mindmap-inline-detail-item {
color: #606266;
line-height: 1.6;
margin-bottom: 6px;
white-space: pre-wrap;
}
.mindmap-inline-detail-item:last-child {
margin-bottom: 0;
}
.mindmap-inline-actions {
text-align: right;
margin-top: 8px;
}
.mindmap-node-meta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.mindmap-meta-text {
color: #909399;
font-size: 12px;
}
.mindmap-empty {
color: #909399;
text-align: center;
line-height: 220px;
}
</style>