1323 lines
56 KiB
HTML
1323 lines
56 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ student.name }} - 学员详情 - 有音个性化教学系统{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h4><i class="bi bi-person"></i> 学员详情</h4>
|
|
<div>
|
|
<a href="/students" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i> 返回列表
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-4">
|
|
<div class="card mb-4">
|
|
<div class="card-body text-center">
|
|
<div class="avatar mb-3">
|
|
<i class="bi bi-person-circle" style="font-size: 60px; color: #6c757d;"></i>
|
|
</div>
|
|
<h5 id="detailName">{{ student.name }}</h5>
|
|
<p class="text-muted" id="detailWechatNickname">{{ student.wechat_nickname or '未填写' }}</p>
|
|
<p class="text-muted" id="detailPhone">{{ student.phone or '未填写' }}</p>
|
|
<p class="small">
|
|
<span class="badge bg-info" id="detailPracticeTime">{{ student.practice_time or '30分钟' }}</span>
|
|
</p>
|
|
<p class="small" id="detailClass">
|
|
{% if student.class_obj %}
|
|
<span class="badge bg-secondary">{{ student.class_obj.name }}</span>
|
|
{% else %}
|
|
<span class="text-muted">未分配班级</span>
|
|
{% endif %}
|
|
</p>
|
|
<p class="small text-muted" id="detailNotes">{{ student.notes or '无备注' }}</p>
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showEditStudentModal()">
|
|
<i class="bi bi-pencil"></i> 编辑
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteStudent()">
|
|
<i class="bi bi-trash"></i> 删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-8">
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h6 class="mb-0"><i class="bi bi-list-check"></i> 当前问题</h6>
|
|
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
|
|
<i class="bi bi-plus"></i> 添加问题
|
|
</button>
|
|
</div>
|
|
<div class="card-body" id="problemList">
|
|
<p class="text-muted">加载中...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 学员目标区块 -->
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">🎯 练习目标</h5>
|
|
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#assignGoalModal">
|
|
+ 分配目标
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="student-goals-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h6 class="mb-0"><i class="bi bi-clipboard-check"></i> 练习方案</h6>
|
|
<button class="btn btn-sm btn-primary" onclick="generatePlan()">
|
|
<i class="bi bi-plus"></i> 生成方案
|
|
</button>
|
|
</div>
|
|
<div class="card-body" id="planList">
|
|
<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>
|
|
|
|
<!-- 编辑学员 Modal -->
|
|
<div class="modal fade" id="editStudentModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">编辑学员</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="editStudentId" value="{{ student.id }}">
|
|
<div class="mb-3">
|
|
<label class="form-label">姓名 *</label>
|
|
<input type="text" class="form-control" id="editStudentName" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">手机号</label>
|
|
<input type="text" class="form-control" id="editStudentPhone">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">微信昵称</label>
|
|
<input type="text" class="form-control" id="editStudentWechat">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">每日练习时间</label>
|
|
<select class="form-select" id="editStudentPracticeTime">
|
|
<option value="15分钟">15分钟</option>
|
|
<option value="30分钟">30分钟</option>
|
|
<option value="45分钟">45分钟</option>
|
|
<option value="60分钟">60分钟</option>
|
|
<option value="90分钟">90分钟</option>
|
|
<option value="120分钟">120分钟</option>
|
|
<option value="150分钟以上">150分钟以上</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">备注</label>
|
|
<textarea class="form-control" id="editStudentNotes" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveStudentEdit()">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 添加问题 Modal -->
|
|
<div class="modal fade" id="addProblemModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">添加问题</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">问题 *</label>
|
|
<select class="form-select" id="addProblemSelect" required>
|
|
<option value="">选择问题...</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">严重程度 *</label>
|
|
<select class="form-select" id="addProblemSeverity" required>
|
|
<option value="轻微">轻微</option>
|
|
<option value="中等" selected>中等</option>
|
|
<option value="严重">严重</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">级别 *</label>
|
|
<select class="form-select" id="addProblemLevel" required>
|
|
<option value="启蒙" selected>启蒙</option>
|
|
<option value="入门">入门</option>
|
|
<option value="进阶">进阶</option>
|
|
<option value="熟练">熟练</option>
|
|
<option value="精通">精通</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveAddProblem()">添加</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑问题 Modal -->
|
|
<div class="modal fade" id="editProblemModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">编辑问题</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="editProblemId">
|
|
<div class="mb-3">
|
|
<label class="form-label">问题</label>
|
|
<input type="text" class="form-control" id="editProblemName" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">严重程度 *</label>
|
|
<select class="form-select" id="editProblemSeverity" required>
|
|
<option value="轻微">轻微</option>
|
|
<option value="中等">中等</option>
|
|
<option value="严重">严重</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">级别 *</label>
|
|
<select class="form-select" id="editProblemLevel" required>
|
|
<option value="启蒙">启蒙</option>
|
|
<option value="入门">入门</option>
|
|
<option value="进阶">进阶</option>
|
|
<option value="熟练">熟练</option>
|
|
<option value="精通">精通</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveProblemEdit()">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 生成方案 Modal -->
|
|
<div class="modal fade" id="generatePlanModal" tabindex="-1" data-bs-backdrop="static">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">生成练习方案</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">学员:{{ student.name }}</label>
|
|
</div>
|
|
<div class="mb-3">
|
|
<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;">
|
|
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar" style="width: 0%">0%</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<strong id="progressText">准备中...</strong>
|
|
</div>
|
|
<div id="progressLog" class="bg-light p-3 rounded" style="height: 200px; overflow-y: auto; font-family: monospace;">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" id="startGenerateBtn" onclick="startGeneratePlan()">开始生成</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% include "fragments/assign_goal_modal.html" with context %}
|
|
|
|
<!-- 调整目标 Modal -->
|
|
<div class="modal fade" id="adjustGoalModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">调整目标</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="adjust-goal-id">
|
|
<p class="fw-bold mb-3" id="adjust-goal-name"></p>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">开始日期</label>
|
|
<input type="date" class="form-control" id="adjust-start-date">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">评估日期</label>
|
|
<input type="date" class="form-control" id="adjust-assessment-date">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer-with-top">
|
|
<button type="button" class="btn btn-danger" id="remove-assigned-goal">移除目标</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" id="confirm-adjust-goal">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 评估目标 Modal -->
|
|
<div class="modal fade" id="assessGoalModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">评估目标</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</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-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>
|
|
<option value="2">⭐⭐ 初级</option>
|
|
<option value="3">⭐⭐⭐ 进阶</option>
|
|
<option value="4">⭐⭐⭐⭐ 熟练</option>
|
|
<option value="5">⭐⭐⭐⭐⭐ 精通</option>
|
|
</select>
|
|
</div>
|
|
<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>
|
|
<textarea class="form-control" id="assess-comment" rows="3" placeholder="评估意见..."></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer-with-top">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" id="confirm-assess-goal">保存评估</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.modal-footer-with-top {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border-top: 1px solid #dee2e6;
|
|
background: #f8f9fa;
|
|
position: sticky;
|
|
bottom: 0;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
const currentStudentId = {{ student.id }};
|
|
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'));
|
|
editStudentModal = new bootstrap.Modal(document.getElementById('editStudentModal'));
|
|
assignGoalModal = new bootstrap.Modal(document.getElementById('assignGoalModal'));
|
|
|
|
// 填充编辑学员表单初始值
|
|
document.getElementById('editStudentName').value = document.getElementById('detailName').textContent;
|
|
document.getElementById('editStudentPhone').value = document.getElementById('detailPhone').textContent === '未填写' ? '' : document.getElementById('detailPhone').textContent;
|
|
document.getElementById('editStudentWechat').value = document.getElementById('detailWechatNickname').textContent === '未填写' ? '' : document.getElementById('detailWechatNickname').textContent;
|
|
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() {
|
|
try {
|
|
const resp = await fetch('/api/problems');
|
|
const problems = await resp.json();
|
|
const select = document.getElementById('addProblemSelect');
|
|
select.innerHTML = '<option value="">选择问题...</option>';
|
|
problems.forEach(p => {
|
|
select.innerHTML += `<option value="${p.id}">${p.no} - ${p.name}</option>`;
|
|
});
|
|
} catch (e) {
|
|
console.error('加载问题列表失败', e);
|
|
}
|
|
}
|
|
|
|
async function loadProblems() {
|
|
try {
|
|
const resp = await fetch(`/api/students/${currentStudentId}/problems`);
|
|
const problems = await resp.json();
|
|
renderProblemList(problems);
|
|
} catch (e) {
|
|
document.getElementById('problemList').innerHTML = '<p class="text-danger">加载失败</p>';
|
|
}
|
|
}
|
|
|
|
function renderProblemList(problems) {
|
|
const container = document.getElementById('problemList');
|
|
if (problems.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">暂无问题记录</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = problems.map(p => `
|
|
<div class="d-flex justify-content-between align-items-center mb-2 p-2 border rounded">
|
|
<div>
|
|
<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}')">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteProblem(${p.id})">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// 学习历程时间线(替代原 loadPlans)
|
|
async function loadPlans() {
|
|
await loadTimeline();
|
|
}
|
|
|
|
// 学习历程时间线
|
|
async function loadTimeline() {
|
|
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');
|
|
}
|
|
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(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 => {
|
|
timeline.push({
|
|
date: new Date(p.created_at),
|
|
type: 'plan',
|
|
plan: p
|
|
});
|
|
});
|
|
|
|
// 按时间逆序排序
|
|
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) {
|
|
const container = document.getElementById('planList');
|
|
if (timeline.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">暂无学习记录</p>';
|
|
return;
|
|
}
|
|
|
|
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">
|
|
<span class="badge bg-primary">目标启动</span>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<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>
|
|
${durationBadge}
|
|
</div>
|
|
<div class="small text-muted">
|
|
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.assessmentDate ? formatDate(entry.assessmentDate) : '未设置'}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
} 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 ${e.is_final ? 'border-success' : 'border-info'}">
|
|
<div class="me-2">
|
|
${badge}
|
|
</div>
|
|
<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(e.goal_name || '未知目标')}</strong>
|
|
<span class="text-muted ms-2">${formatDate(entry.date)}</span>
|
|
</div>
|
|
<span>${stars}</span>
|
|
</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;
|
|
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="查看">
|
|
<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_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>
|
|
</div>`;
|
|
}
|
|
}).join('');
|
|
}
|
|
|
|
function showEditStudentModal() {
|
|
editStudentModal.show();
|
|
}
|
|
|
|
async function saveStudentEdit() {
|
|
const data = {
|
|
name: document.getElementById('editStudentName').value,
|
|
phone: document.getElementById('editStudentPhone').value,
|
|
wechat_nickname: document.getElementById('editStudentWechat').value,
|
|
practice_time: document.getElementById('editStudentPracticeTime').value,
|
|
notes: document.getElementById('editStudentNotes').value,
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(`/api/students/${currentStudentId}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (resp.ok) {
|
|
editStudentModal.hide();
|
|
location.reload();
|
|
} else {
|
|
alert('保存失败');
|
|
}
|
|
} catch (e) {
|
|
alert('保存失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function deleteStudent() {
|
|
if (!confirm('确定删除该学员?所有问题和方案都将被删除!')) return;
|
|
fetch(`/api/students/${currentStudentId}`, {method: 'DELETE'})
|
|
.then(r => r.json())
|
|
.then(() => window.location.href = '/students');
|
|
}
|
|
|
|
function showAddProblemModal() {
|
|
document.getElementById('addProblemSelect').value = '';
|
|
document.getElementById('addProblemSeverity').value = '中等';
|
|
document.getElementById('addProblemLevel').value = '启蒙';
|
|
problemModal.show();
|
|
}
|
|
|
|
async function saveAddProblem() {
|
|
const problemId = document.getElementById('addProblemSelect').value;
|
|
const severity = document.getElementById('addProblemSeverity').value;
|
|
const level = document.getElementById('addProblemLevel').value;
|
|
|
|
if (!problemId) {
|
|
alert('请选择问题');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(`/api/students/${currentStudentId}/problems`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
problems: [{problem_id: parseInt(problemId), severity, level}]
|
|
})
|
|
});
|
|
if (resp.ok) {
|
|
problemModal.hide();
|
|
loadProblems();
|
|
loadRecommendedPlans(currentRecommendedFilter);
|
|
} else {
|
|
const err = await resp.json();
|
|
alert('添加失败: ' + (err.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('添加失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function showEditProblemModal(id, name, severity, level) {
|
|
document.getElementById('editProblemId').value = id;
|
|
document.getElementById('editProblemName').value = name;
|
|
document.getElementById('editProblemSeverity').value = severity;
|
|
document.getElementById('editProblemLevel').value = level;
|
|
editProblemModal.show();
|
|
}
|
|
|
|
async function saveProblemEdit() {
|
|
const id = document.getElementById('editProblemId').value;
|
|
const severity = document.getElementById('editProblemSeverity').value;
|
|
const level = document.getElementById('editProblemLevel').value;
|
|
|
|
// 获取当前问题列表找到对应的 problem_db_id
|
|
const resp = await fetch(`/api/students/${currentStudentId}/problems`);
|
|
const problems = await resp.json();
|
|
const problem = problems.find(p => p.id === parseInt(id));
|
|
if (!problem) {
|
|
alert('问题不存在');
|
|
return;
|
|
}
|
|
|
|
// 调用更新 API
|
|
try {
|
|
const updateResp = await fetch(`/api/students/${currentStudentId}/problems/${id}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({severity, level})
|
|
});
|
|
if (updateResp.ok) {
|
|
editProblemModal.hide();
|
|
loadProblems();
|
|
loadRecommendedPlans(currentRecommendedFilter);
|
|
} else {
|
|
alert('更新失败');
|
|
}
|
|
} catch (e) {
|
|
alert('更新失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteProblem(id) {
|
|
if (!confirm('确定删除该问题?')) return;
|
|
try {
|
|
const resp = await fetch(`/api/students/${currentStudentId}/problems/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (resp.ok) {
|
|
loadProblems();
|
|
loadRecommendedPlans(currentRecommendedFilter);
|
|
} else {
|
|
alert('删除失败');
|
|
}
|
|
} catch (e) {
|
|
alert('删除失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function generatePlan() {
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressText = document.getElementById('progressText');
|
|
const progressLog = document.getElementById('progressLog');
|
|
|
|
progressBar.style.width = '0%';
|
|
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 startBtn = document.getElementById('startGenerateBtn');
|
|
|
|
// 禁用开始按钮
|
|
startBtn.disabled = true;
|
|
startBtn.textContent = '生成中...';
|
|
|
|
progressBar.style.width = '0%';
|
|
progressText.textContent = '准备中...';
|
|
progressLog.innerHTML = '';
|
|
|
|
function addLog(msg, isError = false) {
|
|
const time = new Date().toLocaleTimeString();
|
|
progressLog.innerHTML += `<div class="${isError ? 'text-danger' : 'text-muted'} small">${time} ${msg}</div>`;
|
|
progressLog.scrollTop = progressLog.scrollHeight;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/generate-plan', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({student_id: currentStudentId, use_ai: true, template_id: document.getElementById('aiTemplateSelect').value || null})
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
while (true) {
|
|
const {done, value} = await reader.read();
|
|
if (done) break;
|
|
|
|
const text = decoder.decode(value);
|
|
const lines = text.split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.slice(6));
|
|
|
|
progressBar.style.width = data.progress + '%';
|
|
progressBar.textContent = data.progress + '%';
|
|
progressText.textContent = data.message;
|
|
|
|
let logMsg = data.message;
|
|
if (data.detail) {
|
|
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);
|
|
}
|
|
|
|
if (data.error) {
|
|
addLog(data.error, true);
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '开始生成';
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
addLog('请求失败:' + err.message, true);
|
|
progressText.textContent = '生成失败';
|
|
startBtn.disabled = false;
|
|
startBtn.textContent = '开始生成';
|
|
}
|
|
}
|
|
|
|
async function deletePlan(id) {
|
|
if (!confirm('确定删除该方案?')) return;
|
|
try {
|
|
const resp = await fetch(`/api/plans/${id}`, {method: 'DELETE'});
|
|
if (resp.ok) {
|
|
loadPlans();
|
|
} else {
|
|
alert('删除失败');
|
|
}
|
|
} catch (e) {
|
|
alert('删除失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ===== 目标相关 =====
|
|
let adjustGoalModal, assessGoalModal;
|
|
|
|
async function loadStudentGoals() {
|
|
const res = await fetch(`/api/students/${currentStudentId}/goals`);
|
|
const goals = await res.json();
|
|
|
|
if (goals.length === 0) {
|
|
document.getElementById('student-goals-list').innerHTML =
|
|
'<p class="text-muted">暂无分配的目标</p>';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('student-goals-list').innerHTML = goals.map(g => `
|
|
<div class="d-flex justify-content-between align-items-center mb-2 p-2 border rounded">
|
|
<div>
|
|
<strong>${escapeHtml(g.goal_name)}</strong>
|
|
<div class="small">
|
|
<span class="badge bg-secondary me-1">${g.goal_level || '入门'}</span>
|
|
<span class="badge bg-info me-1">${g.goal_category || '综合'}</span>
|
|
<span class="badge ${g.status === '进行中' ? 'bg-primary' : g.status === '未开始' ? 'bg-warning' : 'bg-secondary'}">${g.status}</span>
|
|
</div>
|
|
<div class="small text-muted mt-1">
|
|
${g.start_date ? '开始: '+formatDate(g.start_date) : '未开始'}
|
|
${g.assessment_date ? ' | 评估: '+formatDate(g.assessment_date) : ''}
|
|
${g.mastery_level ? ' | ⭐'.repeat(g.mastery_level) : ''}
|
|
</div>
|
|
</div>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary" onclick="openAdjustGoal(${g.goal_id})">调整目标</button>
|
|
<button class="btn btn-outline-success" onclick="openAssessGoal(${g.goal_id})">评估目标</button>
|
|
</div>
|
|
</div>
|
|
`).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);
|
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
}
|
|
|
|
async function openAdjustGoal(goalId) {
|
|
const goals = await fetch(`/api/students/${currentStudentId}/goals`).then(r => r.json());
|
|
const goal = goals.find(g => g.goal_id === goalId);
|
|
if (!goal) return;
|
|
|
|
document.getElementById('adjust-goal-id').value = goalId;
|
|
document.getElementById('adjust-goal-name').textContent = goal.goal_name;
|
|
document.getElementById('adjust-start-date').value = goal.start_date ? goal.start_date.split('T')[0] : '';
|
|
document.getElementById('adjust-assessment-date').value = goal.assessment_date ? goal.assessment_date.split('T')[0] : '';
|
|
|
|
new bootstrap.Modal(document.getElementById('adjustGoalModal')).show();
|
|
}
|
|
|
|
document.getElementById('remove-assigned-goal').addEventListener('click', () => {
|
|
const goalId = document.getElementById('adjust-goal-id').value;
|
|
if (!confirm('确定移除此目标?此操作不可恢复。')) return;
|
|
fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {method: 'DELETE'})
|
|
.then(() => {
|
|
bootstrap.Modal.getInstance(document.getElementById('adjustGoalModal')).hide();
|
|
loadStudentGoals();
|
|
loadTimeline();
|
|
});
|
|
});
|
|
|
|
document.getElementById('confirm-adjust-goal').addEventListener('click', async () => {
|
|
const goalId = document.getElementById('adjust-goal-id').value;
|
|
const startDate = document.getElementById('adjust-start-date').value;
|
|
const assessmentDate = document.getElementById('adjust-assessment-date').value;
|
|
|
|
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
start_date: startDate || null,
|
|
assessment_date: assessmentDate || null
|
|
})
|
|
});
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('adjustGoalModal')).hide();
|
|
loadStudentGoals();
|
|
});
|
|
|
|
async function openAssessGoal(goalId) {
|
|
const goals = await fetch(`/api/students/${currentStudentId}/goals`).then(r => r.json());
|
|
const goal = goals.find(g => g.goal_id === 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-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 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(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');
|
|
const goals = await res.json();
|
|
const select = document.getElementById('assign-goal-select');
|
|
|
|
// 获取已分配的目标
|
|
const assignedRes = await fetch(`/api/students/${currentStudentId}/goals`);
|
|
const assigned = await assignedRes.json();
|
|
const assignedIds = assigned.map(g => g.goal_id);
|
|
|
|
select.innerHTML = goals
|
|
.filter(g => !assignedIds.includes(g.id))
|
|
.map(g => `<option value="${g.id}">${escapeHtml(g.name)} [${g.level || '入门'} - ${g.category || '综合'}]</option>`)
|
|
.join('');
|
|
|
|
// 设置默认开始日期为今天,默认评估日期为90天后
|
|
document.getElementById('assign-start-date').value = new Date().toISOString().split('T')[0];
|
|
document.getElementById('assign-assessment-days').value = '90';
|
|
// 联动设置评估日期
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + 90);
|
|
document.getElementById('assign-assessment-date').value = d.toISOString().split('T')[0];
|
|
}
|
|
|
|
// 评估日期联动:选择天数后自动计算日期,或直接选日期
|
|
document.getElementById('assign-assessment-days').addEventListener('change', function() {
|
|
const days = parseInt(this.value);
|
|
if (days) {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
document.getElementById('assign-assessment-date').value = d.toISOString().split('T')[0];
|
|
}
|
|
});
|
|
|
|
document.getElementById('assign-assessment-date').addEventListener('change', function() {
|
|
if (this.value) {
|
|
document.getElementById('assign-assessment-days').value = '';
|
|
}
|
|
});
|
|
|
|
// 开始日期联动:修改开始日期后,如果使用"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;
|
|
const assessmentDate = document.getElementById('assign-assessment-date').value;
|
|
const startDate = document.getElementById('assign-start-date').value;
|
|
|
|
if (!goalId) { alert('请选择目标'); return; }
|
|
if (!assessmentDays && !assessmentDate) { alert('请选择评估方式'); return; }
|
|
|
|
const res = await fetch(`/api/students/${currentStudentId}/goals`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
goal_id: parseInt(goalId),
|
|
assessment_days: assessmentDays || null,
|
|
assessment_date: assessmentDate || null,
|
|
start_date: startDate || null,
|
|
start_now: !startDate
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
assignGoalModal.hide();
|
|
loadStudentGoals();
|
|
// 重置表单
|
|
document.getElementById('assign-assessment-days').value = '';
|
|
document.getElementById('assign-assessment-date').value = '';
|
|
document.getElementById('assign-start-date').value = new Date().toISOString().split('T')[0];
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.error || '分配失败');
|
|
}
|
|
});
|
|
|
|
document.getElementById('assignGoalModal').addEventListener('show.bs.modal', loadGoalOptions);
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast-message alert alert-' + (type === 'success' ? 'success' : 'danger');
|
|
toast.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:200px;';
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
</script>
|
|
{% endblock %} |