/* 统计看板:8 个统计接口,每个都是「图表 + 表格」两张视图,
表格用来核对真值,图表用来看趋势。
注意用 v-if 而不是 v-show 切卡片:隐藏容器里 ECharts 量不到宽度,切回来会是空白图。 */
window.PAGES.push({
key: 'dashboard',
title: '统计看板',
sub: '班级、成绩、就业三个维度的汇总统计',
group: '总览',
icon: 'dashboard',
component: {
name: 'DashboardPage',
template: `
① 班级人数与性别分布
含 0 人的空班级;性别按库里的「男 / 女」统计
刷新
{{ classLabel(row.class_id) }}
② 各次考试班级平均分
按考核序次分组,每个班级一条线{{ avgScoreNote }}
默认
高→低
低→高
刷新
{{ classLabel(row.class_id) }}
{{ round1(row.avg_score) }}
③ 每次考试都超过分数线的学生
要求每一次考核都高于分数线
(已隐藏 {{ hiddenNoScore }} 名暂无成绩的学生)
分数线
查询
第{{ s.exam_order }}次:{{ s.score }}
④ 不及格次数超过指定次数的学生
不及格线固定 60 分(后端设定),次数为「严格大于」
不及格次数 >
查询
第{{ s.exam_order }}次:{{ s.score }}
{{ classLabel(row.class_id) }}
{{ failNum(row) }}
⑤ 就业薪资排名 TopN
按 offer 薪资从高到低
取前
名
查询
{{ classLabel(row.class_id) }}
⑥ 每个学生的就业时长
offer 下发时间 − 就业开放时间;0 天表示尚未就业
刷新
{{ row.emp_total_time }}
未就业
⑦ 各班平均就业时长
只统计已进入就业阶段且已拿到 offer 的学生
默认
高→低
低→高
刷新
{{ classLabel(row.class_id) }}
{{ row.avg_emp_time }}
无就业学生
⑧ 按年龄段筛选学生
右侧图表是命中学生的年龄分布
到
查询
{{ classLabel(row.class_id) }}
{{ dash(row.stu_gender) }}
`,
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}
${classLabel(r.class_id)}
薪资:${r.salary}
公司:${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)}
平均时长:${r.avg_emp_time === null ? '无就业学生' : r.avg_emp_time + ' 天'}
参与人数:${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,
});
},
},
});