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
+84
View File
@@ -182,6 +182,90 @@ window.pageInit = function(data) {
const addClassBtn = document.getElementById('addClassBtn');
if (addClassBtn) addClassBtn.style.display = 'inline-block';
}
restoreClassFilterState();
loadClasses();
};
// 班级筛选状态管理
const CLASS_FILTER_KEY = 'class_filters';
function saveClassFilterState() {
const state = {
activeFilter: document.getElementById('activeFilter').value,
mineActive: document.getElementById('mineFilterBtn').classList.contains('active')
};
sessionStorage.setItem(CLASS_FILTER_KEY, JSON.stringify(state));
}
function restoreClassFilterState() {
const saved = sessionStorage.getItem(CLASS_FILTER_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
document.getElementById('activeFilter').value = state.activeFilter || '';
const btn = document.getElementById('mineFilterBtn');
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');
}
}
saveClassFilterState();
} catch (e) {
console.error('恢复班级筛选状态失败', e);
}
}
// 我的班级筛选
function toggleMineFilter() {
const btn = document.getElementById('mineFilterBtn');
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');
}
saveClassFilterState();
loadClasses();
}
// 加载班级列表
function loadClasses() {
saveClassFilterState();
const activeFilter = document.getElementById('activeFilter').value;
const mineFilter = document.getElementById('mineFilterBtn').classList.contains('active');
let url = '/api/classes?';
if (activeFilter) url += 'active=' + activeFilter + '&';
if (mineFilter) url += 'mine=true&';
url = url.endsWith('&') ? url.slice(0, -1) : url;
url = url.endsWith('?') ? '/api/classes' : url;
fetch(url).then(r => r.json()).then(classes => {
const tbody = document.querySelector('#classesTable tbody');
const isAdmin = currentUserRole === 'admin';
tbody.innerHTML = classes.map(c => `
<tr>
<td>${c.id}</td>
<td>${c.name}</td>
<td>${c.level || '启蒙'}</td>
<td>${c.description || '-'}</td>
<td>${c.active ? '<span class="badge bg-success">进行中</span>' : '<span class="badge bg-secondary">已结束</span>'}</td>
<td><a href="#" onclick="viewClassStudents(${c.id})"> ${c.student_count}</a></td>
<td>${c.created_at}</td>
<td>
<button type="button" class="btn btn-sm btn-success me-1" onclick="openAssignGoalModal(${c.id}, '${c.name}')">分配目标</button>
${isAdmin ? `<button type="button" class="btn btn-sm btn-primary me-1" onclick="editClass(${c.id}, '${c.name}', ${c.teacher_id || 'null'}, '${c.description || ''}', ${c.active}, '${c.level || '启蒙'}')">编辑</button>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
</td>
</tr>
`).join('');
});
}
loadClasses();
};
+29
View File
@@ -174,8 +174,34 @@
{{ super() }}
<script>
const API_BASE = '/api/goals';
const GOAL_FILTER_KEY = 'goal_filters';
let allGoals = []; // 缓存所有目标数据
// 保存筛选状态
function saveGoalFilterState() {
const state = {
filterLevel: document.getElementById('filter-level').value,
filterCategory: document.getElementById('filter-category').value,
groupBy: document.getElementById('group-by').value
};
sessionStorage.setItem(GOAL_FILTER_KEY, JSON.stringify(state));
}
// 恢复筛选状态
function restoreGoalFilterState() {
const saved = sessionStorage.getItem(GOAL_FILTER_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
if (state.filterLevel) document.getElementById('filter-level').value = state.filterLevel;
if (state.filterCategory) document.getElementById('filter-category').value = state.filterCategory;
if (state.groupBy) document.getElementById('group-by').value = state.groupBy;
saveGoalFilterState();
} catch (e) {
console.error('恢复目标筛选状态失败', e);
}
}
// 加载目标列表
async function loadGoals() {
const res = await fetch(API_BASE);
@@ -188,11 +214,14 @@ async function loadGoals() {
return {...g, children: children};
}));
restoreGoalFilterState();
applyFilters();
}
// 应用筛选和分组
function applyFilters() {
saveGoalFilterState();
const filterLevel = document.getElementById('filter-level').value;
const filterCategory = document.getElementById('filter-category').value;
const groupBy = document.getElementById('group-by').value;
+51 -5
View File
@@ -389,12 +389,49 @@ 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();
loadStudents();
loadClassFilter().then(() => {
restoreStudentFilterState();
loadStudents();
});
initProblemCheckboxes();
// 检查 URL 参数,自动打开学员详情
@@ -481,6 +518,8 @@ function importStudents(input) {
// 加载学员列表
async function loadStudents() {
saveStudentFilterState();
const classId = document.getElementById('classFilter').value;
const name = document.getElementById('nameFilter').value;
const mineFilter = document.getElementById('mineStudentFilterBtn').classList.contains('active');
@@ -552,10 +591,17 @@ function renderStudentList(students) {
} else {
problemText = s.problem_names.join('、');
}
} else {
} 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">
@@ -564,8 +610,8 @@ function renderStudentList(students) {
<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>
<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>
+39 -8
View File
@@ -28,16 +28,40 @@ var currentPlanId = null;
async function loadPlan() {
currentPlanId = window.location.pathname.split('/').pop();
// 清除编辑页标记(从编辑页返回后不要再跳回去)
sessionStorage.removeItem('fromEdit');
// 记录来源页面
const referrer = document.referrer;
if (referrer.includes('/student/')) {
sessionStorage.setItem('plan_detail_referrer', 'student');
} else if (referrer.includes('/plans')) {
sessionStorage.setItem('plan_detail_referrer', 'plans');
} else {
sessionStorage.setItem('plan_detail_referrer', 'unknown');
}
// 如果是从编辑页返回(plan_detail_reload被设置),强制刷新
const needsReload = sessionStorage.getItem('plan_detail_reload') === 'true';
if (needsReload) {
sessionStorage.removeItem('plan_detail_reload');
}
try {
const resp = await fetch(`/api/plans/${currentPlanId}`);
const data = await resp.json();
window.currentStudentId = data.student_id;
let editInfo = '';
if (data.updated_at) {
const editor = data.updated_by_name ? ` by ${data.updated_by_name}` : '';
editInfo = `<span class="text-muted">(于${data.updated_at}${editor}编辑)</span>`;
}
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} &nbsp;&nbsp;
<strong>生成时间:</strong>${data.created_at} ${editInfo} &nbsp;&nbsp;
<strong>模板:</strong>${data.template_name || '无'}
<div class="mt-2">
<a href="/student/${data.student_id}" class="btn btn-sm btn-outline-primary">
@@ -131,21 +155,28 @@ async function loadTemplates() {
async function toggleTypical(planId, isTypical) {
try {
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
// 标记需要刷新方案列表
sessionStorage.setItem('plans_needs_refresh', 'true');
} catch (e) {
alert('设置失败: ' + e.message);
}
}
// 返回按钮处理:如果是编辑页返回的,跳过编辑页
// 返回按钮处理
function goBack() {
if (sessionStorage.getItem('fromEdit') === 'true') {
sessionStorage.removeItem('fromEdit');
history.go(-2); // 跳过编辑页
} else {
history.back();
}
// 标记需要刷新推荐方案列表
sessionStorage.setItem('needs_refresh_recommended', 'true');
history.back();
}
// 处理 bfcache - 页面从缓存恢复时需要重新加载以获取最新数据
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// 页面从 bfcache 恢复,需要重新加载
loadPlan();
}
});
// 标记来源为编辑页(编辑页点击"返回详情"前设置)
function markFromEdit() {
sessionStorage.setItem('fromEdit', 'true');
+5 -5
View File
@@ -65,6 +65,7 @@ async function loadPlanForEdit() {
try {
const resp = await fetch(`/api/plans/${planId}`);
const data = await resp.json();
window.currentStudentId = data.student_id;
const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content;
document.getElementById('editAiReport').value = content.ai_report || '';
@@ -147,18 +148,17 @@ async function savePlanContent() {
if (!confirm('确定要保存修改吗?')) return;
try {
const resp = await fetch(`/api/plans/${planId}`, {
const resp = await fetch(`/api/plans/${planId}/content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ai_report: currentAiReport,
daily_schedule: tableData
content: JSON.stringify({ ai_report: currentAiReport, daily_schedule: tableData })
})
});
if (resp.ok) {
alert('保存成功');
window.location.href = `/plan/${planId}`;
// 保存后返回上一页(编辑页的上一页是方案详情,已从bfcache恢复)
history.back();
} else {
alert('保存失败');
}
+93 -3
View File
@@ -75,18 +75,86 @@
<script>
// 防抖定时器
let debounceTimer = null;
const STORAGE_KEY = 'plans_filters';
function debounceLoad() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadPlans, 300);
}
// 保存筛选状态到 sessionStorage
function saveFilterState() {
const state = {
classId: document.getElementById('filterClass').value,
templateId: document.getElementById('filterTemplate').value,
isTypical: document.getElementById('filterTypical').value,
studentName: document.getElementById('filterStudentName').value,
problemId: document.getElementById('filterProblem').value,
mineActive: document.getElementById('minePlansBtn')?.classList.contains('active')
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
// 恢复筛选状态
function restoreFilterState() {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
if (state.classId) document.getElementById('filterClass').value = state.classId;
if (state.templateId) document.getElementById('filterTemplate').value = state.templateId;
if (state.isTypical) document.getElementById('filterTypical').value = state.isTypical;
if (state.studentName) document.getElementById('filterStudentName').value = state.studentName;
if (state.problemId) document.getElementById('filterProblem').value = state.problemId;
const btn = document.getElementById('minePlansBtn');
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');
}
}
// 确保状态同步保存
saveFilterState();
} catch (e) {
console.error('恢复筛选状态失败', e);
}
}
// 页面初始化
window.pageInit = function() {
loadFilters();
loadPlans();
checkAndRefresh();
};
// 检查是否需要刷新(从详情页返回)
function checkAndRefresh() {
const needsRefresh = sessionStorage.getItem('plans_needs_refresh') === 'true';
if (needsRefresh) {
sessionStorage.removeItem('plans_needs_refresh');
loadFilters().then(() => {
restoreFilterState();
loadPlans(true);
});
} else {
loadFilters().then(() => {
restoreFilterState();
loadPlans(false);
});
}
}
// pageshow 事件处理 bfcache 恢复的情况
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// 页面从 bfcache 恢复,需要重新检查刷新标记
checkAndRefresh();
}
});
// 加载筛选器选项
async function loadFilters() {
// 加载班级
@@ -107,7 +175,7 @@ async function loadFilters() {
const problems = await resp.json();
const problemSelect = document.getElementById('filterProblem');
problems.forEach(p => {
problemSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
problemSelect.innerHTML += `<option value="${p.id}">${p.no} - ${p.name}</option>`;
});
} catch (e) {
console.error('加载问题失败', e);
@@ -128,6 +196,9 @@ async function loadFilters() {
// 加载方案列表
async function loadPlans() {
// 保存当前筛选状态
saveFilterState();
const container = document.getElementById('plansContainer');
container.innerHTML = '<div class="text-center text-muted py-5"><i class="bi bi-hourglass fs-4"></i><p class="mt-2">加载中...</p></div>';
@@ -199,6 +270,7 @@ async function loadPlans() {
<td class="text-muted small">${p.created_at || ''}</td>
<td>
<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>
</td>
</tr>
`;
@@ -224,6 +296,7 @@ function clearFilters() {
mineBtn.classList.remove('active', 'btn-primary');
mineBtn.classList.add('btn-outline-secondary');
}
sessionStorage.removeItem(STORAGE_KEY);
loadPlans();
}
@@ -239,6 +312,7 @@ function toggleMinePlans() {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-secondary');
}
saveFilterState();
loadPlans();
}
@@ -246,5 +320,21 @@ function toggleMinePlans() {
function viewPlan(planId) {
window.location.href = `/plan/${planId}`;
}
// 删除方案
async function deletePlan(planId) {
if (!confirm('确定删除该方案?删除后无法恢复。')) return;
try {
const resp = await fetch(`/api/plans/${planId}`, { method: 'DELETE' });
if (resp.ok) {
loadPlans(); // 刷新列表
} else {
const err = await resp.json();
alert('删除失败: ' + (err.error || '未知错误'));
}
} catch (e) {
alert('删除失败: ' + e.message);
}
}
</script>
{% endblock %}
+27
View File
@@ -189,6 +189,30 @@
let allProblems = [];
let currentEditId = null;
let currentDeleteId = null;
const PROBLEM_FILTER_KEY = 'problem_filters';
function saveProblemFilterState() {
const state = {
search: document.getElementById('searchInput').value,
filterCategory: document.getElementById('filterCategory').value,
groupBy: document.getElementById('groupByCategory').value
};
sessionStorage.setItem(PROBLEM_FILTER_KEY, JSON.stringify(state));
}
function restoreProblemFilterState() {
const saved = sessionStorage.getItem(PROBLEM_FILTER_KEY);
if (!saved) return;
try {
const state = JSON.parse(saved);
if (state.search) document.getElementById('searchInput').value = state.search;
if (state.filterCategory) document.getElementById('filterCategory').value = state.filterCategory;
if (state.groupBy) document.getElementById('groupByCategory').value = state.groupBy;
saveProblemFilterState();
} catch (e) {
console.error('恢复问题筛选状态失败', e);
}
}
window.pageInit = function() {
loadProblems();
@@ -199,10 +223,13 @@ window.pageInit = function() {
async function loadProblems() {
const response = await fetch('/api/problems');
allProblems = await response.json();
restoreProblemFilterState();
applyProblemFilters();
}
function applyProblemFilters() {
saveProblemFilterState();
const search = document.getElementById('searchInput').value.toLowerCase();
const filterCategory = document.getElementById('filterCategory').value;
const groupBy = document.getElementById('groupByCategory').value;
+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);