532 lines
23 KiB
Vue
532 lines
23 KiB
Vue
<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>
|