feat: v1.4.0 - 典型方案采纳、推荐方案列表、审计字段、导航优化

- 添加典型方案采纳功能 (POST /api/plans/<id>/adopt)
- 添加推荐方案列表 (GET /api/students/<id>/recommended-plans)
- PracticePlan 新增 created_by/updated_by/updated_at 审计字段
- 方案编辑/详情页导航优化 (bfcache 处理、pageshow 事件)
- 方案列表支持删除功能
- 学员列表'暂无方案/问题'样式统一
- 更新文档:问题文件已废弃(迁移到数据库)
- 更新部署脚本和验证清单
This commit is contained in:
hmo
2026-04-27 02:01:22 +08:00
parent 6abdd49c04
commit e50a9207b4
20 changed files with 873 additions and 88 deletions
+123
View File
@@ -80,6 +80,20 @@
<p class="text-muted">加载中...</p>
</div>
</div>
<!-- 推荐方案区块 -->
<div class="card mb-4 border-warning">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">📋 推荐方案</h5>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary active" id="filterAll" onclick="setRecommendedFilter('all')">全部</button>
<button class="btn btn-outline-secondary" id="filterMine" onclick="setRecommendedFilter('mine')">我的</button>
</div>
</div>
<div class="card-body" id="recommendedPlanList">
<p class="text-muted">加载中...</p>
</div>
</div>
</div>
</div>
@@ -359,6 +373,11 @@ const studentName = "{{ student.name }}";
let problemModal, editProblemModal, generateModal, editStudentModal, assignGoalModal;
document.addEventListener('DOMContentLoaded', function() {
initPage();
});
// 页面初始化
function initPage() {
problemModal = new bootstrap.Modal(document.getElementById('addProblemModal'));
editProblemModal = new bootstrap.Modal(document.getElementById('editProblemModal'));
generateModal = new bootstrap.Modal(document.getElementById('generatePlanModal'));
@@ -372,10 +391,29 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('editStudentPracticeTime').value = document.getElementById('detailPracticeTime').textContent;
document.getElementById('editStudentNotes').value = document.getElementById('detailNotes').textContent === '无备注' ? '' : document.getElementById('detailNotes').textContent;
// 检查是否需要刷新推荐方案
const needsRefreshRecommended = sessionStorage.getItem('needs_refresh_recommended') === 'true';
if (needsRefreshRecommended) {
sessionStorage.removeItem('needs_refresh_recommended');
// 恢复推荐方案筛选状态
const savedFilter = sessionStorage.getItem('recommended_filter') || 'all';
loadRecommendedPlans(savedFilter);
} else {
loadRecommendedPlans('all');
}
loadProblems();
loadPlans();
loadProblemOptions();
loadStudentGoals();
}
// pageshow 事件处理 bfcache 恢复的情况
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// 页面从 bfcache 恢复,需要重新检查刷新标记
initPage();
}
});
async function loadProblemOptions() {
@@ -590,13 +628,17 @@ function renderTimeline(timeline) {
</div>`;
} else {
const p = entry.plan;
const adoptedFrom = p.adopted_from;
const editInfo = p.updated_at ? `<span class="text-muted small">(于${p.updated_at}编辑)</span>` : '';
return `
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<a href="/plan/${p.id}" class="fw-bold text-decoration-none">${formatDate(entry.date)}</a>
${editInfo}
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
${adoptedFrom ? `<span class="badge bg-info ms-1">采纳自${escapeHtml(adoptedFrom.student_name)}的方案</span>` : ''}
</div>
<div class="btn-group">
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看">
@@ -682,6 +724,7 @@ async function saveAddProblem() {
if (resp.ok) {
problemModal.hide();
loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else {
const err = await resp.json();
alert('添加失败: ' + (err.error || '未知错误'));
@@ -723,6 +766,7 @@ async function saveProblemEdit() {
if (updateResp.ok) {
editProblemModal.hide();
loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else {
alert('更新失败');
}
@@ -739,6 +783,7 @@ async function deleteProblem(id) {
});
if (resp.ok) {
loadProblems();
loadRecommendedPlans(currentRecommendedFilter);
} else {
alert('删除失败');
}
@@ -970,6 +1015,84 @@ async function loadStudentGoals() {
`).join('');
}
// ===== 推荐方案相关 =====
let currentRecommendedFilter = 'all';
function setRecommendedFilter(filter) {
currentRecommendedFilter = filter;
sessionStorage.setItem('recommended_filter', filter);
document.getElementById('filterAll').classList.toggle('active', filter === 'all');
document.getElementById('filterMine').classList.toggle('active', filter === 'mine');
loadRecommendedPlans(filter);
}
async function loadRecommendedPlans(filter) {
const container = document.getElementById('recommendedPlanList');
container.innerHTML = '<p class="text-muted">加载中...</p>';
try {
const url = `/api/students/${currentStudentId}/recommended-plans${filter === 'mine' ? '?mine=true' : ''}`;
const res = await fetch(url);
if (!res.ok) throw new Error('API error');
const plans = await res.json();
if (plans.length === 0) {
container.innerHTML = '<p class="text-muted">暂无匹配的推荐方案</p>';
return;
}
container.innerHTML = plans.map(p => `
<div class="d-flex justify-content-between align-items-start mb-2 p-2 border rounded bg-light">
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-center">
<strong>${escapeHtml(p.student_name)}的方案</strong>
<span class="badge bg-warning text-dark">典型</span>
</div>
<div class="small text-muted mt-1">
问题匹配: ${p.matched_problems ? p.matched_problems.join(', ') : ''}
<span class="text-info">(${p.matched_count || 0}个)</span>
</div>
<div class="small text-muted">
${p.created_at ? '创建: ' + formatDate(p.created_at) : ''}
${p.template_name ? ' | 模板: ' + p.template_name : ''}
</div>
</div>
<div class="btn-group btn-group-sm ms-2">
<a href="/plan/${p.id}" class="btn btn-outline-primary">查看</a>
${p.can_adopt
? `<button class="btn btn-success" onclick="adoptTypicalPlan(${p.id})">采纳</button>`
: ''}
</div>
</div>
`).join('');
} catch (e) {
console.error('加载推荐方案失败', e);
container.innerHTML = '<p class="text-danger">加载失败</p>';
}
}
async function adoptTypicalPlan(planId) {
if (!confirm('确定采纳此典型方案?系统将复制该方案到当前学员。')) return;
try {
const res = await fetch(`/api/students/${currentStudentId}/plans/from-typical/${planId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (res.ok) {
const data = await res.json();
loadPlans(); // 刷新方案列表
loadRecommendedPlans(currentRecommendedFilter); // 刷新推荐列表
} else {
const err = await res.json();
alert('采纳失败: ' + (err.error || '未知错误'));
}
} catch (e) {
alert('采纳失败: ' + e.message);
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);