Browse Source
- 新增 src/views/analysis/index.vue:处理时效与积分排行左右一行、分类/级别分布(echarts)、月度运营报告+导出 - 新增 src/api/analysis/index.js:stats/timeliness/ranking/report 相关接口封装 - 修正 report/monthly 与 /export 需必填 year(文档未写),补年份选择 - 按甲方调整:去掉隐患热力图与顶部筛选列 - 同步更新 PAGES_TODO / CURRENT_STATUS / DEVELOPMENT_LOG;提交前 eslint 与 build:prod 通过master
5 changed files with 427 additions and 2 deletions
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
import request from '@/utils/request' |
||||
|
||||
// 隐患统计-按分类(饼图/柱状)
|
||||
export function getStatsCategory() { |
||||
return request({ url: '/business/points/analysis/stats/category', method: 'get' }) |
||||
} |
||||
|
||||
// 隐患统计-按级别
|
||||
export function getStatsLevel() { |
||||
return request({ url: '/business/points/analysis/stats/level', method: 'get' }) |
||||
} |
||||
|
||||
// 处理时效-按级别(平均处理时长 / 已完成数)
|
||||
export function getTimelinessByLevel() { |
||||
return request({ url: '/business/points/analysis/timeliness/level', method: 'get' }) |
||||
} |
||||
|
||||
// 积分排行榜-总入账
|
||||
export function getRankingPoints() { |
||||
return request({ url: '/business/points/analysis/ranking/points', method: 'get' }) |
||||
} |
||||
|
||||
// 上报积分排行
|
||||
export function getRankingReport() { |
||||
return request({ url: '/business/points/analysis/ranking/report', method: 'get' }) |
||||
} |
||||
|
||||
// 兑换排行统计(按单位)
|
||||
export function getRankingExchange() { |
||||
return request({ url: '/business/points/analysis/ranking/exchange', method: 'get' }) |
||||
} |
||||
|
||||
// 月度安全运营报告(year 必填;month=0 全年,1~12 指定月份)
|
||||
export function getMonthlyReport(params) { |
||||
return request({ url: '/business/points/analysis/report/monthly', method: 'get', params }) |
||||
} |
||||
|
||||
// 月度安全运营报告导出(Excel,直接下载;year 必填)
|
||||
export function exportMonthlyReport(params) { |
||||
return request({ |
||||
url: '/business/points/analysis/report/monthly/export', |
||||
method: 'get', |
||||
params, |
||||
responseType: 'blob' |
||||
}) |
||||
} |
||||
@ -0,0 +1,365 @@
@@ -0,0 +1,365 @@
|
||||
<template> |
||||
<div v-loading="loading" class="app-container analysis-page"> |
||||
<div class="two-col"> |
||||
<!-- 处理时效 --> |
||||
<div class="panel"> |
||||
<div class="panel-title">处理时效(按级别)</div> |
||||
<el-table :data="timeliness" size="mini" class="analysis-table"> |
||||
<el-table-column label="级别" prop="levelName" min-width="120"> |
||||
<template slot-scope="scope">{{ scope.row.levelName || '—' }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="平均处理时长" width="140" align="center"> |
||||
<template slot-scope="scope">{{ hoursText(scope.row.avgHours) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="已完成数" width="110" align="center"> |
||||
<template slot-scope="scope">{{ num(scope.row.completedCount) }}</template> |
||||
</el-table-column> |
||||
</el-table> |
||||
<div v-if="!timeliness.length && !loading" class="panel-empty">暂无数据</div> |
||||
</div> |
||||
|
||||
<!-- 积分排行 --> |
||||
<div class="panel"> |
||||
<div class="panel-title">积分排行</div> |
||||
<el-tabs v-model="rankTab"> |
||||
<el-tab-pane label="积分" name="points"> |
||||
<el-table :data="rankPoints" size="mini" class="analysis-table"> |
||||
<el-table-column label="#" width="60" align="center"> |
||||
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="用户" prop="userName" min-width="120"> |
||||
<template slot-scope="scope">{{ scope.row.userName || '—' }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="总入账积分" prop="totalIncome" width="120" align="right" /> |
||||
</el-table> |
||||
</el-tab-pane> |
||||
<el-tab-pane label="上报" name="report"> |
||||
<el-table :data="rankReport" size="mini" class="analysis-table"> |
||||
<el-table-column label="#" width="60" align="center"> |
||||
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="用户" prop="userName" min-width="120"> |
||||
<template slot-scope="scope">{{ scope.row.userName || '—' }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="上报次数" prop="reportCount" width="120" align="right" /> |
||||
</el-table> |
||||
</el-tab-pane> |
||||
<el-tab-pane label="兑换" name="exchange"> |
||||
<el-table :data="rankExchange" size="mini" class="analysis-table"> |
||||
<el-table-column label="#" width="60" align="center"> |
||||
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="单位" prop="unitName" min-width="140"> |
||||
<template slot-scope="scope">{{ scope.row.unitName || '—' }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="兑换单数" prop="orderCount" width="100" align="right" /> |
||||
<el-table-column label="消耗积分" prop="totalPoints" width="110" align="right" /> |
||||
<el-table-column label="兑换人数" prop="userCount" width="100" align="right" /> |
||||
</el-table> |
||||
</el-tab-pane> |
||||
</el-tabs> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="two-col"> |
||||
<div class="panel"> |
||||
<div class="panel-title">隐患分类分布</div> |
||||
<div v-if="categoryList.length" ref="categoryChart" class="chart" /> |
||||
<div v-else class="panel-empty">暂无数据</div> |
||||
</div> |
||||
<div class="panel"> |
||||
<div class="panel-title">隐患级别分布</div> |
||||
<div v-if="levelList.length" ref="levelChart" class="chart" /> |
||||
<div v-else class="panel-empty">暂无数据</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="panel"> |
||||
<div class="panel-head"> |
||||
<div class="panel-title">月度安全运营报告</div> |
||||
<div class="panel-actions"> |
||||
<el-select v-model="reportYear" size="mini" style="width:110px" @change="loadMonthlyReport"> |
||||
<el-option v-for="y in yearOptions" :key="y" :label="`${y} 年`" :value="y" /> |
||||
</el-select> |
||||
<el-select v-model="reportMonth" size="mini" style="width:120px" @change="loadMonthlyReport"> |
||||
<el-option label="全年各月" :value="0" /> |
||||
<el-option v-for="m in 12" :key="m" :label="`${m} 月`" :value="m" /> |
||||
</el-select> |
||||
<el-button size="mini" type="primary" :loading="exporting" @click="handleExport">导出报告</el-button> |
||||
</div> |
||||
</div> |
||||
<el-table :data="monthlyReport" size="mini" class="analysis-table"> |
||||
<el-table-column label="月份" width="80" align="center"> |
||||
<template slot-scope="scope">{{ scope.row.month === 0 ? '全年合计' : `${scope.row.month} 月` }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="隐患总数" prop="hazardCount" align="right" /> |
||||
<el-table-column label="已通过" prop="approvedCount" align="right" /> |
||||
<el-table-column label="已驳回" prop="rejectedCount" align="right" /> |
||||
<el-table-column label="发放积分" prop="pointsIssued" align="right" /> |
||||
<el-table-column label="平均处理时长" align="right"> |
||||
<template slot-scope="scope">{{ hoursText(scope.row.avgProcessHours) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="准时率" align="right"> |
||||
<template slot-scope="scope">{{ percent(scope.row.onTimeRate) }}</template> |
||||
</el-table-column> |
||||
<el-table-column label="兑换单数" prop="exchangeOrderCount" align="right" /> |
||||
<el-table-column label="兑换消耗积分" prop="exchangePoints" align="right" /> |
||||
</el-table> |
||||
<div v-if="!monthlyReport.length && !loading" class="panel-empty">暂无数据</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import * as echarts from 'echarts' |
||||
import { saveAs } from 'file-saver' |
||||
import { blobValidate } from '@/utils/ruoyi' |
||||
import { |
||||
getStatsCategory, |
||||
getStatsLevel, |
||||
getTimelinessByLevel, |
||||
getRankingPoints, |
||||
getRankingReport, |
||||
getRankingExchange, |
||||
getMonthlyReport, |
||||
exportMonthlyReport |
||||
} from '@/api/analysis' |
||||
|
||||
export default { |
||||
name: 'DataAnalysis', |
||||
data() { |
||||
return { |
||||
loading: false, |
||||
exporting: false, |
||||
rankTab: 'points', |
||||
timeliness: [], |
||||
rankPoints: [], |
||||
rankReport: [], |
||||
rankExchange: [], |
||||
categoryList: [], |
||||
levelList: [], |
||||
reportYear: new Date().getFullYear(), |
||||
reportMonth: 0, |
||||
monthlyReport: [], |
||||
categoryChart: null, |
||||
levelChart: null |
||||
} |
||||
}, |
||||
computed: { |
||||
yearOptions() { |
||||
const current = new Date().getFullYear() |
||||
return [current, current - 1, current - 2, current - 3, current - 4] |
||||
} |
||||
}, |
||||
created() { |
||||
this.loadData() |
||||
}, |
||||
mounted() { |
||||
window.addEventListener('resize', this.handleResize) |
||||
}, |
||||
activated() { |
||||
this.handleResize() |
||||
}, |
||||
beforeDestroy() { |
||||
window.removeEventListener('resize', this.handleResize) |
||||
this.disposeCharts() |
||||
}, |
||||
deactivated() { |
||||
this.disposeCharts() |
||||
}, |
||||
methods: { |
||||
loadData() { |
||||
this.loading = true |
||||
Promise.all([ |
||||
getTimelinessByLevel().catch(() => ({ data: [] })), |
||||
getRankingPoints().catch(() => ({ data: [] })), |
||||
getRankingReport().catch(() => ({ data: [] })), |
||||
getRankingExchange().catch(() => ({ data: [] })), |
||||
getStatsCategory().catch(() => ({ data: [] })), |
||||
getStatsLevel().catch(() => ({ data: [] })) |
||||
]) |
||||
.then(([timeliness, points, report, exchange, category, level]) => { |
||||
this.timeliness = this.toList(timeliness) |
||||
this.rankPoints = this.toList(points) |
||||
this.rankReport = this.toList(report) |
||||
this.rankExchange = this.toList(exchange) |
||||
this.categoryList = this.toList(category) |
||||
this.levelList = this.toList(level) |
||||
this.$nextTick(this.renderCharts) |
||||
}) |
||||
.catch(() => { |
||||
this.$modal.msgError('数据分析加载失败,请检查后端服务和登录状态') |
||||
}) |
||||
.finally(() => { |
||||
this.loading = false |
||||
}) |
||||
this.loadMonthlyReport() |
||||
}, |
||||
loadMonthlyReport() { |
||||
getMonthlyReport({ year: this.reportYear, month: this.reportMonth }) |
||||
.then(response => { |
||||
const data = response.data || response |
||||
this.monthlyReport = Array.isArray(data) ? data : (data && (data.list || data.rows)) || [] |
||||
}) |
||||
.catch(() => { |
||||
this.monthlyReport = [] |
||||
}) |
||||
}, |
||||
toList(response) { |
||||
const data = response && response.data |
||||
if (Array.isArray(data)) return data |
||||
if (data && Array.isArray(data.list)) return data.list |
||||
if (data && Array.isArray(data.rows)) return data.rows |
||||
return [] |
||||
}, |
||||
renderCharts() { |
||||
this.renderCategoryChart() |
||||
this.renderLevelChart() |
||||
}, |
||||
renderCategoryChart() { |
||||
const el = this.$refs.categoryChart |
||||
if (!el || !this.categoryList.length) return |
||||
if (!this.categoryChart) this.categoryChart = echarts.init(el) |
||||
const names = this.categoryList.map(item => item.categoryName || '未分类') |
||||
const values = this.categoryList.map(item => Number(item.count || 0)) |
||||
this.categoryChart.setOption({ |
||||
grid: { top: 16, left: 8, right: 24, bottom: 8, containLabel: true }, |
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }}, |
||||
xAxis: { type: 'value', minInterval: 1, axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f0f0f2' }}, axisLabel: { color: '#9898ae', fontSize: 11 }}, |
||||
yAxis: { type: 'category', data: names, axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e7eb' }}, axisLabel: { color: '#5a5a72', fontSize: 11 }}, |
||||
series: [{ type: 'bar', data: values, barWidth: '46%', itemStyle: { color: '#1890ff', borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', color: '#5a5a72', fontSize: 11 }}] |
||||
}, true) |
||||
this.categoryChart.resize() |
||||
}, |
||||
renderLevelChart() { |
||||
const el = this.$refs.levelChart |
||||
if (!el || !this.levelList.length) return |
||||
if (!this.levelChart) this.levelChart = echarts.init(el) |
||||
const data = this.levelList.map(item => ({ name: item.levelName || '未分级', value: Number(item.count || 0) })) |
||||
this.levelChart.setOption({ |
||||
tooltip: { trigger: 'item' }, |
||||
legend: { bottom: 0, textStyle: { color: '#5a5a72', fontSize: 11 }}, |
||||
color: ['#1890ff', '#e8952f', '#d64550', '#2d8a4e'], |
||||
series: [{ |
||||
type: 'pie', |
||||
radius: ['42%', '68%'], |
||||
center: ['50%', '46%'], |
||||
avoidLabelOverlap: true, |
||||
label: { color: '#5a5a72', fontSize: 11 }, |
||||
data |
||||
}] |
||||
}, true) |
||||
this.levelChart.resize() |
||||
}, |
||||
handleResize() { |
||||
this.categoryChart && this.categoryChart.resize() |
||||
this.levelChart && this.levelChart.resize() |
||||
}, |
||||
disposeCharts() { |
||||
if (this.categoryChart) { this.categoryChart.dispose(); this.categoryChart = null } |
||||
if (this.levelChart) { this.levelChart.dispose(); this.levelChart = null } |
||||
}, |
||||
handleExport() { |
||||
this.exporting = true |
||||
exportMonthlyReport({ year: this.reportYear, month: this.reportMonth }) |
||||
.then(async data => { |
||||
if (blobValidate(data)) { |
||||
const scope = this.reportMonth === 0 ? '全年' : `${this.reportMonth}月` |
||||
saveAs(new Blob([data]), `月度安全运营报告-${this.reportYear}年${scope}.xlsx`) |
||||
this.$modal.msgSuccess('报告已导出') |
||||
} else { |
||||
const text = typeof data.text === 'function' ? await data.text() : String(data) |
||||
let message = '导出失败' |
||||
try { message = JSON.parse(text).msg || message } catch (error) { /* 非 JSON 响应 */ } |
||||
this.$modal.msgError(message) |
||||
} |
||||
}) |
||||
.catch(() => { |
||||
this.$modal.msgError('导出失败,请稍后重试') |
||||
}) |
||||
.finally(() => { |
||||
this.exporting = false |
||||
}) |
||||
}, |
||||
rankBadge(index) { |
||||
return ['🥇', '🥈', '🥉'][index] || String(index + 1) |
||||
}, |
||||
num(value) { |
||||
return value === undefined || value === null || value === '' ? '—' : value |
||||
}, |
||||
hoursText(value) { |
||||
if (value === undefined || value === null || value === '') return '—' |
||||
const n = Number(value) |
||||
if (Number.isNaN(n)) return String(value) |
||||
return n >= 24 ? `${(n / 24).toFixed(1)} d` : `${n} h` |
||||
}, |
||||
percent(value) { |
||||
if (value === undefined || value === null || value === '') return '—' |
||||
const n = Number(value) |
||||
return Number.isNaN(n) ? String(value) : `${n}%` |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.analysis-page { |
||||
min-height: calc(100vh - 52px); |
||||
} |
||||
|
||||
.two-col { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 14px; |
||||
margin-bottom: 14px; |
||||
} |
||||
|
||||
.panel { |
||||
padding: 16px 18px; |
||||
background: var(--prototype-panel, #fff); |
||||
border: 1px solid var(--prototype-divider, #f0f0f2); |
||||
border-radius: var(--prototype-radius, 8px); |
||||
} |
||||
|
||||
.panel-head { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
gap: 16px; |
||||
} |
||||
|
||||
.panel-title { |
||||
margin-bottom: 12px; |
||||
color: var(--prototype-text, #1a1a2e); |
||||
font-size: 14px; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.panel-head .panel-title { |
||||
margin-bottom: 12px; |
||||
} |
||||
|
||||
.panel-actions { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.panel-empty { |
||||
padding: 28px 0; |
||||
color: var(--prototype-text-muted, #9898ae); |
||||
font-size: 12px; |
||||
text-align: center; |
||||
} |
||||
|
||||
.chart { |
||||
width: 100%; |
||||
height: 264px; |
||||
} |
||||
|
||||
@media (max-width: 1100px) { |
||||
.two-col { |
||||
grid-template-columns: 1fr; |
||||
} |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue