Files
piano-plan/app/templates/student.html
T
hmo baaa6ca2f8 feat: 添加学员详情/方案编辑/方案列表新页面
- student.html: 学员详情页,支持编辑/添加/删除问题
- plan_edit.html: 方案编辑页
- plans.html: 方案列表页
- home.html: 首页
2026-04-23 06:38:44 +08:00

583 lines
24 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ student.name }} - 学员详情 - 钢琴练习方案系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-person"></i> 学员详情</h4>
<div>
<a href="/students" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> 返回列表
</a>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="card-body text-center">
<div class="avatar mb-3">
<i class="bi bi-person-circle" style="font-size: 60px; color: #6c757d;"></i>
</div>
<h5 id="detailName">{{ student.name }}</h5>
<p class="text-muted" id="detailWechatNickname">{{ student.wechat_nickname or '未填写' }}</p>
<p class="text-muted" id="detailPhone">{{ student.phone or '未填写' }}</p>
<p class="small">
<span class="badge bg-info" id="detailPracticeTime">{{ student.practice_time or '30分钟' }}</span>
</p>
<p class="small" id="detailClass">
{% if student.class_obj %}
<span class="badge bg-secondary">{{ student.class_obj.name }}</span>
{% else %}
<span class="text-muted">未分配班级</span>
{% endif %}
</p>
<p class="small text-muted" id="detailNotes">{{ student.notes or '无备注' }}</p>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showEditStudentModal()">
<i class="bi bi-pencil"></i> 编辑
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteStudent()">
<i class="bi bi-trash"></i> 删除
</button>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-list-check"></i> 问题记录</h6>
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
<i class="bi bi-plus"></i> 添加问题
</button>
</div>
<div class="card-body" id="problemList">
<p class="text-muted">加载中...</p>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> 练习方案</h6>
<button class="btn btn-sm btn-primary" onclick="generatePlan()">
<i class="bi bi-plus"></i> 生成方案
</button>
</div>
<div class="card-body" id="planList">
<p class="text-muted">加载中...</p>
</div>
</div>
</div>
</div>
<!-- 编辑学员 Modal -->
<div class="modal fade" id="editStudentModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑学员</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editStudentId" value="{{ student.id }}">
<div class="mb-3">
<label class="form-label">姓名 *</label>
<input type="text" class="form-control" id="editStudentName" required>
</div>
<div class="mb-3">
<label class="form-label">手机号</label>
<input type="text" class="form-control" id="editStudentPhone">
</div>
<div class="mb-3">
<label class="form-label">微信昵称</label>
<input type="text" class="form-control" id="editStudentWechat">
</div>
<div class="mb-3">
<label class="form-label">每日练习时间</label>
<select class="form-select" id="editStudentPracticeTime">
<option value="15分钟">15分钟</option>
<option value="30分钟">30分钟</option>
<option value="45分钟">45分钟</option>
<option value="60分钟">60分钟</option>
<option value="90分钟">90分钟</option>
<option value="120分钟">120分钟</option>
<option value="150分钟以上">150分钟以上</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">备注</label>
<textarea class="form-control" id="editStudentNotes" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveStudentEdit()">保存</button>
</div>
</div>
</div>
</div>
<!-- 添加问题 Modal -->
<div class="modal fade" id="addProblemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">添加问题</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">问题 *</label>
<select class="form-select" id="addProblemSelect" required>
<option value="">选择问题...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">严重程度 *</label>
<select class="form-select" id="addProblemSeverity" required>
<option value="轻微">轻微</option>
<option value="中等" selected>中等</option>
<option value="严重">严重</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">级别 *</label>
<select class="form-select" id="addProblemLevel" required>
<option value="启蒙">启蒙</option>
<option value="入门" selected>入门</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveAddProblem()">添加</button>
</div>
</div>
</div>
</div>
<!-- 编辑问题 Modal -->
<div class="modal fade" id="editProblemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑问题</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editProblemId">
<div class="mb-3">
<label class="form-label">问题</label>
<input type="text" class="form-control" id="editProblemName" readonly>
</div>
<div class="mb-3">
<label class="form-label">严重程度 *</label>
<select class="form-select" id="editProblemSeverity" required>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">级别 *</label>
<select class="form-select" id="editProblemLevel" required>
<option value="启蒙">启蒙</option>
<option value="入门">入门</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveProblemEdit()">保存</button>
</div>
</div>
</div>
</div>
<!-- 生成方案 Modal -->
<div class="modal fade" id="generatePlanModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">生成练习方案</h5>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">学员:{{ student.name }}</label>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="useAiReport" checked>
<label class="form-check-label" for="useAiReport">
生成AI个性化报告(需要配置API)
</label>
</div>
</div>
<div class="mb-3">
<div class="progress" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%">0%</div>
</div>
</div>
<div class="mb-3">
<strong id="progressText">准备中...</strong>
</div>
<div id="progressLog" class="bg-light p-3 rounded" style="height: 200px; overflow-y: auto; font-family: monospace;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="startGenerateBtn" onclick="startGeneratePlan()">开始生成</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const currentStudentId = {{ student.id }};
const studentName = "{{ student.name }}";
let problemModal, editProblemModal, generateModal, editStudentModal;
document.addEventListener('DOMContentLoaded', function() {
problemModal = new bootstrap.Modal(document.getElementById('addProblemModal'));
editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal'));
generateModal = new bootstrap.Modal(document.getElementById('generatePlanModal'));
editStudentModal = new bootstrap.Modal(document.getElementById('editStudentModal'));
// 填充编辑学员表单初始值
document.getElementById('editStudentName').value = document.getElementById('detailName').textContent;
document.getElementById('editStudentPhone').value = document.getElementById('detailPhone').textContent === '未填写' ? '' : document.getElementById('detailPhone').textContent;
document.getElementById('editStudentWechat').value = document.getElementById('detailWechatNickname').textContent === '未填写' ? '' : document.getElementById('detailWechatNickname').textContent;
document.getElementById('editStudentPracticeTime').value = document.getElementById('detailPracticeTime').textContent;
document.getElementById('editStudentNotes').value = document.getElementById('detailNotes').textContent === '无备注' ? '' : document.getElementById('detailNotes').textContent;
loadProblems();
loadPlans();
loadProblemOptions();
});
async function loadProblemOptions() {
try {
const resp = await fetch('/api/problems');
const problems = await resp.json();
const select = document.getElementById('addProblemSelect');
select.innerHTML = '<option value="">选择问题...</option>';
problems.forEach(p => {
select.innerHTML += `<option value="${p.id}">${p.no} - ${p.name}</option>`;
});
} catch (e) {
console.error('加载问题列表失败', e);
}
}
async function loadProblems() {
try {
const resp = await fetch(`/api/students/${currentStudentId}/problems`);
const problems = await resp.json();
renderProblemList(problems);
} catch (e) {
document.getElementById('problemList').innerHTML = '<p class="text-danger">加载失败</p>';
}
}
function renderProblemList(problems) {
const container = document.getElementById('problemList');
if (problems.length === 0) {
container.innerHTML = '<p class="text-muted">暂无问题记录</p>';
return;
}
container.innerHTML = problems.map(p => `
<div class="d-flex justify-content-between align-items-center mb-2 p-2 border rounded">
<div>
<strong>${p.problem_name}</strong>
<span class="badge bg-${p.severity === '严重' ? 'danger' : p.severity === '中等' ? 'warning' : 'info'} ms-2">${p.severity}</span>
<span class="badge bg-secondary ms-1">${p.level}</span>
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-1" onclick="showEditProblemModal(${p.id}, '${p.problem_name}', '${p.severity}', '${p.level}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteProblem(${p.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
}
async function loadPlans() {
try {
const resp = await fetch(`/api/students/${currentStudentId}/plans`);
const plans = await resp.json();
renderPlanList(plans);
} catch (e) {
document.getElementById('planList').innerHTML = '<p class="text-danger">加载失败</p>';
}
}
function renderPlanList(plans) {
const container = document.getElementById('planList');
if (plans.length === 0) {
container.innerHTML = '<p class="text-muted">暂无练习方案</p>';
return;
}
container.innerHTML = plans.map(p => `
<div class="d-flex align-items-center mb-2 p-2 border rounded">
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary me-2">查看</a>
<span class="flex-grow-1">${p.created_at ? p.created_at.substring(0, 10) : '未知'}</span>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
<i class="bi bi-trash"></i>
</button>
</div>
`).join('');
}
function showEditStudentModal() {
editStudentModal.show();
}
async function saveStudentEdit() {
const data = {
name: document.getElementById('editStudentName').value,
phone: document.getElementById('editStudentPhone').value,
wechat_nickname: document.getElementById('editStudentWechat').value,
practice_time: document.getElementById('editStudentPracticeTime').value,
notes: document.getElementById('editStudentNotes').value,
};
try {
const resp = await fetch(`/api/students/${currentStudentId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (resp.ok) {
editStudentModal.hide();
location.reload();
} else {
alert('保存失败');
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
function deleteStudent() {
if (!confirm('确定删除该学员?所有问题和方案都将被删除!')) return;
fetch(`/api/students/${currentStudentId}`, {method: 'DELETE'})
.then(r => r.json())
.then(() => window.location.href = '/students');
}
function showAddProblemModal() {
document.getElementById('addProblemSelect').value = '';
document.getElementById('addProblemSeverity').value = '中等';
document.getElementById('addProblemLevel').value = '入门';
problemModal.show();
}
async function saveAddProblem() {
const problemId = document.getElementById('addProblemSelect').value;
const severity = document.getElementById('addProblemSeverity').value;
const level = document.getElementById('addProblemLevel').value;
if (!problemId) {
alert('请选择问题');
return;
}
try {
const resp = await fetch(`/api/students/${currentStudentId}/problems`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
problems: [{problem_id: parseInt(problemId), severity, level}]
})
});
if (resp.ok) {
problemModal.hide();
loadProblems();
} else {
const err = await resp.json();
alert('添加失败: ' + (err.error || '未知错误'));
}
} catch (e) {
alert('添加失败: ' + e.message);
}
}
function showEditProblemModal(id, name, severity, level) {
document.getElementById('editProblemId').value = id;
document.getElementById('editProblemName').value = name;
document.getElementById('editProblemSeverity').value = severity;
document.getElementById('editProblemLevel').value = level;
editProblemModal.show();
}
async function saveProblemEdit() {
const id = document.getElementById('editProblemId').value;
const severity = document.getElementById('editProblemSeverity').value;
const level = document.getElementById('editProblemLevel').value;
// 获取当前问题列表找到对应的 problem_db_id
const resp = await fetch(`/api/students/${currentStudentId}/problems`);
const problems = await resp.json();
const problem = problems.find(p => p.id === parseInt(id));
if (!problem) {
alert('问题不存在');
return;
}
// 调用更新 API
try {
const updateResp = await fetch(`/api/students/${currentStudentId}/problems/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({severity, level})
});
if (updateResp.ok) {
editProblemModal.hide();
loadProblems();
} else {
alert('更新失败');
}
} catch (e) {
alert('更新失败: ' + e.message);
}
}
async function deleteProblem(id) {
if (!confirm('确定删除该问题?')) return;
try {
const resp = await fetch(`/api/students/${currentStudentId}/problems/${id}`, {
method: 'DELETE'
});
if (resp.ok) {
loadProblems();
} else {
alert('删除失败');
}
} catch (e) {
alert('删除失败: ' + e.message);
}
}
function generatePlan() {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressLog = document.getElementById('progressLog');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
progressText.textContent = '准备中...';
progressLog.innerHTML = '';
generateModal.show();
}
async function startGeneratePlan() {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressLog = document.getElementById('progressLog');
const useAi = document.getElementById('useAiReport').checked;
progressBar.style.width = '0%';
progressText.textContent = '准备中...';
progressLog.innerHTML = '';
function addLog(msg, isError = false) {
const time = new Date().toLocaleTimeString();
progressLog.innerHTML += `<div class="${isError ? 'text-danger' : 'text-muted'} small">${time} ${msg}</div>`;
progressLog.scrollTop = progressLog.scrollHeight;
}
try {
const response = await fetch('/api/generate-plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({student_id: currentStudentId, use_ai: useAi})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {done, value} = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
progressBar.style.width = data.progress + '%';
progressBar.textContent = data.progress + '%';
progressText.textContent = data.message;
let logMsg = data.message;
if (data.detail) {
if (data.step === 'ai_prompt') {
logMsg = '【AI提示词】\n' + data.detail;
addLog(logMsg);
continue;
} else {
logMsg += '\n └ ' + data.detail.replace(/\n/g, '\n └ ');
}
}
addLog(logMsg);
if (data.step === 'complete') {
setTimeout(() => {
generateModal.hide();
alert('方案生成成功!');
loadPlans();
}, 500);
}
if (data.error) {
addLog(data.error, true);
}
} catch (e) {}
}
}
}
} catch (err) {
addLog('请求失败:' + err.message, true);
progressText.textContent = '生成失败';
}
}
async function deletePlan(id) {
if (!confirm('确定删除该方案?')) return;
try {
const resp = await fetch(`/api/plans/${id}`, {method: 'DELETE'});
if (resp.ok) {
loadPlans();
} else {
alert('删除失败');
}
} catch (e) {
alert('删除失败: ' + e.message);
}
}
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = 'toast-message alert alert-' + (type === 'success' ? 'success' : 'danger');
toast.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:200px;';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
</script>
{% endblock %}