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