新增分析:实现考勤专题统计与异常排行

This commit is contained in:
wangzhibiao committed 2026-09-20 20:36:33 +08:00
1 parent f17526b409
commit d0ae543a4d
1 file changed
+593
@@ -0,0 +1,593 @@
<template>
<div class="app-container attendance-analysis-container">
<!-- 1. 顶部筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :model="queryParams" ref="queryForm" :inline="true" size="small" label-width="78px">
<el-form-item label="起止时间" prop="dateRange">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
:picker-options="pickerOptions"
@change="handleDateRangeChange"
style="width: 240px;"
/>
</el-form-item>
<el-form-item label="培训计划" prop="planId">
<el-select v-model="queryParams.planId" placeholder="全部培训计划" clearable filterable style="width: 220px;">
<el-option
v-for="item in planOptions"
:key="item.planId"
:label="item.planName"
:value="item.planId"
/>
</el-select>
</el-form-item>
<el-form-item label="所属课程" prop="courseId">
<el-select v-model="queryParams.courseId" placeholder="全部课程" clearable filterable style="width: 200px;">
<el-option
v-for="item in courseOptions"
:key="item.cId"
:label="item.cName"
:value="item.cId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询分析</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 2. 统计指标卡片 (带口径待确认警示) -->
<el-row :gutter="16" class="mt16" v-loading="loadingSummary">
<el-col :xs="12" :sm="12" :md="5">
<div class="kpi-box">
<div class="kpi-title">累计已开课次</div>
<div class="kpi-num">{{ summary.lessonTotal || 0 }} <span class="unit">节</span></div>
<div class="kpi-desc">范围内实际调度课次</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="5">
<div class="kpi-box">
<div class="kpi-title">应到人次</div>
<div class="kpi-num">{{ summary.expectedAttendTotal || 0 }} <span class="unit">人次</span></div>
<div class="kpi-desc">班级参训基准总负荷</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="5">
<div class="kpi-box">
<div class="kpi-title">正常出勤人次</div>
<div class="kpi-num text-success">{{ summary.normalAttendTotal || 0 }} <span class="unit">人次</span></div>
<div class="kpi-desc">按时到课核验记录</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="4">
<div class="kpi-box">
<div class="kpi-title">异常考勤人次</div>
<div class="kpi-num text-danger">{{ summary.abnormalAttendTotal || 0 }} <span class="unit">人次</span></div>
<div class="kpi-desc">缺勤/迟到/早退/请假/无打卡</div>
</div>
</el-col>
<el-col :xs="24" :sm="24" :md="5">
<div class="kpi-box rate-box">
<div class="kpi-title">
综合出勤率
<el-tooltip :content="summary.metricDefinitionNotice || '出勤率分母采用开课所属班级参训学员应到人次,业务标准待甲方确认'" placement="top">
<i class="el-icon-question" style="color: #E6A23C; cursor: pointer; margin-left: 4px;" />
</el-tooltip>
</div>
<div class="kpi-num text-primary">{{ summary.attendanceRate != null ? summary.attendanceRate : 0 }}%</div>
<div class="kpi-desc">正常人次 / 应到总人次</div>
</div>
</el-col>
</el-row>
<!-- 业务口径状态警示条 -->
<el-alert
:title="summary.metricDefinitionNotice"
type="warning"
:closable="false"
show-icon
class="mt16"
/>
<!-- 3. 图表区 1:趋势折线图 & 状态环形图 -->
<el-row :gutter="16" class="mt16">
<el-col :xs="24" :sm="24" :lg="15">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-data-line" style="color: #409EFF; margin-right: 6px;"></i>每日考勤打卡趋势走势 (Daily Attendance Trend)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="dailyTrendChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.dailyTrend || charts.dailyTrend.length === 0)" class="empty-chart">
<el-empty description="所选筛选范围内暂无每日打卡数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :lg="9">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-pie-chart" style="color: #E6A23C; margin-right: 6px;"></i>考勤打卡状态构成 (含无签到记录)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="statusDistChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.statusDistribution || charts.statusDistribution.length === 0)" class="empty-chart">
<el-empty description="暂无考勤状态分布" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 4. 图表区 2:培训计划对比 & 课程对比 -->
<el-row :gutter="16" class="mt16">
<el-col :xs="24" :sm="24" :lg="12">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-s-cooperation" style="color: #409EFF; margin-right: 6px;"></i>培训计划出勤对比 (Top 10)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="planCompChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.planComparison || charts.planComparison.length === 0)" class="empty-chart">
<el-empty description="暂无班级考勤对比数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :lg="12">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-notebook-2" style="color: #67C23A; margin-right: 6px;"></i>课程考勤对比 (Top 10)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="courseCompChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.courseComparison || charts.courseComparison.length === 0)" class="empty-chart">
<el-empty description="暂无课程考勤对比数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 5. 异常学员 Top 10 列表与柱图穿透 -->
<el-card shadow="never" class="mt16" v-loading="loadingCharts">
<div slot="header" class="card-header">
<span><i class="el-icon-user" style="color: #F56C6C; margin-right: 6px;"></i>考勤异常重点学员排行 Top 10 (异常出勤频次预警)</span>
<span class="tip-text">点击学员或操作栏可直接穿透至【学员360画像】</span>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :lg="10">
<div ref="abnormalChart" style="height: 340px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.abnormalStudentTop10 || charts.abnormalStudentTop10.length === 0)" class="empty-chart">
<el-empty description="当前无异常考勤学员" :image-size="70" />
</div>
</el-col>
<el-col :xs="24" :sm="24" :lg="14">
<el-table :data="charts.abnormalStudentTop10 || []" border size="mini" stripe style="width: 100%; margin-top: 10px;">
<el-table-column type="index" label="排名" width="50" align="center" />
<el-table-column prop="studentId" label="学号" width="110" align="center" />
<el-table-column label="姓名" width="100" align="center">
<template slot-scope="scope">
<el-link type="primary" :underline="false" @click="drillToPortrait(scope.row)">
{{ scope.row.studentName }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="planName" label="所属培训班" min-width="140" show-overflow-tooltip />
<el-table-column prop="absentCount" label="缺勤" width="60" align="center">
<template slot-scope="scope">
<span :class="scope.row.absentCount > 0 ? 'text-danger font-bold' : ''">{{ scope.row.absentCount }}</span>
</template>
</el-table-column>
<el-table-column prop="lateCount" label="迟到" width="60" align="center" />
<el-table-column prop="leaveCount" label="请假" width="60" align="center" />
<el-table-column prop="totalAbnormalCount" label="总异常次数" width="90" align="center">
<template slot-scope="scope">
<el-tag size="mini" type="danger">{{ scope.row.totalAbnormalCount }} 次</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-view" @click="drillToPortrait(scope.row)">
画像
</el-button>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script>
import { getAttendanceSummary, getAttendanceCharts } from '@/api/train/analysis'
import { listPlan } from '@/api/train/plan'
import { listCourse } from '@/api/train/course'
export default {
name: 'AttendanceAnalysis',
data() {
return {
dateRange: [],
queryParams: {
startDate: '',
endDate: '',
planId: '',
courseId: ''
},
planOptions: [],
courseOptions: [],
summary: {},
charts: {},
loadingSummary: false,
loadingCharts: false,
pickerOptions: {
shortcuts: [
{
text: '最近30天',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
picker.$emit('pick', [start, end])
}
},
{
text: '最近90天',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90)
picker.$emit('pick', [start, end])
}
},
{
text: '最近1年',
onClick(picker) {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 365)
picker.$emit('pick', [start, end])
}
}
]
},
dailyTrendInstance: null,
statusDistInstance: null,
planCompInstance: null,
courseCompInstance: null,
abnormalInstance: null
}
},
created() {
this.initDefaultDates()
this.loadFilterOptions()
this.handleQuery()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
this.disposeCharts()
},
methods: {
initDefaultDates() {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
const format = d => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
this.dateRange = [format(start), format(end)]
this.queryParams.startDate = this.dateRange[0]
this.queryParams.endDate = this.dateRange[1]
},
loadFilterOptions() {
listPlan({ pageNum: 1, pageSize: 100 }).then(res => {
this.planOptions = res.rows || []
})
listCourse({ pageNum: 1, pageSize: 100 }).then(res => {
this.courseOptions = res.rows || []
})
},
handleDateRangeChange(val) {
if (val && val.length === 2) {
this.queryParams.startDate = val[0]
this.queryParams.endDate = val[1]
} else {
this.queryParams.startDate = ''
this.queryParams.endDate = ''
}
},
handleQuery() {
this.fetchSummary()
this.fetchCharts()
},
resetQuery() {
this.initDefaultDates()
this.queryParams.planId = ''
this.queryParams.courseId = ''
this.handleQuery()
},
fetchSummary() {
this.loadingSummary = true
getAttendanceSummary(this.queryParams).then(res => {
if (res.code === 200) {
this.summary = res.data || {}
}
}).finally(() => {
this.loadingSummary = false
})
},
fetchCharts() {
this.loadingCharts = true
getAttendanceCharts(this.queryParams).then(res => {
if (res.code === 200) {
this.charts = res.data || {}
this.$nextTick(() => {
this.renderDailyTrend()
this.renderStatusDist()
this.renderPlanComp()
this.renderCourseComp()
this.renderAbnormalChart()
})
}
}).finally(() => {
this.loadingCharts = false
})
},
renderDailyTrend() {
if (!this.$refs.dailyTrendChart) return
if (!this.dailyTrendInstance) {
this.dailyTrendInstance = this.$echarts.init(this.$refs.dailyTrendChart)
}
const raw = this.charts.dailyTrend || []
const dates = raw.map(i => i.date)
const normal = raw.map(i => i.normalCount)
const abnormal = raw.map(i => i.abnormalCount)
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['正常出勤人次', '异常出勤人次'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '正常出勤人次',
type: 'line',
smooth: true,
data: normal,
itemStyle: { color: '#67C23A' }
},
{
name: '异常出勤人次',
type: 'line',
smooth: true,
data: abnormal,
itemStyle: { color: '#F56C6C' }
}
]
}
this.dailyTrendInstance.setOption(option)
},
renderStatusDist() {
if (!this.$refs.statusDistChart) return
if (!this.statusDistInstance) {
this.statusDistInstance = this.$echarts.init(this.$refs.statusDistChart)
}
const raw = this.charts.statusDistribution || []
const data = raw.map(i => ({ name: i.name, value: i.value }))
const option = {
tooltip: { trigger: 'item', formatter: '{b}: {c} 人次 ({d}%)' },
legend: { bottom: 0, left: 'center' },
color: ['#67C23A', '#E6A23C', '#F56C6C', '#409EFF', '#909399', '#C0C4CC'],
series: [
{
name: '考勤状态',
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: data
}
]
}
this.statusDistInstance.setOption(option)
},
renderPlanComp() {
if (!this.$refs.planCompChart) return
if (!this.planCompInstance) {
this.planCompInstance = this.$echarts.init(this.$refs.planCompChart)
}
const raw = this.charts.planComparison || []
const names = raw.map(i => i.name).reverse()
const normal = raw.map(i => i.normalCount).reverse()
const abnormal = raw.map(i => i.abnormalCount).reverse()
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['正常出勤', '异常考勤'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'value', minInterval: 1 },
yAxis: {
type: 'category',
data: names,
axisLabel: { formatter: val => val.length > 8 ? val.substring(0, 8) + '...' : val }
},
series: [
{ name: '正常出勤', type: 'bar', stack: 'total', data: normal, itemStyle: { color: '#67C23A' } },
{ name: '异常考勤', type: 'bar', stack: 'total', data: abnormal, itemStyle: { color: '#F56C6C' } }
]
}
this.planCompInstance.setOption(option)
},
renderCourseComp() {
if (!this.$refs.courseCompChart) return
if (!this.courseCompInstance) {
this.courseCompInstance = this.$echarts.init(this.$refs.courseCompChart)
}
const raw = this.charts.courseComparison || []
const names = raw.map(i => i.name).reverse()
const normal = raw.map(i => i.normalCount).reverse()
const abnormal = raw.map(i => i.abnormalCount).reverse()
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['正常出勤', '异常考勤'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'value', minInterval: 1 },
yAxis: {
type: 'category',
data: names,
axisLabel: { formatter: val => val.length > 8 ? val.substring(0, 8) + '...' : val }
},
series: [
{ name: '正常出勤', type: 'bar', stack: 'total', data: normal, itemStyle: { color: '#409EFF' } },
{ name: '异常考勤', type: 'bar', stack: 'total', data: abnormal, itemStyle: { color: '#E6A23C' } }
]
}
this.courseCompInstance.setOption(option)
},
renderAbnormalChart() {
if (!this.$refs.abnormalChart) return
if (!this.abnormalInstance) {
this.abnormalInstance = this.$echarts.init(this.$refs.abnormalChart)
}
const raw = this.charts.abnormalStudentTop10 || []
const names = raw.map(i => i.studentName).reverse()
const counts = raw.map(i => i.totalAbnormalCount).reverse()
const option = {
title: { text: '异常频次分布', textStyle: { fontSize: 13, color: '#606266' } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '6%', bottom: '3%', containLabel: true },
xAxis: { type: 'value', minInterval: 1 },
yAxis: { type: 'category', data: names },
series: [
{
name: '总异常人次',
type: 'bar',
data: counts,
itemStyle: { color: '#F56C6C' },
label: { show: true, position: 'right' }
}
]
}
this.abnormalInstance.setOption(option)
},
handleResize() {
if (this.dailyTrendInstance) this.dailyTrendInstance.resize()
if (this.statusDistInstance) this.statusDistInstance.resize()
if (this.planCompInstance) this.planCompInstance.resize()
if (this.courseCompInstance) this.courseCompInstance.resize()
if (this.abnormalInstance) this.abnormalInstance.resize()
},
disposeCharts() {
if (this.dailyTrendInstance) { this.dailyTrendInstance.dispose(); this.dailyTrendInstance = null }
if (this.statusDistInstance) { this.statusDistInstance.dispose(); this.statusDistInstance = null }
if (this.planCompInstance) { this.planCompInstance.dispose(); this.planCompInstance = null }
if (this.courseCompInstance) { this.courseCompInstance.dispose(); this.courseCompInstance = null }
if (this.abnormalInstance) { this.abnormalInstance.dispose(); this.abnormalInstance = null }
},
drillToPortrait(row) {
if (row && row.studentId) {
this.$router.push({
path: '/train/portrait',
query: { studentId: row.studentId, planId: row.planId }
})
}
}
}
}
</script>
<style scoped lang="scss">
.attendance-analysis-container {
background-color: #f0f2f5;
padding: 16px 20px;
}
.mt16 {
margin-top: 16px;
}
.kpi-box {
background: #fff;
border-radius: 4px;
padding: 14px 16px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.kpi-title {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.kpi-num {
font-size: 22px;
font-weight: bold;
color: #303133;
line-height: 1.2;
margin-bottom: 6px;
.unit {
font-size: 12px;
font-weight: normal;
color: #909399;
}
}
.kpi-desc {
font-size: 11px;
color: #909399;
}
}
.rate-box {
border-top: 3px solid #409EFF;
}
.text-primary { color: #409EFF !important; }
.text-success { color: #67C23A !important; }
.text-danger { color: #F56C6C !important; }
.font-bold { font-weight: bold; }
.chart-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 14px;
.tip-text {
font-size: 12px;
color: #909399;
font-weight: normal;
}
}
.chart-wrapper {
position: relative;
.empty-chart {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>