课程-章节-课时顺序调整完成

This commit is contained in:
CakeCN
2025-09-03 20:33:23 +08:00
parent a0dd8cd234
commit 9de817d416
8 changed files with 443 additions and 8 deletions

View File

@@ -112,6 +112,49 @@ def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teach
# 如果删除操作成功res.modified_count 应为 1 # 如果删除操作成功res.modified_count 应为 1
return res.modified_count == 1 return res.modified_count == 1
def reorder_material_structure(material_id, order):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False
chapter_map = {ch["chapter_name"]: ch for ch in material["chapters"]}
new_chapters = []
for chapter_order in order:
ch_name = chapter_order['chapter_name']
if ch_name not in chapter_map:
continue # 如果前端传了不存在的章节名,可以选择跳过或报错
original_ch = chapter_map[ch_name]
# 建立 lesson_name -> lesson dict
lesson_map = {ls["lesson_name"]: ls for ls in original_ch["lessons"]}
# 按顺序重排 lessons
new_lessons = []
for ls_name in chapter_order['lessons']:
if ls_name in lesson_map:
new_lessons.append(lesson_map[ls_name])
# 如果还有遗漏(前端没传的),可以追加到最后,避免丢数据
for ls_name, ls in lesson_map.items():
if ls not in new_lessons:
new_lessons.append(ls)
# 构造新的 chapter
new_chapter = {**original_ch, "lessons": new_lessons}
new_chapters.append(new_chapter)
# 更新 material
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters": new_chapters,
"updated_at": datetime.now()
}
}
)
return True
def save_materials_markdown_cos( def save_materials_markdown_cos(
material_id: str, material_id: str,
chapter_name: str, chapter_name: str,

View File

@@ -0,0 +1,40 @@
/* 开启排序模式时,统一卡片样式 */
#chapter-list.reorder-mode .chapter-item,
#chapter-list.reorder-mode .lesson-item {
border: 2px solid #ddd;
border-radius: 10px;
background: #fff;
cursor: grab;
transition: box-shadow .2s, border-color .2s;
}
/* 间距与内边距 */
.chapter-item { padding: 8px 10px; margin: 8px 0; }
.lesson-item { padding: 6px 10px; margin: 6px 0; }
/* hover/active 反馈 */
#chapter-list.reorder-mode .chapter-item:hover,
#chapter-list.reorder-mode .lesson-item:hover {
border-color: #4CAF50;
box-shadow: 0 2px 6px rgba(0,0,0,.1);
}
#chapter-list.reorder-mode .chapter-item:active,
#chapter-list.reorder-mode .lesson-item:active {
cursor: grabbing;
}
.dragging { opacity: .6; }
/* 放置位置提示线(在元素顶部/底部) */
.drop-indicator-top { box-shadow: inset 0 3px 0 0 #4CAF50; }
.drop-indicator-bottom { box-shadow: inset 0 -3px 0 0 #4CAF50; }
/* 把 lesson 拖到 chapter 标题上时,高亮标题,表示会追加到该章节末尾 */
.chapter-header.drop-target {
outline: 2px dashed #4CAF50;
border-radius: 6px;
}
.chapter-header {
display: block; /* 使元素成为块级元素,占满整行 */
/* 文字默认左对齐无需额外设置text-align: left */
}

View File

@@ -0,0 +1,11 @@
window.EDIT_COURSE_CURRENT = window.EDIT_COURSE_CURRENT || {
material_id: null,
outline: {
// phases: [{ text: chapterName }, ...]
phases: [],
// stepsByPhase: { [chapterName]: [lessonName, ...] }
stepsByPhase: {}
},
meta: {}
};

View File

@@ -0,0 +1,314 @@
const chapterListEl = document.getElementById('chapter-list');
const toggleBtn = document.getElementById('toggle-reorder');
toggleBtn.addEventListener('click', () => {
const active = toggleBtn.getAttribute('aria-pressed') === 'true';
active ? disableReorderMode() : enableReorderMode();
});
function enableReorderMode() {
toggleBtn.setAttribute('aria-pressed', 'true');
chapterListEl.classList.add('reorder-mode');
makeChaptersDraggable(true);
makeLessonsDraggable(true);
document.querySelectorAll('.add-lesson-btn').forEach(btn => {
btn.style.display = 'none';
});
document.querySelectorAll('.add-chapter-btn').forEach(btn => {
btn.style.display = 'none';
});
}
function disableReorderMode() {
toggleBtn.setAttribute('aria-pressed', 'false');
chapterListEl.classList.remove('reorder-mode');
makeChaptersDraggable(false);
makeLessonsDraggable(false);
document.querySelectorAll('.add-lesson-btn').forEach(btn => {
btn.style.display = 'block';
});
document.querySelectorAll('.add-chapter-btn').forEach(btn => {
btn.style.display = 'block';
});
}
/* 工具选择器 */
function getChapterItems() {
return Array.from(chapterListEl.querySelectorAll(':scope > li.chapter-item'));
}
function getLessonItems(chapterLi) {
const list = chapterLi.querySelector(':scope > ul.lessons-list');
return list ? Array.from(list.children).filter(el => el.classList.contains('lesson-item')) : [];
}
/* —— 章节拖拽 —— */
let draggingEl = null; // 当前拖拽的元素chapter or lesson
let draggingType = null; // 'chapter' | 'lesson'
function makeChaptersDraggable(enable) {
getChapterItems().forEach(chLi => {
const header = chLi.querySelector(':scope > .chapter-header');
if (!header) return;
header.draggable = enable; // 只让 header 可拖
if (enable) {
header.addEventListener('dragstart', e => onChapterHeaderDragStart(e, chLi));
header.addEventListener('dragend', onDragEndCommon);
} else {
header.removeEventListener('dragstart', e => onChapterHeaderDragStart(e, chLi));
header.removeEventListener('dragend', onDragEndCommon);
}
// 章节 li 仍作为“放置目标”
chLi.addEventListener('dragover', onChapterDragOver);
chLi.addEventListener('dragleave', onDragLeaveCommon);
chLi.addEventListener('drop', onChapterDrop);
});
}
function onChapterHeaderDragStart(e, chLi) {
// 若从 lesson 起手,直接拦截(双保险)
if (e.target.closest('.lesson-item')) { e.preventDefault(); return; }
draggingEl = chLi; // 拖动的是整个章节 li
draggingType = 'chapter';
chLi.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', chLi.dataset.chapterName || '');
}
function onChapterDragStart(e) {
draggingEl = this;
draggingType = 'chapter';
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.dataset.chapterName || '');
}
function onChapterDragOver(e) {
e.preventDefault();
if (draggingType !== 'chapter' || draggingEl === this) return;
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
this.classList.toggle('drop-indicator-top', before);
this.classList.toggle('drop-indicator-bottom', !before);
}
function onChapterDrop(e) {
e.preventDefault();
this.classList.remove('dragging')
if (draggingType !== 'chapter' || draggingEl === this) return;
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
const parent = chapterListEl;
if (before) parent.insertBefore(draggingEl, this);
else parent.insertBefore(draggingEl, this.nextSibling);
clearIndicators();
syncOutlineFromDOM();
}
/* —— 课时拖拽(支持跨章节)—— */
function makeLessonsDraggable(enable) {
getChapterItems().forEach(chLi => {
// 子列表作为 drop 容器
let ul = chLi.querySelector(':scope > ul.lessons-list');
if (!ul) {
ul = document.createElement('ul');
ul.className = 'lessons-list';
chLi.appendChild(ul);
}
// lessons 本身可拖拽
getLessonItems(chLi).forEach(li => {
li.draggable = enable;
if (enable) {
li.addEventListener('dragstart', onLessonDragStart);
li.addEventListener('dragend', onDragEndCommon);
li.addEventListener('dragover', onLessonDragOver);
li.addEventListener('dragleave', onDragLeaveCommon);
li.addEventListener('drop', onLessonDrop);
} else {
li.removeEventListener('dragstart', onLessonDragStart);
li.removeEventListener('dragend', onDragEndCommon);
li.removeEventListener('dragover', onLessonDragOver);
li.removeEventListener('dragleave', onDragLeaveCommon);
li.removeEventListener('drop', onLessonDrop);
li.classList.remove('dragging','drop-indicator-top','drop-indicator-bottom');
}
});
// 容器允许作为 drop 目标:把 lesson 拖入空列表时可追加
if (enable) {
ul.addEventListener('dragover', onLessonsListDragOver);
ul.addEventListener('drop', onLessonsListDrop);
} else {
ul.removeEventListener('dragover', onLessonsListDragOver);
ul.removeEventListener('drop', onLessonsListDrop);
}
});
}
function onLessonDragStart(e) {
draggingEl = this;
draggingType = 'lesson';
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.dataset.lessonName || '');
}
function onLessonDragOver(e) {
e.preventDefault();
if (draggingType !== 'lesson' || draggingEl === this) return;
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
this.classList.toggle('drop-indicator-top', before);
this.classList.toggle('drop-indicator-bottom', !before);
}
function onLessonDrop(e) {
e.preventDefault();
if (draggingType !== 'lesson' || draggingEl === this) return;
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
const parent = this.parentElement; // lessons-list
if (before) parent.insertBefore(draggingEl, this);
else parent.insertBefore(draggingEl, this.nextSibling);
clearIndicators();
syncOutlineFromDOM();
}
/* 把 lesson 拖到某章节“标题”上:表示追加到该章节末尾 */
function onChapterHeaderDragOver(e) {
if (draggingType !== 'lesson') return;
e.preventDefault();
this.classList.add('drop-target');
}
function onChapterHeaderDragLeave() {
this.classList.remove('drop-target');
}
function onChapterHeaderDrop(e) {
if (draggingType !== 'lesson') return;
e.preventDefault();
const chapterLi = this.closest('li.chapter-item');
const ul = chapterLi.querySelector(':scope > ul.lessons-list');
ul.appendChild(draggingEl); // 追加到末尾
this.classList.remove('drop-target');
clearIndicators();
syncOutlineFromDOM();
}
/* 拖到空 lessons-list 上:也允许追加 */
function onLessonsListDragOver(e) {
if (draggingType !== 'lesson') return;
e.preventDefault();
}
function onLessonsListDrop(e) {
if (draggingType !== 'lesson') return;
e.preventDefault();
this.appendChild(draggingEl);
clearIndicators();
syncOutlineFromDOM();
}
/* 通用 */
function onDragEndCommon() {
// 确保结束时移除 'dragging' 状态
if (draggingEl) {
draggingEl.classList.remove('dragging'); // 移除 'dragging' 状态
draggingEl = null;
draggingType = null;
}
getChapterItems().forEach(chLi => {
const ul = chLi.querySelector(':scope > ul.lessons-list');
if (!ul) return;
console.log(getLessonItems(chLi).length);
// 如果课时数量大于 2移除虚拟的课时元素
if (getLessonItems(chLi).length >= 1) {
const placeholder = ul.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
}
// 如果章节为空,添加一个虚拟的课时
if (getLessonItems(chLi).length == 0) {
const placeholderLesson = document.createElement('li');
placeholderLesson.classList.add('lesson-item', 'placeholder');
placeholderLesson.textContent = '拖拽课时到这里';
ul.appendChild(placeholderLesson);
}
});
}
function onDragLeaveCommon() {
this.classList.remove('drop-indicator-top','drop-indicator-bottom');
}
function clearIndicators() {
chapterListEl.querySelectorAll('.drop-indicator-top, .drop-indicator-bottom')
.forEach(el => el.classList.remove('drop-indicator-top','drop-indicator-bottom'));
chapterListEl.querySelectorAll('.chapter-header.drop-target')
.forEach(el => el.classList.remove('drop-target'));
}
function syncOutlineFromDOM() {
const chapters = getChapterItems().map(chLi => {
const name =
chLi.dataset.chapterName ||
chLi.querySelector(':scope > .chapter-header')?.textContent.trim();
const lessons = getLessonItems(chLi)
.filter(li => !li.classList.contains('placeholder'))
.map(li => li.dataset.lessonName || li.textContent.trim());
return { chapter_name: name, lessons };
});
// 写入 CURRENT
if (window.EDIT_COURSE_CURRENT) {
EDIT_COURSE_CURRENT.outline.phases = chapters.map(c => ({ text: c.chapter_name }));
EDIT_COURSE_CURRENT.outline.stepsByPhase = {};
chapters.forEach(c => {
EDIT_COURSE_CURRENT.outline.stepsByPhase[c.chapter_name] = c.lessons;
});
}
// 立即持久化
schedulePersistOrder(chapters);
}
// 定义一个全局/模块级变量,用来存放定时器 id
let persistOrderTimer = null;
function schedulePersistOrder(chapters) {
// 如果已有定时器,先清掉
if (persistOrderTimer) {
clearTimeout(persistOrderTimer);
}
// 设置新的定时器1 秒后真正执行
persistOrderTimer = setTimeout(() => {
persistOrderNow(chapters);
persistOrderTimer = null; // 清理引用
}, 1000);
}
// 真正的持久化逻辑
async function persistOrderNow(chapters) {
try {
const payload = chapters || getChapterItems().map(chLi => ({
chapter_name:
chLi.dataset.chapterName ||
chLi.querySelector(':scope > .chapter-header')?.textContent.trim(),
lessons: getLessonItems(chLi).map(
li => li.dataset.lessonName || li.textContent.trim()
)
}));
const res = await fetch(`/materials/structure/reorder/${encodeURIComponent(EDIT_COURSE_CURRENT.material_id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ order: payload })
});
if (!res.ok) throw new Error('保存新顺序失败');
console.log('已保存顺序')
} catch (err) {
console.error(err);
}
}

View File

@@ -122,6 +122,11 @@ function renderChapters(courseId) {
const chapterList = document.getElementById('chapter-list'); const chapterList = document.getElementById('chapter-list');
chapterList.innerHTML = ""; // 清空章节列表 chapterList.innerHTML = ""; // 清空章节列表
material_id = courseId; material_id = courseId;
window.material_id = courseId; // 兼容你原来的全局变量
window.EDIT_COURSE_CURRENT = window.EDIT_COURSE_CURRENT || {};
EDIT_COURSE_CURRENT.material_id = courseId; // 新的统一来源
fetch(`/materials/${courseId}`) fetch(`/materials/${courseId}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -137,13 +142,13 @@ function renderChapters(courseId) {
data.chapters.forEach(chapter => { data.chapters.forEach(chapter => {
const chapterItem = document.createElement('li'); const chapterItem = document.createElement('li');
chapterItem.innerHTML = `<strong>${chapter.chapter_name}</strong>`; chapterItem.classList.add('chapter-item');
chapterItem.innerHTML = `<strong class="chapter-header">${chapter.chapter_name}</strong>`;
const lessonList = document.createElement('ul'); const lessonList = document.createElement('ul');
lessonList.classList.add('lessons-list');
chapter.lessons.forEach(lesson => { chapter.lessons.forEach(lesson => {
const lessonItem = document.createElement('li'); lessonList.innerHTML += lessonTemplate(material_id,chapter.chapter_name,lesson.lesson_name);
lessonItem.innerHTML = lessonTemplate(material_id,chapter.chapter_name,lesson.lesson_name);
lessonList.appendChild(lessonItem);
}); });
chapterItem.appendChild(lessonList); chapterItem.appendChild(lessonList);
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');

View File

@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/css/teacherboard.css"> <link rel="stylesheet" href="/static/css/teacherboard.css">
<link rel="stylesheet" href="/static/css/dashboard.css"> <link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/lesson.css"> <link rel="stylesheet" href="/static/css/lesson.css">
<link rel="stylesheet" href="/static/css/teacher_course_setting.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@@ -24,7 +25,11 @@
<div class="details-content" id="details-content"> <div class="details-content" id="details-content">
<p>...</p> <!-- 加载覆盖 --> <p>...</p> <!-- 加载覆盖 -->
</div> </div>
<h3>课程章节列表:</h3> <h3>课程章节列表:
<button id="toggle-reorder" class="reorder-btn" title="设置章节顺序" aria-pressed="false">
<i class="fa-solid fa-arrow-up-short-wide"></i>
</button>
</h3>
<ul id="chapter-list"> <ul id="chapter-list">
<div id="loading-message"><p>加载中...</p></div> <div id="loading-message"><p>加载中...</p></div>
</ul> </ul>
@@ -62,7 +67,9 @@
</main> </main>
</div> </div>
<script src="/static/js/course_current.js"></script>
<script src="/static/js/teacherboard.js"></script> <script src="/static/js/teacherboard.js"></script>
<script src="/static/js/teacher_course_setting.js"></script>
<script src="/static/js/lesson.js"></script> <script src="/static/js/lesson.js"></script>
<script> <script>
window.lesson = {{ lesson|tojson|safe }}; window.lesson = {{ lesson|tojson|safe }};

View File

@@ -6,6 +6,7 @@
<title>教师个人主页</title> <title>教师个人主页</title>
<link rel="stylesheet" href="/static/css/teacherboard.css"> <link rel="stylesheet" href="/static/css/teacherboard.css">
<link rel="stylesheet" href="/static/css/dashboard.css"> <link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/teacher_course_setting.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </head>
@@ -62,7 +63,11 @@
<button class="close-btn" onclick="closeCourseDetails()">×</button> <button class="close-btn" onclick="closeCourseDetails()">×</button>
</div> </div>
<div class="details-content" id="details-content"></div> <div class="details-content" id="details-content"></div>
<h3>课程章节列表:</h3> <h3>课程章节列表:
<button id="toggle-reorder" class="reorder-btn" title="设置章节顺序" aria-pressed="false">
<i class="fa-solid fa-arrow-up-short-wide"></i>
</button>
</h3>
<ul id="chapter-list"> <ul id="chapter-list">
<div id="loading-message"> <div id="loading-message">
<p>加载中...</p> <!-- 加载中效果 --> <p>加载中...</p> <!-- 加载中效果 -->
@@ -122,8 +127,9 @@
</form> </form>
</div> </div>
</div> </div>
<script src="/static/js/course_current.js"></script>
<script src="/static/js/teacherboard.js"></script> <script src="/static/js/teacherboard.js"></script>
<script src="/static/js/teacher_course_setting.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,5 @@
from flask import Blueprint, request, jsonify, current_app, session, render_template, session from flask import Blueprint, request, jsonify, current_app, session, render_template, session
from ..services.course_service import create_material, update_material, get_materials_by_teacher, load_material, get_materials_by_teacher_dict, add_material_chapter, add_material_chapter_lesson, delete_material_chapter_lesson from ..services.course_service import create_material, update_material, get_materials_by_teacher, load_material, get_materials_by_teacher_dict, add_material_chapter, add_material_chapter_lesson, delete_material_chapter_lesson, reorder_material_structure
from ..auth.decorators import require_role from ..auth.decorators import require_role
from ..services.cos_service import upload_file from ..services.cos_service import upload_file
bp = Blueprint('material', __name__) bp = Blueprint('material', __name__)
@@ -91,6 +91,15 @@ def delete_lesson(material_id):
return jsonify({"message": "课时删除成功"}), 200 return jsonify({"message": "课时删除成功"}), 200
return jsonify({"message": "课时删除失败"}), 404 return jsonify({"message": "课时删除失败"}), 404
@bp.route('/materials/structure/reorder/<material_id>', methods=['POST'])
@require_role(roles="teacher")
def reorder_material(material_id):
data = request.get_json()
order = data.get('order')
if reorder_material_structure(material_id, order):
return jsonify({"message": "教材顺序更新成功"}), 200
return jsonify({"message": "教材顺序更新失败"}), 404
@bp.route('/teacher/materials', methods=['GET']) @bp.route('/teacher/materials', methods=['GET'])
@require_role(roles="teacher") @require_role(roles="teacher")
def get_materials_by_teacher_view(): def get_materials_by_teacher_view():