feat: 问题数据迁移到数据库;学员详情页URL导航改造;侧边栏统一

- 问题从文件系统迁移到数据库 problems 表
- 移除 PROBLEMS_DIR 配置和文件读取逻辑
- student.html 完整重写:编辑/添加/删除问题,生成方案进度显示
- 学员详情页支持独立URL访问 (/student/<id>)
- 统一侧边栏到 base.html
- 更新文档:DEPLOYMENT_SOP, MODELS, STRUCTURE, FRONTEND_ARCH
- 部署到生产环境 v1.2.0
This commit is contained in:
hmo
2026-04-23 06:35:32 +08:00
parent fd593bddf4
commit 18351212e8
18 changed files with 857 additions and 488 deletions
+286 -139
View File
@@ -24,33 +24,36 @@
.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 active" href="#" onclick="showStudentList()">
<a class="nav-link {% if active_nav == 'students' %}active{% endif %}" href="/students">
<i class="bi bi-people"></i> 学员管理
</a>
<a class="nav-link" href="#" onclick="showSettings()" id="settingsNav">
<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" 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;">
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
<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 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()">
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
<i class="bi bi-key"></i> 修改密码
</a>
<a class="nav-link" href="#" onclick="logout()">
<a class="nav-link" href="#" onclick="logout(); return false;">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a>
{% endblock %}
@@ -90,8 +93,8 @@
<!-- 学员详情页面 -->
<div id="studentDetailPage" style="display: none;">
<button class="btn btn-link mb-3" onclick="showStudentList()">
<i class="bi bi-arrow-left"></i> 返回列表
<button class="btn btn-link mb-3" onclick="goBack()">
<i class="bi bi-arrow-left"></i> <span id="backBtnText">返回列表</span>
</button>
<div class="row">
@@ -223,7 +226,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-secondary" id="cancelProblemsBtn">取消</button>
<button type="button" class="btn btn-primary" onclick="saveProblems()">保存</button>
</div>
</div>
@@ -232,7 +235,7 @@
<!-- 方案查看模态框 -->
<div class="modal fade" id="planDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">练习方案</h5>
@@ -322,7 +325,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<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>
@@ -373,7 +376,6 @@
{% 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;
@@ -384,41 +386,21 @@ 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';
});
// 页面初始化(base.html 统一登录检查后调用)
window.pageInit = function(data) {
loadAiTemplates();
loadReportTemplates();
loadClassFilter();
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() {
@@ -532,23 +514,39 @@ async function loadClassFilter() {
// 渲染学员列表
function renderStudentList(students) {
const container = document.getElementById('studentList');
if (students.length === 0) {
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 {
problemText = `${s.problem_count} 个问题`;
}
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 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 bg-secondary">${problemText}</span>
<span class="badge bg-primary">${s.plan_count} 个方案</span>
</div>
</a>
</div>
</div>
`;
@@ -684,30 +682,57 @@ async function saveStudent() {
// 显示学员详情
async function showStudentDetail(studentId) {
currentStudentId = studentId;
try {
currentStudentId = studentId;
const response = await fetch(`/api/students/${studentId}`);
const data = await response.json();
// 检查来源页面
const urlParams = new URLSearchParams(window.location.search);
window.returnUrl = urlParams.get('from');
document.getElementById('backBtnText').textContent = window.returnUrl ? '返回' : '返回列表';
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 response = await fetch(`/api/students/${studentId}`);
const data = await response.json();
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('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);
}
}
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 goBack() {
if (window.returnUrl) {
window.location.href = window.returnUrl;
} else {
showStudentList();
}
}
// 返回学员列表
@@ -808,10 +833,29 @@ function renderPlanList(plans) {
let html = '';
plans.forEach(p => {
// 构建显示文本:问题【模板 | 时间】
let problemText = '';
if (p.problem_names && p.problem_names.length > 0) {
const problems = p.problem_names.slice(0, 3).join('、');
const more = p.problem_names.length > 3 ? `${p.problem_names.length}` : '';
problemText = `${problems}${more}`;
}
const template = p.template_name || '无模板';
const time = p.created_at || '';
const metaText = `${template}${time ? ' | ' + time : ''}`;
html += `
<div class="d-flex justify-content-between align-items-center mb-2">
<span>生成于 ${p.created_at}</span>
<div>
<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>
@@ -821,7 +865,25 @@ function renderPlanList(plans) {
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())
@@ -829,14 +891,14 @@ function showProblemsModal() {
const currentProblemIds = currentProblems.map(p => p.problem_id);
document.querySelectorAll('.problem-checkbox-input').forEach(cb => {
const isChecked = currentProblemIds.includes(cb.value);
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 === cb.value);
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 => {
@@ -849,9 +911,82 @@ function showProblemsModal() {
}
}
});
// 记录加载后的原始状态
problemsModalOriginalState = getProblemsModalState();
});
new bootstrap.Modal(document.getElementById('problemsModal')).show();
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;
}
// 保存问题
@@ -859,10 +994,10 @@ async function saveProblems() {
const problems = [];
document.querySelectorAll('.problem-checkbox-input:checked').forEach(cb => {
const problemId = cb.value;
const problemId = parseInt(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`);
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,
@@ -970,56 +1105,9 @@ async function generatePlan() {
}
}
// 查看方案
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();
// 查看方案 - 跳转到方案详情页
function viewPlan(planId) {
window.location.href = `/plan/${planId}`;
}
// 下载PDF
@@ -1061,6 +1149,8 @@ async function previewReportTemplate() {
// 编辑方案内容
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();
@@ -1070,6 +1160,9 @@ async function editPlanContent() {
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;
@@ -1095,6 +1188,9 @@ async function editPlanContent() {
purpose: item.purpose || ''
}));
// 记录原始表格数据
editPlanOriginalState.scheduleData = JSON.parse(JSON.stringify(scheduleData));
scheduleTable = new Tabulator("#editDailyScheduleTable", {
data: scheduleData,
layout: "fitDataFill",
@@ -1119,7 +1215,58 @@ async function editPlanContent() {
]
});
new bootstrap.Modal(document.getElementById('editPlanContentModal')).show();
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;
}
// 添加一行