feat: add student/class statistics page with Chart.js visualizations
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user