Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a82ac48a | |||
| ad621d9426 | |||
| a33a5532cc | |||
| f4ea6c9a77 | |||
| 08cc0541f2 | |||
| 812715cf5e | |||
| 070533326a | |||
| 8e0f6ec46d | |||
| 587aa79c16 | |||
| a133f26fd5 |
+76
-1
@@ -1,8 +1,9 @@
|
|||||||
# 班级管理路由
|
# 班级管理路由
|
||||||
|
|
||||||
from flask import request, jsonify, render_template, session
|
from flask import request, jsonify, render_template, session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from app.routes import main_bp
|
from app.routes import main_bp
|
||||||
from app.models import db, Class, Student
|
from app.models import db, Class, Student, StudentGoal, Goal
|
||||||
from app.routes.auth import login_required_json, admin_required
|
from app.routes.auth import login_required_json, admin_required
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -149,3 +150,77 @@ def api_classes_assign(class_id):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger.error(f"分配学员失败: {str(e)}")
|
logger.error(f"分配学员失败: {str(e)}")
|
||||||
return jsonify({"error": "操作失败,请稍后重试", "code": "SERVER_ERROR"}), 500
|
return jsonify({"error": "操作失败,请稍后重试", "code": "SERVER_ERROR"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/api/classes/<int:class_id>/goals", methods=["POST"])
|
||||||
|
@login_required_json
|
||||||
|
def api_classes_assign_goals(class_id):
|
||||||
|
"""批量分配目标给班级所有学员"""
|
||||||
|
cls = Class.query.get_or_404(class_id)
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
goal_id = data.get("goal_id")
|
||||||
|
assessment_days = data.get("assessment_days")
|
||||||
|
assessment_date = data.get("assessment_date")
|
||||||
|
start_date = data.get("start_date")
|
||||||
|
start_now = data.get("start_now", True)
|
||||||
|
|
||||||
|
if not goal_id:
|
||||||
|
return jsonify({"error": "请选择目标", "code": "VALIDATION_ERROR"}), 400
|
||||||
|
if not assessment_days and not assessment_date:
|
||||||
|
return jsonify({"error": "请选择评估方式", "code": "VALIDATION_ERROR"}), 400
|
||||||
|
|
||||||
|
# 检查目标是否存在
|
||||||
|
goal = Goal.query.get(goal_id)
|
||||||
|
if not goal:
|
||||||
|
return jsonify({"error": "目标不存在", "code": "NOT_FOUND"}), 404
|
||||||
|
|
||||||
|
# 获取班级所有学员
|
||||||
|
students = Student.query.filter_by(class_id=class_id).all()
|
||||||
|
if not students:
|
||||||
|
return jsonify({"error": "班级没有学员", "code": "NO_STUDENTS"}), 400
|
||||||
|
|
||||||
|
# 处理评估日期
|
||||||
|
final_assessment_date = None
|
||||||
|
if assessment_date:
|
||||||
|
final_assessment_date = datetime.fromisoformat(assessment_date)
|
||||||
|
elif assessment_days:
|
||||||
|
final_assessment_date = datetime.now() + timedelta(days=int(assessment_days))
|
||||||
|
|
||||||
|
# 处理开始日期
|
||||||
|
final_start_date = None
|
||||||
|
if start_date:
|
||||||
|
final_start_date = datetime.fromisoformat(start_date)
|
||||||
|
elif start_now:
|
||||||
|
final_start_date = datetime.now()
|
||||||
|
|
||||||
|
# 批量创建 StudentGoal
|
||||||
|
assigned_count = 0
|
||||||
|
skipped = []
|
||||||
|
for student in students:
|
||||||
|
# 检查是否已分配
|
||||||
|
existing = StudentGoal.query.filter_by(student_id=student.id, goal_id=goal_id).first()
|
||||||
|
if existing:
|
||||||
|
skipped.append(student.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = StudentGoal(
|
||||||
|
student_id=student.id,
|
||||||
|
goal_id=goal_id,
|
||||||
|
start_date=final_start_date,
|
||||||
|
assessment_date=final_assessment_date
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
assigned_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
result = {"message": f"成功分配 {assigned_count} 个学员", "assigned": assigned_count}
|
||||||
|
if skipped:
|
||||||
|
result["skipped"] = skipped
|
||||||
|
result["skipped_count"] = len(skipped)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger.error(f"分配目标失败: {str(e)}")
|
||||||
|
return jsonify({"error": "操作失败,请稍后重试", "code": "SERVER_ERROR"}), 500
|
||||||
|
|||||||
@@ -36,6 +36,19 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<!-- 新增/编辑班级弹窗 -->
|
<!-- 新增/编辑班级弹窗 -->
|
||||||
<div class="modal fade" id="classModal" tabindex="-1">
|
<div class="modal fade" id="classModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -114,6 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set modal_title = '分配目标' %}
|
||||||
|
{% include "fragments/assign_goal_modal.html" with context %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -165,6 +181,7 @@ function loadClasses() {
|
|||||||
<td><a href="#" onclick="viewClassStudents(${c.id})">${c.student_count}</a></td>
|
<td><a href="#" onclick="viewClassStudents(${c.id})">${c.student_count}</a></td>
|
||||||
<td>${c.created_at}</td>
|
<td>${c.created_at}</td>
|
||||||
<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.description || ''}', ${c.active})">编辑</button>
|
||||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
|
<button type="button" class="btn btn-sm btn-danger" onclick="deleteClass(${c.id})">删除</button>` : ''}
|
||||||
</td>
|
</td>
|
||||||
@@ -281,5 +298,95 @@ document.getElementById('addClassBtn').onclick = () => {
|
|||||||
document.getElementById('classModalTitle').textContent = '新增班级';
|
document.getElementById('classModalTitle').textContent = '新增班级';
|
||||||
new bootstrap.Modal(document.getElementById('classModal')).show();
|
new bootstrap.Modal(document.getElementById('classModal')).show();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== 分配目标功能 ==========
|
||||||
|
|
||||||
|
let assignGoalModal;
|
||||||
|
|
||||||
|
document.getElementById('assignGoalModal').addEventListener('show.bs.modal', function () {
|
||||||
|
loadClassGoalOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAssignGoalModal(classId, className) {
|
||||||
|
currentClassId = classId;
|
||||||
|
document.getElementById('assignGoalModalSubtitle').textContent = ' - ' + className;
|
||||||
|
// 重置表单
|
||||||
|
document.getElementById('assign-assessment-days').value = '';
|
||||||
|
document.getElementById('assign-assessment-date').value = '';
|
||||||
|
document.getElementById('assign-start-date').value = '';
|
||||||
|
assignGoalModal = new bootstrap.Modal(document.getElementById('assignGoalModal'));
|
||||||
|
assignGoalModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClassGoalOptions() {
|
||||||
|
const res = await fetch('/api/goals');
|
||||||
|
const goals = await res.json();
|
||||||
|
const select = document.getElementById('assign-goal-select');
|
||||||
|
select.innerHTML = goals.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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确认分配目标
|
||||||
|
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; }
|
||||||
|
|
||||||
|
// 弹出确认框
|
||||||
|
if (!confirm('将给班级所有学员分配此目标,确定吗?')) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/classes/${currentClassId}/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) {
|
||||||
|
const data = await res.json();
|
||||||
|
assignGoalModal.hide();
|
||||||
|
alert(data.message + (data.skipped_count ? `(${data.skipped_count}个学员已分配此目标,跳过)` : ''));
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || '分配失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!-- 分配目标 Modal (共享 Fragment) -->
|
||||||
|
<!--
|
||||||
|
通过 data 属性传递上下文:
|
||||||
|
- data-context: "student" 或 "class"
|
||||||
|
- data-target-id: 学员ID 或 班级ID
|
||||||
|
- data-modal-title: 模态窗体标题
|
||||||
|
-->
|
||||||
|
<div class="modal fade" id="assignGoalModal" tabindex="-1"
|
||||||
|
data-context="{{ context | default('student') }}"
|
||||||
|
data-target-id="{{ target_id | default('') }}"
|
||||||
|
data-modal-title="{{ modal_title | default('分配目标') }}">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ modal_title | default('分配目标') }}<span id="assignGoalModalSubtitle"></span></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="assign-goal-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<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" selected>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">
|
||||||
|
<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-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>
|
||||||
+129
-72
@@ -46,7 +46,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0"><i class="bi bi-list-check"></i> 问题记录</h6>
|
<h6 class="mb-0"><i class="bi bi-list-check"></i> 当前问题</h6>
|
||||||
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
|
<button class="btn btn-sm btn-primary" onclick="showAddProblemModal()">
|
||||||
<i class="bi bi-plus"></i> 添加问题
|
<i class="bi bi-plus"></i> 添加问题
|
||||||
</button>
|
</button>
|
||||||
@@ -252,61 +252,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分配目标 Modal -->
|
{% include "fragments/assign_goal_modal.html" with context %}
|
||||||
<div class="modal fade" id="assignGoalModal" 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">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">选择目标 *</label>
|
|
||||||
<select class="form-select" id="assign-goal-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<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">
|
|
||||||
<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-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 -->
|
<!-- 调整目标 Modal -->
|
||||||
<div class="modal fade" id="adjustGoalModal" tabindex="-1">
|
<div class="modal fade" id="adjustGoalModal" tabindex="-1">
|
||||||
@@ -474,23 +420,112 @@ function renderProblemList(problems) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 学习历程时间线(替代原 loadPlans)
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
try {
|
await loadTimeline();
|
||||||
const resp = await fetch(`/api/students/${currentStudentId}/plans`);
|
|
||||||
const plans = await resp.json();
|
|
||||||
renderPlanList(plans);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('planList').innerHTML = '<p class="text-danger">加载失败</p>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlanList(plans) {
|
// 学习历程时间线
|
||||||
|
async function loadTimeline() {
|
||||||
|
const [plansRes, goalsRes] = await Promise.all([
|
||||||
|
fetch(`/api/students/${currentStudentId}/plans`),
|
||||||
|
fetch(`/api/students/${currentStudentId}/goals`)
|
||||||
|
]);
|
||||||
|
const plans = await plansRes.json();
|
||||||
|
const goals = await goalsRes.json();
|
||||||
|
|
||||||
|
// 构建时间线条目
|
||||||
|
const timeline = [];
|
||||||
|
|
||||||
|
// 添加目标开始记录
|
||||||
|
goals.forEach(g => {
|
||||||
|
if (g.start_date) {
|
||||||
|
const startDate = new Date(g.start_date);
|
||||||
|
const endDate = g.assessment_date ? new Date(g.assessment_date) : null;
|
||||||
|
const days = endDate ? Math.ceil((endDate - startDate) / (1000*60*60*24)) : null;
|
||||||
|
timeline.push({
|
||||||
|
date: startDate,
|
||||||
|
type: 'goal_start',
|
||||||
|
goal: g,
|
||||||
|
days: days,
|
||||||
|
endDate: endDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 添加目标达成记录
|
||||||
|
if (g.achievement_date) {
|
||||||
|
timeline.push({
|
||||||
|
date: new Date(g.achievement_date),
|
||||||
|
type: 'goal_achieved',
|
||||||
|
goal: g
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加方案生成记录
|
||||||
|
plans.forEach(p => {
|
||||||
|
timeline.push({
|
||||||
|
date: new Date(p.created_at),
|
||||||
|
type: 'plan',
|
||||||
|
plan: p
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按时间逆序排序
|
||||||
|
timeline.sort((a, b) => b.date - a.date);
|
||||||
|
|
||||||
|
renderTimeline(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(timeline) {
|
||||||
const container = document.getElementById('planList');
|
const container = document.getElementById('planList');
|
||||||
if (plans.length === 0) {
|
if (timeline.length === 0) {
|
||||||
container.innerHTML = '<p class="text-muted">暂无练习方案</p>';
|
container.innerHTML = '<p class="text-muted">暂无学习记录</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = plans.map(p => `
|
|
||||||
|
container.innerHTML = timeline.map(entry => {
|
||||||
|
if (entry.type === 'goal_start') {
|
||||||
|
const g = entry.goal;
|
||||||
|
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>
|
||||||
|
<span class="badge bg-secondary">预期 ${entry.days} 天</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
${g.goal_level || '入门'} | ${g.goal_category || '综合'} | 评估日期: ${entry.endDate ? formatDate(entry.endDate) : '未设置'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (entry.type === 'goal_achieved') {
|
||||||
|
const g = entry.goal;
|
||||||
|
const stars = '⭐'.repeat(g.mastery_level || 1);
|
||||||
|
return `
|
||||||
|
<div class="d-flex align-items-start mb-2 p-2 border rounded border-success">
|
||||||
|
<div class="me-2">
|
||||||
|
<span class="badge bg-success">目标达成</span>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
<span>${stars}</span>
|
||||||
|
</div>
|
||||||
|
${g.comment ? `<div class="small text-muted mt-1"><em>"${escapeHtml(g.comment)}"</em></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const p = entry.plan;
|
||||||
|
return `
|
||||||
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
<div class="d-flex align-items-start mb-2 p-2 border rounded ${p.is_typical ? 'border-warning bg-light' : ''}">
|
||||||
<div class="form-check me-2">
|
<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})">
|
<input class="form-check-input" type="checkbox" id="typical-${p.id}" ${p.is_typical ? 'checked' : ''} onchange="toggleTypical(${p.id})">
|
||||||
@@ -499,7 +534,7 @@ function renderPlanList(plans) {
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<a href="/plan/${p.id}" class="fw-bold text-decoration-none">${p.created_at ? p.created_at.substring(0, 16) : '未知'}</a>
|
<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>' : ''}
|
${p.is_typical ? '<span class="badge bg-warning text-dark ms-1">典型</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePlan(${p.id})">
|
||||||
@@ -511,13 +546,14 @@ function renderPlanList(plans) {
|
|||||||
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
${p.template_name ? ' | 模板: ' + p.template_name : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleTypical(planId) {
|
async function toggleTypical(planId) {
|
||||||
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
await fetch(`/api/plans/${planId}/typical`, {method: 'POST'});
|
||||||
loadPlans();
|
loadTimeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditStudentModal() {
|
function showEditStudentModal() {
|
||||||
@@ -885,10 +921,31 @@ async function loadGoalOptions() {
|
|||||||
.map(g => `<option value="${g.id}">${escapeHtml(g.name)} [${g.level || '入门'} - ${g.category || '综合'}]</option>`)
|
.map(g => `<option value="${g.id}">${escapeHtml(g.name)} [${g.level || '入门'} - ${g.category || '综合'}]</option>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// 设置默认开始日期为今天
|
// 设置默认开始日期为今天,默认评估日期为90天后
|
||||||
document.getElementById('assign-start-date').value = new Date().toISOString().split('T')[0];
|
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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
|
document.getElementById('confirm-assign-goal').addEventListener('click', async () => {
|
||||||
const goalId = document.getElementById('assign-goal-select').value;
|
const goalId = document.getElementById('assign-goal-select').value;
|
||||||
const assessmentDays = document.getElementById('assign-assessment-days').value;
|
const assessmentDays = document.getElementById('assign-assessment-days').value;
|
||||||
|
|||||||
+39
@@ -266,6 +266,45 @@ POST /api/classes/<id>/assign
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 批量分配目标给班级学员
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/classes/<id>/goals
|
||||||
|
```
|
||||||
|
|
||||||
|
**权限**: 登录用户
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"goal_id": 1,
|
||||||
|
"assessment_days": "30",
|
||||||
|
"assessment_date": null,
|
||||||
|
"start_date": null,
|
||||||
|
"start_now": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| goal_id | int | 是 | 目标ID |
|
||||||
|
| assessment_days | string | 否 | 评估天数(15/30/60/90/180) |
|
||||||
|
| assessment_date | string | 否 | 评估日期(YYYY-MM-DD),与 assessment_days 二选一 |
|
||||||
|
| start_date | string | 否 | 开始日期(YYYY-MM-DD) |
|
||||||
|
| start_now | bool | 否 | 是否立即开始(默认true) |
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "成功分配 5 个学员",
|
||||||
|
"assigned": 5,
|
||||||
|
"skipped": ["李四", "王五"],
|
||||||
|
"skipped_count": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 学员管理
|
## 学员管理
|
||||||
|
|
||||||
### 获取学员列表
|
### 获取学员列表
|
||||||
|
|||||||
+202
-188
@@ -1,7 +1,7 @@
|
|||||||
# 钢琴练习方案系统 - 部署 SOP
|
# 钢琴练习方案系统 - 部署 SOP
|
||||||
|
|
||||||
> 版本:v1.2
|
> 版本:v1.3
|
||||||
> 日期:2026-04-23
|
> 日期:2026-04-24
|
||||||
> 核心原则:**不删除,只备份后新增/替换**
|
> 核心原则:**不删除,只备份后新增/替换**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,24 @@
|
|||||||
|
|
||||||
### 脚本优先原则(铁律)
|
### 脚本优先原则(铁律)
|
||||||
|
|
||||||
> **当脚本执行失败时:修复脚本,而非绕过脚本。**
|
> **使脚本执行失败时:修复脚本,而非绕过脚本。**
|
||||||
|
|
||||||
|
### 双备份先行原则(铁律 - 新增)
|
||||||
|
|
||||||
|
> **在双备份(本地 `releases/v{version}/bk/` + 服务器 `/opt/piano-plan/releases/v{version}/bk/`)全部完成之前,禁止执行任何实质性的部署操作。**
|
||||||
|
|
||||||
|
以下操作在双备份完成前**严禁**执行:
|
||||||
|
- ❌ 更新/修改生产数据库 schema
|
||||||
|
- ❌ 停止旧 Docker 容器
|
||||||
|
- ❌ 启动新 Docker 容器
|
||||||
|
- ❌ 上传/覆盖生产环境数据
|
||||||
|
- ❌ 加载新镜像到服务器
|
||||||
|
|
||||||
|
正确流程:
|
||||||
|
1. ✅ 完成本地 `releases/v{version}/bk/` 备份
|
||||||
|
2. ✅ 完成服务器 `/opt/piano-plan/releases/v{version}/bk/` 备份
|
||||||
|
3. ✅ **验证两份备份均存在且完整**
|
||||||
|
4. ✅ **方可执行部署操作**
|
||||||
|
|
||||||
| 错误行为 | 正确行为 |
|
| 错误行为 | 正确行为 |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
@@ -50,59 +67,129 @@
|
|||||||
| Volume | `piano-plan-output` | `/app/output` | PDF 输出 |
|
| Volume | `piano-plan-output` | `/app/output` | PDF 输出 |
|
||||||
| Bind Mount | `/opt/piano-plan/config` | `/app/config` | API 配置 |
|
| Bind Mount | `/opt/piano-plan/config` | `/app/config` | API 配置 |
|
||||||
|
|
||||||
> ⚠️ **已移除**:`/opt/piano-plan/个性化方案` 挂载点(问题文件已迁移到数据库 `problems` 表)
|
---
|
||||||
|
|
||||||
|
## 三、本地发布包准备
|
||||||
|
|
||||||
|
### 3.1 版本目录结构
|
||||||
|
|
||||||
|
每个版本发布前,在本地 `releases/` 目录下创建:
|
||||||
|
|
||||||
|
```
|
||||||
|
releases/
|
||||||
|
└── v1.3.0/
|
||||||
|
├── bk/ # 上次生产环境备份(来自服务器)
|
||||||
|
│ ├── piano_plans.db # 上次生产数据库
|
||||||
|
│ ├── docker/ # 上次版本的 docker 镜像 tar
|
||||||
|
│ │ └── piano-plan.tar
|
||||||
|
│ └── config/ # 上次版本的配置备份
|
||||||
|
│ └── api_config.json
|
||||||
|
└── toRelease/ # 本次发布文件
|
||||||
|
├── program/ # 本次发布的程序包
|
||||||
|
│ └── piano-plan.tar
|
||||||
|
├── schema.sql # 数据库 schema(仅结构,不含数据)
|
||||||
|
├── scripts/ # 发布脚本
|
||||||
|
│ └── deploy.sh
|
||||||
|
└── config/ # 本次配置(如有变更)
|
||||||
|
└── api_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 生成 schema.sql(仅结构)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
|
||||||
|
# 进入版本目录
|
||||||
|
mkdir -p releases/v1.3.0/toRelease/scripts
|
||||||
|
|
||||||
|
# 导出数据库结构(不含数据)
|
||||||
|
docker exec piano-plan-for-export sqlite3 /app/data/piano_plans.db ".schema" > releases/v1.3.0/toRelease/schema.sql
|
||||||
|
|
||||||
|
# 或使用 python
|
||||||
|
python -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('data/piano_plans.db')
|
||||||
|
with open('releases/v1.3.0/toRelease/schema.sql', 'w', encoding='utf-8') as f:
|
||||||
|
for line in conn.iterdump():
|
||||||
|
if 'CREATE' in line or 'CREATE' in line.upper():
|
||||||
|
f.write(line + '\n')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 构建 Docker 镜像并打包
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
||||||
|
|
||||||
|
# 1. 构建镜像
|
||||||
|
docker build -t piano-plan:v1.3.0 .
|
||||||
|
|
||||||
|
# 2. 保存镜像
|
||||||
|
docker save piano-plan:v1.3.0 -o releases/v1.3.0/toRelease/program/piano-plan.tar
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、部署步骤
|
## 四、服务器部署步骤
|
||||||
|
|
||||||
### 3.1 本地构建
|
### 4.1 SSH 到服务器
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1. 启动 Docker Desktop(Windows)
|
|
||||||
Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
|
|
||||||
|
|
||||||
# 2. 等待 Docker 就绪
|
|
||||||
docker version # 看到 Server: Docker Desktop 即为就绪
|
|
||||||
|
|
||||||
# 3. 进入项目目录
|
|
||||||
cd "D:\F\NewI\opencode\daily-workspace\projects\青年钢琴集体课\练习方案系统"
|
|
||||||
|
|
||||||
# 4. 构建 Docker 镜像
|
|
||||||
docker build -t piano-plan:latest .
|
|
||||||
|
|
||||||
# 5. 保存镜像为 tar 文件
|
|
||||||
docker save piano-plan:latest -o piano-plan.tar
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 上传到服务器
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 6. 上传到服务器临时目录
|
|
||||||
scp -i ~/.ssh/id_rsa piano-plan.tar root@47.106.65.108:/opt/piano-plan/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 服务器部署
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 7. SSH 到服务器
|
ssh -i ~/.ssh/id_rsa root@47.106.65.108
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 创建版本目录并备份上次生产环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建版本目录
|
||||||
|
mkdir -p /opt/piano-plan/releases/v1.3.0/bk
|
||||||
|
mkdir -p /opt/piano-plan/releases/v1.3.0/toRelease
|
||||||
|
|
||||||
|
# 备份当前生产环境到 bk/
|
||||||
|
# 1. 备份数据库
|
||||||
|
docker cp piano-plan:/app/data/piano_plans.db /opt/piano-plan/releases/v1.3.0/bk/piano_plans.db.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 2. 备份 docker 镜像(如果存在)
|
||||||
|
docker save piano-plan:$(docker inspect piano-plan --format '{{.Config.Image}}' | sed 's/piano-plan://') -o /opt/piano-plan/releases/v1.3.0/bk/docker/piano-plan.tar 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. 备份配置
|
||||||
|
cp -r /opt/piano-plan/config /opt/piano-plan/releases/v1.3.0/bk/config.bak.$(date +%Y%m%d)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 上传本次发布文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在本地执行上传
|
||||||
|
exit # 先退出服务器 SSH
|
||||||
|
|
||||||
|
# 上传发布包
|
||||||
|
scp -i ~/.ssh/id_rsa -r releases/v1.3.0/toRelease/* root@47.106.65.108:/opt/piano-plan/releases/v1.3.0/toRelease/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 服务器执行部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 再次连接
|
||||||
ssh -i ~/.ssh/id_rsa root@47.106.65.108
|
ssh -i ~/.ssh/id_rsa root@47.106.65.108
|
||||||
|
|
||||||
# 8. 创建带时间戳的备份目录
|
cd /opt/piano-plan/releases/v1.3.0
|
||||||
mkdir -p /opt/piano-plan/backups/backup_$(date +%Y%m%d)
|
|
||||||
|
|
||||||
# 9. 备份当前数据库和配置
|
# 1. 停止旧容器
|
||||||
docker cp piano-plan:/app/data/piano_plans.db /opt/piano-plan/backups/backup_$(date +%Y%m%d)/
|
|
||||||
cp /opt/piano-plan/config/api_config.json /opt/piano-plan/backups/backup_$(date +%Y%m%d)/
|
|
||||||
|
|
||||||
# 10. 停止旧容器
|
|
||||||
docker stop piano-plan
|
docker stop piano-plan
|
||||||
docker rm piano-plan
|
docker rm piano-plan
|
||||||
|
|
||||||
# 11. 加载新镜像
|
# 2. 加载新镜像
|
||||||
docker load -i /opt/piano-plan/piano-plan.tar
|
docker load -i toRelease/program/piano-plan.tar
|
||||||
|
|
||||||
# 12. 启动新容器(无个性化方案挂载!)
|
# 3. 执行数据库迁移(仅 schema,不覆盖数据)
|
||||||
|
# 方法A:使用 sqlite3 直接执行 schema(如果有 ALTER TABLE 新增字段)
|
||||||
|
docker cp toRelease/schema.sql piano-plan:/tmp/schema.sql
|
||||||
|
docker exec piano-plan sqlite3 /app/data/piano_plans.db < /tmp/schema.sql
|
||||||
|
|
||||||
|
# 方法B:如果 schema 变更较大,先备份再重建表结构(不丢失数据)
|
||||||
|
# 具体看本次变更内容决定
|
||||||
|
|
||||||
|
# 4. 启动新容器
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name piano-plan \
|
--name piano-plan \
|
||||||
-p 5001:5001 \
|
-p 5001:5001 \
|
||||||
@@ -111,51 +198,30 @@ docker run -d \
|
|||||||
-v piano-plan-data:/app/data \
|
-v piano-plan-data:/app/data \
|
||||||
-v piano-plan-output:/app/output \
|
-v piano-plan-output:/app/output \
|
||||||
-v /opt/piano-plan/config:/app/config \
|
-v /opt/piano-plan/config:/app/config \
|
||||||
piano-plan:latest
|
piano-plan:v1.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 数据同步(特殊情况下从开发环境覆盖生产数据库)
|
### 4.5 验证
|
||||||
|
|
||||||
> ⚠️ **警告**:这是**特殊处理**,仅在开发环境和生产环境数据结构需要统一时执行。正常部署不应覆盖生产数据库。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 13. 停止容器
|
# 检查容器状态
|
||||||
docker stop piano-plan
|
|
||||||
|
|
||||||
# 14. 上传开发环境数据库到服务器(在本地执行)
|
|
||||||
scp -i ~/.ssh/id_rsa data/piano_plans.db root@47.106.65.108:/opt/piano-plan/backups/
|
|
||||||
|
|
||||||
# 15. 覆盖生产数据库
|
|
||||||
docker cp /opt/piano-plan/backups/piano_plans.db piano-plan:/app/data/piano_plans.db
|
|
||||||
|
|
||||||
# 16. 重启容器
|
|
||||||
docker start piano-plan
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 17. 检查容器状态
|
|
||||||
docker ps --filter name=piano-plan
|
docker ps --filter name=piano-plan
|
||||||
|
|
||||||
# 18. 检查日志
|
# 检查日志
|
||||||
docker logs piano-plan --tail 20
|
docker logs piano-plan --tail 30
|
||||||
|
|
||||||
# 19. 验证服务
|
# 验证数据库表结构
|
||||||
|
docker exec piano-plan sqlite3 /app/data/piano_plans.db ".tables"
|
||||||
|
|
||||||
|
# 验证服务
|
||||||
curl -I http://localhost:5001/
|
curl -I http://localhost:5001/
|
||||||
|
|
||||||
# 20. 验证数据库表
|
|
||||||
docker exec piano-plan ls /app/data/
|
|
||||||
|
|
||||||
# 21. 验证 API 配置
|
|
||||||
docker exec piano-plan cat /app/config/api_config.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、数据保护规范
|
## 五、数据保护规范
|
||||||
|
|
||||||
### 4.1 必须保护的数据(绝对不删除)
|
### 5.1 必须保护的数据(绝对不删除)
|
||||||
|
|
||||||
| 数据类型 | 存储位置 | 说明 |
|
| 数据类型 | 存储位置 | 说明 |
|
||||||
|----------|----------|------|
|
|----------|----------|------|
|
||||||
@@ -163,96 +229,40 @@ docker exec piano-plan cat /app/config/api_config.json
|
|||||||
| 学员数据 | piano-plan-data:/app/data | students, student_problems 表 |
|
| 学员数据 | piano-plan-data:/app/data | students, student_problems 表 |
|
||||||
| 班级数据 | piano-plan-data:/app/data | classes 表 |
|
| 班级数据 | piano-plan-data:/app/data | classes 表 |
|
||||||
| 练习方案 | piano-plan-data:/app/data | practice_plans 表 |
|
| 练习方案 | piano-plan-data:/app/data | practice_plans 表 |
|
||||||
| 问题数据 | piano-plan-data:/app/data | problems 表(已从文件迁移到数据库) |
|
| 目标数据 | piano-plan-data:/app/data | goals, goal_relations, student_goals 表 |
|
||||||
|
| 问题数据 | piano-plan-data:/app/data | problems 表 |
|
||||||
|
|
||||||
### 4.2 新增/更新的数据
|
### 5.2 本次发布 schema 变更
|
||||||
|
|
||||||
| 数据类型 | 说明 |
|
> 本次发布涉及新增目标管理模块,需要执行数据库迁移:
|
||||||
|----------|------|
|
> - 新增 `goals` 表
|
||||||
| templates 表 | AI提示词模板、报告导出模板 |
|
> - 新增 `goal_relations` 表
|
||||||
| api_config.json | API 配置(provider, model, api_key) |
|
> - 新增 `student_goals` 表
|
||||||
|
> - 修改 `problems` 表:更新分类体系
|
||||||
|
> - 修改 `students` 表:无变更
|
||||||
|
|
||||||
### 4.3 备份操作
|
### 5.3 双备份保障
|
||||||
|
|
||||||
```bash
|
| 备份位置 | 内容 | 说明 |
|
||||||
# 备份数据库(volume)
|
|----------|------|------|
|
||||||
docker cp piano-plan:/app/data/piano_plans.db /opt/piano-plan/backups/piano_plans.db.$(date +%Y%m%d)
|
| `/opt/piano-plan/releases/v1.3.0/bk/` | 上次生产环境完整备份 | 版本回退用 |
|
||||||
|
| `/opt/piano-plan/releases/v1.3.1/bk/` | 本次发布前备份 | 下次发布时自动创建 |
|
||||||
# 备份 API 配置
|
|
||||||
cp /opt/piano-plan/config/api_config.json /opt/piano-plan/backups/
|
|
||||||
|
|
||||||
# 列出所有备份
|
|
||||||
ls -la /opt/piano-plan/backups/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 更新模板数据(部署后必做)
|
|
||||||
|
|
||||||
当代码中的模板内容更新时,需要手动更新生产数据库:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# update_templates.py - 在服务器容器内执行
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
NEW_AI_PROMPT = '''你是一位资深的钢琴教师。请根据学员的具体问题详情,生成一份个性化练习方案报告。
|
|
||||||
|
|
||||||
## 学员基本信息
|
|
||||||
- **姓名**: {student_name}
|
|
||||||
- **微信昵称**: {wechat_nickname}
|
|
||||||
- **每日可练习时间**: {practice_time}
|
|
||||||
|
|
||||||
## 学员被诊断的问题
|
|
||||||
{student_problems}
|
|
||||||
|
|
||||||
## 每个问题的详细信息和练习方法(请务必基于这些内容生成方案)
|
|
||||||
|
|
||||||
{problems}
|
|
||||||
|
|
||||||
## 任务要求
|
|
||||||
请根据上述学员的问题诊断和详细信息,生成一份针对性的练习方案报告:
|
|
||||||
1. 先简述该学员当前存在的主要问题
|
|
||||||
2. 给出一个每日练习安排建议
|
|
||||||
3. 针对每个问题给出具体的日常练习方法
|
|
||||||
4. 给出3-5条重点注意事项
|
|
||||||
|
|
||||||
请使用Markdown格式,语言专业、简洁、有鼓励性。'''
|
|
||||||
|
|
||||||
conn = sqlite3.connect('/app/data/piano_plans.db')
|
|
||||||
conn.execute("UPDATE templates SET content = ? WHERE type = 'ai_prompt'", (NEW_AI_PROMPT,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
上传脚本到服务器执行:
|
|
||||||
```bash
|
|
||||||
scp update_templates.py root@47.106.65.108:/tmp/
|
|
||||||
ssh -i ~/.ssh/id_rsa root@47.106.65.108 "docker cp /tmp/update_templates.py piano-plan:/tmp/ && docker exec piano-plan python /tmp/update_templates.py"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、回滚流程
|
## 六、回滚流程
|
||||||
|
|
||||||
### 5.1 从备份恢复数据库
|
### 6.1 回滚到上一版本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 停止容器
|
# 停止当前容器
|
||||||
docker stop piano-plan
|
|
||||||
|
|
||||||
# 从备份目录恢复(替换日期)
|
|
||||||
docker cp /opt/piano-plan/backups/backup_20260423/piano_plans.db piano-plan:/app/data/piano_plans.db
|
|
||||||
|
|
||||||
# 重启容器
|
|
||||||
docker start piano-plan
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 完整回滚(恢复旧镜像+数据库)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停止并删除当前容器
|
|
||||||
docker stop piano-plan
|
docker stop piano-plan
|
||||||
docker rm piano-plan
|
docker rm piano-plan
|
||||||
|
|
||||||
# 使用旧镜像重新启动(如果镜像还在本地)
|
# 使用 bk 中的旧镜像(如果有)
|
||||||
|
docker load -i /opt/piano-plan/releases/v1.3.0/bk/docker/piano-plan.tar
|
||||||
|
|
||||||
|
# 启动旧容器
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name piano-plan \
|
--name piano-plan \
|
||||||
-p 5001:5001 \
|
-p 5001:5001 \
|
||||||
@@ -261,17 +271,25 @@ docker run -d \
|
|||||||
-v piano-plan-data:/app/data \
|
-v piano-plan-data:/app/data \
|
||||||
-v piano-plan-output:/app/output \
|
-v piano-plan-output:/app/output \
|
||||||
-v /opt/piano-plan/config:/app/config \
|
-v /opt/piano-plan/config:/app/config \
|
||||||
piano-plan:latest
|
piano-plan:旧版本标签
|
||||||
|
```
|
||||||
|
|
||||||
# 从备份恢复数据库
|
### 6.2 数据库紧急回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止容器
|
||||||
docker stop piano-plan
|
docker stop piano-plan
|
||||||
docker cp /opt/piano-plan/backups/backup_YYYYMMDD/piano_plans.db piano-plan:/app/data/piano_plans.db
|
|
||||||
|
# 从 bk 恢复数据库
|
||||||
|
cp /opt/piano-plan/releases/v1.3.0/bk/piano_plans.db.上次的日期 piano-plan:/app/data/piano_plans.db
|
||||||
|
|
||||||
|
# 重启
|
||||||
docker start piano-plan
|
docker start piano-plan
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、服务信息
|
## 七、服务信息
|
||||||
|
|
||||||
| 项目 | 值 |
|
| 项目 | 值 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
@@ -280,8 +298,8 @@ docker start piano-plan
|
|||||||
| 容器名 | piano-plan |
|
| 容器名 | piano-plan |
|
||||||
| 端口 | 5001 |
|
| 端口 | 5001 |
|
||||||
| 数据库位置 | piano-plan-data volume |
|
| 数据库位置 | piano-plan-data volume |
|
||||||
| 问题文件位置 | /opt/piano-plan/个性化方案 |
|
|
||||||
| API配置位置 | /opt/piano-plan/config |
|
| API配置位置 | /opt/piano-plan/config |
|
||||||
|
| 发布包位置 | /opt/piano-plan/releases/ |
|
||||||
|
|
||||||
### Volume 列表
|
### Volume 列表
|
||||||
|
|
||||||
@@ -296,13 +314,11 @@ docker start piano-plan
|
|||||||
|-----|------------|------|
|
|-----|------------|------|
|
||||||
| /opt/piano-plan/config | /app/config | API 配置 |
|
| /opt/piano-plan/config | /app/config | API 配置 |
|
||||||
|
|
||||||
> ⚠️ **已移除**:`/opt/piano-plan/个性化方案` 挂载(问题文件已迁移到数据库)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、API 配置说明
|
## 八、API 配置说明
|
||||||
|
|
||||||
### 7.1 支持的 Provider
|
### 8.1 支持的 Provider
|
||||||
|
|
||||||
| Provider | Endpoint | 模型 |
|
| Provider | Endpoint | 模型 |
|
||||||
|----------|----------|------|
|
|----------|----------|------|
|
||||||
@@ -310,7 +326,7 @@ docker start piano-plan
|
|||||||
| volcengine | https://ark.cn-beijing.volces.com/api/coding/v3 | doubao-seed-2.0-pro |
|
| volcengine | https://ark.cn-beijing.volces.com/api/coding/v3 | doubao-seed-2.0-pro |
|
||||||
| deepseek | https://api.deepseek.com | deepseek-chat |
|
| deepseek | https://api.deepseek.com | deepseek-chat |
|
||||||
|
|
||||||
### 7.2 API 配置存储
|
### 8.2 API 配置存储
|
||||||
|
|
||||||
- 配置存储在 `/opt/piano-plan/config/api_config.json`
|
- 配置存储在 `/opt/piano-plan/config/api_config.json`
|
||||||
- 每个 provider 的 key 存储在 `api_keys` 映射中
|
- 每个 provider 的 key 存储在 `api_keys` 映射中
|
||||||
@@ -318,47 +334,45 @@ docker start piano-plan
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、常见问题
|
## 九、常见问题
|
||||||
|
|
||||||
### Q: 数据库是空的?
|
### Q: 数据库 schema 迁移失败?
|
||||||
A: 检查 volume `piano-plan-data` 是否被错误覆盖,尝试从备份恢复
|
A: 检查 sqlite3 版本兼容性,确保 .schema 导出的 SQL 兼容目标数据库版本
|
||||||
|
|
||||||
### Q: 容器无法启动?
|
### Q: 容器无法启动?
|
||||||
A: 检查日志 `docker logs piano-plan`,常见原因:端口被占用、volume 权限问题
|
A: 检查日志 `docker logs piano-plan`,常见原因:端口被占用、volume 权限问题
|
||||||
|
|
||||||
### Q: API 配置没生效?
|
### Q: 目标管理功能报错?
|
||||||
A: 检查 `/opt/piano-plan/config/api_config.json` 是否存在且正确
|
A: 检查数据库是否成功执行了 schema 迁移,新增了 goals, goal_relations, student_goals 表
|
||||||
|
|
||||||
### Q: SSE 不完整,提示词显示不出来或卡在95%?
|
### Q: SSE 不完整?
|
||||||
A: nginx 需要为 SSE (Server-Sent Events) 配置特定的代理设置:
|
A: nginx 需要为 SSE 配置特定的代理设置,参考之前文档
|
||||||
```nginx
|
|
||||||
location /api/generate-plan {
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
tcp_nodelay on;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection '';
|
|
||||||
proxy_buffers 8 32k;
|
|
||||||
proxy_buffer_size 32k;
|
|
||||||
proxy_max_temp_file_size 1024m;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
配置文件位置:`/srv/nginx/conf/conf.d/piano.yoin.fun.conf`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、检查清单(部署完成后必填)
|
## 十、检查清单(部署完成后必填)
|
||||||
|
|
||||||
```
|
```
|
||||||
[ ] 容器状态:running
|
[ ] 容器状态:running
|
||||||
[ ] 服务响应:HTTP 200/302
|
[ ] 服务响应:HTTP 200/302
|
||||||
[ ] 数据库记录:users, students, classes, student_problems, practice_plans, problems 完整
|
[ ] 数据库表完整:users, students, classes, student_problems, practice_plans, problems, goals, goal_relations, student_goals
|
||||||
[ ] templates 表存在且包含 AI提示词模板、报告导出模板
|
[ ] 目标管理功能正常:创建目标、分配目标、评估目标
|
||||||
[ ] API 配置:provider, model, api_key 正确
|
[ ] API 配置正确
|
||||||
[ ] 功能验证:能生成练习方案
|
[ ] 功能验证:能生成练习方案
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **最后更新**:2026-04-23
|
## 十一、版本历史
|
||||||
> **更新原因**:v1.2 部署更新;移除个性化方案挂载(问题已迁移到数据库);更新备份和回滚流程
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.3 | 2026-04-24 | 目标管理模块:Goal/GoalRelation/StudentGoal;问题分类重构;学习历程时间线 |
|
||||||
|
| v1.2 | 2026-04-23 | 问题迁移到数据库;移除个性化方案挂载 |
|
||||||
|
| v1.1 | 2026-04-20 | 模板管理;API配置界面 |
|
||||||
|
| v1.0 | 2026-04-17 | 初始版本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **最后更新**:2026-04-24
|
||||||
|
> **更新原因**:v1.3.1 代码更新;DRY 规范入撰;Fragment 复用方案;班级批量分配目标
|
||||||
|
|||||||
@@ -143,6 +143,54 @@ deploy: v1.2.0 生产环境部署
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 代码复用规范(铁律)
|
||||||
|
|
||||||
|
### DRY 原则(Don't Repeat Yourself)
|
||||||
|
|
||||||
|
> **复制粘贴代码是严重的 Code Smell。任何重复代码必须提取为可复用组件。**
|
||||||
|
|
||||||
|
### 前端模板复用
|
||||||
|
|
||||||
|
| 场景 | 复用方式 |
|
||||||
|
|------|---------|
|
||||||
|
| 多个页面共用 Modal | 提取为 `app/templates/fragments/` 下的 Fragment,用 `{% include %}` 引用 |
|
||||||
|
| 多个页面共用样式 | 在 `base.html` 或页面级别 `<style>` 中定义 |
|
||||||
|
| 多个页面共用 JavaScript 函数 | 提取到 `base.html` 的 `<script>` 或单独 JS 文件 |
|
||||||
|
|
||||||
|
### Fragment 提取规范
|
||||||
|
|
||||||
|
1. **创建 Fragment 文件**:`app/templates/fragments/{component_name}.html`
|
||||||
|
2. **通过 data 属性传递上下文**:
|
||||||
|
```html
|
||||||
|
<!-- 学员详情用 -->
|
||||||
|
<div id="assignGoalModal" data-student-id="{{ student.id }}">
|
||||||
|
|
||||||
|
<!-- 班级管理用 -->
|
||||||
|
<div id="assignGoalModal" data-class-id="{{ class.id }}">
|
||||||
|
```
|
||||||
|
3. **JavaScript 读取 data 属性决定行为**:
|
||||||
|
```javascript
|
||||||
|
const studentId = modalElement.dataset.studentId;
|
||||||
|
const classId = modalElement.dataset.classId;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查清单
|
||||||
|
|
||||||
|
新增代码前,先问:
|
||||||
|
- [ ] 这个 UI 组件是否在其他页面也存在?
|
||||||
|
- [ ] 这段 JavaScript 函数是否已有类似实现?
|
||||||
|
- [ ] 这个 HTML 结构是否可以直接复用?
|
||||||
|
|
||||||
|
如果答案是"是",**先提取复用组件,再使用**。
|
||||||
|
|
||||||
|
### 禁止的行为
|
||||||
|
|
||||||
|
❌ 直接复制粘贴另一个页面的 Modal HTML
|
||||||
|
❌ 复制粘贴另一个页面的 JavaScript 函数
|
||||||
|
❌ 在不同文件里写功能完全相同的函数,仅变量名不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 版本历史
|
## 版本历史
|
||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
| 版本 | 日期 | 说明 |
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
│ │ └── pdf_generator.py # PDF生成器
|
│ │ └── pdf_generator.py # PDF生成器
|
||||||
│ │
|
│ │
|
||||||
│ └── templates/ # 前端模板
|
│ └── templates/ # 前端模板
|
||||||
|
│ ├── fragments/ # 可复用模态窗体片段({% include %})
|
||||||
|
│ │ └── assign_goal_modal.html # 分配目标模态窗体
|
||||||
│ ├── base.html # 基础模板(所有页面继承,统一侧边栏)
|
│ ├── base.html # 基础模板(所有页面继承,统一侧边栏)
|
||||||
│ ├── index.html # 学员管理页面(继承base)
|
│ ├── index.html # 学员管理页面(继承base)
|
||||||
│ ├── home.html # 默认首页(显示统计信息)
|
│ ├── home.html # 默认首页(显示统计信息)
|
||||||
@@ -168,6 +170,7 @@ def create_app():
|
|||||||
| `/api/classes/<id>` | DELETE | 删除班级 | 管理员 |
|
| `/api/classes/<id>` | DELETE | 删除班级 | 管理员 |
|
||||||
| `/api/classes/<id>/students` | GET | 班级学员 | 登录用户 |
|
| `/api/classes/<id>/students` | GET | 班级学员 | 登录用户 |
|
||||||
| `/api/classes/<id>/assign` | POST | 分配学员 | 登录用户 |
|
| `/api/classes/<id>/assign` | POST | 分配学员 | 登录用户 |
|
||||||
|
| `/api/classes/<id>/goals` | POST | 批量分配目标 | 登录用户 |
|
||||||
|
|
||||||
### routes/users.py(新增)
|
### routes/users.py(新增)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user