更新:models/routes/services/templates/docs

This commit is contained in:
hmo
2026-04-26 18:02:36 +08:00
parent f7a82ac48a
commit 6abdd49c04
31 changed files with 1480 additions and 676 deletions
+4 -1
View File
@@ -98,7 +98,7 @@
<a class="nav-link {% if active_nav == 'problems' %}active{% endif %}" href="/problems">
<i class="bi bi-gear"></i> 问题配置
</a>
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes">
<a class="nav-link {% if active_nav == 'classes' %}active{% endif %}" href="/classes" id="classesNav">
<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;">
@@ -107,6 +107,9 @@
<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>
<a class="nav-link {% if active_nav == 'users' %}active{% endif %}" href="/users" id="usersNav" style="display:none;">
<i class="bi bi-person"></i> 用户管理
</a>
{% block sidebar_extra %}
<hr>
<a class="nav-link" href="#" onclick="showChangePasswordModal(); return false;">
+77 -4
View File
@@ -11,6 +11,9 @@
<option value="true" selected>进行中</option>
<option value="false">已结束</option>
</select>
<button class="btn btn-primary btn-sm active" id="mineFilterBtn" onclick="toggleMineFilter()">
<i class="bi bi-person"></i> 我的
</button>
</div>
<button class="btn btn-primary" id="addClassBtn" style="display:none;">
<i class="bi bi-plus-circle"></i> 新增班级
@@ -24,6 +27,7 @@
<tr>
<th>ID</th>
<th>班级名称</th>
<th>级别</th>
<th>描述</th>
<th>进行中</th>
<th>学员数</th>
@@ -64,10 +68,26 @@
<label class="form-label">班级名称</label>
<input type="text" class="form-control" id="className" required>
</div>
<div class="mb-3">
<label class="form-label">班主任</label>
<select class="form-select" id="classTeacher">
<option value="">未指定</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">描述</label>
<textarea class="form-control" id="classDesc" rows="2"></textarea>
</div>
<div class="mb-3">
<label class="form-label">级别</label>
<select class="form-select" id="classLevel">
<option value="启蒙">启蒙</option>
<option value="启蒙" selected>启蒙</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
</select>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="classActive" checked>
<label class="form-check-label" for="classActive">进行中</label>
@@ -165,10 +185,29 @@ window.pageInit = function(data) {
loadClasses();
};
// 我的班级筛选
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');
}
loadClasses();
}
// 加载班级列表
function loadClasses() {
const activeFilter = document.getElementById('activeFilter').value;
const url = activeFilter ? '/api/classes?active=' + activeFilter : '/api/classes';
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';
@@ -176,13 +215,14 @@ function loadClasses() {
<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.description || ''}', ${c.active})">编辑</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>
@@ -194,7 +234,9 @@ function loadClasses() {
document.getElementById('saveClassBtn').onclick = () => {
const id = document.getElementById('classId').value;
const name = document.getElementById('className').value.trim();
const teacherId = document.getElementById('classTeacher').value;
const description = document.getElementById('classDesc').value;
const level = document.getElementById('classLevel').value;
const active = document.getElementById('classActive').checked;
if (!name) {
@@ -206,7 +248,7 @@ document.getElementById('saveClassBtn').onclick = () => {
fetch('/api/classes' + (id ? '/' + id : ''), {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, active })
body: JSON.stringify({ name, description, level, active, teacher_id: teacherId ? parseInt(teacherId) : null })
}).then(r => r.json()).then(data => {
if (data.error) {
showToast(data.error);
@@ -221,12 +263,14 @@ document.getElementById('saveClassBtn').onclick = () => {
};
// 编辑班级
function editClass(id, name, desc, active) {
function editClass(id, name, teacherId, desc, active, level) {
document.getElementById('classId').value = id;
document.getElementById('className').value = name;
document.getElementById('classDesc').value = desc;
document.getElementById('classActive').checked = active !== false;
document.getElementById('classLevel').value = level || '启蒙';
document.getElementById('classModalTitle').textContent = '编辑班级';
loadTeacherOptions(teacherId);
new bootstrap.Modal(document.getElementById('classModal')).show();
}
@@ -289,6 +333,18 @@ document.getElementById('confirmAssignBtn').onclick = () => {
});
};
// 加载班主任选项
function loadTeacherOptions(selectedId) {
fetch('/api/teachers').then(r => r.json()).then(users => {
const select = document.getElementById('classTeacher');
select.innerHTML = '<option value="">未指定</option>';
users.forEach(u => {
const selected = u.id === selectedId ? 'selected' : '';
select.innerHTML += `<option value="${u.id}" ${selected}>${u.name}</option>`;
});
});
}
// 新增班级按钮
document.getElementById('addClassBtn').onclick = () => {
document.getElementById('classId').value = '';
@@ -296,6 +352,7 @@ document.getElementById('addClassBtn').onclick = () => {
document.getElementById('classDesc').value = '';
document.getElementById('classActive').checked = true;
document.getElementById('classModalTitle').textContent = '新增班级';
loadTeacherOptions(null);
new bootstrap.Modal(document.getElementById('classModal')).show();
};
@@ -348,6 +405,22 @@ document.getElementById('assign-assessment-date').addEventListener('change', fun
}
});
// 开始日期联动:修改开始日期后,如果使用"XX天后"评估,自动重新计算评估日期
document.getElementById('assign-start-date').addEventListener('change', function() {
const startDateStr = this.value;
const daysStr = document.getElementById('assign-assessment-days').value;
if (startDateStr && daysStr) {
const days = parseInt(daysStr);
const [y, m, d] = startDateStr.split('-').map(Number);
const startDate = new Date(y, m - 1, d);
startDate.setDate(startDate.getDate() + days);
const yy = startDate.getFullYear();
const mm = String(startDate.getMonth() + 1).padStart(2, '0');
const dd = String(startDate.getDate()).padStart(2, '0');
document.getElementById('assign-assessment-date').value = `${yy}-${mm}-${dd}`;
}
});
// 确认分配目标
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
const goalId = document.getElementById('assign-goal-select').value;
+7 -2
View File
@@ -339,8 +339,13 @@ function editGoal(id) {
}
async function deleteGoal(id) {
if (!confirm('确定删除此目标?')) return;
await fetch(`${API_BASE}/${id}`, {method: 'DELETE'});
if (!confirm('确定删除此目标?此操作不可恢复。')) return;
const resp = await fetch(`${API_BASE}/${id}`, {method: 'DELETE'});
const data = await resp.json();
if (!resp.ok) {
alert('删除失败:' + data.error);
return;
}
loadGoals();
}
+26 -4
View File
@@ -68,6 +68,9 @@
<option value="">全部班级</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:150px;" placeholder="搜索姓名..." id="nameFilter" oninput="loadStudents()">
<button class="btn btn-primary btn-sm active" id="mineStudentFilterBtn" onclick="toggleMineStudentFilter()">
<i class="bi bi-person"></i> 我的
</button>
</div>
<div class="btn-group">
<button class="btn btn-outline-secondary" onclick="downloadTemplate()">
@@ -480,10 +483,14 @@ function importStudents(input) {
async function loadStudents() {
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 (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) {
@@ -494,6 +501,20 @@ async function loadStudents() {
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 {
@@ -545,6 +566,7 @@ function renderStudentList(students) {
<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>
${s.goal_count > 0 ? `<span class="badge bg-success">${s.goal_count}个目标(${s.completed_goal_count}已达成)</span>` : ''}
</div>
</a>
</div>
@@ -835,9 +857,9 @@ function renderPlanList(plans) {
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}` : '';
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}`;
}
+63 -22
View File
@@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-file-text"></i> 方案详情</h4>
<div>
<button onclick="history.back()" class="btn btn-outline-secondary">
<button onclick="goBack()" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> 返回
</button>
</div>
@@ -40,16 +40,24 @@ async function loadPlan() {
<strong>生成时间:</strong>${data.created_at} &nbsp;&nbsp;
<strong>模板:</strong>${data.template_name || '无'}
<div class="mt-2">
<a href="/?student_id=${data.student_id}&from=${encodeURIComponent(window.location.href)}" class="btn btn-sm btn-outline-primary">
<a href="/student/${data.student_id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person"></i> 查看学员
</a>
<a href="/plan/${currentPlanId}/edit" class="btn btn-sm btn-warning">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="typicalToggle" ${data.is_typical ? 'checked' : ''} onchange="toggleTypical(${currentPlanId}, this.checked)">
<label class="form-check-label" for="typicalToggle">典型方案</label>
</div>
<a href="/plan/${currentPlanId}/edit" class="btn btn-sm btn-warning" onclick="markFromEdit()">
<i class="bi bi-edit"></i> 编辑
</a>
<button onclick="downloadPDF()" class="btn btn-sm btn-primary">
<div class="form-check form-check-inline">
<select id="reportTemplateSelect" class="form-select form-select-sm" style="width: auto;" onchange="updateDownloadLinks()">
</select>
</div>
<button onclick="downloadPDFWithTemplate()" class="btn btn-sm btn-primary">
<i class="bi bi-download"></i> 下载PDF
</button>
<button onclick="downloadMD()" class="btn btn-sm btn-outline-primary">
<button onclick="downloadMDWithTemplate()" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-markdown"></i> 下载MD
</button>
</div>
@@ -78,20 +86,8 @@ async function loadPlan() {
`;
}
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('planContent').innerHTML = html;
loadTemplates();
} catch (e) {
document.getElementById('planContent').innerHTML = `
<div class="card-body text-center text-danger py-5">
@@ -102,12 +98,57 @@ async function loadPlan() {
}
}
function downloadPDF() {
window.open(`/api/plans/${currentPlanId}/pdf`, '_blank');
function updateDownloadLinks() {
// No longer needed - buttons now use downloadPDFWithTemplate/downloadMDWithTemplate directly
}
function downloadMD() {
window.open(`/api/plans/${currentPlanId}/md`, '_blank');
function downloadPDFWithTemplate() {
const templateId = document.getElementById('reportTemplateSelect')?.value;
const suffix = templateId ? `?template_id=${templateId}` : '';
window.open(`/api/plans/${currentPlanId}/pdf${suffix}`, '_blank');
}
function downloadMDWithTemplate() {
const templateId = document.getElementById('reportTemplateSelect')?.value;
const suffix = templateId ? `?template_id=${templateId}` : '';
window.open(`/api/plans/${currentPlanId}/md${suffix}`, '_blank');
}
async function loadTemplates() {
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);
}
}
// 设为典型
async function toggleTypical(planId, isTypical) {
try {
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
} catch (e) {
alert('设置失败: ' + e.message);
}
}
// 返回按钮处理:如果是编辑页返回的,跳过编辑页
function goBack() {
if (sessionStorage.getItem('fromEdit') === 'true') {
sessionStorage.removeItem('fromEdit');
history.go(-2); // 跳过编辑页
} else {
history.back();
}
}
// 标记来源为编辑页(编辑页点击"返回详情"前设置)
function markFromEdit() {
sessionStorage.setItem('fromEdit', 'true');
}
window.currentStudentId = null;
+2 -2
View File
@@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-edit"></i> 编辑方案</h4>
<div>
<a href="/plan/{{ plan_id }}" class="btn btn-outline-secondary">
<a href="/plan/{{ plan_id }}" class="btn btn-outline-secondary" onclick="location.replace(this.href); return false;">
<i class="bi bi-arrow-left"></i> 返回详情
</a>
</div>
@@ -42,7 +42,7 @@
</div>
</div>
<div class="card-footer">
<button type="button" class="btn btn-secondary" onclick="history.back()">取消</button>
<button type="button" class="btn btn-secondary" onclick="location.replace('/plan/{{ plan_id }}')">取消</button>
<button type="button" class="btn btn-primary" onclick="savePlanContent()">
<i class="bi bi-save"></i> 保存
</button>
+34 -5
View File
@@ -19,6 +19,9 @@
<div class="card mb-3">
<div class="card-body">
<div class="row g-3">
<div class="col-md-1 d-flex align-items-end">
<button class="btn btn-primary active w-100" id="minePlansBtn" onclick="toggleMinePlans()">我的</button>
</div>
<div class="col-md-2">
<label class="form-label small">班级</label>
<select class="form-select" id="filterClass" onchange="loadPlans()">
@@ -44,7 +47,7 @@
<option value="true">仅典型</option>
</select>
</div>
<div class="col-md-3">
<div class="col-md-2">
<label class="form-label small">学员姓名</label>
<input type="text" class="form-control" id="filterStudentName" placeholder="模糊搜索..." oninput="debounceLoad()">
</div>
@@ -148,6 +151,11 @@ async function loadPlans() {
params.append('problem_ids', parseInt(problemId));
}
const mineBtn = document.getElementById('minePlansBtn');
if (mineBtn && mineBtn.classList.contains('active')) {
params.append('mine', 'true');
}
const resp = await fetch(`/api/plans?${params}`);
const plans = await resp.json();
@@ -173,8 +181,9 @@ async function loadPlans() {
`;
plans.forEach(p => {
const problems = (p.problem_names || []).slice(0, 3).join('、');
const moreProblems = (p.problem_names || []).length > 3 ? `${p.problem_names.length}` : '';
const details = p.problem_details || [];
const problemText = details.slice(0, 3).map(d => `${d.name}[${d.level}/${d.severity}]`).join('');
const moreProblems = details.length > 3 ? `${details.length}` : '';
const template = p.template_name || '无模板';
const studentName = p.student_name || '未知';
const className = p.class_name || '-';
@@ -182,9 +191,9 @@ async function loadPlans() {
html += `
<tr>
<td><strong>${studentName}</strong></td>
<td><a href="/student/${p.student_id}" class="text-decoration-none"><strong>${studentName}</strong></a></td>
<td>${className}</td>
<td><span class="plan-problem-text">${problems}${moreProblems}</span></td>
<td><span class="plan-problem-text">${problemText}${moreProblems}</span></td>
<td class="text-muted small">${template}</td>
<td class="text-center">${isTypical ? '<span class="text-warning">★</span>' : ''}</td>
<td class="text-muted small">${p.created_at || ''}</td>
@@ -210,6 +219,26 @@ function clearFilters() {
document.getElementById('filterTemplate').value = '';
document.getElementById('filterTypical').value = '';
document.getElementById('filterStudentName').value = '';
const mineBtn = document.getElementById('minePlansBtn');
if (mineBtn) {
mineBtn.classList.remove('active', 'btn-primary');
mineBtn.classList.add('btn-outline-secondary');
}
loadPlans();
}
// 我的筛选
function toggleMinePlans() {
const btn = document.getElementById('minePlansBtn');
if (!btn) return;
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');
}
loadPlans();
}
+284 -84
View File
@@ -156,8 +156,8 @@
<div class="mb-3">
<label class="form-label">级别 *</label>
<select class="form-select" id="addProblemLevel" required>
<option value="启蒙">启蒙</option>
<option value="入门" selected>入门</option>
<option value="启蒙" selected>启蒙</option>
<option value="入门">入门</option>
<option value="进阶">进阶</option>
<option value="熟练">熟练</option>
<option value="精通">精通</option>
@@ -225,12 +225,10 @@
<label class="form-label">学员:{{ student.name }}</label>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="useAiReport" checked>
<label class="form-check-label" for="useAiReport">
生成AI个性化报告(需要配置API)
</label>
</div>
<label class="form-label">AI提示词模板</label>
<select class="form-select" id="aiTemplateSelect">
<option value="">加载中...</option>
</select>
</div>
<div class="mb-3">
<div class="progress" style="height: 25px;">
@@ -296,10 +294,15 @@
</div>
<div class="modal-body">
<input type="hidden" id="assess-goal-id">
<input type="hidden" id="assess-evaluation-id">
<p class="fw-bold mb-3" id="assess-goal-name"></p>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">评估日期</label>
<input type="date" class="form-control" id="assess-date">
</div>
<div class="col-md-3">
<label class="form-label">掌握程度</label>
<select class="form-select" id="assess-mastery">
<option value="1">⭐ 入门</option>
@@ -309,14 +312,18 @@
<option value="5">⭐⭐⭐⭐⭐ 精通</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">达成日期</label>
<input type="date" class="form-control" id="assess-achievement-date">
</div>
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">当前状态</label>
<input type="text" class="form-control" id="assess-current-status" readonly>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="assess-is-final">
<label class="form-check-label" for="assess-is-final">
最终评估
</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">评语</label>
@@ -407,6 +414,7 @@ function renderProblemList(problems) {
<strong>${p.problem_name}</strong>
<span class="badge bg-${p.severity === '严重' ? 'danger' : p.severity === '中等' ? 'warning' : 'info'} ms-2">${p.severity}</span>
<span class="badge bg-secondary ms-1">${p.level}</span>
<span class="text-muted ms-2" style="font-size: 0.8em;">添加: ${p.created_at ? p.created_at.split('T')[0] : ''}</span>
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-1" onclick="showEditProblemModal(${p.id}, '${p.problem_name}', '${p.severity}', '${p.level}')">
@@ -427,39 +435,56 @@ async function loadPlans() {
// 学习历程时间线
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
});
try {
const [plansRes, goalsRes, evalsRes] = await Promise.all([
fetch(`/api/students/${currentStudentId}/plans`),
fetch(`/api/students/${currentStudentId}/goals`),
fetch(`/api/students/${currentStudentId}/evaluations`)
]);
if (!plansRes.ok || !goalsRes.ok || !evalsRes.ok) {
throw new Error('API request failed');
}
// 添加目标达成记录
if (g.achievement_date) {
const plans = await plansRes.json();
const goals = await goalsRes.json();
const evaluations = await evalsRes.json();
// 构建时间线条目
const timeline = [];
const today = new Date();
// 添加所有评估记录
evaluations.forEach(e => {
if (!e.assessment_date) return;
timeline.push({
date: new Date(g.achievement_date),
type: 'goal_achieved',
goal: g
date: new Date(e.assessment_date),
type: 'evaluation',
evaluation: e,
goalName: e.goal_name,
goalLevel: e.goal_level
});
}
});
});
// 添加目标开始记录
goals.forEach(g => {
if (g.start_date) {
const startDate = new Date(g.start_date);
const assessmentDate = g.assessment_date ? new Date(g.assessment_date) : null;
const days = assessmentDate ? Math.ceil((assessmentDate - startDate) / (1000*60*60*24)) : null;
// 计算尚余天数(对于未完成的目标)
let daysRemaining = null;
if (days && g.status !== '已完成') {
daysRemaining = Math.ceil((assessmentDate - today) / (1000*60*60*24));
}
timeline.push({
date: startDate,
type: 'goal_start',
goal: g,
days: days,
daysRemaining: daysRemaining,
assessmentDate: assessmentDate
});
}
});
// 添加方案生成记录
plans.forEach(p => {
@@ -474,6 +499,10 @@ async function loadTimeline() {
timeline.sort((a, b) => b.date - a.date);
renderTimeline(timeline);
} catch (err) {
console.error('loadTimeline error:', err);
document.getElementById('planList').innerHTML = '<p class="text-danger">加载失败</p>';
}
}
function renderTimeline(timeline) {
@@ -486,6 +515,17 @@ function renderTimeline(timeline) {
container.innerHTML = timeline.map(entry => {
if (entry.type === 'goal_start') {
const g = entry.goal;
const isCompleted = g.status === '已完成';
let durationBadge = '';
if (entry.days) {
if (isCompleted) {
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span>`;
} else if (entry.daysRemaining !== null) {
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span> <span class="badge bg-info">尚余 ${entry.daysRemaining} 天</span>`;
} else {
durationBadge = `<span class="badge bg-secondary">预期 ${entry.days} 天</span>`;
}
}
return `
<div class="d-flex align-items-start mb-2 p-2 border rounded border-primary">
<div class="me-2">
@@ -497,52 +537,78 @@ function renderTimeline(timeline) {
<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>
${durationBadge}
</div>
<div class="small text-muted">
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.endDate ? formatDate(entry.endDate) : '未设置'}
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.assessmentDate ? formatDate(entry.assessmentDate) : '未设置'}
</div>
</div>
</div>`;
} else if (entry.type === 'goal_achieved') {
const g = entry.goal;
const stars = '⭐'.repeat(g.mastery_level || 1);
} else if (entry.type === 'evaluation') {
const e = entry.evaluation;
const stars = '⭐'.repeat(e.mastery_level || 1);
const badge = e.is_final
? '<span class="badge bg-warning text-dark">最终评估</span>'
: '<span class="badge bg-info">阶段评估</span>';
let timingBadge = '';
if (e.is_final && e.goal_start_date && e.goal_assessment_date) {
const startDate = new Date(e.goal_start_date);
const achievementDate = new Date(e.assessment_date);
const assessmentDate = new Date(e.goal_assessment_date);
const actualDays = Math.ceil((achievementDate - startDate) / (1000*60*60*24));
const expectedDays = Math.ceil((assessmentDate - startDate) / (1000*60*60*24));
const diff = actualDays - expectedDays;
let timingInfo;
if (diff < 0) {
timingInfo = `提前${Math.abs(diff)}天达成`;
} else if (diff > 0) {
timingInfo = `延迟${diff}天达成`;
} else {
timingInfo = '按期达成';
}
timingBadge = `<div class="mt-1"><span class="badge bg-success">${timingInfo},共耗时 ${actualDays} 天</span></div>`;
}
return `
<div class="d-flex align-items-start mb-2 p-2 border rounded border-success">
<div class="d-flex align-items-start mb-2 p-2 border rounded ${e.is_final ? 'border-success' : 'border-info'}">
<div class="me-2">
<span class="badge bg-success">目标达成</span>
${badge}
</div>
<div class="flex-grow-1">
<div class="flex-grow-1" style="cursor:pointer" onclick='openAssessGoalFromEvaluation(${JSON.stringify(e)})'>
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${escapeHtml(g.goal_name)}</strong>
<strong>${escapeHtml(e.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>` : ''}
${timingBadge}
${e.comment ? `<div class="small text-muted mt-1"><em>"${escapeHtml(e.comment)}"</em></div>` : ''}
</div>
<button class="btn btn-sm btn-outline-danger ms-2" onclick="event.stopPropagation(); deleteEvaluation(${e.id})" title="删除">
<i class="bi bi-trash"></i>
</button>
</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 class="btn-group">
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary" title="查看">
<i class="bi bi-eye"></i>
</a>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="small text-muted">
${p.problem_names && p.problem_names.length > 0 ? '问题: ' + p.problem_names.join(', ') : ''}
${p.problem_details && p.problem_details.length > 0 ? '问题: ' + p.problem_details.map(d => d.name + '[' + d.level + '/' + d.severity + ']').join(', ') : ''}
${p.template_name ? ' | 模板: ' + p.template_name : ''}
</div>
</div>
@@ -551,11 +617,6 @@ function renderTimeline(timeline) {
}).join('');
}
async function toggleTypical(planId) {
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
loadTimeline();
}
function showEditStudentModal() {
editStudentModal.show();
}
@@ -596,7 +657,7 @@ function deleteStudent() {
function showAddProblemModal() {
document.getElementById('addProblemSelect').value = '';
document.getElementById('addProblemSeverity').value = '中等';
document.getElementById('addProblemLevel').value = '入门';
document.getElementById('addProblemLevel').value = '启蒙';
problemModal.show();
}
@@ -695,14 +756,80 @@ function generatePlan() {
progressBar.textContent = '0%';
progressText.textContent = '准备中...';
progressLog.innerHTML = '';
// 加载AI模板列表
loadAiTemplates();
generateModal.show();
}
// 模态框显示后加载提示词预览
document.getElementById('generatePlanModal').addEventListener('shown.bs.modal', function () {
loadPromptPreview();
});
// 模板切换时更新预览
document.getElementById('aiTemplateSelect').addEventListener('change', function () {
loadPromptPreview();
});
// 加载提示词预览
async function loadPromptPreview() {
const progressLog = document.getElementById('progressLog');
const progressText = document.getElementById('progressText');
progressText.textContent = '正在生成提示词预览...';
progressLog.innerHTML = '<div class="text-muted small">加载中...</div>';
try {
const templateId = document.getElementById('aiTemplateSelect').value || null;
const resp = await fetch('/api/generate-plan/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ student_id: currentStudentId, template_id: templateId })
});
if (resp.ok) {
const data = await resp.json();
// 显示完整提示词
progressLog.innerHTML = `<div class="small" style="white-space: pre-wrap; font-family: monospace;">${escapeHtml(data.prompt)}</div>`;
progressText.textContent = `提示词预览(共${data.prompt_length}字)`;
} else {
const err = await resp.json();
progressLog.innerHTML = `<div class="text-danger small">加载失败: ${err.error || '未知错误'}</div>`;
progressText.textContent = '加载失败';
}
} catch (e) {
progressLog.innerHTML = `<div class="text-danger small">加载失败: ${e.message}</div>`;
progressText.textContent = '加载失败';
}
}
// 加载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('');
// 模板加载完成后自动显示预览
loadPromptPreview();
}
} catch (e) {
console.error('加载AI模板失败:', e);
}
}
async function startGeneratePlan() {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressLog = document.getElementById('progressLog');
const useAi = document.getElementById('useAiReport').checked;
const startBtn = document.getElementById('startGenerateBtn');
// 禁用开始按钮
startBtn.disabled = true;
startBtn.textContent = '生成中...';
progressBar.style.width = '0%';
progressText.textContent = '准备中...';
@@ -718,7 +845,7 @@ async function startGeneratePlan() {
const response = await fetch('/api/generate-plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({student_id: currentStudentId, use_ai: useAi})
body: JSON.stringify({student_id: currentStudentId, use_ai: true, template_id: document.getElementById('aiTemplateSelect').value || null})
});
const reader = response.body.getReader();
@@ -742,19 +869,35 @@ async function startGeneratePlan() {
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 └ ');
}
logMsg += '\n └ ' + data.detail.replace(/\n/g, '\n └ ');
}
addLog(logMsg);
// AI报告完成后显示字数统计
if (data.step === 'complete') {
addLog('─'.repeat(40));
if (data.prompt_length) {
addLog(`📤 提示词:${data.prompt_length}`);
}
if (data.student_problems_length) {
addLog(` └ 问题摘要:${data.student_problems_length}`);
}
if (data.problems_length) {
addLog(` └ 问题详情:${data.problems_length}`);
}
if (data.student_goals_length) {
addLog(` └ 学员目标:${data.student_goals_length}`);
}
if (data.ai_report_length) {
addLog(`📥 AI报告:${data.ai_report_length}`);
}
}
if (data.step === 'complete') {
setTimeout(() => {
generateModal.hide();
startBtn.disabled = false;
startBtn.textContent = '开始生成';
alert('方案生成成功!');
loadPlans();
}, 500);
@@ -762,6 +905,8 @@ async function startGeneratePlan() {
if (data.error) {
addLog(data.error, true);
startBtn.disabled = false;
startBtn.textContent = '开始生成';
}
} catch (e) {}
}
@@ -770,6 +915,8 @@ async function startGeneratePlan() {
} catch (err) {
addLog('请求失败:' + err.message, true);
progressText.textContent = '生成失败';
startBtn.disabled = false;
startBtn.textContent = '开始生成';
}
}
@@ -849,6 +996,7 @@ document.getElementById('remove-assigned-goal').addEventListener('click', () =>
.then(() => {
bootstrap.Modal.getInstance(document.getElementById('adjustGoalModal')).hide();
loadStudentGoals();
loadTimeline();
});
});
@@ -876,35 +1024,69 @@ async function openAssessGoal(goalId) {
if (!goal) return;
document.getElementById('assess-goal-id').value = goalId;
document.getElementById('assess-evaluation-id').value = '';
document.getElementById('assess-goal-name').textContent = goal.goal_name;
document.getElementById('assess-date').value = new Date().toISOString().split('T')[0];
document.getElementById('assess-mastery').value = goal.mastery_level || '1';
document.getElementById('assess-achievement-date').value = goal.achievement_date ? goal.achievement_date.split('T')[0] : '';
document.getElementById('assess-current-status').value = goal.status;
document.getElementById('assess-comment').value = goal.comment || '';
document.getElementById('assess-is-final').checked = false;
new bootstrap.Modal(document.getElementById('assessGoalModal')).show();
}
function openAssessGoalFromEvaluation(evaluation) {
// evaluation has: id, student_goal_id, mastery_level, comment, is_final, goal_name, assessment_date, etc.
document.getElementById('assess-goal-id').value = evaluation.student_goal_goal_id;
document.getElementById('assess-evaluation-id').value = evaluation.id;
document.getElementById('assess-goal-name').textContent = evaluation.goal_name || '未知目标';
document.getElementById('assess-date').value = evaluation.assessment_date ? evaluation.assessment_date.split('T')[0] : new Date().toISOString().split('T')[0];
document.getElementById('assess-mastery').value = evaluation.mastery_level || '1';
document.getElementById('assess-comment').value = evaluation.comment || '';
document.getElementById('assess-is-final').checked = evaluation.is_final || false;
// Status cannot be edited from evaluation modal, show current status
document.getElementById('assess-current-status').value = evaluation.is_final ? '已完成' : '进行中';
new bootstrap.Modal(document.getElementById('assessGoalModal')).show();
}
document.getElementById('confirm-assess-goal').addEventListener('click', async () => {
const goalId = document.getElementById('assess-goal-id').value;
const evaluationId = document.getElementById('assess-evaluation-id').value;
const assessmentDate = document.getElementById('assess-date').value;
const masteryLevel = document.getElementById('assess-mastery').value;
const achievementDate = document.getElementById('assess-achievement-date').value;
const comment = document.getElementById('assess-comment').value;
const isFinal = document.getElementById('assess-is-final').checked;
const body = {
mastery_level: parseInt(masteryLevel),
comment: comment,
is_final: isFinal,
assessment_date: assessmentDate || null
};
if (evaluationId) {
body.evaluation_id = parseInt(evaluationId);
}
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
mastery_level: parseInt(masteryLevel),
achievement_date: achievementDate || null,
comment: comment
})
body: JSON.stringify(body)
});
bootstrap.Modal.getInstance(document.getElementById('assessGoalModal')).hide();
loadStudentGoals();
loadTimeline();
});
async function deleteEvaluation(evaluationId) {
if (!confirm('确定要删除这条评估记录吗?')) return;
await fetch(`/api/evaluations/${evaluationId}`, { method: 'DELETE' });
loadTimeline();
}
// 加载可选目标列表到 Modal
async function loadGoalOptions() {
const res = await fetch('/api/goals');
@@ -946,6 +1128,24 @@ document.getElementById('assign-assessment-date').addEventListener('change', fun
}
});
// 开始日期联动:修改开始日期后,如果使用"XX天后"评估,自动重新计算评估日期
function updateAssessmentDateFromStartDate() {
const startDateStr = document.getElementById('assign-start-date').value;
const daysStr = document.getElementById('assign-assessment-days').value;
if (startDateStr && daysStr) {
const days = parseInt(daysStr);
const [y, m, d] = startDateStr.split('-').map(Number);
const startDate = new Date(y, m - 1, d);
startDate.setDate(startDate.getDate() + days);
const yy = startDate.getFullYear();
const mm = String(startDate.getMonth() + 1).padStart(2, '0');
const dd = String(startDate.getDate()).padStart(2, '0');
document.getElementById('assign-assessment-date').value = `${yy}-${mm}-${dd}`;
}
}
document.getElementById('assign-start-date').addEventListener('change', updateAssessmentDateFromStartDate);
document.getElementById('assign-start-date').addEventListener('input', updateAssessmentDateFromStartDate);
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
const goalId = document.getElementById('assign-goal-select').value;
const assessmentDays = document.getElementById('assign-assessment-days').value;
+29 -11
View File
@@ -145,18 +145,36 @@ function renderTemplateList() {
list.innerHTML = '<div class="p-3 text-muted">暂无模板</div>';
return;
}
list.innerHTML = templates.map(t => `
<div class="template-card card m-2 p-2 ${currentTemplate && currentTemplate.id === t.id ? 'active' : ''}"
onclick="selectTemplate(${t.id})">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${t.name}</strong>
<br><small class="text-muted">${t.type === 'ai_prompt' ? 'AI提示词' : '报告模板'}</small>
// 按类型分组
const groups = {};
templates.forEach(t => {
if (!groups[t.type]) groups[t.type] = [];
groups[t.type].push(t);
});
const typeNames = { 'ai_prompt': 'AI提示词模板', 'report': '报告导出模板' };
let html = '';
Object.keys(groups).forEach(type => {
html += `<div class="mb-3">
<h6 class="text-muted border-bottom pb-1 mb-2">${typeNames[type] || type}</h6>`;
groups[type].forEach(t => {
html += `
<div class="template-card card m-2 p-2 ${currentTemplate && currentTemplate.id === t.id ? 'active' : ''}"
onclick="selectTemplate(${t.id})">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${t.name}</strong>
<br><small class="text-muted">${t.description || ''}</small>
</div>
<i class="bi bi-chevron-right"></i>
</div>
<i class="bi bi-chevron-right"></i>
</div>
</div>
`).join('');
</div>`;
});
html += '</div>';
});
list.innerHTML = html;
}
async function selectTemplate(id) {
+52 -6
View File
@@ -5,7 +5,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="bi bi-person-badge"></i> 用户管理</h4>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal">
<button class="btn btn-primary" onclick="openAddUser()">
<i class="bi bi-plus-circle"></i> 新增用户
</button>
</div>
@@ -17,6 +17,7 @@
<tr>
<th>ID</th>
<th>用户名</th>
<th>姓名</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
@@ -42,6 +43,10 @@
<label class="form-label">用户名</label>
<input type="text" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label class="form-label">姓名</label>
<input type="text" class="form-control" id="userName">
</div>
<div class="mb-3">
<label class="form-label" id="passwordLabel">初始密码</label>
<input type="password" class="form-control" id="password">
@@ -93,6 +98,8 @@
{% block extra_js %}
<script>
const ROLE_LABELS = { 'admin': '管理员', 'user': '普通用户' };
window.pageInit = function() {
loadUsers();
};
@@ -125,17 +132,19 @@ function loadUsers() {
.then(users => {
const tbody = document.querySelector('#usersTable tbody');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">暂无用户</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">暂无用户</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td>${u.name || '-'}</td>
<td><span class="badge ${u.role === 'admin' ? 'bg-danger' : 'bg-secondary'}">${ROLE_LABELS[u.role]}</span></td>
<td>${u.created_at}</td>
<td>
<button class="btn btn-sm btn-primary me-1" onclick="openEditUser(${u.id}, '${u.username}', '${u.name || ''}', '${u.role}')">编辑</button>
<button class="btn btn-sm btn-warning me-1" onclick="openResetPwd(${u.id})">重置密码</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>
${u.role === 'admin' ? `<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">删除</button>` : ''}
</td>
</tr>
`).join('');
@@ -147,23 +156,60 @@ function loadUsers() {
});
}
// 打开新增用户
function openAddUser() {
document.getElementById('userId').value = '';
document.getElementById('username').value = '';
document.getElementById('username').readOnly = false;
document.getElementById('userName').value = '';
document.getElementById('password').value = '';
document.getElementById('password').parentElement.querySelector('.form-text').textContent = '8位以上,包含大小写字母、数字和特殊字符';
document.getElementById('role').value = 'user';
document.getElementById('role').disabled = false;
document.getElementById('userModalTitle').textContent = '新增用户';
new bootstrap.Modal(document.getElementById('userModal')).show();
}
// 打开编辑用户
function openEditUser(id, username, name, role) {
document.getElementById('userId').value = id;
document.getElementById('username').value = username;
document.getElementById('username').readOnly = true;
document.getElementById('userName').value = name;
document.getElementById('password').value = '';
document.getElementById('password').parentElement.querySelector('.form-text').textContent = '不填则保持不变';
document.getElementById('role').value = role;
document.getElementById('role').disabled = true;
document.getElementById('userModalTitle').textContent = '编辑用户';
new bootstrap.Modal(document.getElementById('userModal')).show();
}
// 保存用户
document.getElementById('saveUserBtn').onclick = () => {
const id = document.getElementById('userId').value;
const username = document.getElementById('username').value.trim();
const name = document.getElementById('userName').value.trim();
const password = document.getElementById('password').value;
const role = document.getElementById('role').value;
if (!username || !password) {
alert('请填写完整');
if (!username) {
alert('请填写用户名');
return;
}
if (!id && !password) {
alert('请填写密码');
return;
}
const method = id ? 'PUT' : 'POST';
const body = { username, role };
if (name) body.name = name;
if (password) body.password = password;
fetch('/api/users' + (id ? '/' + id : ''), {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
body: JSON.stringify(body)
}).then(r => r.json()).then(data => {
if (data.error) {
showToast(data.error);
-14
View File
@@ -155,20 +155,6 @@
{% endfor %}
</div>
{% endif %}
<div class="section">
<div class="section-title">📅 每日练习计划</div>
{% for item in content.daily_schedule %}
<div class="schedule-item">
<div class="phase">{{ item.phase }}</div>
<div class="details">
<div class="content">{{ item.content }}</div>
<div class="purpose">{{ item.purpose }}</div>
</div>
<div class="duration">{{ item.duration }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="footer">