733 lines
33 KiB
JavaScript
733 lines
33 KiB
JavaScript
/* 统计看板:8 个统计接口,每个都是「图表 + 表格」两张视图,
|
||
表格用来核对真值,图表用来看趋势。
|
||
注意用 v-if 而不是 v-show 切卡片:隐藏容器里 ECharts 量不到宽度,切回来会是空白图。 */
|
||
|
||
window.PAGES.push({
|
||
key: 'dashboard',
|
||
title: '统计看板',
|
||
sub: '班级、成绩、就业三个维度的汇总统计',
|
||
group: '总览',
|
||
icon: 'dashboard',
|
||
|
||
component: {
|
||
name: 'DashboardPage',
|
||
template: `
|
||
<div>
|
||
<div class="kpi-row">
|
||
<div class="kpi">
|
||
<div class="kpi-label">学生总数</div>
|
||
<div class="kpi-value">{{ kpi.students }}<span class="kpi-unit">人</span></div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="kpi-label">班级总数</div>
|
||
<div class="kpi-value">{{ kpi.classes }}<span class="kpi-unit">个</span></div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="kpi-label">教师总数</div>
|
||
<div class="kpi-value">{{ kpi.teachers }}<span class="kpi-unit">位</span></div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="kpi-label">顾问总数</div>
|
||
<div class="kpi-value">{{ kpi.advisors }}<span class="kpi-unit">位</span></div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="kpi-label">已就业人数</div>
|
||
<div class="kpi-value">{{ kpi.emps }}<span class="kpi-unit">人</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" v-if="kpi.students === 0">
|
||
<el-empty description="数据库里还没有数据:先去「班级管理」建一个班级,再到「学生管理」录入学生" />
|
||
</div>
|
||
|
||
<!-- ① 班级人数与性别分布 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">① 班级人数与性别分布</div>
|
||
<div class="card-hint">含 0 人的空班级;性别按库里的「男 / 女」统计</div>
|
||
</div>
|
||
<div class="card-tools"><el-button @click="loadClassCount">刷新</el-button></div>
|
||
</div>
|
||
<div v-if="classCount.length" class="chart-card">
|
||
<div ref="c1" class="chart-box"></div>
|
||
<el-table :data="classCount" border size="small" max-height="300">
|
||
<el-table-column label="班级" width="150">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total" label="总人数" width="90" sortable />
|
||
<el-table-column prop="male_count" label="男" width="70" sortable />
|
||
<el-table-column prop="female_count" label="女" width="70" sortable />
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无班级数据" />
|
||
</div>
|
||
|
||
<!-- ② 各次考试班级平均分 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">② 各次考试班级平均分</div>
|
||
<div class="card-hint">按考核序次分组,每个班级一条线{{ avgScoreNote }}</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<el-radio-group v-model="avgSort" @change="loadClassAvgScore">
|
||
<el-radio-button value="">默认</el-radio-button>
|
||
<el-radio-button value="desc">高→低</el-radio-button>
|
||
<el-radio-button value="asc">低→高</el-radio-button>
|
||
</el-radio-group>
|
||
<el-button @click="loadClassAvgScore">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="classAvgScore.length" class="chart-card">
|
||
<div ref="c2" class="chart-box"></div>
|
||
<el-table :data="classAvgScore" border size="small" max-height="300">
|
||
<el-table-column label="班级" width="150">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="exam_order" label="考核序次" width="100" sortable />
|
||
<el-table-column prop="avg_score" label="平均分" sortable>
|
||
<template #default="{ row }">{{ round1(row.avg_score) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无成绩数据" />
|
||
</div>
|
||
|
||
<!-- ③ 每次都超分数线的学生 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">③ 每次考试都超过分数线的学生</div>
|
||
<div class="card-hint">
|
||
要求每一次考核都高于分数线
|
||
<span v-if="hiddenNoScore" class="muted">(已隐藏 {{ hiddenNoScore }} 名暂无成绩的学生)</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<span class="muted">分数线</span>
|
||
<el-input-number v-model="scoreLine" :min="0" :max="100" size="small"
|
||
controls-position="right" style="width:110px" />
|
||
<el-button type="primary" @click="loadOutScoreLine">查询</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="outScoreLine.shown.length" class="chart-card">
|
||
<div ref="c3" class="chart-box"></div>
|
||
<el-table :data="outScoreLine.shown" border size="small" max-height="300"
|
||
row-key="stu_no">
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="score-detail">
|
||
<el-tag v-for="s in row.scores" :key="s.exam_order" size="small" effect="plain"
|
||
style="margin-right:8px">
|
||
第{{ s.exam_order }}次:{{ s.score }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="stu_name" label="姓名" width="110" />
|
||
<el-table-column prop="stu_no" label="学号" width="140" />
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="没有每次考核都超过该分数线的学生" />
|
||
</div>
|
||
|
||
<!-- ④ 不及格次数超标的学生 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">④ 不及格次数超过指定次数的学生</div>
|
||
<div class="card-hint">不及格线固定 60 分(后端设定),次数为「严格大于」</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<span class="muted">不及格次数 ></span>
|
||
<el-input-number v-model="failCount" :min="0" :max="20" size="small"
|
||
controls-position="right" style="width:110px" />
|
||
<el-button type="primary" @click="loadFailScore">查询</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="failRows.length" class="chart-card">
|
||
<div ref="c4" class="chart-box"></div>
|
||
<el-table :data="failRows" border size="small" max-height="300"
|
||
:row-key="r => r.stu_name + '-' + r.class_id">
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="score-detail">
|
||
<el-tag v-for="s in row.scores" :key="s.exam_order" size="small"
|
||
:type="s.score < 60 ? 'danger' : 'info'" effect="plain" style="margin-right:8px">
|
||
第{{ s.exam_order }}次:{{ s.score }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="stu_name" label="姓名" width="110" />
|
||
<el-table-column label="班级" width="160">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="不及格门数">
|
||
<template #default="{ row }">{{ failNum(row) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="没有不及格次数超过该值的学生" />
|
||
</div>
|
||
|
||
<!-- ⑤ 就业薪资 TopN -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">⑤ 就业薪资排名 TopN</div>
|
||
<div class="card-hint">按 offer 薪资从高到低</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<span class="muted">取前</span>
|
||
<el-input-number v-model="rankN" :min="1" :max="50" size="small"
|
||
controls-position="right" style="width:110px" />
|
||
<span class="muted">名</span>
|
||
<el-button type="primary" @click="loadTopRank">查询</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="topRank.length" class="chart-card">
|
||
<div ref="c5" class="chart-box"></div>
|
||
<el-table :data="topRank" border size="small" max-height="300">
|
||
<el-table-column type="index" label="排名" width="70" />
|
||
<el-table-column prop="stu_name" label="姓名" width="100" />
|
||
<el-table-column label="班级" width="150">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="salary" label="薪资" width="100" sortable />
|
||
<el-table-column prop="company_name" label="公司" min-width="140" show-overflow-tooltip />
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无就业数据" />
|
||
</div>
|
||
|
||
<!-- ⑥ 每个学生的就业时长 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">⑥ 每个学生的就业时长</div>
|
||
<div class="card-hint">offer 下发时间 − 就业开放时间;0 天表示尚未就业</div>
|
||
</div>
|
||
<div class="card-tools"><el-button @click="loadEmpTime">刷新</el-button></div>
|
||
</div>
|
||
<div v-if="empTime.length" class="chart-card">
|
||
<div ref="c6" class="chart-box tall"></div>
|
||
<el-table :data="empTime" border size="small" max-height="360">
|
||
<el-table-column prop="stu_name" label="姓名" width="110" />
|
||
<el-table-column prop="emp_total_time" label="就业时长(天)" sortable>
|
||
<template #default="{ row }">
|
||
<span v-if="row.emp_total_time > 0">{{ row.emp_total_time }}</span>
|
||
<el-tag v-else size="small" type="info" effect="plain">未就业</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无学生数据" />
|
||
</div>
|
||
|
||
<!-- ⑦ 各班平均就业时长 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">⑦ 各班平均就业时长</div>
|
||
<div class="card-hint">只统计已进入就业阶段且已拿到 offer 的学生</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<el-radio-group v-model="empSort" @change="loadClassAvgEmpTime">
|
||
<el-radio-button value="">默认</el-radio-button>
|
||
<el-radio-button value="desc">高→低</el-radio-button>
|
||
<el-radio-button value="asc">低→高</el-radio-button>
|
||
</el-radio-group>
|
||
<el-button @click="loadClassAvgEmpTime">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="classAvgEmpTime.length" class="chart-card">
|
||
<div ref="c7" class="chart-box"></div>
|
||
<el-table :data="classAvgEmpTime" border size="small" max-height="300">
|
||
<el-table-column label="班级" width="150">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="平均时长(天)" sortable>
|
||
<template #default="{ row }">
|
||
<span v-if="row.avg_emp_time !== null">{{ row.avg_emp_time }}</span>
|
||
<el-tag v-else size="small" type="info" effect="plain">无就业学生</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="emp_stu_count" label="参与人数" width="100" sortable />
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="暂无班级数据" />
|
||
</div>
|
||
|
||
<!-- ⑧ 按年龄筛选 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">⑧ 按年龄段筛选学生</div>
|
||
<div class="card-hint">右侧图表是命中学生的年龄分布</div>
|
||
</div>
|
||
<div class="card-tools">
|
||
<el-select v-model="ageOp" style="width:130px">
|
||
<el-option v-for="o in AGE_OP_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||
</el-select>
|
||
<el-input-number v-model="ageValue" :min="0" :max="100" size="small"
|
||
controls-position="right" style="width:110px" />
|
||
<template v-if="ageOp === 'between'">
|
||
<span class="muted">到</span>
|
||
<el-input-number v-model="ageValue2" :min="0" :max="100" size="small"
|
||
controls-position="right" style="width:110px" />
|
||
</template>
|
||
<el-button type="primary" @click="loadAgeRange">查询</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="ageRows.length" class="chart-card">
|
||
<div ref="c8" class="chart-box"></div>
|
||
<el-table :data="ageRows" border size="small" max-height="300" row-key="stu_no">
|
||
<el-table-column prop="stu_no" label="学号" width="130" />
|
||
<el-table-column prop="stu_name" label="姓名" width="100" />
|
||
<el-table-column label="班级" width="150">
|
||
<template #default="{ row }">{{ classLabel(row.class_id) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="stu_age" label="年龄" width="80" sortable />
|
||
<el-table-column label="性别" width="80">
|
||
<template #default="{ row }">{{ dash(row.stu_gender) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<el-empty v-else description="没有符合条件的学生" />
|
||
</div>
|
||
</div>
|
||
`,
|
||
|
||
setup() {
|
||
/* ---------- 配色与图表通用配置 ---------- */
|
||
// 固定顺序取色,不循环、不手挑,保证同一个系列在任何图里颜色一致
|
||
const SERIES = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
|
||
const INK = { primary: '#0F172A', secondary: '#475569', muted: '#64748B' };
|
||
const LINE = '#E2E8F0';
|
||
const AXIS = '#CBD5E1';
|
||
const DEEMPH = '#CBD5E1';
|
||
const FONT = 'system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif';
|
||
|
||
const grid = { left: 8, right: 20, top: 36, bottom: 8, containLabel: true };
|
||
const tooltipAxis = { trigger: 'axis', axisPointer: { type: 'shadow' } };
|
||
|
||
// 分类轴的通用样式:文字用中性墨色,不用系列色
|
||
const catAxis = (data, extra) => Object.assign({
|
||
type: 'category',
|
||
data: data,
|
||
axisLine: { lineStyle: { color: AXIS } },
|
||
axisTick: { show: false },
|
||
axisLabel: { color: INK.secondary, fontFamily: FONT, fontSize: 11 },
|
||
}, extra || {});
|
||
|
||
const valAxis = (extra) => Object.assign({
|
||
type: 'value',
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
axisLabel: { color: INK.muted, fontFamily: FONT, fontSize: 11 },
|
||
splitLine: { lineStyle: { color: LINE } },
|
||
}, extra || {});
|
||
|
||
const round1 = (v) => (v === null || v === undefined) ? '' : Math.round(v * 10) / 10;
|
||
|
||
// ECharts 的分类轴是按「标签文本」去重的:两个同名学生会塌成同一根柱子。
|
||
// 有重名时给重复的补个序号,保证一人一根柱。
|
||
const uniqueNames = (rows, fmt) => {
|
||
const raw = rows.map(fmt);
|
||
const count = {};
|
||
raw.forEach(n => { count[n] = (count[n] || 0) + 1; });
|
||
const used = {};
|
||
return raw.map(n => {
|
||
if (count[n] === 1) return n;
|
||
used[n] = (used[n] || 0) + 1;
|
||
return `${n}(${used[n]})`;
|
||
});
|
||
};
|
||
|
||
/* ---------- 图表实例管理 ---------- */
|
||
const charts = [];
|
||
const refs = {
|
||
c1: ref(null), c2: ref(null), c3: ref(null), c4: ref(null),
|
||
c5: ref(null), c6: ref(null), c7: ref(null), c8: ref(null),
|
||
};
|
||
|
||
// 容器必须已经渲染出来才能量宽度,所以放在 nextTick 里
|
||
const draw = (elRef, option) => {
|
||
nextTick(() => {
|
||
const el = elRef.value;
|
||
if (!el) return;
|
||
let inst = echarts.getInstanceByDom(el);
|
||
if (!inst) { inst = echarts.init(el); charts.push(inst); }
|
||
inst.setOption(option, true);
|
||
inst.resize();
|
||
});
|
||
};
|
||
|
||
const onResize = () => charts.forEach(c => c.resize());
|
||
|
||
/* ---------- KPI ---------- */
|
||
const kpi = ref({ students: 0, classes: 0, teachers: 0, advisors: 0, emps: 0 });
|
||
|
||
const loadKpi = async () => {
|
||
const [s, c, t, a, e] = await Promise.allSettled([
|
||
StudentApi.list({}),
|
||
Cache.loadClasses(),
|
||
TeacherApi.list(),
|
||
AdvisorApi.list({ limit: 1000 }),
|
||
EmpApi.list({ limit: 1000 }),
|
||
]);
|
||
kpi.value = {
|
||
students: s.status === 'fulfilled' ? s.value.length : 0,
|
||
classes: c.status === 'fulfilled' ? c.value.length : 0,
|
||
teachers: t.status === 'fulfilled' ? t.value.length : 0,
|
||
advisors: a.status === 'fulfilled' ? a.value.length : 0,
|
||
emps: e.status === 'fulfilled' ? e.value.length : 0,
|
||
};
|
||
};
|
||
|
||
/* ---------- ① 班级人数与性别分布 ---------- */
|
||
const classCount = ref([]);
|
||
|
||
const loadClassCount = async () => {
|
||
try {
|
||
classCount.value = await StatsApi.classCount();
|
||
if (classCount.value.length) {
|
||
const labels = classCount.value.map(r => classLabel(r.class_id));
|
||
draw(refs.c1, {
|
||
color: [SERIES[0], SERIES[1]],
|
||
tooltip: tooltipAxis,
|
||
legend: { data: ['男', '女'], top: 0, textStyle: { color: INK.secondary, fontFamily: FONT } },
|
||
grid: grid,
|
||
xAxis: catAxis(labels, { axisLabel: { color: INK.secondary, fontFamily: FONT, fontSize: 10, interval: 0, rotate: labels.length > 4 ? 20 : 0 } }),
|
||
yAxis: valAxis(),
|
||
series: [
|
||
{
|
||
name: '男', type: 'bar', stack: 'total', barMaxWidth: 34,
|
||
itemStyle: { borderWidth: 2, borderColor: '#fff' },
|
||
label: { show: true, position: 'inside', color: '#fff', fontFamily: FONT, fontSize: 11 },
|
||
data: classCount.value.map(r => r.male_count),
|
||
},
|
||
{
|
||
name: '女', type: 'bar', stack: 'total', barMaxWidth: 34,
|
||
itemStyle: { borderWidth: 2, borderColor: '#fff', borderRadius: [4, 4, 0, 0] },
|
||
label: { show: true, position: 'inside', color: '#fff', fontFamily: FONT, fontSize: 11 },
|
||
data: classCount.value.map(r => r.female_count),
|
||
},
|
||
],
|
||
});
|
||
}
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ② 各次考试班级平均分 ---------- */
|
||
const classAvgScore = ref([]);
|
||
const avgSort = ref('');
|
||
const avgScoreNote = computed(() => {
|
||
const n = new Set(classAvgScore.value.map(r => r.class_id)).size;
|
||
return n > 8 ? `(共 ${n} 个班级,图中只画前 8 个)` : '';
|
||
});
|
||
|
||
const loadClassAvgScore = async () => {
|
||
try {
|
||
classAvgScore.value = await StatsApi.classAvgScore(avgSort.value);
|
||
const rows = classAvgScore.value;
|
||
if (!rows.length) return;
|
||
|
||
// 透视:x 轴是所有出现过的考核序次,每个班级一条线
|
||
const orders = [...new Set(rows.map(r => r.exam_order))].sort((a, b) => a - b);
|
||
const classIds = [...new Set(rows.map(r => r.class_id))].sort((a, b) => a - b);
|
||
const shown = classIds.slice(0, 8);
|
||
|
||
const lines = shown.map((cid, i) => ({
|
||
name: classLabel(cid),
|
||
type: 'line',
|
||
smooth: true,
|
||
symbolSize: 8,
|
||
lineStyle: { width: 2 },
|
||
itemStyle: { color: SERIES[i % SERIES.length] },
|
||
data: orders.map(o => {
|
||
const hit = rows.find(r => r.class_id === cid && r.exam_order === o);
|
||
return hit ? Math.round(hit.avg_score * 10) / 10 : null;
|
||
}),
|
||
}));
|
||
|
||
draw(refs.c2, {
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'line' } },
|
||
legend: { top: 0, type: 'scroll', textStyle: { color: INK.secondary, fontFamily: FONT } },
|
||
grid: grid,
|
||
xAxis: catAxis(orders.map(o => '第' + o + '次')),
|
||
yAxis: valAxis(),
|
||
series: lines,
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ③ 每次都超分数线的学生 ---------- */
|
||
const scoreLine = ref(80);
|
||
const outScoreLine = ref({ all: [], shown: [], hidden: 0 });
|
||
// 后端用 all(score > line) 判断,一条成绩都没有的学生会"空真"命中,
|
||
// 这类没意义的结果在前端滤掉,并提示隐藏了多少人
|
||
const hiddenNoScore = computed(() => outScoreLine.value.hidden);
|
||
|
||
const loadOutScoreLine = async () => {
|
||
try {
|
||
const all = await StatsApi.outScoreLine(scoreLine.value);
|
||
const shown = all.filter(r => r.scores && r.scores.length > 0);
|
||
outScoreLine.value = { all, shown, hidden: all.length - shown.length };
|
||
if (!shown.length) return;
|
||
|
||
// 每个考核序次一个系列,横过来画,中文姓名不会被挤在一起
|
||
const orders = [...new Set(shown.flatMap(r => r.scores.map(s => s.exam_order)))].sort((a, b) => a - b);
|
||
// 横向柱的 y 轴从下往上排,倒过来后第一行才在顶部
|
||
const ordered = shown.slice().reverse();
|
||
const names = uniqueNames(ordered, r => r.stu_name || r.stu_no);
|
||
|
||
draw(refs.c3, {
|
||
color: SERIES.slice(0, Math.max(orders.length, 1)),
|
||
tooltip: tooltipAxis,
|
||
legend: orders.length > 1
|
||
? { data: orders.map(o => '第' + o + '次'), top: 0, textStyle: { color: INK.secondary, fontFamily: FONT } }
|
||
: undefined,
|
||
grid: grid,
|
||
xAxis: valAxis({ name: '分数', nameTextStyle: { color: INK.muted, fontFamily: FONT } }),
|
||
yAxis: catAxis(names),
|
||
series: orders.map((o, i) => ({
|
||
name: '第' + o + '次',
|
||
type: 'bar',
|
||
barMaxWidth: 18,
|
||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||
data: ordered.map(r => {
|
||
const hit = r.scores.find(s => s.exam_order === o);
|
||
return hit ? hit.score : null;
|
||
}),
|
||
markLine: i === 0 ? {
|
||
symbol: 'none',
|
||
silent: true,
|
||
lineStyle: { color: '#E34948', type: 'dashed', width: 1.5 },
|
||
label: {
|
||
formatter: '分数线 ' + scoreLine.value,
|
||
color: '#E34948', fontFamily: FONT, fontSize: 11, position: 'end',
|
||
},
|
||
data: [{ xAxis: scoreLine.value }],
|
||
} : undefined,
|
||
})),
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ④ 不及格次数超标 ---------- */
|
||
const failCount = ref(2);
|
||
const failRows = ref([]);
|
||
const failNum = (row) => (row.scores || []).filter(s => s.score < 60).length;
|
||
|
||
const loadFailScore = async () => {
|
||
try {
|
||
failRows.value = await StatsApi.failScore(failCount.value);
|
||
if (!failRows.value.length) return;
|
||
|
||
const sorted = failRows.value.slice().sort((a, b) => failNum(a) - failNum(b));
|
||
const names = uniqueNames(sorted, r => r.stu_name || '未命名');
|
||
|
||
draw(refs.c4, {
|
||
color: [SERIES[0]],
|
||
tooltip: tooltipAxis,
|
||
grid: grid,
|
||
xAxis: valAxis({ name: '不及格门数', nameTextStyle: { color: INK.muted, fontFamily: FONT }, minInterval: 1 }),
|
||
yAxis: catAxis(names),
|
||
series: [{
|
||
type: 'bar',
|
||
barMaxWidth: 18,
|
||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||
label: { show: true, position: 'right', color: INK.secondary, fontFamily: FONT, fontSize: 11 },
|
||
data: sorted.map(failNum),
|
||
markLine: {
|
||
symbol: 'none', silent: true,
|
||
lineStyle: { color: '#E34948', type: 'dashed', width: 1.5 },
|
||
label: {
|
||
formatter: '阈值 ' + failCount.value + ' 门',
|
||
color: '#E34948', fontFamily: FONT, fontSize: 11, position: 'end',
|
||
},
|
||
data: [{ xAxis: failCount.value }],
|
||
},
|
||
}],
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ⑤ 薪资 TopN ---------- */
|
||
const rankN = ref(5);
|
||
const topRank = ref([]);
|
||
|
||
const loadTopRank = async () => {
|
||
try {
|
||
topRank.value = await StatsApi.topRank(rankN.value);
|
||
if (!topRank.value.length) return;
|
||
|
||
const sorted = topRank.value.slice().sort((a, b) => a.salary - b.salary);
|
||
draw(refs.c5, {
|
||
color: [SERIES[0]],
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (ps) => {
|
||
const r = sorted[ps[0].dataIndex];
|
||
return `${r.stu_name}<br/>${classLabel(r.class_id)}<br/>薪资:${r.salary}<br/>公司:${r.company_name}`;
|
||
} },
|
||
grid: grid,
|
||
xAxis: valAxis({ name: '薪资', nameTextStyle: { color: INK.muted, fontFamily: FONT } }),
|
||
yAxis: catAxis(uniqueNames(sorted, r => r.stu_name || '未命名')),
|
||
series: [{
|
||
type: 'bar',
|
||
barMaxWidth: 18,
|
||
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
||
label: { show: true, position: 'right', color: INK.secondary, fontFamily: FONT, fontSize: 11 },
|
||
data: sorted.map(r => r.salary),
|
||
}],
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ⑥ 每个学生的就业时长 ---------- */
|
||
const empTime = ref([]);
|
||
|
||
const loadEmpTime = async () => {
|
||
try {
|
||
empTime.value = await StatsApi.empTime();
|
||
if (!empTime.value.length) return;
|
||
|
||
// 图上只画时长最长的前 15 人,表格里给全量
|
||
const top = empTime.value.slice()
|
||
.sort((a, b) => b.emp_total_time - a.emp_total_time)
|
||
.slice(0, 15)
|
||
.reverse();
|
||
|
||
draw(refs.c6, {
|
||
tooltip: tooltipAxis,
|
||
grid: Object.assign({}, grid, { right: 44 }),
|
||
xAxis: valAxis({ name: '天', nameTextStyle: { color: INK.muted, fontFamily: FONT }, minInterval: 1 }),
|
||
yAxis: catAxis(uniqueNames(top, r => r.stu_name || '未命名')),
|
||
series: [{
|
||
type: 'bar',
|
||
barMaxWidth: 16,
|
||
itemStyle: {
|
||
borderRadius: [0, 4, 4, 0],
|
||
// 0 天 = 还没就业,用灰色弱化,不占用系列色
|
||
color: (p) => (p.value > 0 ? SERIES[0] : DEEMPH),
|
||
},
|
||
label: {
|
||
show: true, position: 'right', fontFamily: FONT, fontSize: 11,
|
||
color: INK.secondary,
|
||
formatter: (p) => (p.value > 0 ? p.value : '未就业'),
|
||
},
|
||
data: top.map(r => r.emp_total_time),
|
||
}],
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ⑦ 各班平均就业时长 ---------- */
|
||
const classAvgEmpTime = ref([]);
|
||
const empSort = ref('');
|
||
|
||
const loadClassAvgEmpTime = async () => {
|
||
try {
|
||
classAvgEmpTime.value = await StatsApi.classAvgEmpTime(empSort.value);
|
||
if (!classAvgEmpTime.value.length) return;
|
||
|
||
const labels = classAvgEmpTime.value.map(r => classLabel(r.class_id));
|
||
draw(refs.c7, {
|
||
color: [SERIES[0]],
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (ps) => {
|
||
const r = classAvgEmpTime.value[ps[0].dataIndex];
|
||
return `${classLabel(r.class_id)}<br/>平均时长:${r.avg_emp_time === null ? '无就业学生' : r.avg_emp_time + ' 天'}<br/>参与人数:${r.emp_stu_count}`;
|
||
} },
|
||
grid: grid,
|
||
xAxis: catAxis(labels, { axisLabel: { color: INK.secondary, fontFamily: FONT, fontSize: 10, interval: 0, rotate: labels.length > 4 ? 20 : 0 } }),
|
||
yAxis: valAxis({ name: '天', nameTextStyle: { color: INK.muted, fontFamily: FONT } }),
|
||
series: [{
|
||
type: 'bar',
|
||
barMaxWidth: 34,
|
||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||
// 平均时长为 null 的班级不画柱子(ECharts 会跳过 null),只在表里显示"无就业学生"
|
||
data: classAvgEmpTime.value.map(r => r.avg_emp_time),
|
||
label: { show: true, position: 'top', color: INK.secondary, fontFamily: FONT, fontSize: 11 },
|
||
}],
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- ⑧ 按年龄筛选 ---------- */
|
||
const ageOp = ref('gt');
|
||
const ageValue = ref(20);
|
||
const ageValue2 = ref(30);
|
||
const ageRows = ref([]);
|
||
|
||
const loadAgeRange = async () => {
|
||
// 区间查询缺上界后端会返回 400,这里先拦一次,省一趟请求
|
||
if (ageOp.value === 'between' && (ageValue2.value === null || ageValue2.value === undefined)) {
|
||
toastErr('区间查询需要填写上界'); return;
|
||
}
|
||
try {
|
||
ageRows.value = await StatsApi.studentsByAge(
|
||
ageOp.value, ageValue.value,
|
||
ageOp.value === 'between' ? ageValue2.value : undefined);
|
||
if (!ageRows.value.length) return;
|
||
|
||
// 命中学生的年龄分布:按岁分组计数
|
||
const buckets = {};
|
||
ageRows.value.forEach(r => {
|
||
const k = r.stu_age === null || r.stu_age === undefined ? '未知' : String(r.stu_age);
|
||
buckets[k] = (buckets[k] || 0) + 1;
|
||
});
|
||
const keys = Object.keys(buckets).filter(k => k !== '未知').sort((a, b) => a - b);
|
||
if (buckets['未知']) keys.push('未知');
|
||
|
||
draw(refs.c8, {
|
||
color: [SERIES[0]],
|
||
tooltip: tooltipAxis,
|
||
grid: grid,
|
||
xAxis: catAxis(keys.map(k => k === '未知' ? k : k + ' 岁')),
|
||
yAxis: valAxis({ minInterval: 1 }),
|
||
series: [{
|
||
type: 'bar',
|
||
barMaxWidth: 28,
|
||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||
label: { show: true, position: 'top', color: INK.secondary, fontFamily: FONT, fontSize: 11 },
|
||
data: keys.map(k => buckets[k]),
|
||
}],
|
||
});
|
||
} catch (e) { toastErr(e); }
|
||
};
|
||
|
||
/* ---------- 首次加载:8 个卡片并行拉,单个失败不影响整页 ---------- */
|
||
const loadAll = async () => {
|
||
await Promise.allSettled([
|
||
loadClassCount(), loadClassAvgScore(), loadOutScoreLine(), loadFailScore(),
|
||
loadTopRank(), loadEmpTime(), loadClassAvgEmpTime(), loadAgeRange(),
|
||
]);
|
||
};
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener('resize', onResize);
|
||
await loadKpi(); // 先把班级缓存准备好,图上的班级名才显示得出来
|
||
loadAll();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', onResize);
|
||
charts.forEach(c => c.dispose());
|
||
charts.length = 0;
|
||
});
|
||
|
||
return Object.assign({}, refs, {
|
||
kpi, classCount, classAvgScore, avgSort, avgScoreNote,
|
||
scoreLine, outScoreLine, hiddenNoScore, failCount, failRows,
|
||
rankN, topRank, empTime, classAvgEmpTime, empSort,
|
||
ageOp, ageValue, ageValue2, ageRows, AGE_OP_OPTIONS,
|
||
loadClassCount, loadClassAvgScore, loadOutScoreLine, loadFailScore,
|
||
loadTopRank, loadEmpTime, loadClassAvgEmpTime, loadAgeRange,
|
||
round1, failNum, classLabel, dash,
|
||
});
|
||
},
|
||
},
|
||
});
|