feat: 学员详情页改为学习历程时间线,显示目标启动/达成和方案生成记录
This commit is contained in:
+122
-32
@@ -46,7 +46,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0"><i class="bi bi-list-check"></i> 问题记录</h6>
|
<h6 class="mb-0"><i class="bi bi-list-check"></i> 当前问题</h6>
|
||||||
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
|
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
|
||||||
<i class="bi bi-plus"></i> 添加问题
|
<i class="bi bi-plus"></i> 添加问题
|
||||||
</button>
|
</button>
|
||||||
@@ -474,50 +474,140 @@ function renderProblemList(problems) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 学习历程时间线(替代原 loadPlans)
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
try {
|
await loadTimeline();
|
||||||
const resp = await fetch(`/api/students/${currentStudentId}/plans`);
|
|
||||||
const plans = await resp.json();
|
|
||||||
renderPlanList(plans);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('planList').innerHTML = '<p class="text-danger">加载失败</p>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlanList(plans) {
|
// 学习历程时间线
|
||||||
|
async function loadTimeline() {
|
||||||
|
const [plansRes, goalsRes] = await Promise.all([
|
||||||
|
fetch(`/api/students/${currentStudentId}/plans`),
|
||||||
|
fetch(`/api/students/${currentStudentId}/goals`)
|
||||||
|
]);
|
||||||
|
const plans = await plansRes.json();
|
||||||
|
const goals = await goalsRes.json();
|
||||||
|
|
||||||
|
// 构建时间线条目
|
||||||
|
const timeline = [];
|
||||||
|
|
||||||
|
// 添加目标开始记录
|
||||||
|
goals.forEach(g => {
|
||||||
|
if (g.start_date) {
|
||||||
|
const startDate = new Date(g.start_date);
|
||||||
|
const endDate = g.assessment_date ? new Date(g.assessment_date) : null;
|
||||||
|
const days = endDate ? Math.ceil((endDate - startDate) / (1000*60*60*24)) : null;
|
||||||
|
timeline.push({
|
||||||
|
date: startDate,
|
||||||
|
type: 'goal_start',
|
||||||
|
goal: g,
|
||||||
|
days: days,
|
||||||
|
endDate: endDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 添加目标达成记录
|
||||||
|
if (g.achievement_date) {
|
||||||
|
timeline.push({
|
||||||
|
date: new Date(g.achievement_date),
|
||||||
|
type: 'goal_achieved',
|
||||||
|
goal: g
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加方案生成记录
|
||||||
|
plans.forEach(p => {
|
||||||
|
timeline.push({
|
||||||
|
date: new Date(p.created_at),
|
||||||
|
type: 'plan',
|
||||||
|
plan: p
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间逆序排序
|
||||||
|
timeline.sort((a, b) => b.date - a.date);
|
||||||
|
|
||||||
|
renderTimeline(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(timeline) {
|
||||||
const container = document.getElementById('planList');
|
const container = document.getElementById('planList');
|
||||||
if (plans.length === 0) {
|
if (timeline.length === 0) {
|
||||||
container.innerHTML = '<p class="text-muted">暂无练习方案</p>';
|
container.innerHTML = '<p class="text-muted">暂无学习记录</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = plans.map(p => `
|
|
||||||
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
container.innerHTML = timeline.map(entry => {
|
||||||
<div class="form-check me-2">
|
if (entry.type === 'goal_start') {
|
||||||
<input class="form-check-input" type="checkbox" id="typical-${p.id}" ${p.is_typical ? 'checked' : ''} onchange="toggleTypical(${p.id})">
|
const g = entry.goal;
|
||||||
<label class="form-check-label" for="typical-${p.id}" title="设为典型"></label>
|
return `
|
||||||
</div>
|
<div class="d-flex align-items-start mb-2 p-2 border rounded border-primary">
|
||||||
<div class="flex-grow-1">
|
<div class="me-2">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<span class="badge bg-primary">目标启动</span>
|
||||||
<div>
|
</div>
|
||||||
<a href="/plan/${p.id}" class="fw-bold text-decoration-none">${p.created_at ? p.created_at.substring(0, 16) : '未知'}</a>
|
<div class="flex-grow-1">
|
||||||
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(g.goal_name)}</strong>
|
||||||
|
<span class="text-muted ms-2">${formatDate(entry.date)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary">预期 ${entry.days} 天</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.endDate ? formatDate(entry.endDate) : '未设置'}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted">
|
</div>`;
|
||||||
${p.problem_names && p.problem_names.length > 0 ? '问题: ' + p.problem_names.join(', ') : ''}
|
} else if (entry.type === 'goal_achieved') {
|
||||||
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
const g = entry.goal;
|
||||||
|
const stars = '⭐'.repeat(g.mastery_level || 1);
|
||||||
|
return `
|
||||||
|
<div class="d-flex align-items-start mb-2 p-2 border rounded border-success">
|
||||||
|
<div class="me-2">
|
||||||
|
<span class="badge bg-success">目标达成</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-grow-1">
|
||||||
</div>
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
`).join('');
|
<div>
|
||||||
|
<strong>${escapeHtml(g.goal_name)}</strong>
|
||||||
|
<span class="text-muted ms-2">${formatDate(entry.date)}</span>
|
||||||
|
</div>
|
||||||
|
<span>${stars}</span>
|
||||||
|
</div>
|
||||||
|
${g.comment ? `<div class="small text-muted mt-1"><em>"${escapeHtml(g.comment)}"</em></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const p = entry.plan;
|
||||||
|
return `
|
||||||
|
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
||||||
|
<div class="form-check me-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="typical-${p.id}" ${p.is_typical ? 'checked' : ''} onchange="toggleTypical(${p.id})">
|
||||||
|
<label class="form-check-label" for="typical-${p.id}" title="设为典型"></label>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
${p.problem_names && p.problem_names.length > 0 ? '问题: ' + p.problem_names.join(', ') : ''}
|
||||||
|
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleTypical(planId) {
|
async function toggleTypical(planId) {
|
||||||
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
||||||
loadPlans();
|
loadTimeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditStudentModal() {
|
function showEditStudentModal() {
|
||||||
|
|||||||
Reference in New Issue
Block a user