1414 lines
57 KiB
HTML
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>
|
|
${s.plan_count > 0 ? `<a href="/plan/${s.latest_plan_id}" class="text-decoration-none" onclick="event.stopPropagation()"><span class="badge bg-primary">${s.plan_count} 个方案</span></a>` : '<span class="badge bg-light text-muted">暂无方案</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 %} |