feat(monitor): 添加操作日志绩效统计功能

- 在SysOperLogService中新增绩效概览、人员绩效和模块排行接口
- 在SysOperLogMapper中添加模块统计、人员统计和全局概览查询方法
- 在SysOperLogMapper.xml中实现绩效相关的SQL查询和ResultMap
- 在SysOperLogServiceImpl中实现绩效统计业务逻辑和评分算法
- 创建OperModuleStatVO、OperPersonVO和OperSummaryVO数据传输对象
- 新增OperPerformanceController提供绩效统计API接口
- 添加前端performance页面实现数据可视化展示和图表渲染
This commit is contained in:
2026-07-01 15:43:26 +08:00
parent ad25227400
commit 9233d09edc
11 changed files with 1101 additions and 4 deletions

View File

@@ -0,0 +1,28 @@
import request from '@/utils/request'
// 绩效概览统计
export function getSummary(params) {
return request({
url: '/monitor/performance/summary',
method: 'get',
params: params
})
}
// 人员绩效列表(含模块明细)
export function getPersonList(params) {
return request({
url: '/monitor/performance/person',
method: 'get',
params: params
})
}
// 模块使用排行
export function getModuleRanking(params) {
return request({
url: '/monitor/performance/module',
method: 'get',
params: params
})
}

View File

@@ -0,0 +1,481 @@
<template>
<div class="perf-container" v-loading="loading">
<!-- ========== 筛选栏 ========== -->
<div class="perf-filter">
<el-form :inline="true" :model="filterForm" size="small" class="perf-filter-form">
<el-form-item label="时间范围">
<el-date-picker
v-model="filterForm.dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width:230px"
/>
</el-form-item>
<el-form-item label="部门">
<el-input v-model="filterForm.deptName" placeholder="部门" clearable style="width:130px" />
</el-form-item>
<el-form-item label="人员">
<el-input v-model="filterForm.operName" placeholder="人员" clearable style="width:130px" />
</el-form-item>
<el-form-item label="模块">
<el-input v-model="filterForm.title" placeholder="模块" clearable style="width:130px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- ========== 六大指标卡 ========== -->
<div class="perf-kpi-row">
<div class="perf-kpi kpi-total">
<div class="kpi-icon"><i class="el-icon-s-data" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.totalOperations || 0 }}</div>
<div class="kpi-lbl">总操作量</div>
</div>
</div>
<div class="perf-kpi kpi-ok">
<div class="kpi-icon"><i class="el-icon-circle-check" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.successCount || 0 }}</div>
<div class="kpi-lbl">成功操作</div>
</div>
</div>
<div class="perf-kpi kpi-fail">
<div class="kpi-icon"><i class="el-icon-circle-close" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.failCount || 0 }}</div>
<div class="kpi-lbl">失败操作</div>
</div>
</div>
<div class="perf-kpi kpi-user">
<div class="kpi-icon"><i class="el-icon-user" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.activePersonCount || 0 }}</div>
<div class="kpi-lbl">活跃人数</div>
</div>
</div>
<div class="perf-kpi kpi-mod">
<div class="kpi-icon"><i class="el-icon-menu" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.activeModuleCount || 0 }}</div>
<div class="kpi-lbl">活跃模块</div>
</div>
</div>
<div class="perf-kpi kpi-avg">
<div class="kpi-icon"><i class="el-icon-data-line" /></div>
<div class="kpi-body">
<div class="kpi-val">{{ summaryData.avgPerPerson || 0 }}</div>
<div class="kpi-lbl">人均操作</div>
</div>
</div>
</div>
<!-- ========== 图表区 两行每行两栏高度统一 ========== -->
<div class="perf-charts">
<!-- Row 1 -->
<el-row :gutter="16" class="perf-row">
<el-col :span="10">
<div class="perf-panel">
<div class="panel-hd">模块操作排行 Top 10</div>
<div class="panel-bd"><div ref="moduleOpsChartRef" class="chart-box" /></div>
</div>
</el-col>
<el-col :span="14">
<div class="perf-panel">
<div class="panel-hd">模块使用明细 谁用了哪个模块多少次</div>
<div class="panel-bd"><div ref="modulePersonDetailRef" class="chart-box" /></div>
</div>
</el-col>
</el-row>
<!-- Row 2 -->
<el-row :gutter="16" class="perf-row">
<el-col :span="14">
<div class="perf-panel">
<div class="panel-hd">人员操作排行 Top 15</div>
<div class="panel-bd"><div ref="personBarChartRef" class="chart-box" /></div>
</div>
</el-col>
<el-col :span="10">
<div class="perf-panel">
<div class="panel-hd">业务类型分布</div>
<div class="panel-bd"><div ref="businessPieChartRef" class="chart-box" /></div>
</div>
</el-col>
</el-row>
</div>
<!-- ========== 人员明细表格 ========== -->
<div class="perf-table">
<div class="perf-panel">
<div class="panel-hd">
<span>人员绩效明细</span>
<el-button
style="float:right"
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['monitor:performance:list']"
>导出</el-button>
</div>
<div class="panel-bd" style="padding:0">
<el-table :data="personList" border stripe row-key="operName" style="width:100%">
<el-table-column type="expand">
<template slot-scope="s">
<div v-if="s.row.moduleStats && s.row.moduleStats.length" class="expand-box">
<el-table :data="s.row.moduleStats" border size="mini">
<el-table-column prop="title" label="模块" />
<el-table-column prop="totalCount" label="次数" width="70" align="center" />
<el-table-column prop="addCount" label="新增" width="55" align="center" />
<el-table-column prop="editCount" label="修改" width="55" align="center" />
<el-table-column prop="deleteCount" label="删除" width="55" align="center" />
<el-table-column prop="otherCount" label="其它" width="55" align="center" />
<el-table-column prop="successRate" label="成功率%" width="80" align="center" />
</el-table>
</div>
<div v-else class="expand-empty">暂无模块明细</div>
</template>
</el-table-column>
<el-table-column prop="operName" label="操作人员" min-width="100" show-overflow-tooltip />
<el-table-column prop="deptName" label="部门" min-width="100" show-overflow-tooltip />
<el-table-column prop="totalCount" label="总次数" width="80" align="center" sortable />
<el-table-column prop="successCount" label="成功" width="65" align="center" />
<el-table-column prop="failCount" label="失败" width="65" align="center" />
<el-table-column prop="successRate" label="成功率%" width="85" align="center" />
<el-table-column prop="addCount" label="新增" width="60" align="center" />
<el-table-column prop="editCount" label="修改" width="60" align="center" />
<el-table-column prop="deleteCount" label="删除" width="60" align="center" />
<el-table-column prop="score" label="综合评分" width="90" align="center" sortable>
<template slot-scope="s">
<el-tag
:type="s.row.score >= 80 ? 'success' : s.row.score >= 50 ? 'warning' : 'danger'"
size="small"
effect="plain"
>{{ s.row.score }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastOperTime" label="最近操作时间" width="160" align="center">
<template slot-scope="s">
<span>{{ parseTime(s.row.lastOperTime) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
<script>
import { getSummary, getPersonList, getModuleRanking } from "@/api/monitor/performance";
import * as echarts from "echarts";
export default {
name: "Performance",
data() {
return {
loading: false,
filterForm: {
dateRange: [],
deptName: "",
operName: "",
title: ""
},
summaryData: {},
personList: [],
moduleRanking: [],
personBarChart: null,
moduleOpsChart: null,
modulePersonDetailChart: null,
businessPieChart: null
};
},
mounted() {
this.handleQuery();
window.addEventListener("resize", this.handleResize);
},
beforeDestroy() {
window.removeEventListener("resize", this.handleResize);
this.disposeCharts();
},
methods: {
buildParams() {
const p = {};
if (this.filterForm.dateRange && this.filterForm.dateRange.length === 2) {
p.beginTime = this.filterForm.dateRange[0] + " 00:00:00";
p.endTime = this.filterForm.dateRange[1] + " 23:59:59";
}
if (this.filterForm.deptName) p.deptName = this.filterForm.deptName;
if (this.filterForm.operName) p.operName = this.filterForm.operName;
if (this.filterForm.title) p.title = this.filterForm.title;
return p;
},
async handleQuery() {
this.loading = true;
try {
const params = this.buildParams();
const [sr, pr, mr] = await Promise.all([
getSummary(params),
getPersonList(params),
getModuleRanking(params)
]);
this.summaryData = sr.data || {};
this.personList = pr.data || [];
this.moduleRanking = mr.data || [];
this.$nextTick(() => this.renderCharts());
} catch (e) {
console.error("加载绩效数据失败", e);
} finally {
this.loading = false;
}
},
handleReset() {
this.filterForm = { dateRange: [], deptName: "", operName: "", title: "" };
this.handleQuery();
},
renderCharts() {
this.renderModuleOps();
this.renderModulePersonDetail();
this.renderPersonBar();
this.renderPie();
},
/* ---- 模块操作次数排行 ---- */
renderModuleOps() {
if (!this.$refs.moduleOpsChartRef) return;
if (!this.moduleOpsChart) this.moduleOpsChart = echarts.init(this.$refs.moduleOpsChartRef);
const top = this.moduleRanking.slice(0, 10);
this.moduleOpsChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
grid: { left: 0, right: 24, bottom: 24, top: 8, containLabel: true },
xAxis: { type: "category", data: top.map(m => m.title), axisLabel: { rotate: 35, fontSize: 11 } },
yAxis: { type: "value" },
series: [{
type: "bar", data: top.map(m => m.totalCount), barWidth: "55%",
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#409EFF" }, { offset: 1, color: "#a0cfff" }
])
},
label: { show: true, position: "top", fontSize: 11 }
}]
}, true);
},
/* ---- 模块-人员明细堆叠 ---- */
renderModulePersonDetail() {
if (!this.$refs.modulePersonDetailRef) return;
if (!this.modulePersonDetailChart) this.modulePersonDetailChart = echarts.init(this.$refs.modulePersonDetailRef);
const map = {};
this.personList.forEach(p => {
(p.moduleStats || []).forEach(m => {
if (!map[m.title]) map[m.title] = {};
map[m.title][p.operName] = (map[m.title][p.operName] || 0) + (m.totalCount || 0);
});
});
const tops = Object.entries(map)
.map(([t, pm]) => ({ title: t, personMap: pm, total: Object.values(pm).reduce((s, c) => s + c, 0) }))
.sort((a, b) => b.total - a.total).slice(0, 6);
if (!tops.length) return;
const allSet = new Set();
const others = [];
tops.forEach(mod => {
const e = Object.entries(mod.personMap).sort((a, b) => b[1] - a[1]);
mod._top5 = e.slice(0, 5);
mod._other = e.slice(5).reduce((s, [, n]) => s + n, 0);
mod._top5.forEach(([n]) => allSet.add(n));
});
const names = Array.from(allSet);
const colors = ["#5470C6","#91CC75","#FAC858","#EE6666","#73C0DE","#FC8452","#9A60B4","#3BA272","#EA7CCC","#48C9B0","#D48265","#61A0A8","#CA8622","#BDA29A","#6E7074","#FF9F7F","#A5D8FF","#FFD666","#95DE64","#9E87FF"];
const cmap = {}; names.forEach((n, i) => cmap[n] = colors[i % colors.length]);
const ser = names.map(n => ({ name: n, type: "bar", stack: "a", emphasis: { focus: "series" }, data: tops.map(m => { const f = m._top5.find(([x]) => x === n); return f ? f[1] : 0; }), itemStyle: { color: cmap[n] } }));
const ob = tops.map(m => m._other);
if (ob.some(v => v > 0)) ser.push({ name: "其他", type: "bar", stack: "a", emphasis: { focus: "series" }, data: ob, itemStyle: { color: "#C0C4CC" } });
this.modulePersonDetailChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" }, formatter(p) { let h = "<b>" + p[0].axisValue + "</b><br/>"; p.filter(x => x.value > 0).sort((a, b) => b.value - a.value).forEach(x => { h += '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px;background:' + x.color + '"/></span>'; h += x.seriesName + ": <b>" + x.value + "次</b><br/>"; }); return h; } },
legend: { type: "scroll", bottom: 0, textStyle: { fontSize: 10 } },
grid: { left: 0, right: 24, bottom: 48, top: 8, containLabel: true },
xAxis: { type: "value" },
yAxis: { type: "category", data: tops.map(m => m.title), axisLabel: { fontSize: 12 } },
series: ser
}, true);
},
/* ---- 人员操作排行 ---- */
renderPersonBar() {
if (!this.$refs.personBarChartRef) return;
if (!this.personBarChart) this.personBarChart = echarts.init(this.$refs.personBarChartRef);
const top = this.personList.slice(0, 15);
this.personBarChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
grid: { left: 0, right: 24, bottom: 24, top: 8, containLabel: true },
xAxis: { type: "category", data: top.map(p => p.operName), axisLabel: { rotate: 35, fontSize: 11 } },
yAxis: { type: "value" },
series: [{
type: "bar", data: top.map(p => p.totalCount || 0), barWidth: "50%",
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#5470C6" }, { offset: 1, color: "#b3c8ff" }
])
},
label: { show: true, position: "top", fontSize: 11 }
}]
}, true);
},
/* ---- 业务类型饼图 ---- */
renderPie() {
if (!this.$refs.businessPieChartRef) return;
if (!this.businessPieChart) this.businessPieChart = echarts.init(this.$refs.businessPieChartRef);
let a = 0, e = 0, d = 0, o = 0;
this.personList.forEach(p => { a += p.addCount || 0; e += p.editCount || 0; d += p.deleteCount || 0; o += p.otherCount || 0; });
const data = [{ value: a, name: "新增" }, { value: e, name: "修改" }, { value: d, name: "删除" }, { value: o, name: "其它" }].filter(x => x.value > 0);
this.businessPieChart.setOption({
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
legend: { orient: "vertical", left: 8, top: "center", textStyle: { fontSize: 12 } },
series: [{
type: "pie", radius: ["45%", "72%"], center: ["58%", "50%"],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },
label: { show: true, formatter: "{b}\n{d}%" },
emphasis: { label: { fontSize: 16, fontWeight: "bold" } },
data
}]
}, true);
},
handleResize() {
[this.personBarChart, this.moduleOpsChart, this.modulePersonDetailChart, this.businessPieChart].forEach(c => c && c.resize());
},
disposeCharts() {
[this.personBarChart, this.moduleOpsChart, this.modulePersonDetailChart, this.businessPieChart].forEach(c => c && c.dispose());
},
handleExport() {
this.download("monitor/operlog/export", { ...this.buildParams() }, `performance_${new Date().getTime()}.xlsx`);
}
}
};
</script>
<style lang="scss" scoped>
// ===================== 容器 =====================
.perf-container {
padding: 16px 20px 24px;
background: #f0f2f5;
min-height: 100%;
}
// ===================== 筛选栏 =====================
.perf-filter {
background: #fff;
border-radius: 8px;
padding: 14px 20px 2px;
margin-bottom: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,.04);
}
.perf-filter-form ::v-deep .el-form-item {
margin-right: 12px;
margin-bottom: 12px;
}
// ===================== KPI 卡片行 =====================
.perf-kpi-row {
display: flex;
gap: 14px;
margin-bottom: 14px;
}
.perf-kpi {
flex: 1;
display: flex;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 18px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.04);
transition: box-shadow .2s;
cursor: default;
&:hover { box-shadow: 0 4px 14px rgba(0,0,0,.08); }
}
.kpi-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; color: #fff; flex-shrink: 0; margin-right: 14px;
}
.kpi-body { overflow: hidden; }
.kpi-val { font-size: 26px; font-weight: 700; line-height: 1.2; color: #1d2129; }
.kpi-lbl { font-size: 12px; color: #86909c; margin-top: 2px; }
// 六色渐变
.kpi-total .kpi-icon { background: linear-gradient(135deg, #5470C6, #8ba7f0); }
.kpi-ok .kpi-icon { background: linear-gradient(135deg, #00b42a, #57d27a); }
.kpi-fail .kpi-icon { background: linear-gradient(135deg, #f53f3f, #f98981); }
.kpi-user .kpi-icon { background: linear-gradient(135deg, #0fc6c2, #5ce0db); }
.kpi-mod .kpi-icon { background: linear-gradient(135deg, #ff7d00, #ffb566); }
.kpi-avg .kpi-icon { background: linear-gradient(135deg, #722ed1, #b491e8); }
// ===================== 面板卡片 =====================
.perf-row { margin-bottom: 14px; }
.perf-panel {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.04);
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-hd {
padding: 12px 16px;
font-size: 14px; font-weight: 600; color: #1d2129;
border-bottom: 1px solid #f2f3f5;
background: #fafbfc;
overflow: hidden;
}
.panel-bd {
flex: 1;
padding: 12px 8px 8px;
min-height: 0;
}
.chart-box {
width: 100%;
height: 340px;
}
// ===================== 表格区 =====================
.perf-table { margin-top: 14px; }
.perf-table .perf-panel .panel-bd { padding: 0; }
.expand-box {
padding: 10px 28px;
background: #f7f8fa;
}
.expand-empty {
padding: 16px;
text-align: center;
color: #c9cdd4;
font-size: 13px;
}
// 表格微调
.perf-table ::v-deep .el-table th {
background: #f7f8fa;
font-weight: 600;
color: #4e5969;
}
.perf-table ::v-deep .el-table--striped .el-table__body tr.el-table__row--striped td {
background: #fafbfc;
}
</style>