feat: 重构学员目标系统,支持评估日期/状态自动计算/评估目标,同时恢复方案列表的典型设置

This commit is contained in:
hmo
2026-04-24 00:06:26 +08:00
parent 68e106018b
commit 035c599c2f
4 changed files with 355 additions and 68 deletions
+19
View File
@@ -154,6 +154,25 @@ def create_app():
{"new_cat": new_cat, "old_cat": old_cat}
)
db.session.commit()
# 检查student_goals表是否有新字段
result8 = db.session.execute(text("PRAGMA table_info(student_goals)"))
sg_columns = [row[1] for row in result8]
if "start_date" not in sg_columns:
db.session.execute(text("ALTER TABLE student_goals ADD COLUMN start_date TIMESTAMP"))
db.session.commit()
if "assessment_date" not in sg_columns:
db.session.execute(text("ALTER TABLE student_goals ADD COLUMN assessment_date TIMESTAMP"))
db.session.commit()
if "achievement_date" not in sg_columns:
db.session.execute(text("ALTER TABLE student_goals ADD COLUMN achievement_date TIMESTAMP"))
db.session.commit()
if "comment" not in sg_columns:
db.session.execute(text("ALTER TABLE student_goals ADD COLUMN comment TEXT"))
db.session.commit()
# 删除不再使用的字段
# deadline 和 completed_at 已被 start_date, assessment_date, achievement_date 取代
# status 字段现在由日期计算,不再存储
except Exception as e:
print(f"数据库迁移: {e}")
+25 -7
View File
@@ -235,25 +235,43 @@ class StudentGoal(db.Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey("students.id"), nullable=False)
goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False)
status = db.Column(db.String(20), default="未开始") # 开始/进行中/已完成
mastery_level = db.Column(db.Integer, default=1) # 1-5
deadline = db.Column(db.DateTime)
completed_at = db.Column(db.DateTime)
start_date = db.Column(db.DateTime) # 开始日期
assessment_date = db.Column(db.DateTime) # 评估日期
mastery_level = db.Column(db.Integer, nullable=True) # 掌握程度 1-5(评估时填写)
achievement_date = db.Column(db.DateTime, nullable=True) # 达成日期
comment = db.Column(db.Text, nullable=True) # 评语
created_at = db.Column(db.DateTime, default=datetime.now)
student = db.relationship("Student", backref="goal_records")
goal = db.relationship("Goal")
def get_status(self):
"""根据日期自动计算状态"""
from datetime import datetime
now = datetime.now()
if self.start_date and now < self.start_date:
return "未开始"
elif self.assessment_date and now > self.assessment_date:
return "已结束"
else:
return "进行中"
def to_dict(self):
status = self.get_status()
return {
"id": self.id,
"student_id": self.student_id,
"goal_id": self.goal_id,
"goal_name": self.goal.name if self.goal else None,
"status": self.status,
"goal_level": self.goal.level if self.goal else None,
"goal_category": self.goal.category if self.goal else None,
"status": status,
"start_date": self.start_date.isoformat() if self.start_date else None,
"assessment_date": self.assessment_date.isoformat() if self.assessment_date else None,
"mastery_level": self.mastery_level,
"deadline": self.deadline.isoformat() if self.deadline else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"achievement_date": self.achievement_date.isoformat() if self.achievement_date else None,
"comment": self.comment,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+58 -11
View File
@@ -1,13 +1,34 @@
from flask import Blueprint, request, jsonify
from app.models import db, StudentGoal, Goal
from app.routes.auth import login_required_json
from datetime import datetime, timedelta
student_goals_bp = Blueprint("student_goals", __name__)
def compute_status(start_date, assessment_date):
"""根据日期计算状态"""
now = datetime.now()
if start_date and now < start_date:
return "未开始"
elif assessment_date and now > assessment_date:
return "已结束"
else:
return "进行中"
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["GET"])
@login_required_json
def get_student_goals(student_id):
records = StudentGoal.query.filter_by(student_id=student_id).all()
# 计算状态并排序
def sort_key(r):
status_order = {"进行中": 1, "未开始": 2, "已结束": 3}
status = compute_status(r.start_date, r.assessment_date)
order = status_order.get(status, 4)
assessment = r.assessment_date if r.assessment_date else datetime.min
return (order, -assessment.timestamp() if assessment else 0)
records.sort(key=sort_key)
return jsonify([r.to_dict() for r in records])
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["POST"])
@@ -25,12 +46,28 @@ def assign_goal(student_id):
if existing:
return jsonify({"error": "目标已分配"}), 400
# 处理评估日期
assessment_date = None
if data.get("assessment_date"):
# 直接指定日期
assessment_date = datetime.fromisoformat(data["assessment_date"])
elif data.get("assessment_days"):
# 指定天数后
assessment_date = datetime.now() + timedelta(days=int(data["assessment_days"]))
# 处理开始日期
start_date = None
if data.get("start_date"):
start_date = datetime.fromisoformat(data["start_date"])
# 如果没有指定开始日期,默认为当前时间
elif data.get("start_now"):
start_date = datetime.now()
record = StudentGoal(
student_id=student_id,
goal_id=data["goal_id"],
status=data.get("status", "未开始"),
mastery_level=data.get("mastery_level", 1),
deadline=data.get("deadline")
start_date=start_date,
assessment_date=assessment_date
)
db.session.add(record)
db.session.commit()
@@ -42,15 +79,25 @@ def update_student_goal(student_id, goal_id):
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
data = request.get_json()
if "status" in data:
record.status = data["status"]
if data["status"] == "已完成" and not record.completed_at:
from datetime import datetime
record.completed_at = datetime.now()
if "start_date" in data:
if data["start_date"]:
record.start_date = datetime.fromisoformat(data["start_date"])
else:
record.start_date = None
if "assessment_date" in data:
if data["assessment_date"]:
record.assessment_date = datetime.fromisoformat(data["assessment_date"])
else:
record.assessment_date = None
if "mastery_level" in data:
record.mastery_level = data["mastery_level"]
if "deadline" in data:
record.deadline = data["deadline"]
if "achievement_date" in data:
if data["achievement_date"]:
record.achievement_date = datetime.fromisoformat(data["achievement_date"])
else:
record.achievement_date = None
if "comment" in data:
record.comment = data["comment"]
db.session.commit()
return jsonify(record.to_dict())
@@ -61,4 +108,4 @@ def remove_student_goal(student_id, goal_id):
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
db.session.delete(record)
db.session.commit()
return jsonify({"message": "移除成功"})
return jsonify({"message": "移除成功"})
+253 -50
View File
@@ -254,7 +254,7 @@
<!-- 分配目标 Modal -->
<div class="modal fade" id="assignGoalModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">分配目标</h5>
@@ -262,32 +262,141 @@
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">选择目标</label>
<label class="form-label">选择目标 *</label>
<select class="form-select" id="assign-goal-select"></select>
</div>
<div class="mb-3">
<label class="form-label">状态</label>
<select class="form-select" id="assign-status">
<option value="未开始">未开始</option>
<option value="进行中">进行中</option>
</select>
<label class="form-label">评估日期</label>
<div class="row g-2">
<div class="col-auto">
<select class="form-select" id="assign-assessment-days">
<option value="">指定日期</option>
<option value="15">15天后</option>
<option value="30">30天后</option>
<option value="60">60天后</option>
<option value="90">90天后</option>
<option value="180">180天后</option>
</select>
</div>
<div class="col-auto">
<input type="date" class="form-control" id="assign-assessment-date">
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">掌握程度 (1-5)</label>
<input type="number" class="form-control" id="assign-mastery" value="1" min="1" max="5">
</div>
<div class="mb-3">
<label class="form-label">截止日期</label>
<input type="date" class="form-control" id="assign-deadline">
<a class="text-decoration-none" data-bs-toggle="collapse" href="#assignMoreSettings" role="button">
更多设置 ▼
</a>
<div class="collapse" id="assignMoreSettings">
<div class="card card-body mt-2">
<div class="mb-3">
<label class="form-label">开始日期</label>
<input type="date" class="form-control" id="assign-start-date">
<small class="text-muted">默认立即开始</small>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<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-assign-goal">分配</button>
</div>
</div>
</div>
</div>
<!-- 调整目标 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">
<p class="fw-bold mb-3" id="assess-goal-name"></p>
<div class="row g-3 mb-3">
<div class="col-md-4">
<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-4">
<label class="form-label">达成日期</label>
<input type="date" class="form-control" id="assess-achievement-date">
</div>
<div class="col-md-4">
<label class="form-label">当前状态</label>
<input type="text" class="form-control" id="assess-current-status" readonly>
</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 %}
@@ -382,16 +491,35 @@ function renderPlanList(plans) {
return;
}
container.innerHTML = plans.map(p => `
<div class="d-flex align-items-center mb-2 p-2 border rounded">
<a href="/plan/${p.id}" class="btn btn-sm btn-outline-primary me-2">查看</a>
<span class="flex-grow-1">${p.created_at ? p.created_at.substring(0, 10) : '未知'}</span>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
<i class="bi bi-trash"></i>
</button>
<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">${p.created_at ? p.created_at.substring(0, 16) : '未知'}</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) {
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
loadPlans();
}
function showEditStudentModal() {
editStudentModal.show();
}
@@ -624,6 +752,8 @@ async function deletePlan(id) {
}
// ===== 目标相关 =====
let adjustGoalModal, assessGoalModal;
async function loadStudentGoals() {
const res = await fetch(`/api/students/${currentStudentId}/goals`);
const goals = await res.json();
@@ -638,42 +768,106 @@ async function loadStudentGoals() {
<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 text-muted">
掌握程度:${'⭐'.repeat(g.mastery_level)}${'☆'.repeat(5-g.mastery_level)}
| 状态:${g.status}
${g.deadline ? '| 截止:'+g.deadline : ''}
<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>
<button class="btn btn-sm btn-outline-primary" onclick="editStudentGoal(${g.goal_id})">编辑</button>
<button class="btn btn-sm btn-outline-danger" onclick="removeStudentGoal(${g.goal_id})">移除</button>
<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('');
}
async function editStudentGoal(goalId) {
const goal = await fetch(`/api/students/${currentStudentId}/goals`).then(r => r.json())
.then(goals => goals.find(g => g.goal_id === goalId));
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')}`;
}
const status = prompt('状态 (未开始/进行中/已完成)', goal.status);
const mastery = prompt('掌握程度 (1-5)', goal.mastery_level);
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;
if (status && mastery) {
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status, mastery_level: parseInt(mastery)})
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();
});
loadStudentGoals();
}
});
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-goal-name').textContent = goal.goal_name;
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 || '';
new bootstrap.Modal(document.getElementById('assessGoalModal')).show();
}
async function removeStudentGoal(goalId) {
if (!confirm('确定移除此目标?')) return;
await fetch(`/api/students/${currentStudentId}/goals/${goalId}`, {method: 'DELETE'});
document.getElementById('confirm-assess-goal').addEventListener('click', async () => {
const goalId = document.getElementById('assess-goal-id').value;
const masteryLevel = document.getElementById('assess-mastery').value;
const achievementDate = document.getElementById('assess-achievement-date').value;
const comment = document.getElementById('assess-comment').value;
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
})
});
bootstrap.Modal.getInstance(document.getElementById('assessGoalModal')).hide();
loadStudentGoals();
}
});
// 加载可选目标列表到 Modal
async function loadGoalOptions() {
@@ -688,32 +882,41 @@ async function loadGoalOptions() {
select.innerHTML = goals
.filter(g => !assignedIds.includes(g.id))
.map(g => `<option value="${g.id}">${escapeHtml(g.name)}</option>`)
.map(g => `<option value="${g.id}">${escapeHtml(g.name)} [${g.level || '入门'} - ${g.category || '综合'}]</option>`)
.join('');
// 设置默认开始日期为今天
document.getElementById('assign-start-date').value = new Date().toISOString().split('T')[0];
}
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
const goalId = document.getElementById('assign-goal-select').value;
const status = document.getElementById('assign-status').value;
const mastery = document.getElementById('assign-mastery').value;
const deadline = document.getElementById('assign-deadline').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),
status,
mastery_level: parseInt(mastery),
deadline: deadline || null
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 || '分配失败');