Files
1/static/js/pages/dashboard.js
T
2026-09-14 10:23:36 +08:00

733 lines
33 KiB
JavaScript
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.
/* 统计看板: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">不及格次数 &gt;</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,
});
},
},
});