feat: add student/class statistics page with Chart.js visualizations

This commit is contained in:
hmo
2026-04-27 19:28:04 +08:00
parent 7c35f4cac3
commit dcc0457848
3 changed files with 329 additions and 0 deletions
+75
View File
@@ -16,6 +16,7 @@ from app.models import (
db,
Student,
Class,
StudentProblem,
PracticePlan,
PROBLEM_LIST,
SEVERITY_LEVELS,
@@ -67,6 +68,14 @@ def students_page():
)
@main_bp.route("/statistics")
def statistics_page():
"""数据统计页"""
if not check_login():
return redirect("/login")
return render_template("statistics.html", active_nav="statistics")
@main_bp.route("/api/students", methods=["GET"])
@login_required_json
def get_students():
@@ -366,3 +375,69 @@ def import_students():
except Exception as e:
return jsonify({"error": f"导入失败: {str(e)}"}), 500
@main_bp.route("/api/statistics/students")
def get_student_statistics():
"""获取学员统计数据"""
# 全体学员数量
total_students = Student.query.count()
# 全体问题记录
problems = StudentProblem.query.all()
# 问题级别分布(来自 StudentProblem.level
levels = ["启蒙", "入门", "进阶", "熟练", "精通"]
level_dist = {lv: 0 for lv in levels}
for p in problems:
if p.level in level_dist:
level_dist[p.level] += 1
# 问题严重程度分布
severity_dist = {"轻微": 0, "中等": 0, "严重": 0}
for p in problems:
if p.severity in severity_dist:
severity_dist[p.severity] += 1
# 各班级学员数量
classes = Class.query.filter_by(active=True).all()
class_student_count = []
for c in classes:
class_student_count.append({
"class_id": c.id,
"class_name": c.name,
"level": c.level,
"student_count": len(c.students)
})
# 问题×级别矩阵(严重程度 × 学员级别)
severities = ["轻微", "中等", "严重"]
matrix = {}
for sev in severities:
matrix[sev] = {}
for lv in levels:
matrix[sev][lv] = 0
for p in problems:
if p.level in levels and p.severity in severities:
matrix[p.severity][p.level] += 1
# 各班级问题数量
class_problem_count = []
for c in classes:
class_problems = [p for p in problems if p.student.class_id == c.id]
class_problem_count.append({
"class_id": c.id,
"class_name": c.name,
"problem_count": len(class_problems)
})
return jsonify({
"total_students": total_students,
"total_problems": len(problems),
"level_distribution": level_dist,
"severity_distribution": severity_dist,
"class_student_count": class_student_count,
"class_problem_count": class_problem_count,
"problem_level_matrix": matrix,
})
+5
View File
@@ -10,6 +10,8 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/css/tabulator.min.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% block extra_css %}{% endblock %}
<style>
@@ -86,6 +88,9 @@
<small id="currentUserDisplay" class="text-light"></small>
</div>
<nav class="nav flex-column">
<a class="nav-link {% if active_nav == 'statistics' %}active{% endif %}" href="/statistics">
<i class="bi bi-bar-chart"></i> 数据统计
</a>
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
<i class="bi bi-people"></i> 学员管理
</a>
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title %}数据统计 - 钢琴练习方案系统{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<h2 class="mb-4"><i class="bi bi-bar-chart"></i> 数据统计</h2>
<!-- 顶部统计卡片 -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="mb-0" id="totalStudents">-</h3>
<small class="text-muted">学员总数</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="mb-0" id="totalProblems">-</h3>
<small class="text-muted">问题记录总数</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h3 class="mb-0" id="avgProblems">-</h3>
<small class="text-muted">人均问题数</small>
</div>
</div>
</div>
</div>
<!-- 饼图区 -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-exclamation-triangle"></i> 问题严重程度分布</h5>
<canvas id="severityChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-speedometer2"></i> 问题级别分布</h5>
<canvas id="levelChart"></canvas>
</div>
</div>
</div>
</div>
<!-- 问题×级别矩阵 -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-grid"></i> 问题级别×严重程度矩阵</h5>
<div class="table-responsive">
<table class="table table-bordered text-center" id="matrixTable">
<thead>
<tr>
<th>严重程度</th>
<th>启蒙</th>
<th>入门</th>
<th>进阶</th>
<th>熟练</th>
<th>精通</th>
<th>合计</th>
</tr>
</thead>
<tbody id="matrixBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 班级统计 -->
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-people"></i> 各班级学员数量</h5>
<canvas id="classStudentChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-list-check"></i> 各班级问题数量</h5>
<canvas id="classProblemChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let severityChart, levelChart, classStudentChart, classProblemChart;
async function loadStatistics() {
try {
const resp = await fetch('/api/statistics/students');
const data = await resp.json();
// 更新顶部卡片
document.getElementById('totalStudents').textContent = data.total_students || 0;
document.getElementById('totalProblems').textContent = data.total_problems || 0;
const avg = data.total_students > 0
? (data.total_problems / data.total_students).toFixed(1)
: 0;
document.getElementById('avgProblems').textContent = avg;
// 严重程度饼图
const sevCtx = document.getElementById('severityChart').getContext('2d');
if (severityChart) severityChart.destroy();
severityChart = new Chart(sevCtx, {
type: 'doughnut',
data: {
labels: Object.keys(data.severity_distribution),
datasets: [{
data: Object.values(data.severity_distribution),
backgroundColor: ['#28a745', '#ffc107', '#dc3545'],
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
// 级别饼图
const lvCtx = document.getElementById('levelChart').getContext('2d');
if (levelChart) levelChart.destroy();
levelChart = new Chart(lvCtx, {
type: 'doughnut',
data: {
labels: Object.keys(data.level_distribution),
datasets: [{
data: Object.values(data.level_distribution),
backgroundColor: ['#17a2b8', '#6610f2', '#e85d04', '#e83e8c', '#20c997', '#6c757d'],
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
// 矩阵表格
const matrixBody = document.getElementById('matrixBody');
const matrix = data.problem_level_matrix;
const levels = ['启蒙', '入门', '进阶', '熟练', '精通'];
const severities = ['轻微', '中等', '严重'];
let html = '';
let sevTotals = {轻微: 0, 中等: 0, 严重: 0};
let lvTotals = {启蒙: 0, 入门: 0, 进阶: 0, 熟练: 0, 精通: 0};
let grandTotal = 0;
for (const sev of severities) {
html += `<tr><td><strong>${sev}</strong></td>`;
let rowTotal = 0;
for (const lv of levels) {
const count = matrix[sev]?.[lv] || 0;
html += `<td>${count}</td>`;
rowTotal += count;
lvTotals[lv] += count;
grandTotal += count;
}
html += `<td><strong>${rowTotal}</strong></td></tr>`;
sevTotals[sev] = rowTotal;
}
// 合计行
html += `<tr class="table-secondary"><td><strong>合计</strong></td>`;
for (const lv of levels) {
html += `<td><strong>${lvTotals[lv]}</strong></td>`;
}
html += `<td><strong>${grandTotal}</strong></td></tr>`;
matrixBody.innerHTML = html;
// 班级学员条形图
const csCtx = document.getElementById('classStudentChart').getContext('2d');
if (classStudentChart) classStudentChart.destroy();
classStudentChart = new Chart(csCtx, {
type: 'bar',
data: {
labels: data.class_student_count.map(c => c.class_name),
datasets: [{
label: '学员数',
data: data.class_student_count.map(c => c.student_count),
backgroundColor: '#0d6efd',
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true }
}
}
});
// 班级问题条形图
const cpCtx = document.getElementById('classProblemChart').getContext('2d');
if (classProblemChart) classProblemChart.destroy();
classProblemChart = new Chart(cpCtx, {
type: 'bar',
data: {
labels: data.class_problem_count.map(c => c.class_name),
datasets: [{
label: '问题数',
data: data.class_problem_count.map(c => c.problem_count),
backgroundColor: '#fd7e14',
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true }
}
}
});
} catch (e) {
console.error('加载统计失败:', e);
}
}
document.addEventListener('DOMContentLoaded', loadStatistics);
</script>
{% endblock %}