功能更新:新增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

@@ -0,0 +1,701 @@
<template>
<div class="page-wrap">
<page-section title="Bug 统计">
<el-form :inline="true" size="small" class="filter-bar" @submit.native.prevent>
<el-form-item label="产品">
<el-select
v-model="query.productId"
filterable
clearable
placeholder="请选择产品"
style="width: 220px;"
@change="onProductChange"
@focus="loadProductOptions">
<el-option v-for="p in productOptions" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="项目">
<el-select
v-model="query.projectId"
filterable
clearable
placeholder="请选择项目"
style="width: 220px;"
:disabled="!query.productId"
@change="onProjectChange">
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
<div v-loading="loading" class="stats-body">
<el-row :gutter="16" class="stat-row">
<el-col v-for="card in summaryCardsRow1" :key="card.key" :span="6">
<div class="stat-card" :class="card.tone">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">{{ card.value }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="stat-row">
<el-col v-for="card in summaryCardsRow2" :key="card.key" :span="8">
<div class="stat-card" :class="card.tone">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">{{ card.value }}</div>
</div>
</el-col>
</el-row>
<div class="report-section">
<el-row :gutter="16">
<el-col :span="5" class="report-sidebar">
<div class="sidebar-title">请选择报表类型</div>
<el-checkbox-group v-model="selectedReportKeys" class="report-check-list">
<div v-for="opt in reportTypeDefs" :key="opt.key" class="report-check-row">
<el-checkbox :label="opt.key">{{ opt.label }}</el-checkbox>
</div>
</el-checkbox-group>
<div class="sidebar-actions">
<el-button type="text" size="small" @click="selectAllReportTypes">全选</el-button>
<el-button type="text" size="small" @click="clearReportTypes">清空</el-button>
</div>
<el-button type="primary" size="small" class="btn-generate" @click="applyReport">生成报表</el-button>
</el-col>
<el-col :span="19" class="report-main">
<p class="report-hint">
报表数据来自当前产品 / 项目下接口返回的统计 Bug 列表使用相同筛选维度未返回的维度将显示暂无数据
</p>
<el-radio-group v-model="chartType" size="small" class="chart-type-bar" @change="onChartTypeChange">
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="pie">饼图</el-radio-button>
<el-radio-button label="bar">柱状图</el-radio-button>
<el-radio-button label="line">折线图</el-radio-button>
</el-radio-group>
<template v-if="appliedReportKeys.length">
<el-tabs v-model="activeTabKey" class="report-tabs" @tab-click="onReportTabClick">
<el-tab-pane
v-for="key in appliedReportKeys"
:key="'tab-' + key"
:name="key"
:label="reportLabel(key)">
</el-tab-pane>
</el-tabs>
<div v-if="activeTabKey" class="report-pane-title">{{ reportLabel(activeTabKey) }}</div>
<el-row :gutter="12" class="report-pane-body">
<template v-if="chartType === 'default'">
<el-col :span="24">
<p class="default-mode-tip">以下为明细数据切换到饼图 / 柱状图 / 折线图可同时查看图表与表格</p>
<el-table :data="activeDetailRows" border size="small" class="detail-table" max-height="420">
<el-table-column prop="name" label="条目" min-width="120" show-overflow-tooltip />
<el-table-column prop="value" label="值" width="90" align="right" />
<el-table-column prop="percent" label="百分比" width="100" align="right" />
</el-table>
</el-col>
</template>
<template v-else>
<el-col :span="14">
<div ref="chartRef" class="chart-box"></div>
</el-col>
<el-col :span="10">
<el-table :data="activeDetailRows" border size="small" class="detail-table" max-height="380">
<el-table-column prop="name" label="条目" min-width="120" show-overflow-tooltip />
<el-table-column prop="value" label="值" width="90" align="right" />
<el-table-column prop="percent" label="百分比" width="100" align="right" />
</el-table>
</el-col>
</template>
</el-row>
</template>
<div v-else class="report-empty">请勾选报表类型后点击生成报表</div>
</el-col>
</el-row>
</div>
</div>
</page-section>
</div>
</template>
<script>
import echarts from 'echarts'
import PageSection from '@/components/TestPlatform/common/PageSection'
import { getBugStats } from '@/api/bugApi'
import { getProductList } from '@/api/productApi'
import { getProjectList } from '@/api/projectApi'
import {
readLastProductProjectCache,
saveLastProductProjectCache,
pickIdFromOptions
} from '@/utils/lastProductProjectCache'
import { STATUS_MAP } from '@/utils/bugMaps'
const SOLUTION_LABEL_MAP = {
by_design: '设计如此',
duplicate_bug: '重复Bug',
external_reason: '外部原因',
solution_resolved: '已解决',
cannot_reproduce: '无法重现',
deferred: '延期处理',
wont_fix: '不予解决'
}
/** 不含:按严重程度、按优先级、按类型 */
const REPORT_TYPE_DEFS = [
{ key: 'iteration', label: '迭代Bug数量' },
{ key: 'version', label: '版本Bug数量' },
{ key: 'module', label: '模块Bug数量' },
{ key: 'daily_new', label: '每天新增Bug数' },
{ key: 'daily_resolved', label: '每天解决Bug数' },
{ key: 'daily_closed', label: '每天关闭的Bug数' },
{ key: 'by_reporter', label: '每人提交的Bug数' },
{ key: 'by_resolver', label: '每人解决的Bug数' },
{ key: 'by_closer', label: '每人关闭的Bug数' },
{ key: 'solution', label: '按Bug解决方案统计' },
{ key: 'status', label: '按Bug状态统计' },
{ key: 'activation', label: '按Bug激活次数统计' },
{ key: 'by_assignee', label: '按指派给统计' }
]
function firstNonEmptyObject(stats, paths) {
const s = stats || {}
for (let i = 0; i < paths.length; i++) {
const p = paths[i]
const v = p.split('.').reduce((o, k) => (o != null && o[k] !== undefined ? o[k] : undefined), s)
if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length) return v
}
return null
}
function firstDefined(stats, paths) {
const s = stats || {}
for (let i = 0; i < paths.length; i++) {
const p = paths[i]
const v = p.split('.').reduce((o, k) => (o != null && o[k] !== undefined ? o[k] : undefined), s)
if (v !== undefined && v !== null) return v
}
return null
}
function normalizeTimeSeries(raw) {
if (!raw) return { categories: [], values: [] }
if (Array.isArray(raw)) {
const categories = raw.map(
item => String(item.date || item.day || item.label || item.name || item.stat_date || '-')
)
const values = raw.map(item => {
const v =
item.count !== undefined && item.count !== null && item.count !== ''
? item.count
: item.value !== undefined && item.value !== null && item.value !== ''
? item.value
: item.total !== undefined && item.total !== null && item.total !== ''
? item.total
: item.num
return Number(v) || 0
})
return { categories, values }
}
if (typeof raw === 'object') {
const keys = Object.keys(raw).sort()
return {
categories: keys,
values: keys.map(k => Number(raw[k]) || 0)
}
}
return { categories: [], values: [] }
}
function mapObjectRows(obj, labelResolver) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { categories: [], values: [] }
const keys = Object.keys(obj)
const categories = keys.map(k => (labelResolver ? labelResolver(k, obj[k]) : String(k)))
const values = keys.map(k => Number(obj[k]) || 0)
return { categories, values }
}
export default {
name: 'BugStats',
components: { PageSection },
data() {
return {
loading: false,
productOptions: [],
projectOptions: [],
query: {
productId: '',
projectId: ''
},
stats: {},
reportTypeDefs: REPORT_TYPE_DEFS,
selectedReportKeys: ['by_resolver'],
appliedReportKeys: [],
activeTabKey: '',
chartType: 'line',
chartInstance: null,
_resizeHandler: null
}
},
computed: {
summaryCardsRow1() {
const s = this.stats || {}
const pick = (a, b, def) => {
const v = a !== undefined && a !== null ? a : b
return v !== undefined && v !== null ? v : def
}
return [
{ key: 'total', label: '总数', value: pick(s.total, s.total_count, 0), tone: 'tone-default' },
{ key: 'new', label: '新建', value: pick(s.new, s.new_count, 0), tone: 'tone-info' },
{ key: 'pending', label: '待处理', value: pick(s.pending, s.pending_count, 0), tone: 'tone-warn' },
{ key: 'in_progress', label: '进行中', value: pick(s.in_progress, s.inProgress, 0), tone: 'tone-primary' }
]
},
summaryCardsRow2() {
const s = this.stats || {}
const pick = (a, b, def) => {
const v = a !== undefined && a !== null ? a : b
return v !== undefined && v !== null ? v : def
}
return [
{ key: 'resolved', label: '已解决', value: pick(s.resolved, s.resolved_count, 0), tone: 'tone-success' },
{ key: 'closed', label: '已关闭', value: pick(s.closed, s.closed_count, 0), tone: 'tone-muted' },
{ key: 'rejected', label: '已拒绝', value: pick(s.rejected, s.rejected_count, 0), tone: 'tone-danger' }
]
},
activeDetailRows() {
const { categories, values } = this.getSeriesForKey(this.activeTabKey)
return this.buildDetailRows(categories, values)
}
},
watch: {
stats() {
if (this.appliedReportKeys.length) {
this.$nextTick(() => this.renderChart())
}
}
},
beforeDestroy() {
this.disposeChart()
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler)
}
},
methods: {
reportLabel(key) {
const row = this.reportTypeDefs.find(r => r.key === key)
return row ? row.label : key
},
selectAllReportTypes() {
this.selectedReportKeys = this.reportTypeDefs.map(r => r.key)
},
clearReportTypes() {
this.selectedReportKeys = []
},
applyReport() {
if (!this.selectedReportKeys.length) {
this.$message.warning('请至少选择一种报表类型')
return
}
this.appliedReportKeys = [...this.selectedReportKeys]
this.activeTabKey = this.appliedReportKeys[0]
this.$nextTick(() => this.renderChart())
},
onChartTypeChange() {
this.$nextTick(() => this.renderChart())
},
onReportTabClick() {
this.$nextTick(() => this.renderChart())
},
buildDetailRows(categories, values) {
const total = (values || []).reduce((a, b) => a + (Number(b) || 0), 0)
const t = total > 0 ? total : 1
return (categories || []).map((name, i) => {
const v = Number(values[i]) || 0
return {
name: name || '-',
value: v,
percent: `${((v / t) * 100).toFixed(2)}%`
}
})
},
getSeriesForKey(key) {
const s = this.stats || {}
switch (key) {
case 'iteration': {
const o = firstNonEmptyObject(s, [
'by_iteration',
'iteration_bugs',
'iteration_stats',
'iterationStats',
'bugs_by_iteration'
])
return mapObjectRows(o, k => k)
}
case 'version': {
const o = firstNonEmptyObject(s, ['by_version', 'version_bugs', 'version_stats', 'bugs_by_version'])
return mapObjectRows(o, k => k)
}
case 'module': {
const o = firstNonEmptyObject(s, ['by_module', 'module_bugs', 'module_stats', 'bugs_by_module'])
return mapObjectRows(o, k => k)
}
case 'daily_new': {
const raw = firstDefined(s, ['daily_new', 'dailyNew', 'new_by_day', 'bugs_new_daily', 'newDaily'])
return normalizeTimeSeries(raw)
}
case 'daily_resolved': {
const raw = firstDefined(s, ['daily_resolved', 'dailyResolved', 'resolved_by_day', 'bugs_resolved_daily'])
return normalizeTimeSeries(raw)
}
case 'daily_closed': {
const raw = firstDefined(s, ['daily_closed', 'dailyClosed', 'closed_by_day', 'bugs_closed_daily'])
return normalizeTimeSeries(raw)
}
case 'by_reporter': {
const o = firstNonEmptyObject(s, [
'by_reporter',
'byReporter',
'reporter_stats',
'submit_by_user',
'bugs_by_creator'
])
return mapObjectRows(o, k => k)
}
case 'by_resolver': {
const o = firstNonEmptyObject(s, [
'by_resolver',
'byResolver',
'resolved_by_user',
'resolver_stats',
'bugs_by_resolver'
])
return mapObjectRows(o, k => k)
}
case 'by_closer': {
const o = firstNonEmptyObject(s, ['by_closer', 'byCloser', 'closed_by_user', 'closer_stats'])
return mapObjectRows(o, k => k)
}
case 'solution': {
const o = firstNonEmptyObject(s, ['by_solution', 'bySolution', 'solution_stats'])
return mapObjectRows(o, k => SOLUTION_LABEL_MAP[k] || k)
}
case 'status': {
const o = firstNonEmptyObject(s, ['by_status', 'byStatus', 'status_stats'])
return mapObjectRows(o, k => STATUS_MAP[Number(k)] != null ? STATUS_MAP[Number(k)] : STATUS_MAP[k] || k)
}
case 'activation': {
const o = firstNonEmptyObject(s, ['by_activation', 'byActivation', 'activation_stats'])
return mapObjectRows(o, k => k)
}
case 'by_assignee': {
const o = firstNonEmptyObject(s, ['by_assignee', 'byAssignee', 'assignee_stats', 'bugs_by_assignee'])
return mapObjectRows(o, k => k)
}
default:
return { categories: [], values: [] }
}
},
disposeChart() {
if (this.chartInstance) {
try {
this.chartInstance.dispose()
} catch (e) {}
this.chartInstance = null
}
},
renderChart() {
this.disposeChart()
if (!this.appliedReportKeys.length || this.chartType === 'default') return
const el = this.$refs.chartRef
if (!el) return
const { categories, values } = this.getSeriesForKey(this.activeTabKey)
if (!categories.length) {
this.chartInstance = echarts.init(el)
this.chartInstance.setOption({
title: {
text: '暂无数据',
left: 'center',
top: 'middle',
textStyle: { color: '#909399', fontSize: 14 }
}
})
this.bindResize()
return
}
this.chartInstance = echarts.init(el)
const isLine = this.chartType === 'line'
const baseSeries = {
name: this.reportLabel(this.activeTabKey),
data: values,
type: this.chartType === 'pie' ? 'pie' : this.chartType === 'bar' ? 'bar' : 'line',
smooth: isLine,
areaStyle: isLine ? { opacity: 0.12 } : undefined
}
let option
if (this.chartType === 'pie') {
option = {
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9b59b6', '#3498db'],
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
series: [
{
type: 'pie',
radius: ['36%', '62%'],
center: ['50%', '46%'],
data: categories.map((name, i) => ({ name, value: values[i] })),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0,0,0,0.15)'
}
}
}
]
}
} else {
option = {
color: ['#409EFF'],
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisLabel: { rotate: categories.some(c => String(c).length > 6) ? 35 : 0, interval: 0 }
},
yAxis: { type: 'value', minInterval: 1 },
series: [Object.assign({}, baseSeries, this.chartType === 'bar' ? { barMaxWidth: 48 } : {})]
}
}
this.chartInstance.setOption(option)
this.bindResize()
},
bindResize() {
if (!this._resizeHandler) {
this._resizeHandler = () => {
if (this.chartInstance) this.chartInstance.resize()
}
window.addEventListener('resize', this._resizeHandler)
} else if (this.chartInstance) {
this.chartInstance.resize()
}
},
loadProductOptions() {
if (this.productOptions.length) 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 = []
})
},
loadProjects(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 = []
})
},
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.query.productId = pickIdFromOptions(this.productOptions, pid)
return this.loadProjects(this.query.productId).then(() => {
const hasProject = (this.projectOptions || []).some(p => String(p.id) === String(projId))
if (!hasProject) return
this.query.projectId = pickIdFromOptions(this.projectOptions, projId)
})
},
onProductChange(val) {
this.query.projectId = ''
this.projectOptions = []
this.loadProjects(val)
},
onProjectChange(val) {
if (val) {
saveLastProductProjectCache(this.query.productId, val)
}
},
handleSearch() {
saveLastProductProjectCache(this.query.productId, this.query.projectId)
this.fetchStats()
},
fetchStats() {
this.loading = true
const params = {}
if (this.query.productId !== '' && this.query.productId != null) params.productId = this.query.productId
if (this.query.projectId !== '' && this.query.projectId != null) params.projectId = this.query.projectId
getBugStats(params)
.then(res => {
this.stats = (res && res.data) || res || {}
})
.catch(() => {
this.stats = {}
})
.finally(() => {
this.loading = false
})
}
},
created() {
this.loadProductOptions()
.then(() => this.restoreSharedProductProjectCache())
.finally(() => this.fetchStats())
}
}
</script>
<style scoped>
.page-wrap {
padding: 20px;
}
.filter-bar {
margin-bottom: 8px;
}
.stats-body {
min-height: 120px;
}
.stat-row {
margin-bottom: 8px;
}
.stat-card {
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 16px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
margin-top: 8px;
font-size: 26px;
font-weight: 600;
color: #303133;
}
.tone-primary .stat-value {
color: #409eff;
}
.tone-success .stat-value {
color: #67c23a;
}
.tone-warn .stat-value {
color: #e6a23c;
}
.tone-danger .stat-value {
color: #f56c6c;
}
.tone-info .stat-value {
color: #909399;
}
.tone-muted .stat-value {
color: #606266;
}
.report-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
.report-sidebar {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px 12px;
background: #fafbfc;
}
.sidebar-title {
font-weight: 600;
font-size: 14px;
color: #303133;
margin-bottom: 10px;
}
.report-check-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 420px;
overflow-y: auto;
}
.report-check-row {
line-height: 1.5;
}
.sidebar-actions {
margin-top: 10px;
margin-bottom: 8px;
}
.btn-generate {
width: 100%;
}
.report-main {
min-height: 420px;
}
.report-hint {
font-size: 12px;
color: #909399;
margin: 0 0 10px;
line-height: 1.5;
}
.chart-type-bar {
margin-bottom: 12px;
}
.report-tabs {
margin-top: 4px;
}
.report-pane-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
.default-mode-tip {
font-size: 12px;
color: #909399;
margin: 0 0 8px;
}
.report-pane-body {
align-items: stretch;
}
.chart-box {
height: 380px;
width: 100%;
}
.chart-placeholder {
height: 380px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 13px;
padding: 16px;
text-align: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafafa;
}
.detail-table {
width: 100%;
}
.report-empty {
padding: 48px 16px;
text-align: center;
color: #909399;
font-size: 14px;
border: 1px dashed #e4e7ed;
border-radius: 8px;
}
</style>