新增分析:实现管理驾驶舱聚合接口与可视化

This commit is contained in:
wangzhibiao committed 2026-09-20 20:36:30 +08:00
1 parent 992791b7bd
commit f17526b409
23 files changed
+3077 -23

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import request from '@/utils/request'
// 获取管理驾驶舱宏观概览
export function getDashboardOverview() {
return request({
url: '/train/analysis/dashboard/overview',
method: 'get'
})
}
// 获取管理驾驶舱图表数据
export function getDashboardCharts() {
return request({
url: '/train/analysis/dashboard/charts',
method: 'get'
})
}
// 获取考勤专题分析汇总 KPI
export function getAttendanceSummary(params) {
return request({
url: '/train/analysis/attendance/summary',
method: 'get',
params: params
})
}
// 获取考勤专题分析图表集合
export function getAttendanceCharts(params) {
return request({
url: '/train/analysis/attendance/charts',
method: 'get',
params: params
})
}
// 获取指定学员 360 客观全景画像
export function getStudent360(studentId) {
return request({
url: '/train/analysis/portrait/student-360/' + studentId,
method: 'get'
})
}
+3 -23
View File
@@ -4,29 +4,7 @@
<div v-if="loadingRoles" v-loading="true" style="height: 300px;" />
<!-- 1. 管理员优先展示管理端综合驾驶舱 (最高优先级: admin / train_admin) -->
<div v-else-if="isAdmin" class="app-container home">
<el-row :gutter="20">
<el-col :sm="24" :lg="14" style="padding-left: 20px;">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 16px;">SYFJGYD学员管理系统</span>
</div>
<div class="text item" style="margin-bottom: 16px; font-size: 14px; color: #606266; line-height: 1.8;">
<p>欢迎使用SYFJGYD学员管理系统。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。</p>
</div>
<div class="text item" style="margin-bottom: 20px; text-align: center">
<img src="@/assets/images/version.png" alt="系统架构" style="max-width: 100%; border-radius: 4px;">
</div>
<el-divider style="margin: 15px 0;" />
<div class="text item" style="display: flex; justify-content: space-between; color: #909399; font-size: 13px;">
<span>系统版本:V1.0</span>
<span>版权所有:中国科学院沈阳计算技术研究所有限公司</span>
</div>
</el-card>
</el-col>
</el-row>
<el-divider />
</div>
<admin-dashboard v-else-if="isAdmin" />
<!-- 2. 异常检测:同时拥有教师与学员互斥角色,展示受控安全告警 -->
<div v-else-if="hasRoleConflict" class="app-container">
@@ -59,12 +37,14 @@
</template>
<script>
import AdminDashboard from '@/views/train/analysis/dashboard/index'
import TeacherDashboard from '@/views/train/me/teacher/index'
import StudentDashboard from '@/views/train/me/student/index'
export default {
name: 'Index',
components: {
AdminDashboard,
TeacherDashboard,
StudentDashboard
},
@@ -0,0 +1,655 @@
<template>
<div class="dashboard-editor-container">
<!-- 1. 顶部标题与数据更新状态 -->
<div class="dashboard-header">
<div class="header-title">
<h2>SYFJGYD学员管理系统 · 管理驾驶舱</h2>
<span class="subtitle">全校培训教学运行、排课调度、学员考勤与生活保障综合宏观指挥大盘</span>
</div>
<div class="header-action">
<el-button icon="el-icon-refresh" size="mini" type="primary" plain :loading="loading" @click="refreshAll">刷新数据</el-button>
</div>
</div>
<!-- 2. 核心 KPI 卡片 (8项客观清晰指标) -->
<el-row :gutter="16" class="kpi-row" v-loading="loadingOverview">
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">学员总数</div>
<div class="kpi-value text-primary">{{ overview.studentTotal || 0 }}</div>
<div class="kpi-sub">主档档案正常</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">教师总数</div>
<div class="kpi-value text-success">{{ overview.teacherTotal || 0 }}</div>
<div class="kpi-sub">专兼职讲师</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">课程总数</div>
<div class="kpi-value text-info">{{ overview.courseTotal || 0 }}</div>
<div class="kpi-sub">标准课程库</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">培训计划</div>
<div class="kpi-value text-warning">{{ overview.planTotal || 0 }}</div>
<div class="kpi-sub">全周期班级数</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计排课</div>
<div class="kpi-value">{{ overview.lessonTotal || 0 }}</div>
<div class="kpi-sub">课次课时调度</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计参训</div>
<div class="kpi-value">{{ overview.enrollmentTotal || 0 }}</div>
<div class="kpi-sub">人次总规模</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">已报到人数</div>
<div class="kpi-value text-success">{{ overview.reportTotal || 0 }}</div>
<div class="kpi-sub">核验确认人次</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">当前在宿</div>
<div class="kpi-value text-primary">{{ overview.accommodationCurrent || 0 }}</div>
<div class="kpi-sub">宿舍在住学员</div>
</div>
</el-col>
</el-row>
<!-- 3. 计划状态与业务异常提醒条 -->
<el-row :gutter="16" style="margin-top: 16px;">
<!-- 计划运行状态分布 -->
<el-col :xs="24" :sm="12" :md="10">
<el-card shadow="never" class="status-card">
<div slot="header" class="card-header">
<span><i class="el-icon-date" style="color: #409EFF; margin-right: 6px;"></i>开班计划运行状态</span>
</div>
<div class="plan-status-tags" v-loading="loadingOverview">
<div class="status-tag-item">
<span class="dot dot-info"></span>
<span class="label">未开始:</span>
<span class="count">{{ planStatus.notStarted || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-success"></span>
<span class="label">进行中:</span>
<span class="count">{{ planStatus.ongoing || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-ended"></span>
<span class="label">已结束:</span>
<span class="count">{{ planStatus.ended || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-danger"></span>
<span class="label">已取消:</span>
<span class="count">{{ planStatus.cancelled || 0 }}</span> 个
</div>
</div>
</el-card>
</el-col>
<!-- 业务待办与异常提醒 -->
<el-col :xs="24" :sm="12" :md="14">
<el-card shadow="never" class="status-card">
<div slot="header" class="card-header">
<span><i class="el-icon-warning-outline" style="color: #E6A23C; margin-right: 6px;"></i>业务运行待办与治理提醒</span>
<span class="tip-text">点击可直接跳转业务处理</span>
</div>
<div class="alert-grid" v-loading="loadingOverview">
<div class="alert-item clickable" @click="goTo('/train/analysis/attendance')">
<div class="alert-icon bg-danger"><i class="el-icon-time" /></div>
<div class="alert-content">
<div class="alert-num text-danger">{{ overview.abnormalAttendanceCount || 0 }}</div>
<div class="alert-title">异常考勤人次</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/report')">
<div class="alert-icon bg-warning"><i class="el-icon-document-checked" /></div>
<div class="alert-content">
<div class="alert-num text-warning">{{ overview.pendingReportCount || 0 }}</div>
<div class="alert-title">待报到参训学员</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/accommodation')">
<div class="alert-icon bg-primary"><i class="el-icon-office-building" /></div>
<div class="alert-content">
<div class="alert-num text-primary">{{ overview.preallocatedAccommodationCount || 0 }}</div>
<div class="alert-title">住宿预分配待入住</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/extra')">
<div class="alert-icon bg-danger"><i class="el-icon-key" /></div>
<div class="alert-content">
<div class="alert-num text-danger">{{ overview.abnormalAuthCount || 0 }}</div>
<div class="alert-title">门禁授权异常数</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 4. 图表行 1:趋势与班级对比 -->
<el-row :gutter="16" style="margin-top: 16px;">
<!-- 近30天考勤走势 -->
<el-col :xs="24" :sm="24" :lg="14">
<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>近 30 天考勤打卡情况走势</span>
<span class="tip-text">正常签到 vs 异常考勤人次</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="trendChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.trendDates || charts.trendDates.length === 0)" class="empty-chart">
<el-empty description="近 30 天暂无考勤打卡流水" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<!-- 培训计划参训/报到对比 -->
<el-col :xs="24" :sm="24" :lg="10">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-s-data" style="color: #67C23A; margin-right: 6px;"></i>培训计划参训 / 报到对比 (Top 10)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="planCompChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.planComparisonNames || charts.planComparisonNames.length === 0)" class="empty-chart">
<el-empty description="暂无培训计划数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 5. 图表行 2:状态构成与场地使用 -->
<el-row :gutter="16" style="margin-top: 16px; margin-bottom: 24px;">
<!-- 全校考勤状态构成 -->
<el-col :xs="24" :sm="24" :lg="10">
<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>
<span class="tip-text">含无签到记录独立统计</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="statusChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.attendanceStatusDistribution || charts.attendanceStatusDistribution.length === 0)" class="empty-chart">
<el-empty description="暂无考勤状态数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<!-- 教学场地使用频次 Top 5 -->
<el-col :xs="24" :sm="24" :lg="14">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-school" style="color: #909399; margin-right: 6px;"></i>教学场地使用频次与累计课时 Top 5</span>
<span class="tip-text">依排课记录客观统计,不推算无分母利用率</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="venueChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.venueUsageTop5 || charts.venueUsageTop5.length === 0)" class="empty-chart">
<el-empty description="暂无排课场地使用记录" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getDashboardOverview, getDashboardCharts } from '@/api/train/analysis'
export default {
name: 'AdminDashboard',
data() {
return {
loading: false,
loadingOverview: false,
loadingCharts: false,
overview: {},
charts: {},
trendChartInstance: null,
planCompChartInstance: null,
statusChartInstance: null,
venueChartInstance: null
}
},
computed: {
planStatus() {
return this.overview.planStatusCounts || {}
}
},
created() {
this.refreshAll()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
this.disposeCharts()
},
methods: {
refreshAll() {
this.fetchOverview()
this.fetchCharts()
},
fetchOverview() {
this.loadingOverview = true
getDashboardOverview().then(res => {
if (res.code === 200) {
this.overview = res.data || {}
}
}).finally(() => {
this.loadingOverview = false
})
},
fetchCharts() {
this.loadingCharts = true
getDashboardCharts().then(res => {
if (res.code === 200) {
this.charts = res.data || {}
this.$nextTick(() => {
this.renderTrendChart()
this.renderPlanCompChart()
this.renderStatusChart()
this.renderVenueChart()
})
}
}).finally(() => {
this.loadingCharts = false
})
},
renderTrendChart() {
if (!this.$refs.trendChart) return
if (!this.trendChartInstance) {
this.trendChartInstance = this.$echarts.init(this.$refs.trendChart)
}
const dates = this.charts.trendDates || []
const normal = this.charts.trendNormalCounts || []
const abnormal = this.charts.trendAbnormalCounts || []
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['正常签到人次', '异常考勤人次'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: '#909399' } }
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: '正常签到人次',
type: 'line',
smooth: true,
data: normal,
itemStyle: { color: '#67C23A' },
areaStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103,194,58,0.3)' },
{ offset: 1, color: 'rgba(103,194,58,0.02)' }
])
}
},
{
name: '异常考勤人次',
type: 'line',
smooth: true,
data: abnormal,
itemStyle: { color: '#F56C6C' },
areaStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(245,108,108,0.3)' },
{ offset: 1, color: 'rgba(245,108,108,0.02)' }
])
}
}
]
}
this.trendChartInstance.setOption(option)
},
renderPlanCompChart() {
if (!this.$refs.planCompChart) return
if (!this.planCompChartInstance) {
this.planCompChartInstance = this.$echarts.init(this.$refs.planCompChart)
}
const names = this.charts.planComparisonNames || []
const enrolled = this.charts.planEnrolledCounts || []
const reported = this.charts.planReportedCounts || []
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['参训人数', '报到人数'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '8%', containLabel: true },
xAxis: {
type: 'category',
data: names,
axisLabel: {
interval: 0,
rotate: 20,
formatter: val => val.length > 7 ? val.substring(0, 7) + '...' : val
}
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '参训人数',
type: 'bar',
data: enrolled,
itemStyle: { color: '#409EFF' }
},
{
name: '报到人数',
type: 'bar',
data: reported,
itemStyle: { color: '#67C23A' }
}
]
}
this.planCompChartInstance.setOption(option)
},
renderStatusChart() {
if (!this.$refs.statusChart) return
if (!this.statusChartInstance) {
this.statusChartInstance = this.$echarts.init(this.$refs.statusChart)
}
const rawList = this.charts.attendanceStatusDistribution || []
const data = rawList.map(item => ({ name: item.name, value: item.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, position: 'center' },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: 'bold' }
},
data: data
}
]
}
this.statusChartInstance.setOption(option)
},
renderVenueChart() {
if (!this.$refs.venueChart) return
if (!this.venueChartInstance) {
this.venueChartInstance = this.$echarts.init(this.$refs.venueChart)
}
const raw = this.charts.venueUsageTop5 || []
const names = raw.map(i => i.venueName).reverse()
const counts = raw.map(i => i.usageCount).reverse()
const hours = raw.map(i => i.totalHours).reverse()
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const name = params[0].name
let str = `<b>${name}</b><br/>`
params.forEach(p => {
str += `${p.seriesName}: ${p.value} ${p.seriesName.includes('课时') ? '小时' : '次'}<br/>`
})
return str
}
},
legend: { data: ['使用频次', '累计课时(小时)'], top: 0 },
grid: { left: '3%', right: '5%', 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',
data: counts,
itemStyle: { color: '#36cbcb' }
},
{
name: '累计课时(小时)',
type: 'bar',
data: hours,
itemStyle: { color: '#4ecb73' }
}
]
}
this.venueChartInstance.setOption(option)
},
handleResize() {
if (this.trendChartInstance) this.trendChartInstance.resize()
if (this.planCompChartInstance) this.planCompChartInstance.resize()
if (this.statusChartInstance) this.statusChartInstance.resize()
if (this.venueChartInstance) this.venueChartInstance.resize()
},
disposeCharts() {
if (this.trendChartInstance) { this.trendChartInstance.dispose(); this.trendChartInstance = null }
if (this.planCompChartInstance) { this.planCompChartInstance.dispose(); this.planCompChartInstance = null }
if (this.statusChartInstance) { this.statusChartInstance.dispose(); this.statusChartInstance = null }
if (this.venueChartInstance) { this.venueChartInstance.dispose(); this.venueChartInstance = null }
},
goTo(path) {
if (path) this.$router.push(path)
}
}
}
</script>
<style scoped lang="scss">
.dashboard-editor-container {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: calc(100vh - 84px);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 16px 20px;
border-radius: 4px;
margin-bottom: 16px;
border-left: 4px solid #409EFF;
.header-title h2 {
margin: 0 0 4px 0;
font-size: 18px;
color: #303133;
}
.header-title .subtitle {
font-size: 13px;
color: #909399;
}
}
.kpi-row {
margin-bottom: 0px;
}
.kpi-card {
background: #fff;
border-radius: 4px;
padding: 14px 16px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.kpi-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.kpi-value {
font-size: 22px;
font-weight: bold;
color: #303133;
line-height: 1.2;
margin-bottom: 6px;
}
.kpi-sub {
font-size: 11px;
color: #909399;
}
}
.text-primary { color: #409EFF !important; }
.text-success { color: #67C23A !important; }
.text-warning { color: #E6A23C !important; }
.text-danger { color: #F56C6C !important; }
.text-info { color: #909399 !important; }
.status-card {
min-height: 140px;
.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;
}
}
}
.plan-status-tags {
display: flex;
justify-content: space-around;
padding: 10px 0;
.status-tag-item {
display: flex;
align-items: center;
font-size: 13px;
color: #606266;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
&.dot-info { background: #909399; }
&.dot-success { background: #67C23A; }
&.dot-ended { background: #409EFF; }
&.dot-danger { background: #F56C6C; }
}
.count {
font-weight: bold;
color: #303133;
margin-right: 2px;
}
}
}
.alert-grid {
display: flex;
justify-content: space-between;
gap: 12px;
.alert-item {
flex: 1;
display: flex;
align-items: center;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 8px 12px;
transition: all 0.2s;
&.clickable:hover {
cursor: pointer;
background: #f0f7ff;
border-color: #b3d8ff;
}
.alert-icon {
width: 36px;
height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
margin-right: 10px;
&.bg-danger { background: #F56C6C; }
&.bg-warning { background: #E6A23C; }
&.bg-primary { background: #409EFF; }
}
.alert-content {
.alert-num {
font-size: 16px;
font-weight: bold;
}
.alert-title {
font-size: 12px;
color: #909399;
}
}
}
}
.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>