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 @@ |
|||||||
|
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 @@ |
|||||||
|
<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