702 lines
23 KiB
Vue
702 lines
23 KiB
Vue
<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>
|