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

1414 lines
57 KiB
HTML

{% 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; }
/* 方案列表样式 */
.plan-problem-text { font-weight: 600; color: #2c3e50; font-size: 0.95rem; }
.plan-meta-text { color: #95a5a6; font-size: 0.8rem; }
</style>
{% endblock %}
{% block sidebar_nav %}
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
<i class="bi bi-people"></i> 学员管理
</a>
<a class="nav-link {% if active_nav == 'plans' %}active{% endif %}" href="/plans">
<i class="bi bi-clipboard-check"></i> 方案管理
</a>
<a class="nav-link {% if active_nav == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> 问题配置
</a>
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
<i class="bi bi-collection"></i> 班级管理
</a>
<a class="nav-link {% if active_nav == 'api-settings' %}active{% endif %}" href="/api-settings" id="apiSettingsNav" style="display:none;">
<i class="bi bi-key"></i> API设置
</a>
<a class="nav-link {% if active_nav == 'templates' %}active{% endif %}" href="/templates" id="templatesNav" style="display:none;">
<i class="bi bi-file-earmark-text"></i> 模板管理
</a>
<hr>
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
<i class="bi bi-key"></i> 修改密码
</a>
<a class="nav-link" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a>
{% endblock %}
{% block content %}
<!-- 学员列表页面 -->
<div id="studentListPage">
<!-- 筛选操作栏 -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center">
<h5 class="mb-0 me-2"><i class="bi bi-people"></i> 学员列表</h5>
<button class="btn btn-primary btn-sm" id="mineStudentFilterBtn" onclick="toggleMineStudentFilter()">
<i class="bi bi-person"></i> 我的
</button>
<select class="form-select form-select-sm" style="width:auto; min-width:100px;" id="classFilter" onchange="loadStudents()">
<option value="">全部班级</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:100px;" placeholder="姓名..." id="nameFilter" oninput="loadStudents()">
<div class="ms-auto d-flex gap-1">
<button class="btn btn-outline-secondary btn-sm" onclick="downloadTemplate()">
<i class="bi bi-download"></i> 模板
</button>
<button class="btn btn-outline-primary btn-sm" onclick="document.getElementById('importFileInput').click()">
<i class="bi bi-upload"></i> 导入
</button>
<input type="file" id="importFileInput" accept=".csv" style="display:none" onchange="importStudents(this)">
<button class="btn btn-outline-success btn-sm" onclick="exportStudents()">
<i class="bi bi-file-arrow-up"></i> 导出
</button>
<button class="btn btn-primary btn-sm" onclick="showAddStudentModal()">
<i class="bi bi-plus-lg"></i> 新增
</button>
</div>
</div>
</div>
</div>
<div class="row" id="studentList">
<!-- 学员卡片将通过JS动态加载 -->
</div>
</div>
<!-- 学员详情页面 -->
<div id="studentDetailPage" style="display: none;">
<button class="btn btn-link mb-3" onclick="goBack()">
<i class="bi bi-arrow-left"></i> <span id="backBtnText">返回列表</span>
</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" id="cancelProblemsBtn">取消</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-fullscreen modal-dialog-scrollable">
<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" id="cancelEditPlanBtn">取消</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/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 }};
// 学员列表筛选状态管理
const STUDENT_FILTER_KEY = 'index_student_filters';
function saveStudentFilterState() {
const state = {
classId: document.getElementById('classFilter').value,
name: document.getElementById('nameFilter').value,
mineActive: document.getElementById('mineStudentFilterBtn').classList.contains('active')
};
sessionStorage.setItem(STUDENT_FILTER_KEY, JSON.stringify(state));
}
function restoreStudentFilterState() {
const saved = sessionStorage.getItem(STUDENT_FILTER_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
if (state.classId) document.getElementById('classFilter').value = state.classId;
if (state.name) document.getElementById('nameFilter').value = state.name;
const btn = document.getElementById('mineStudentFilterBtn');
if (btn) {
if (state.mineActive) {
btn.classList.add('active', 'btn-primary');
btn.classList.remove('btn-outline-secondary');
} else {
btn.classList.remove('active', 'btn-primary');
btn.classList.add('btn-outline-secondary');
}
}
saveStudentFilterState();
} catch (e) {
console.error('恢复学员筛选状态失败', e);
}
}
// 页面初始化(base.html 统一登录检查后调用)
window.pageInit = function(data) {
loadAiTemplates();
loadReportTemplates();
loadClassFilter().then(() => {
restoreStudentFilterState();
loadStudents();
});
initProblemCheckboxes();
// 检查 URL 参数,自动打开学员详情
const urlParams = new URLSearchParams(window.location.search);
const studentId = urlParams.get('student_id');
if (studentId) {
showStudentDetail(parseInt(studentId));
}
};
// 加载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() {
saveStudentFilterState();
const classId = document.getElementById('classFilter').value;
const name = document.getElementById('nameFilter').value;
const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active');
let url = '/api/students?';
if (classId) url += 'class_id=' + classId + '&';
if (name) url += 'name=' + encodeURIComponent(name) + '&';
if (mineFilter) url += 'mine=true&';
url = url.endsWith('&') ? url.slice(0, -1) : url;
const response = await fetch(url);
if (response.status === 401) {
window.location.href = '/login';
return;
}
const students = await response.json();
renderStudentList(students);
}
// 我的学员筛选
function toggleMineStudentFilter() {
const btn = document.getElementById('mineStudentFilterBtn');
btn.classList.toggle('active');
if (btn.classList.contains('active')) {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-primary');
} else {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-secondary');
}
loadStudents();
}
// 加载班级筛选选项
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 (!container) { console.error('studentList element not found'); return; }
console.log('renderStudentList called with', students?.length, 'students');
if (!students || students.length === 0) {
container.innerHTML = '<div class="col-12 text-center text-muted py-5"><p>暂无学员,请添加</p></div>';
return;
}
let html = '';
students.forEach(s => {
// 构建问题显示文本
let problemText = '';
if (s.problem_names && s.problem_names.length > 0) {
if (s.problem_names.length >= 3) {
problemText = `${s.problem_names[0]}${s.problem_names[1]}${s.problem_names.length}`;
} else {
problemText = s.problem_names.join('、');
}
} else if (s.problem_count > 0) {
problemText = `${s.problem_count} 个问题`;
} else {
problemText = '<span class="text-muted">暂无问题</span>';
}
// 构建方案数量显示(样式与问题一致)
const planCount = s.plan_count > 0;
const planBadgeText = planCount ? `${s.plan_count} 个方案` : '暂无方案';
const planBadgeClass = planCount ? 'bg-primary' : 'bg-light text-muted';
html += `
<div class="col-md-4 col-sm-6 mb-3">
<div class="card">
<a href="/student/${s.id}" class="text-decoration-none">
<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 ${s.problem_count > 0 ? 'bg-secondary' : 'bg-light text-muted'}">${problemText}</span>
<span class="badge ${planBadgeClass}">${planBadgeText}</span>
${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''}
</div>
</a>
</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) {
try {
currentStudentId = studentId;
// 检查来源页面
const urlParams = new URLSearchParams(window.location.search);
window.returnUrl = urlParams.get('from');
document.getElementById('backBtnText').textContent = window.returnUrl ? '返回' : '返回列表';
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';
// 检查是否需要自动打开方案编辑模态框
const action = urlParams.get('action');
const planIdFromUrl = urlParams.get('plan_id');
if (action === 'edit' && planIdFromUrl) {
currentPlanId = parseInt(planIdFromUrl);
// 延迟一下确保 DOM 已渲染
setTimeout(() => editPlanContent(), 100);
}
} catch (err) {
console.error('showStudentDetail error:', err);
}
}
// 返回
function goBack() {
if (window.returnUrl) {
window.location.href = window.returnUrl;
} else {
showStudentList();
}
}
// 返回学员列表
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 => {
// 构建显示文本:问题【模板 | 时间】
let problemText = '';
if (p.problem_details && p.problem_details.length > 0) {
const problems = p.problem_details.slice(0, 3).map(d => `${d.name}[${d.level}/${d.severity}]`).join('、');
const more = p.problem_details.length > 3 ? `${p.problem_details.length}` : '';
problemText = `${problems}${more}`;
}
const template = p.template_name || '无模板';
const time = p.created_at || '';
const metaText = `${template}${time ? ' | ' + time : ''}`;
html += `
<div class="d-flex align-items-center mb-2 gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typical_${p.id}"
${p.is_typical ? 'checked' : ''}
onchange="toggleTypical(${p.id}, this.checked)">
<label class="form-check-label small text-muted" for="typical_${p.id}">典型</label>
</div>
<span class="plan-problem-text">${problemText}</span>
<span class="plan-meta-text">${metaText}</span>
<div class="ms-auto">
<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;
}
// 切换典型方案状态
async function toggleTypical(planId, isTypical) {
try {
const resp = await fetch(`/api/plans/${planId}/typical`, { method: 'POST' });
if (!resp.ok) {
// 恢复原状态
document.getElementById('typical_' + planId).checked = !isTypical;
alert('设置失败');
}
} catch (e) {
// 恢复原状态
document.getElementById('typical_' + planId).checked = !isTypical;
alert('设置失败: ' + e.message);
}
}
// 显示问题记录模态框
let problemsModalOriginalState = []; // 记录原始状态用于检测修改
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(parseInt(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 === parseInt(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 || '中等');
});
}
}
});
// 记录加载后的原始状态
problemsModalOriginalState = getProblemsModalState();
});
const modal = new bootstrap.Modal(document.getElementById('problemsModal'), {
keyboard: false // 禁用 ESC 关闭,由我们手动处理
});
const modalEl = document.getElementById('problemsModal');
// 监听取消按钮 - 如果有修改则确认
document.getElementById('cancelProblemsBtn').onclick = () => {
if (isProblemsModalDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modal.hide();
}
} else {
modal.hide();
}
};
// 监听键盘事件处理 ESC
const handleEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (isProblemsModalDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
} else {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
}
};
modalEl.addEventListener('keydown', handleEscape);
modal.show();
}
// 获取当前问题弹窗的状态
function getProblemsModalState() {
const state = [];
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
if (cb.checked) {
const problemId = cb.value;
const levelEl = document.querySelector(`input[name="level_${problemId}"]:checked`);
const severityEl = document.querySelector(`input[name="severity_${problemId}"]:checked`);
state.push({
problem_id: problemId,
level: levelEl ? levelEl.value : '入门',
severity: severityEl ? severityEl.value : '中等'
});
}
});
return state;
}
// 检测问题弹窗是否有修改
function isProblemsModalDirty() {
const currentState = getProblemsModalState();
// 比较数量
if (currentState.length !== problemsModalOriginalState.length) return true;
// 比较每个问题的状态
for (const current of currentState) {
const original = problemsModalOriginalState.find(o => o.problem_id === current.problem_id);
if (!original) return true;
if (original.level !== current.level || original.severity !== current.severity) return true;
}
return false;
}
// 保存问题
async function saveProblems() {
const problems = [];
document.querySelectorAll('.problem-checkbox-input:checked').forEach(cb => {
const problemId = parseInt(cb.value);
const problemName = cb.dataset.name;
const severityEl = document.querySelector(`input[name="severity_${cb.value}"]:checked`);
const levelEl = document.querySelector(`input[name="level_${cb.value}"]: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 = '生成失败';
}
}
// 查看方案 - 跳转到方案详情页
function viewPlan(planId) {
window.location.href = `/plan/${planId}`;
}
// 下载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;
let editPlanOriginalState = { ai_report: '', scheduleData: [] }; // 记录原始状态
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 + ' 个问题';
// 记录原始状态
editPlanOriginalState.ai_report = content.ai_report || '';
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 || ''
}));
// 记录原始表格数据
editPlanOriginalState.scheduleData = JSON.parse(JSON.stringify(scheduleData));
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();
}
}
]
});
const modal = new bootstrap.Modal(document.getElementById('editPlanContentModal'), {
keyboard: false // 禁用 ESC 关闭,由我们手动处理
});
const modalEl = document.getElementById('editPlanContentModal');
// 取消按钮点击处理
const cancelBtn = document.getElementById('cancelEditPlanBtn');
cancelBtn.onclick = () => {
if (isEditPlanDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modal.hide();
}
} else {
modal.hide();
}
};
// ESC 键处理
const handleEscape = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (isEditPlanDirty()) {
if (confirm('内容已修改,确定要关闭吗?')) {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
} else {
modalEl.removeEventListener('keydown', handleEscape);
modal.hide();
}
}
};
modalEl.addEventListener('keydown', handleEscape);
modal.show();
}
// 检测编辑方案内容是否有修改
function isEditPlanDirty() {
// 检查 AI 报告
const currentAiReport = planContentEditor ? planContentEditor.value() : document.getElementById('editAiReport').value;
if (currentAiReport !== editPlanOriginalState.ai_report) return true;
// 检查表格数据
if (scheduleTable) {
const currentData = scheduleTable.getData();
if (JSON.stringify(currentData) !== JSON.stringify(editPlanOriginalState.scheduleData)) return true;
}
return false;
}
// 添加一行
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 %}