Files
railwaysystem/build_ui/src/views/train/regdata/index.vue
T

532 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-loading="loading" class="app-container class-regdata">
<!-- 顶部标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">班级报到数据</h2>
<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>
</div>
<div class="rd-topbar-actions">
<el-button size="small" icon="el-icon-download" @click="handleExport">导出报到名册</el-button>
<el-button type="primary" size="small" icon="el-icon-plus" @click="goStartRegister">启动班级报到</el-button>
</div>
</div>
<!-- 培训班级标签:仅展示未结束(plan_status != 2)的班级 -->
<div v-loading="planLoading" class="rd-tabs">
<span
v-for="p in visiblePlans"
:key="p.planId"
class="rd-tab"
:class="{ active: p.planId === currentPlanId }"
@click="switchPlan(p.planId)"
>{{ tabLabel(p) }}</span>
<span v-if="!planLoading && !visiblePlans.length" class="rd-empty-tab">暂无未结束的培训班级</span>
</div>
<!-- 统计卡 -->
<div class="rd-stats">
<div class="rd-stat">
<div class="rd-stat-label">应报到</div>
<div class="rd-stat-value dark">{{ summary.expected }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">已报到</div>
<div class="rd-stat-value green">{{ summary.reported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">待报到</div>
<div class="rd-stat-value red">{{ summary.notReported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">报到率</div>
<div class="rd-stat-value green">{{ summary.rate }}%</div>
</div>
</div>
<!-- 中部两栏 -->
<div class="rd-middle">
<!-- 待报到学员 -->
<div class="rd-panel rd-unreported">
<div class="rd-panel-head">
<span class="rd-panel-title">待报到学员</span>
<span class="rd-badge-red">{{ notReportedTotal }} 人待处理</span>
</div>
<el-table :data="notReportedPaged" size="small" class="rd-table" empty-text="该班级暂无待报到学员">
<el-table-column label="学员" min-width="130">
<template slot-scope="scope">
<div class="rd-student">
<span class="rd-avatar" :style="{ background: avatarColor(scope.row.studentName) }">{{ (scope.row.studentName || '').charAt(0) }}</span>
<div>
<div class="rd-student-name">{{ scope.row.studentName }}</div>
<div class="rd-student-code">{{ scope.row.personNo || scope.row.studentId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="100" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phone" width="115" />
<el-table-column label="报到状态" width="86" align="center">
<template slot-scope="scope">
<el-tag type="danger" size="mini">{{ statusTag(scope.row.enrollmentStatus).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" prop="remark" min-width="90" show-overflow-tooltip />
</el-table>
<div class="rd-pager">
<span class="rd-pager-total">共 {{ notReportedTotal }} 条 · 每页 {{ notQuery.pageSize }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="notReportedTotal"
:page-size="notQuery.pageSize"
:current-page.sync="notQuery.pageNum"
/>
</div>
</div>
<!-- 报到时间分布 -->
<div class="rd-panel rd-chart-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到时间分布</span>
<span class="rd-head-right rd-panel-extra">按入班报到时间 join_time 统计</span>
</div>
<div v-if="hourlyBars.length" class="rd-chart">
<div v-for="bar in hourlyBars" :key="bar.hour" class="rd-bar-col">
<span class="rd-bar-value">{{ bar.count }}</span>
<div class="rd-bar-track">
<div class="rd-bar" :class="{ peak: bar.peak }" :style="{ height: bar.percent + '%' }" />
</div>
<span class="rd-bar-label">{{ bar.hour }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无报到数据" />
<div v-if="summary.peakNote" class="rd-chart-note">{{ summary.peakNote }}</div>
</div>
</div>
<!-- 报到名册 -->
<div class="rd-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到名册 · {{ currentPlanLabel }}</span>
<span class="rd-head-right">
<el-input
v-model="rosterFilter.studentName"
size="mini"
class="rd-filter-item"
placeholder="学员姓名"
clearable
@input="handleRosterFilterChange"
/>
<el-select
v-model="rosterFilter.enrollmentStatus"
size="mini"
class="rd-filter-item"
placeholder="全部状态"
clearable
@change="handleRosterFilterChange"
>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span class="rd-panel-extra">{{ rosterRange }}</span>
</span>
</div>
<el-table
:data="rosterPaged"
size="small"
class="rd-table"
empty-text="该班级暂无参训记录"
:default-sort="{ prop: 'studentName', order: 'ascending' }"
@sort-change="handleRosterSort"
>
<el-table-column label="学员" sortable="custom" prop="studentName" min-width="140">
<template slot-scope="scope">
<div class="rd-student">
<span class="rd-avatar" :style="{ background: avatarColor(scope.row.studentName) }">{{ (scope.row.studentName || '').charAt(0) }}</span>
<div>
<div class="rd-student-name">{{ scope.row.studentName }}</div>
<div class="rd-student-code">{{ scope.row.personNo || scope.row.studentId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phone" width="120" />
<el-table-column label="报到状态" width="95" align="center">
<template slot-scope="scope">
<el-tag :type="statusTag(scope.row.enrollmentStatus).type" size="mini">{{ statusTag(scope.row.enrollmentStatus).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报到时间" prop="joinTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.joinTime || '—' }}</template>
</el-table-column>
<el-table-column label="结课时间" prop="completeTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.completeTime || '—' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="90" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="showDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="rd-pager">
<span class="rd-pager-total">共 {{ rosterTotal }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="rosterTotal"
:page-size="rosterQuery.pageSize"
:current-page.sync="rosterQuery.pageNum"
/>
</div>
</div>
<!-- 学员报到详情 -->
<el-dialog title="报到详情" :visible.sync="detailOpen" width="560px" append-to-body>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="学员姓名">{{ detailRow.studentName }}</el-descriptions-item>
<el-descriptions-item label="人员编号">{{ detailRow.personNo || detailRow.studentId }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailRow.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="选派单位">{{ detailRow.deptName || '—' }}</el-descriptions-item>
<el-descriptions-item label="报到状态">
<el-tag :type="statusTag(detailRow.enrollmentStatus).type" size="mini">{{ statusTag(detailRow.enrollmentStatus).text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="住宿需求">{{ detailRow.accommodationRequired === 1 ? '需要住宿' : '不住宿' }}</el-descriptions-item>
<el-descriptions-item label="报到时间">{{ detailRow.joinTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="结课时间">{{ detailRow.completeTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detailRow.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<div slot="footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPlan } from '@/api/train/plan'
import { ENROLLMENT_STATUS_OPTIONS, formatEnrollmentStatusTag } from '@/views/train/checkin/attendancePolicy'
import {
WAITING_STATUS,
filterEffective,
fetchPlanEnrollments,
summarizeEnrollments
} from '@/utils/enrollmentReport'
// 培训计划状态字典:0未开始 1进行中 2已结束 3已取消
// 顶部班级标签只保留"未结束"的班级,即排除已结束(2)
const ENDED_PLAN_STATUS = 2
export default {
name: 'ClassRegData',
data() {
return {
loading: false,
planLoading: false,
planList: [],
currentPlanId: '',
// train_enrollment 全量快照(当前班级)
snapshot: [],
summary: { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' },
statusOptions: ENROLLMENT_STATUS_OPTIONS,
// 待报到学员分页
notQuery: { pageNum: 1, pageSize: 6 },
// 报到名册分页与筛选、排序
rosterQuery: { pageNum: 1, pageSize: 10 },
rosterFilter: { studentName: '', enrollmentStatus: null },
rosterSort: { prop: 'studentName', order: 'ascending' },
// 详情弹窗
detailOpen: false,
detailRow: {}
}
},
computed: {
/** 顶部班级标签数据:过滤已结束的班级(保留全量 planList 供 currentPlan 等区域回查) */
visiblePlans() {
return this.planList.filter(item => Number(item.planStatus) !== ENDED_PLAN_STATUS)
},
currentPlan() {
return this.planList.find(item => item.planId === this.currentPlanId) || null
},
currentPlanLabel() {
const plan = this.currentPlan
if (!plan) return '—'
return plan.className ? plan.planName + '(' + plan.className + ')' : plan.planName
},
/** 有效参训记录(排除已放弃) */
effectiveList() {
return filterEffective(this.snapshot)
},
/** 待报到学员(enrollment_status=0) */
waitingList() {
return this.effectiveList.filter(row => Number(row.enrollmentStatus) === WAITING_STATUS)
},
notReportedTotal() {
return this.waitingList.length
},
notReportedPaged() {
const start = (this.notQuery.pageNum - 1) * this.notQuery.pageSize
return this.waitingList.slice(start, start + this.notQuery.pageSize)
},
/** 名册筛选结果 */
rosterFiltered() {
const keyword = (this.rosterFilter.studentName || '').trim()
const rawStatus = this.rosterFilter.enrollmentStatus
const status = rawStatus === '' || rawStatus === null || rawStatus === undefined ? null : Number(rawStatus)
return this.effectiveList.filter(row => {
if (keyword && String(row.studentName || '').indexOf(keyword) === -1) return false
if (status !== null && Number(row.enrollmentStatus) !== status) return false
return true
})
},
/** 名册全量排序(对筛选后的全部数据排序,再分页) */
rosterSorted() {
const list = this.rosterFiltered.slice()
const prop = this.rosterSort.prop
const order = this.rosterSort.order
if (!prop || !order) return list
const factor = order === 'descending' ? -1 : 1
return list.sort((a, b) => {
const left = a[prop] === null || a[prop] === undefined ? '' : a[prop]
const right = b[prop] === null || b[prop] === undefined ? '' : b[prop]
if (typeof left === 'number' && typeof right === 'number') return (left - right) * factor
return String(left).localeCompare(String(right), 'zh-Hans-CN') * factor
})
},
rosterTotal() {
return this.rosterSorted.length
},
rosterPaged() {
const start = (this.rosterQuery.pageNum - 1) * this.rosterQuery.pageSize
return this.rosterSorted.slice(start, start + this.rosterQuery.pageSize)
},
/** 报到时间分布柱状数据(纯 CSS 柱状图) */
hourlyBars() {
const list = this.summary.hourly || []
const max = Math.max.apply(null, list.map(h => h.count).concat([1]))
return list.map(h => ({
hour: h.hour,
count: h.count,
percent: Math.round(h.count / max * 100),
peak: h.count === max && h.count > 0
}))
},
/** 报到时间窗描述(取自培训计划) */
rosterRange() {
const plan = this.currentPlan
if (!plan) return ''
const fmt = time => (time ? String(time).slice(5, 16) : '')
const start = fmt(plan.startTime)
const end = fmt(plan.endTime)
if (!start && !end) return ''
let text = start + ' ~ ' + end
if (plan.reportDeadline) text += ' · 报到截止 ' + fmt(plan.reportDeadline)
return text
}
},
created() {
this.loadPlans()
},
methods: {
/** 加载培训计划(进行中优先),默认选中第一个未结束的班级 */
loadPlans() {
this.planLoading = true
listPlan({ pageNum: 1, pageSize: 100 }).then(res => {
// 全量保留,供 currentPlan / 详情等区域按 planId 回查
this.planList = (res.rows || []).slice().sort((a, b) => {
const weightA = a.planStatus === 1 ? 0 : 1
const weightB = b.planStatus === 1 ? 0 : 1
return weightA - weightB
})
// 刷新后原选中班级若已结束(或已不存在),重新落到第一个可见班级
const visible = this.visiblePlans
const stillVisible = visible.some(item => item.planId === this.currentPlanId)
if (!stillVisible) {
this.resetPlanData()
if (visible.length) {
this.switchPlan(visible[0].planId)
}
}
}).catch(() => {
this.planList = []
this.resetPlanData()
}).finally(() => {
this.planLoading = false
})
},
/** 清空当前班级相关的展示数据 */
resetPlanData() {
this.currentPlanId = ''
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
this.notQuery.pageNum = 1
this.rosterQuery.pageNum = 1
this.rosterFilter = { studentName: '', enrollmentStatus: null }
},
tabLabel(plan) {
return plan.className || plan.planName
},
/** 切换班级并重新拉取 train_enrollment 数据(已结束班级不允许进入) */
switchPlan(planId) {
if (!planId || this.currentPlanId === planId) return
const target = this.planList.find(item => item.planId === planId)
if (!target || Number(target.planStatus) === ENDED_PLAN_STATUS) return
this.currentPlanId = planId
this.notQuery.pageNum = 1
this.rosterQuery.pageNum = 1
this.rosterFilter = { studentName: '', enrollmentStatus: null }
this.loadEnrollments()
},
/** 拉取当前班级全量参训记录,本地完成统计/分页/筛选 */
loadEnrollments() {
const planId = this.currentPlanId
if (!planId) {
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
return
}
this.loading = true
fetchPlanEnrollments(planId).then(rows => {
// 请求期间用户已切换班级则丢弃过期结果
if (planId !== this.currentPlanId) return
this.snapshot = rows
const summary = summarizeEnrollments(rows)
this.summary = {
expected: summary.expected,
reported: summary.reported,
notReported: summary.notReported,
rate: summary.rate,
hourly: summary.hourly,
peakNote: summary.peakNote
}
if (this.notQuery.pageNum > 1 && !this.notReportedPaged.length) {
this.notQuery.pageNum = 1
}
if (this.rosterQuery.pageNum > 1 && !this.rosterPaged.length) {
this.rosterQuery.pageNum = 1
}
}).catch(() => {
if (planId !== this.currentPlanId) return
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
}).finally(() => {
this.loading = false
})
},
statusTag(status) {
return formatEnrollmentStatusTag(status)
},
handleRosterFilterChange() {
this.rosterQuery.pageNum = 1
},
/** 名册排序:对筛选后的全量数据排序(前端排序,接口不支持动态排序) */
handleRosterSort({ prop, order }) {
this.rosterSort = { prop, order }
this.rosterQuery.pageNum = 1
},
/** 学员报到详情 */
showDetail(row) {
this.detailRow = row
this.detailOpen = true
},
/** 导出当前班级报到名册(字段与页面一致,数据源 train_enrollment) */
handleExport() {
if (!this.currentPlanId) {
this.$modal.msgWarning('请先选择培训班级')
return
}
const list = this.rosterFiltered
if (!list.length) {
this.$modal.msgWarning('当前条件下没有可导出的报到数据')
return
}
const headers = ['学员姓名', '人员编号', '选派单位', '联系电话', '报到状态', '报到时间', '结课时间', '备注']
const lines = [headers.join(',')]
list.forEach(item => {
const cells = [
item.studentName,
item.personNo || item.studentId,
item.deptName,
item.phone,
this.statusTag(item.enrollmentStatus).text,
item.joinTime,
item.completeTime,
item.remark
]
lines.push(cells.map(cell => `"${String(cell === null || cell === undefined ? '' : cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `报到名册_${this.currentPlan ? this.tabLabel(this.currentPlan) : '班级'}_${new Date().getTime()}.csv`
link.click()
URL.revokeObjectURL(link.href)
},
/** 跳转班级报到启动页 */
goStartRegister() {
this.$router.push('/register/classregister')
},
/** 根据姓名生成头像底色 */
avatarColor(name) {
const colors = ['#6c7cff', '#f56c6c', '#e6a23c', '#67c23a', '#36cfc9', '#722ed1', '#eb2f96']
const code = String(name || '').split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
return colors[code % colors.length]
}
}
}
</script>
<style scoped>
.class-regdata { background: #f5f7fa; }
/* 顶栏按钮 */
.rd-topbar-actions { display: flex; gap: 8px; }
/* 班级标签 */
.rd-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; min-height: 28px; }
.rd-tab { padding: 4px 14px; border-radius: 14px; background: #fff; border: 1px solid #e5e8f0; font-size: 12px; color: #4e5969; cursor: pointer; transition: all .2s; }
.rd-tab:hover { color: #2b6bff; border-color: #2b6bff; }
.rd-tab.active { background: #2b6bff; border-color: #2b6bff; color: #fff; font-weight: 600; }
.rd-empty-tab { font-size: 12px; color: #a2aab8; }
/* 统计卡 */
.rd-stats { display: flex; background: #fff; border-radius: 10px; padding: 18px 0; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rd-stat { flex: 1; padding: 0 24px; }
.rd-stat + .rd-stat { border-left: 1px solid #f0f2f7; }
.rd-stat-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.rd-stat-value { font-size: 26px; font-weight: 700; line-height: 1; }
.rd-stat-value.dark { color: #1f2329; }
.rd-stat-value.green { color: #10b981; }
.rd-stat-value.red { color: #f04438; }
.rd-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 2px; }
/* 中部两栏 */
.rd-middle { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
.rd-unreported { flex: 0 0 48%; min-width: 0; }
.rd-chart-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@media (max-width: 1200px) { .rd-middle { flex-direction: column; } .rd-unreported { flex: none; } }
/* 通用面板 */
.rd-panel { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(31,41,71,.06); margin-bottom: 16px; }
.rd-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
.rd-panel-title { font-size: 15px; font-weight: 700; color: #1f2329; }
.rd-badge-red { margin-left: 10px; font-size: 12px; color: #f04438; font-weight: 600; }
.rd-head-right { margin-left: auto; display: flex; align-items: center; }
.rd-panel-extra { font-size: 12px; color: #8a94a6; }
.rd-filter-item { width: 140px; margin-right: 8px; }
/* 学员单元格 */
.rd-student { display: flex; align-items: center; }
.rd-avatar { flex: none; width: 30px; height: 30px; border-radius: 50%; color: #fff; font-size: 13px; display: flex; align-items: center; justify-content: center; margin-right: 8px; }
.rd-student-name { font-size: 13px; color: #1f2329; line-height: 17px; }
.rd-student-code { font-size: 11px; color: #a2aab8; line-height: 15px; }
/* 分页 */
.rd-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.rd-pager-total { font-size: 12px; color: #8a94a6; }
/* 纯 CSS 柱状图 */
.rd-chart { flex: 1; display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 220px; }
.rd-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; }
.rd-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.rd-bar-track { width: 100%; max-width: 34px; height: 150px; display: flex; align-items: flex-end; justify-content: center; }
.rd-bar { width: 22px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); transition: height .3s; }
.rd-bar.peak { background: linear-gradient(180deg, #ffb14d 0%, #f79009 100%); }
.rd-bar-label { margin-top: 6px; font-size: 10px; color: #a2aab8; white-space: nowrap; }
.rd-chart-note { margin-top: 12px; text-align: center; font-size: 12px; color: #8a94a6; }
</style>