372 lines
13 KiB
HTML
372 lines
13 KiB
HTML
{% 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="card mb-4">
|
|
<div class="card-body">
|
|
<div class="d-flex flex-wrap gap-3 align-items-center">
|
|
<button class="btn btn-primary btn-sm" id="mineFilterBtn" onclick="toggleMineFilter()">
|
|
<i class="bi bi-person"></i> 我的
|
|
</button>
|
|
<select class="form-select form-select-sm" style="width:auto; min-width:120px;" id="classFilter" onchange="loadStatistics()">
|
|
<option value="">全部班级</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 顶部统计卡片 -->
|
|
<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 mb-4">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title"><i class="bi bi-bar-chart"></i> 各类问题分布</h5>
|
|
<canvas id="problemNameChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title"><i class="bi bi-grid-3x3-gap"></i> 问题×班级矩阵</h5>
|
|
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
|
|
<table class="table table-sm table-bordered text-center small" id="problemClassMatrix">
|
|
<thead id="problemClassMatrixHead"></thead>
|
|
<tbody id="problemClassMatrixBody"></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, problemNameChart;
|
|
let mineFilterActive = false;
|
|
|
|
function toggleMineFilter() {
|
|
mineFilterActive = !mineFilterActive;
|
|
const btn = document.getElementById('mineFilterBtn');
|
|
if (mineFilterActive) {
|
|
btn.classList.add('active');
|
|
} else {
|
|
btn.classList.remove('active');
|
|
}
|
|
loadStatistics();
|
|
}
|
|
|
|
async function loadStatistics() {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (mineFilterActive) params.set('mine', 'true');
|
|
const classId = document.getElementById('classFilter').value;
|
|
if (classId) params.set('class_id', classId);
|
|
|
|
const resp = await fetch('/api/statistics/students?' + params.toString());
|
|
const data = await resp.json();
|
|
|
|
// 填充班级筛选器
|
|
const classSelect = document.getElementById('classFilter');
|
|
if (classSelect.options.length <= 1) {
|
|
for (const c of data.classes || []) {
|
|
const opt = document.createElement('option');
|
|
opt.value = c.id;
|
|
opt.textContent = c.name;
|
|
classSelect.appendChild(opt);
|
|
}
|
|
}
|
|
if (classId) {
|
|
classSelect.value = classId;
|
|
}
|
|
|
|
// 更新顶部卡片
|
|
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 problemNames = Object.keys(data.problem_name_distribution || {});
|
|
const problemCounts = Object.values(data.problem_name_distribution || {});
|
|
const pnCtx = document.getElementById('problemNameChart').getContext('2d');
|
|
if (problemNameChart) problemNameChart.destroy();
|
|
problemNameChart = new Chart(pnCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: problemNames,
|
|
datasets: [{
|
|
label: '出现次数',
|
|
data: problemCounts,
|
|
backgroundColor: '#0d6efd',
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
x: { beginAtZero: true }
|
|
}
|
|
}
|
|
});
|
|
|
|
// 问题×班级矩阵表
|
|
const classes = data.classes || [];
|
|
const pcmHead = document.getElementById('problemClassMatrixHead');
|
|
let headHtml = '<tr><th>问题</th>';
|
|
for (const c of classes) {
|
|
headHtml += `<th>${c.name}</th>`;
|
|
}
|
|
headHtml += '<th>合计</th></tr>';
|
|
pcmHead.innerHTML = headHtml;
|
|
|
|
const pcmBody = document.getElementById('problemClassMatrixBody');
|
|
let bodyHtml = '';
|
|
const pcmData = data.problem_class_matrix || [];
|
|
for (const row of pcmData) {
|
|
let rowTotal = 0;
|
|
bodyHtml += `<tr><td>${row.problem_name}</td>`;
|
|
for (const c of classes) {
|
|
const count = row[`class_${c.id}`] || 0;
|
|
bodyHtml += `<td>${count}</td>`;
|
|
rowTotal += count;
|
|
}
|
|
bodyHtml += `<td><strong>${rowTotal}</strong></td></tr>`;
|
|
}
|
|
pcmBody.innerHTML = bodyHtml;
|
|
|
|
// 班级学员条形图
|
|
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 %}
|