Files
piano-plan/app/templates/index.html
T

1196 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}钢琴练习方案管理系统{% endblock %}
{% block page_css %}
<style>
.student-card { cursor: pointer; transition: transform 0.2s; }
.student-card:hover { transform: translateY(-3px); }
.problem-tag { font-size: 12px; padding: 3px 8px; margin: 2px; display: inline-block; }
.severity-轻微 { background: #d4edda; color: #155724; }
.severity-中等 { background: #fff3cd; color: #856404; }
.severity-严重 { background: #f8d7da; color: #721c24; }
.modal-body { max-height: 70vh; overflow-y: auto; }
.problem-checkbox { margin-bottom: 8px; }
.problem-row { padding: 10px; border-bottom: 1px solid #eee; }
.problem-row:last-child { border-bottom: none; }
.markdown-body h1 { font-size: 1.5rem; margin-bottom: 1rem; border-bottom: 2px solid #dee2e6; padding-bottom: 0.5rem; }
.markdown-body h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; }
.markdown-body h3 { font-size: 1.1rem; margin: 1rem 0 0.5rem; }
.markdown-body p { margin: 0.5rem 0; line-height: 1.6; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5rem; margin: 0.5rem 0; }
.markdown-body table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
.markdown-body th, .markdown-body td { border: 1px solid #dee2e6; padding: 8px 12px; text-align: left; }
.markdown-body th { background: #f8f9fa; }
.markdown-body code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
.markdown-body blockquote { border-left: 4px solid #dee2e6; margin: 1rem 0; padding: 0.5rem 1rem; background: #f8f9fa; }
</style>
{% endblock %}
{% block sidebar_nav %}
<a class="nav-link active" href="#" onclick="showStudentList()">
<i class="bi bi-people"></i> 学员管理
</a>
<a class="nav-link" href="#" onclick="showSettings()" id="settingsNav">
<i class="bi bi-gear"></i> 问题配置
</a>
<a class="nav-link" href="/api-settings" id="apiSettingsNav" style="display:none;">
<i class="bi bi-key"></i> API设置
</a>
<a class="nav-link" href="/templates" id="templatesNav" style="display:none;">
<i class="bi bi-file-earmark-text"></i> 模板管理
</a>
<a class="nav-link" href="/classes" id="classesNav" style="display:none;">
<i class="bi bi-collection"></i> 班级管理
</a>
<a class="nav-link" href="/users" id="usersNav" style="display:none;">
<i class="bi bi-person-badge"></i> 用户管理
</a>
<hr>
<a class="nav-link" href="#" onclick="showChangePasswordModal()">
<i class="bi bi-key"></i> 修改密码
</a>
<a class="nav-link" href="#" onclick="logout()">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a>
{% endblock %}
{% block content %}
<!-- 学员列表页面 -->
<div id="studentListPage">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center gap-3">
<h4><i class="bi bi-people"></i> 学员列表</h4>
<select class="form-select form-select-sm" style="width:auto;" id="classFilter" onchange="loadStudents()">
<option value="">全部班级</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:150px;" placeholder="搜索姓名..." id="nameFilter" oninput="loadStudents()">
</div>
<div class="btn-group">
<button class="btn btn-outline-secondary" onclick="downloadTemplate()">
<i class="bi bi-download"></i> 模板下载
</button>
<button class="btn btn-outline-primary" onclick="document.getElementById('importFileInput').click()">
<i class="bi bi-upload"></i> CSV导入
</button>
<input type="file" id="importFileInput" accept=".csv" style="display:none" onchange="importStudents(this)">
<button class="btn btn-outline-success" onclick="exportStudents()">
<i class="bi bi-file-earmark-arrow-up"></i> 导出学员
</button>
<button class="btn btn-primary" onclick="showAddStudentModal()">
<i class="bi bi-plus-lg"></i> 新增学员
</button>
</div>
</div>
<div class="row" id="studentList">
<!-- 学员卡片将通过JS动态加载 -->
</div>
</div>
<!-- 学员详情页面 -->
<div id="studentDetailPage" style="display: none;">
<button class="btn btn-link mb-3" onclick="showStudentList()">
<i class="bi bi-arrow-left"></i> 返回列表
</button>
<div class="row">
<div class="col-md-4">
<div class="card mb-3">
<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">学员姓名</h5>
<p class="text-muted" id="detailWechatNickname">微信昵称</p>
<p class="text-muted" id="detailPhone">电话</p>
<p class="small"><span class="badge bg-info" id="detailPracticeTime">30分钟</span></p>
<p class="small" id="detailClass"></p>
<p class="small text-muted" id="detailNotes">备注</p>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="editStudent()">
<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-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">问题记录</h6>
<button class="btn btn-sm btn-primary" onclick="showProblemsModal()">
<i class="bi bi-plus-lg"></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">练习方案</h6>
<div class="d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="aiTemplateSelect" style="width: auto;">
<option value="">加载中...</option>
</select>
<button class="btn btn-sm btn-success" onclick="generatePlan()">
<i class="bi bi-magic"></i> 生成方案
</button>
</div>
</div>
<div class="card-body" id="planList">
<p class="text-muted">暂无练习方案</p>
</div>
</div>
</div>
</div>
</div>
<!-- 新增/编辑学员模态框 -->
<div class="modal fade" id="addStudentModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="studentModalTitle">新增学员</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>
<input type="text" class="form-control" id="studentName" required>
</div>
<div class="mb-3">
<label class="form-label">电话</label>
<input type="text" class="form-control" id="studentPhone">
</div>
<div class="mb-3">
<label class="form-label">微信昵称</label>
<input type="text" class="form-control" id="studentWechatNickname">
</div>
<div class="mb-3">
<label class="form-label">每日练习时间</label>
<select class="form-select" id="studentPracticeTime">
<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>
<small class="text-muted">生成方案时将使用此练习时间</small>
</div>
<div class="mb-3">
<label class="form-label">班级</label>
<select class="form-select" id="studentClassId">
<option value="">未分配班级</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">备注</label>
<textarea class="form-control" id="studentNotes" 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="saveStudent()">保存</button>
</div>
</div>
</div>
</div>
<!-- 问题记录模态框 -->
<div class="modal fade" id="problemsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<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="alert alert-info">
<i class="bi bi-info-circle"></i>
练习时间请在学员信息中统一设置
</div>
<div id="problemCheckboxes">
<!-- 问题复选框将通过JS动态生成 -->
</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="saveProblems()">保存</button>
</div>
</div>
</div>
</div>
<!-- 方案查看模态框 -->
<div class="modal fade" id="planDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<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" id="planDetailContent">
<!-- 方案内容将通过JS动态生成 -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-warning" onclick="editPlanContent()">
<i class="bi bi-edit"></i> 编辑内容
</button>
<select class="form-select form-select-sm" id="reportTemplateSelect" style="width: auto;">
<option value="">加载中...</option>
</select>
<button type="button" class="btn btn-primary" onclick="downloadMD()">
<i class="bi bi-file-markdown"></i> 下载MD
</button>
<button type="button" class="btn btn-success" onclick="downloadPDF()">
<i class="bi bi-download"></i> 下载PDF
</button>
<button type="button" class="btn btn-info" onclick="showWechatCard()">
<i class="bi bi-wechat"></i> 微信卡片
</button>
<button type="button" class="btn btn-outline-secondary" onclick="previewReportTemplate()">
<i class="bi bi-eye"></i> 预览报告
</button>
</div>
</div>
</div>
</div>
<!-- 编辑方案内容弹窗 -->
<div class="modal fade" id="editPlanContentModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-edit"></i> 编辑方案内容</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" id="editContentTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="aiReport-tab" data-bs-toggle="tab" data-bs-target="#aiReportPane" type="button">AI报告</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="dailySchedule-tab" data-bs-toggle="tab" data-bs-target="#dailySchedulePane" type="button">每日练习计划</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="basicInfo-tab" data-bs-toggle="tab" data-bs-target="#basicInfoPane" type="button">基本信息</button>
</li>
</ul>
<div class="tab-content mt-3" id="editContentTabContent">
<div class="tab-pane fade show active" id="aiReportPane" role="tabpanel">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> 使用 Markdown 格式编辑AI报告,支持标题、列表,加粗等格式
</div>
<textarea id="editAiReport" style="display:none;"></textarea>
<div id="editAiReportEditor"></div>
</div>
<div class="tab-pane fade" id="dailySchedulePane" role="tabpanel">
<div class="alert alert-info mb-2">
<i class="bi bi-info-circle"></i> 支持拖拽排序、双击单元格编辑、点击+添加行、点击×删除行
</div>
<div id="editDailyScheduleTable" style="max-height: 400px;"></div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2" onclick="addScheduleRow()">
<i class="bi bi-plus"></i> 添加一行
</button>
</div>
<div class="tab-pane fade" id="basicInfoPane" role="tabpanel">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">练习时间</label>
<input type="text" class="form-control" id="editPracticeTime" readonly>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">生成时间</label>
<input type="text" class="form-control" id="editGeneratedAt" readonly>
</div>
</div>
<div class="mb-3">
<label class="form-label">问题数量</label>
<input type="text" class="form-control" id="editProblemsCount" readonly>
</div>
</div>
</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="savePlanContent()">
<i class="bi bi-save"></i> 保存
</button>
</div>
</div>
</div>
</div>
<!-- 报告模板预览模态框 -->
<div class="modal fade" id="reportPreviewModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-eye"></i> 报告预览</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="reportPreviewContent" class="markdown-body" style="background: #f8f9fa; padding: 15px; border-radius: 8px; min-height: 200px;"></div>
</div>
</div>
</div>
</div>
<!-- 进度显示模态框 -->
<div class="modal fade" id="progressModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">生成练习方案</h5>
</div>
<div class="modal-body">
<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: 150px; overflow-y: auto; font-family: monospace;">
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@5/dist/js/tabulator.min.js"></script>
<script>
let currentStudentId = null;
let currentPlanId = null;
// 问题列表(从后端传入)
const problemList = {{ problem_list | tojson }};
const severityLevels = {{ severity_levels | tojson }};
const practiceTimeOptions = {{ practice_time_options | tojson }};
// 初始化
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/check-login').then(r => r.json()).then(data => {
if (!data.logged_in) {
window.location.href = '/login';
return;
}
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
const userDisplay = data.username + ' (' + (ROLE_LABELS[data.role] || data.role) + ')';
document.getElementById('currentUserDisplay').textContent = userDisplay;
const mobileDisplay = document.getElementById('mobileUserDisplay');
if (mobileDisplay) mobileDisplay.textContent = userDisplay;
if (data.role === 'admin') {
document.getElementById('usersNav').style.display = '';
document.getElementById('settingsNav').style.display = '';
document.getElementById('apiSettingsNav').style.display = '';
document.getElementById('templatesNav').style.display = '';
} else {
document.getElementById('settingsNav').style.display = '';
document.getElementById('apiSettingsNav').style.display = 'none';
document.getElementById('templatesNav').style.display = 'none';
}
document.getElementById('classesNav').style.display = '';
loadAiTemplates();
loadReportTemplates();
}).catch(() => {
window.location.href = '/login';
});
loadClassFilter();
loadStudents();
initProblemCheckboxes();
});
// 加载AI提示词模板列表
async function loadAiTemplates() {
try {
const resp = await fetch('/templates/templates?type=ai_prompt');
if (resp.ok) {
const templates = await resp.json();
const select = document.getElementById('aiTemplateSelect');
select.innerHTML = templates.map(t =>
`<option value="${t.id}">${t.name}</option>`
).join('');
}
} catch (e) {
console.error('加载AI模板失败:', e);
}
}
// 加载报告模板列表
async function loadReportTemplates() {
try {
const resp = await fetch('/templates/templates?type=report');
if (resp.ok) {
const templates = await resp.json();
const select = document.getElementById('reportTemplateSelect');
select.innerHTML = templates.map(t =>
`<option value="${t.id}">${t.name}</option>`
).join('');
}
} catch (e) {
console.error('加载报告模板失败:', e);
}
}
// 下载CSV模板
function downloadTemplate() {
window.location.href = '/api/students/template';
}
// 导出学员CSV
function exportStudents() {
window.location.href = '/api/students/export';
}
// 导入CSV学员
function importStudents(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
fetch('/api/students/import', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if (data.error) {
alert(data.error);
if (data.error_details) {
alert('错误详情:\n' + data.error_details.join('\n'));
}
} else {
alert(data.message);
loadStudents();
}
})
.catch(err => {
console.error(err);
alert('导入失败');
});
input.value = '';
}
// 加载学员列表
async function loadStudents() {
const classId = document.getElementById('classFilter').value;
const name = document.getElementById('nameFilter').value;
let url = '/api/students?';
if (classId) url += 'class_id=' + classId + '&';
if (name) url += 'name=' + encodeURIComponent(name);
const response = await fetch(url);
if (response.status === 401) {
window.location.href = '/login';
return;
}
const students = await response.json();
renderStudentList(students);
}
// 加载班级筛选选项
async function loadClassFilter() {
try {
const response = await fetch('/api/classes');
const classes = await response.json();
const select = document.getElementById('classFilter');
classes.forEach(c => {
const option = document.createElement('option');
option.value = c.id;
option.textContent = c.name;
select.appendChild(option);
});
} catch (e) {
console.error('加载班级失败', e);
}
}
// 渲染学员列表
function renderStudentList(students) {
const container = document.getElementById('studentList');
if (students.length === 0) {
container.innerHTML = '<div class="col-12 text-center text-muted py-5"><p>暂无学员,请添加</p></div>';
return;
}
let html = '';
students.forEach(s => {
html += `
<div class="col-md-4 col-sm-6 mb-3">
<div class="card student-card" onclick="showStudentDetail(${s.id})">
<div class="card-body">
<h5 class="card-title">${s.name}</h5>
<p class="card-text text-muted small">${s.wechat_nickname || ''} ${s.phone ? '| ' + s.phone : ''}</p>
<span class="badge bg-info">${s.practice_time}</span>
<span class="badge bg-secondary">${s.problem_count} 个问题</span>
<span class="badge bg-primary">${s.plan_count} 个方案</span>
</div>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 初始化问题复选框
function initProblemCheckboxes() {
const container = document.getElementById('problemCheckboxes');
const levelOptions = ['启蒙', '入门', '进阶', '熟练', '精通'];
let html = '';
problemList.forEach(p => {
html += `
<div class="problem-row">
<div class="form-check">
<input class="form-check-input problem-checkbox-input" type="checkbox" value="${p.id}" id="prob_${p.id}" data-name="${p.name}">
<label class="form-check-label fw-bold" for="prob_${p.id}">${p.name}</label>
</div>
<div class="mt-2 ps-4" id="severity_${p.id}" style="display:none;">
<div class="mb-2">
<small class="text-muted">严重程度:</small>
<div class="btn-group btn-group-sm" role="group">
${severityLevels.map((sev, idx) => `
<input type="radio" class="btn-check" name="severity_${p.id}" id="sev_${p.id}_${idx}" value="${sev}" ${idx===1 ? 'checked' : ''}>
<label class="btn btn-outline-${idx===2 ? 'danger' : idx===1 ? 'warning' : 'success'}" for="sev_${p.id}_${idx}">${sev}</label>
`).join('')}
</div>
</div>
<div>
<small class="text-muted">当前级别:</small>
<div class="btn-group btn-group-sm" role="group">
${levelOptions.map((lvl, idx) => `
<input type="radio" class="btn-check" name="level_${p.id}" id="lvl_${p.id}_${idx}" value="${lvl}" ${idx===1 ? 'checked' : ''}>
<label class="btn btn-outline-secondary" for="lvl_${p.id}_${idx}">${lvl}</label>
`).join('')}
</div>
</div>
</div>
</div>
`;
});
container.innerHTML = html;
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
cb.addEventListener('change', function() {
const problemId = this.value;
const severityDiv = document.getElementById(`severity_${problemId}`);
severityDiv.style.display = this.checked ? 'block' : 'none';
});
});
}
// 显示新增学员模态框
function showAddStudentModal() {
document.getElementById('studentModalTitle').textContent = '新增学员';
document.getElementById('studentName').value = '';
document.getElementById('studentPhone').value = '';
document.getElementById('studentWechatNickname').value = '';
document.getElementById('studentPracticeTime').value = '30分钟';
document.getElementById('studentNotes').value = '';
document.getElementById('studentClassId').value = '';
currentStudentId = null;
fetch('/api/classes').then(r => r.json()).then(classes => {
const select = document.getElementById('studentClassId');
select.innerHTML = '<option value="">未分配班级</option>' +
classes.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
});
new bootstrap.Modal(document.getElementById('addStudentModal')).show();
}
// 编辑学员
function editStudent() {
document.getElementById('studentModalTitle').textContent = '编辑学员';
document.getElementById('studentName').value = document.getElementById('detailName').textContent;
document.getElementById('studentPhone').value = document.getElementById('detailPhone').textContent === '未填写' ? '' : document.getElementById('detailPhone').textContent;
fetch(`/api/students/${currentStudentId}`)
.then(r => r.json())
.then(data => {
document.getElementById('studentWechatNickname').value = data.student.wechat_nickname || '';
document.getElementById('studentPracticeTime').value = data.student.practice_time || '30分钟';
fetch('/api/classes').then(r => r.json()).then(classes => {
const select = document.getElementById('studentClassId');
select.innerHTML = '<option value="">未分配班级</option>' +
classes.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
select.value = data.student.class_id || '';
});
});
document.getElementById('studentNotes').value = document.getElementById('detailNotes').textContent === '无备注' ? '' : document.getElementById('detailNotes').textContent;
new bootstrap.Modal(document.getElementById('addStudentModal')).show();
}
// 保存学员
async function saveStudent() {
const name = document.getElementById('studentName').value.trim();
if (!name) { alert('请输入姓名'); return; }
const classId = document.getElementById('studentClassId').value;
const data = {
name: name,
phone: document.getElementById('studentPhone').value.trim(),
wechat_nickname: document.getElementById('studentWechatNickname').value.trim(),
practice_time: document.getElementById('studentPracticeTime').value,
notes: document.getElementById('studentNotes').value.trim(),
class_id: classId ? parseInt(classId) : null
};
let response;
if (currentStudentId) {
response = await fetch(`/api/students/${currentStudentId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/students', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
}
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('addStudentModal')).hide();
if (currentStudentId) {
showStudentDetail(currentStudentId);
} else {
loadStudents();
}
}
}
// 显示学员详情
async function showStudentDetail(studentId) {
currentStudentId = studentId;
const response = await fetch(`/api/students/${studentId}`);
const data = await response.json();
document.getElementById('detailName').textContent = data.student.name;
document.getElementById('detailWechatNickname').textContent = data.student.wechat_nickname || '未填写';
document.getElementById('detailPhone').textContent = data.student.phone || '未填写';
document.getElementById('detailPracticeTime').textContent = data.student.practice_time || '30分钟';
const classEl = document.getElementById('detailClass');
if (data.student.class_name) {
classEl.innerHTML = '<span class="badge bg-secondary">' + data.student.class_name + '</span>';
} else {
classEl.innerHTML = '<span class="text-muted">未分配班级</span>';
}
document.getElementById('detailNotes').textContent = data.student.notes || '无备注';
renderProblemList(data.problems);
renderPlanList(data.plans);
document.getElementById('studentListPage').style.display = 'none';
document.getElementById('studentDetailPage').style.display = 'block';
}
// 返回学员列表
function showStudentList() {
document.getElementById('studentListPage').style.display = 'block';
document.getElementById('studentDetailPage').style.display = 'none';
currentStudentId = null;
loadStudents();
}
// 渲染问题列表
function renderProblemList(problems) {
const container = document.getElementById('problemList');
if (problems.length === 0) {
container.innerHTML = '<p class="text-muted">暂无问题记录</p>';
return;
}
const levelOptions = ['启蒙', '入门', '进阶', '熟练', '精通'];
const severityLevels = ['轻微', '中等', '严重'];
let html = '';
problems.forEach(p => {
const currentLevel = p.level || '入门';
const currentSeverity = p.severity || '中等';
const levelSelect = levelOptions.map(l =>
`<option value="${l}" ${l === currentLevel ? 'selected' : ''}>${l}</option>`
).join('');
const severitySelect = severityLevels.map((s, idx) => {
const color = idx === 2 ? 'danger' : idx === 1 ? 'warning' : 'success';
return `<option value="${s}" ${s === currentSeverity ? 'selected' : ''}>${s}</option>`;
}).join('');
html += `
<div class="d-flex justify-content-between align-items-center mb-2">
<span>${p.problem_name}</span>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width: 85px;" onchange="updateProblemField(${p.id}, 'level', this.value)">
${levelSelect}
</select>
<select class="form-select form-select-sm" style="width: 70px;" onchange="updateProblemField(${p.id}, 'severity', this.value)">
${severitySelect}
</select>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteProblem(${p.id})"><i class="bi bi-trash"></i></button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 更新问题字段
async function updateProblemField(problemId, field, value) {
const response = await fetch(`/api/students/${currentStudentId}/problems`);
const problems = await response.json();
const problem = problems.find(p => p.id === problemId);
if (!problem) return;
await fetch(`/api/students/${currentStudentId}/problems/${problem.problem_id}`, {method: 'DELETE'});
await fetch(`/api/students/${currentStudentId}/problems`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
problems: [{
problem_id: problem.problem_id,
problem_name: problem.problem_name,
severity: field === 'severity' ? value : problem.severity,
level: field === 'level' ? value : (problem.level || '入门')
}]
})
});
}
// 删除单个问题
async function deleteProblem(problemId) {
const response = await fetch(`/api/students/${currentStudentId}/problems`);
const problems = await response.json();
const problem = problems.find(p => p.id === problemId);
if (!problem) return;
if (!confirm(`确定要删除问题"${problem.problem_name}"吗?`)) return;
await fetch(`/api/students/${currentStudentId}/problems/${problem.problem_id}`, {method: 'DELETE'});
showStudentDetail(currentStudentId);
}
// 渲染方案列表
function renderPlanList(plans) {
const container = document.getElementById('planList');
if (plans.length === 0) {
container.innerHTML = '<p class="text-muted">暂无练习方案</p>';
return;
}
let html = '';
plans.forEach(p => {
html += `
<div class="d-flex justify-content-between align-items-center mb-2">
<span>生成于 ${p.created_at}</span>
<div>
<button class="btn btn-sm btn-outline-primary" onclick="viewPlan(${p.id})">查看</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">删除</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 显示问题记录模态框
function showProblemsModal() {
fetch(`/api/students/${currentStudentId}/problems`)
.then(r => r.json())
.then(currentProblems => {
const currentProblemIds = currentProblems.map(p => p.problem_id);
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
const isChecked = currentProblemIds.includes(cb.value);
cb.checked = isChecked;
const severityDiv = document.getElementById(`severity_${cb.value}`);
severityDiv.style.display = isChecked ? 'block' : 'none';
if (isChecked) {
const problem = currentProblems.find(p => p.problem_id === cb.value);
if (problem) {
const levelRadios = document.querySelectorAll(`input[name="level_${cb.value}"]`);
levelRadios.forEach(r => {
r.checked = r.value === (problem.level || '入门');
});
const severityRadios = document.querySelectorAll(`input[name="severity_${cb.value}"]`);
severityRadios.forEach(r => {
r.checked = r.value === (problem.severity || '中等');
});
}
}
});
});
new bootstrap.Modal(document.getElementById('problemsModal')).show();
}
// 保存问题
async function saveProblems() {
const problems = [];
document.querySelectorAll('.problem-checkbox-input:checked').forEach(cb => {
const problemId = cb.value;
const problemName = cb.dataset.name;
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
problems.push({
problem_id: problemId,
problem_name: problemName,
severity: severityEl ? severityEl.value : '中等',
level: levelEl ? levelEl.value : '入门'
});
});
if (problems.length === 0) {
alert('请至少选择一个 问题');
return;
}
const response = await fetch(`/api/students/${currentStudentId}/problems`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({problems})
});
if (response.ok) {
bootstrap.Modal.getInstance(document.getElementById('problemsModal')).hide();
showStudentDetail(currentStudentId);
}
}
// 生成方案 - 使用SSE实时显示进度
async function generatePlan() {
if (!confirm('确定要生成练习方案吗?')) return;
const progressModal = new bootstrap.Modal(document.getElementById('progressModal'));
progressModal.show();
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressLog = document.getElementById('progressLog');
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 templateId = document.getElementById('aiTemplateSelect').value;
const response = await fetch('/api/generate-plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({student_id: currentStudentId, use_ai: true, template_id: templateId || null})
});
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(() => {
progressModal.hide();
alert('方案生成成功!');
showStudentDetail(currentStudentId);
}, 500);
}
if (data.error) {
addLog(data.error, true);
}
} catch (e) {}
}
}
}
} catch (err) {
addLog('请求失败:' + err.message, true);
progressText.textContent = '生成失败';
}
}
// 查看方案
async function viewPlan(planId) {
currentPlanId = planId;
const response = await fetch(`/api/plans/${planId}`);
const data = await response.json();
let html = `
<div class="mb-3">
<strong>学员:</strong>${data.student_name} &nbsp;&nbsp;
<strong>练习时间:</strong>${data.content.practice_time} &nbsp;&nbsp;
<strong>生成时间:</strong>${data.created_at}
</div>
<h6>问题诊断</h6>
<div class="mb-3">
`;
data.content.problems.forEach(p => {
html += `<span class="problem-tag severity-${p.severity}">${p.name}${p.severity}</span> `;
});
html += `</div>`;
if (data.content.ai_report) {
const aiReportHtml = marked.parse(data.content.ai_report);
html += `
<h6>AI个性化练习报告</h6>
<div class="mb-3 p-3 bg-light rounded" style="max-height: 500px; overflow-y: auto;">${aiReportHtml}</div>
`;
} else if (data.content.ai_report_error) {
html += `
<h6>AI报告</h6>
<div class="mb-3 p-3 bg-warning rounded">AI生成失败: ${data.content.ai_report_error}</div>
`;
}
html += `
<h6>每日练习计划(共${data.content.total_daily_minutes}分钟)</h6>
<table class="table table-sm">
<thead><tr><th>环节</th><th>时长</th><th>内容</th><th>目的</th></tr></thead>
<tbody>
`;
data.content.daily_schedule.forEach(item => {
html += `<tr><td>${item.phase}</td><td>${item.duration}</td><td>${item.content}</td><td>${item.purpose}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('planDetailContent').innerHTML = html;
new bootstrap.Modal(document.getElementById('planDetailModal')).show();
}
// 下载PDF
function downloadPDF() {
const templateId = document.getElementById('reportTemplateSelect').value;
const url = templateId ? `/api/plans/${currentPlanId}/pdf?template_id=${templateId}` : `/api/plans/${currentPlanId}/pdf`;
window.open(url, '_blank');
}
// 下载MD
function downloadMD() {
const templateId = document.getElementById('reportTemplateSelect').value;
const url = templateId ? `/api/plans/${currentPlanId}/md?template_id=${templateId}` : `/api/plans/${currentPlanId}/md`;
window.open(url, '_blank');
}
// 预览报告模板
async function previewReportTemplate() {
if (!currentPlanId) {
alert('请先选择一个方案');
return;
}
const templateId = document.getElementById('reportTemplateSelect').value;
const url = templateId ? `/api/plans/${currentPlanId}/md?template_id=${templateId}` : `/api/plans/${currentPlanId}/md`;
try {
const resp = await fetch(url);
if (resp.ok) {
const md = await resp.text();
document.getElementById('reportPreviewContent').innerHTML = marked.parse(md);
new bootstrap.Modal(document.getElementById('reportPreviewModal')).show();
} else {
alert('预览失败');
}
} catch (e) {
alert('预览失败: ' + e.message);
}
}
// 编辑方案内容
let planContentEditor = null;
let scheduleTable = null;
async function editPlanContent() {
const resp = await fetch(`/api/plans/${currentPlanId}`);
const data = await resp.json();
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
document.getElementById('editPracticeTime').value = content.practice_time || '';
document.getElementById('editGeneratedAt').value = content.generated_at || '';
document.getElementById('editProblemsCount').value = (content.problems || []).length + ' 个问题';
if (planContentEditor) {
planContentEditor.toTextArea();
planContentEditor = null;
}
document.getElementById('editAiReport').value = content.ai_report || '';
planContentEditor = new EasyMDE({
element: document.getElementById('editAiReport'),
spellChecker: false,
status: false,
toolbar: ['bold', 'italic', 'heading', '|', 'code', 'quote', 'unordered-list', '|', 'side-by-side', 'fullscreen'],
initialValue: content.ai_report || ''
});
if (scheduleTable) {
scheduleTable.destroy();
scheduleTable = null;
}
const scheduleData = (content.daily_schedule || []).map(item => ({
phase: item.phase || '',
duration: item.duration || '',
content: item.content || '',
purpose: item.purpose || ''
}));
scheduleTable = new Tabulator("#editDailyScheduleTable", {
data: scheduleData,
layout: "fitDataFill",
movableRows: true,
editable: true,
addRowPos: "bottom",
columns: [
{ title: "环节", field: "phase", editor: "input", width: 120 },
{ title: "时长", field: "duration", editor: "input", width: 100 },
{ title: "内容", field: "content", editor: "input", minWidth: 200 },
{ title: "目的", field: "purpose", editor: "input", minWidth: 150 },
{
title: "操作",
width: 80,
formatter: function(cell) {
return "<button type='button' class='btn btn-sm btn-outline-danger'><i class='bi bi-trash'></i></button>";
},
cellClick: function(e, cell) {
cell.getRow().delete();
}
}
]
});
new bootstrap.Modal(document.getElementById('editPlanContentModal')).show();
}
// 添加一行
function addScheduleRow() {
if (scheduleTable) {
scheduleTable.addRow({ phase: "新环节", duration: "5", content: "练习内容", purpose: "练习目的" });
}
}
// 保存方案内容
async function savePlanContent() {
try {
const resp = await fetch(`/api/plans/${currentPlanId}`);
const data = await resp.json();
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
content.ai_report = planContentEditor ? planContentEditor.value() : document.getElementById('editAiReport').value;
if (scheduleTable) {
content.daily_schedule = scheduleTable.getData();
}
const saveResp = await fetch(`/api/plans/${currentPlanId}/content`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: JSON.stringify(content)})
});
if (saveResp.ok) {
if (planContentEditor) {
planContentEditor.toTextArea();
planContentEditor = null;
}
if (scheduleTable) {
scheduleTable.destroy();
scheduleTable = null;
}
bootstrap.Modal.getInstance(document.getElementById('editPlanContentModal')).hide();
alert('保存成功');
showStudentDetail(currentStudentId);
} else {
alert('保存失败');
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
// 显示微信卡片
function showWechatCard() {
window.open(`/plans/${currentPlanId}/wechat`, '_blank');
}
// 删除方案
async function deletePlan(planId) {
if (!confirm('确定要删除这个方案吗?')) return;
await fetch(`/api/plans/${planId}`, {method: 'DELETE'});
showStudentDetail(currentStudentId);
}
// 删除学员
async function deleteStudent() {
if (!confirm('确定要删除该学员吗?所有相关记录将被删除!')) return;
await fetch(`/api/students/${currentStudentId}`, {method: 'DELETE'});
showStudentList();
}
// 显示设置
function showSettings() {
window.location.href = '/settings';
}
</script>
{% endblock %}