lesson的步骤、阶段顺序调整ok

This commit is contained in:
CakeCN
2025-09-03 22:39:18 +08:00
parent 9de817d416
commit a0d154b434
3 changed files with 474 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
/* 开启排序模式时的卡片态与反馈 */
#outline-tree.reorder-mode li.lvl2,
#outline-tree.reorder-mode li.lvl3 {
border: 2px solid #ddd;
border-radius: 10px;
background: #fff;
padding: 6px 10px;
margin: 6px 0;
cursor: grab;
transition: box-shadow .2s, border-color .2s;
}
#outline-tree.reorder-mode li.lvl2:hover,
#outline-tree.reorder-mode li.lvl3:hover {
border-color: #4CAF50;
box-shadow: 0 2px 6px rgba(0,0,0,.08);
}
#outline-tree.reorder-mode li.lvl2:active,
#outline-tree.reorder-mode li.lvl3:active {
cursor: grabbing;
}
#outline-tree li.dragging { opacity: .6; }
/* 放置提示线:在目标元素顶部/底部出现内阴影 */
#outline-tree li.drop-indicator-top { box-shadow: inset 0 3px 0 0 #4CAF50; }
#outline-tree li.drop-indicator-bottom { box-shadow: inset 0 -3px 0 0 #4CAF50; }

View File

@@ -610,3 +610,445 @@ function loadStepToEditors(phaseText, stepText) {
`
};
// 假设已存在 parseHeadings(md) => {lines, tokens([{level,text,start,end}])}
function sliceByRange(lines, start, end) {
return lines.slice(start, end + 1).join('\n');
}
// 取整个文档的“主题(#)”区块与其后所有 ## 阶段 token
function splitDoc(md) {
const parsed = parseHeadings(md);
const { lines, tokens } = parsed;
const h1 = tokens.find(t => t.level === 1);
// 文首到第一个##之前(含 # 与其正文)保留为 prelude
const firstPhase = tokens.find(t => t.level === 2);
const preludeEnd = firstPhase ? firstPhase.start - 1 : lines.length - 1;
const prelude = sliceByRange(lines, 0, Math.max(0, preludeEnd));
const phases = tokens.filter(t => t.level === 2);
return { lines, tokens, prelude, phases };
}
// 取某个“## 阶段”的完整文本(到下一个 ## 之前)
function getPhaseBlock(lines, phaseToken) {
return sliceByRange(lines, phaseToken.start, phaseToken.end);
}
// 解析阶段块内的 ### 步骤;返回 {intro, steps: [{name, start,end,text}], order}
function analyzePhaseBlock(phaseBlock) {
const p = parseHeadings(phaseBlock);
const { lines, tokens } = p;
const h2 = tokens.find(t => t.level === 2); // 本块第一行
const steps = tokens.filter(t => t.level === 3);
// intro: 从 h2 之后到第一个 ### 之前的正文
const introStart = (h2 ? h2.start + 1 : 0);
const introEnd = steps.length ? steps[0].start - 1 : lines.length - 1;
const intro = introStart <= introEnd ? sliceByRange(lines, introStart, introEnd) : '';
const stepObjs = steps.map(st => ({
name: st.text,
start: st.start,
end: st.end,
text: sliceByRange(lines, st.start, st.end)
}));
return { intro, steps: stepObjs };
}
// 针对“整篇文档”建立一个全局的 step 索引phaseName::stepName -> text
function buildGlobalStepIndex(lines, tokens) {
const res = new Map();
const phases = tokens.filter(t => t.level === 2);
for (let i = 0; i < phases.length; i++) {
const ph = phases[i];
const phNextStart = (i + 1 < phases.length) ? phases[i+1].start : lines.length;
// 该阶段内的所有 ###
const steps = tokens.filter(t => t.level === 3 && t.start > ph.start && t.start < phNextStart);
for (const st of steps) {
const key = `${ph.text}||${st.text}`;
res.set(key, sliceByRange(lines, st.start, st.end));
}
}
return res;
}
// ===== 选择器与小工具 =====
const treeEl = document.getElementById('outline-tree');
const toggleOutlineBtn = document.getElementById('toggle-outline-reorder');
const isLvl1 = el => el.classList?.contains('lvl1');
const isLvl2 = el => el.classList?.contains('lvl2');
const isLvl3 = el => el.classList?.contains('lvl3');
const isBtn = el => el.tagName === 'BUTTON'; // 新增阶段/步骤的按钮
// 取一个阶段块(从 phaseLi 开始,到下一个 lvl2 之前的所有节点)
function blockOfPhase(phaseLi) {
const block = [phaseLi];
let n = phaseLi.nextElementSibling;
while (n && !isLvl2(n)) {
block.push(n);
n = n.nextElementSibling;
}
return block;
}
// 取阶段块最后一个节点(可能是最后一个 lvl3若无 lvl3 则是 phaseLi 自己)
function lastNodeOfPhase(phaseLi) {
let last = phaseLi;
let n = phaseLi.nextElementSibling;
while (n && !isLvl2(n)) {
last = n;
n = n.nextElementSibling;
}
return last;
}
/**
* 把一组 nodes一个完整阶段块移动到 targetPhaseLi 的前/后
* place: 'before' | 'after'
* 用占位符避免 DOM 移动时锚点错位
*/
function movePhaseBlock(nodes, targetPhaseLi, place = 'before') {
const parent = targetPhaseLi.parentNode;
if (place === 'before') {
const marker = document.createComment('phase-insert-before');
parent.insertBefore(marker, targetPhaseLi); // 在目标前放标记
nodes.forEach(node => parent.insertBefore(node, marker));
parent.removeChild(marker);
} else {
// after要放在“目标阶段块最后一个节点”之后
const last = lastNodeOfPhase(targetPhaseLi);
const marker = document.createComment('phase-insert-after');
parent.insertBefore(marker, last.nextSibling); // 在目标块之后放标记
nodes.forEach(node => parent.insertBefore(node, marker));
parent.removeChild(marker);
}
}
function getAllItems() {
// 只取直接子节点里的 LI忽略按钮等
return Array.from(treeEl.children).filter(el => el.tagName === 'LI');
}
function prevLvl2Of(el) {
// 从当前节点向上找前一个 lvl2步骤所属的阶段
let p = el.previousElementSibling;
while (p) {
if (isLvl2(p)) return p;
p = p.previousElementSibling;
}
return null;
}
function blockOfPhase(phaseLi) {
// 返回从 phaseLi 开始直到下一个 lvl2不含的所有“块内元素”
const block = [phaseLi];
let n = phaseLi.nextElementSibling;
while (n && !isLvl2(n)) {
// 包括 lvl3步骤以及紧跟其后的“新增步骤按钮”若你也插在 ul 里)
block.push(n);
n = n.nextElementSibling;
}
return block;
}
function clearIndicators() {
treeEl.querySelectorAll('.drop-indicator-top, .drop-indicator-bottom')
.forEach(el => el.classList.remove('drop-indicator-top', 'drop-indicator-bottom'));
}
function removeDragging() {
treeEl.querySelectorAll('.dragging').forEach(el => el.classList.remove('dragging'));
}
// ===== 排序模式开关 =====
toggleOutlineBtn.addEventListener('click', () => {
const active = toggleOutlineBtn.getAttribute('aria-pressed') === 'true';
active ? disableOutlineReorder() : enableOutlineReorder();
});
function enableOutlineReorder() {
toggleOutlineBtn.setAttribute('aria-pressed', 'true');
treeEl.classList.add('reorder-mode');
// lvl2/lvl3 可拖拽lvl1主题不可拖
getAllItems().forEach(li => {
if (isLvl1(li) || isBtn(li)) return;
li.draggable = true;
li.addEventListener('dragstart', onDragStart);
li.addEventListener('dragend', onDragEnd);
li.addEventListener('dragover', onDragOver);
li.addEventListener('dragleave', onDragLeave);
li.addEventListener('drop', onDrop);
});
}
function disableOutlineReorder() {
toggleOutlineBtn.setAttribute('aria-pressed', 'false');
treeEl.classList.remove('reorder-mode');
getAllItems().forEach(li => {
li.draggable = false;
li.removeEventListener('dragstart', onDragStart);
li.removeEventListener('dragend', onDragEnd);
li.removeEventListener('dragover', onDragOver);
li.removeEventListener('dragleave', onDragLeave);
li.removeEventListener('drop', onDrop);
li.classList.remove('dragging', 'drop-indicator-top', 'drop-indicator-bottom');
});
// (可选)退出时持久化顺序
// persistOutlineOrderFromDOM();
}
// ===== DnD 状态 =====
let lessen_draggingEl = null; // 实际被拖的 “单个 li”lvl2 或 lvl3
let lessen_draggingType = null; // 'phase' | 'step'
let lessen_draggingBlock = null; // 若拖的是 lvl2这里是整个“阶段块”的节点数组
function onDragStart(e) {
// 不让按钮参与拖拽
if (isBtn(e.target)) { e.preventDefault(); return; }
lessen_draggingEl = this;
lessen_draggingType = isLvl2(this) ? 'phase' : 'step';
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.textContent.trim());
if (lessen_draggingType === 'phase') {
lessen_draggingBlock = blockOfPhase(this); // 包含 phase + 其后的所有 lvl3/按钮
} else {
lessen_draggingBlock = null;
}
}
function onDragEnd() {
removeDragging();
clearIndicators();
lessen_draggingEl = null;
lessen_draggingType = null;
lessen_draggingBlock = null;
}
function onDragOver(e) {
e.preventDefault();
if (!lessen_draggingEl || this === lessen_draggingEl) return;
clearIndicators();
// 仅在合法组合时显示指示
if (lessen_draggingType === 'phase') {
if (!isLvl2(this)) return; // 关键phase 只对 lvl2 生效
} else if (lessen_draggingType === 'step') {
if (!isLvl3(this)) return; // 关键step 只对 lvl3 生效
} else {
return;
}
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
this.classList.add(before ? 'drop-indicator-top' : 'drop-indicator-bottom');
}
function onDragLeave() {
this.classList.remove('drop-indicator-top', 'drop-indicator-bottom');
}
function onDrop(e) {
e.preventDefault();
if (!lessen_draggingEl || this === lessen_draggingEl) return;
const rect = this.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
if (lessen_draggingType === 'phase') {
// ⭐ 仅允许丢在 lvl2 上;丢在 lvl3 直接忽略
if (!isLvl2(this)) return;
const nodes = lessen_draggingBlock || [lessen_draggingEl];
if (before) {
// 插到目标 lvl2 前
movePhaseBlock(nodes, this, 'before');
} else {
// 插到目标“阶段块的最后一个节点”之后
movePhaseBlock(nodes, this, 'after');
}
} else if (lessen_draggingType === 'step') {
// ⭐ 仅允许丢在 lvl3 上;丢在 lvl2阶段不触发步骤重排
if (!isLvl3(this)) return;
const parent = treeEl;
if (before) parent.insertBefore(lessen_draggingEl, this);
else parent.insertBefore(lessen_draggingEl, this.nextSibling);
}
// ⭐ 确保 add-phase-btn 永远在最底部
const btn = treeEl.querySelector('.add-phase-btn');
if (btn) treeEl.appendChild(btn);
clearIndicators();
removeDragging();
syncOutlineFromDOM();
}
// ===== DOM → 内存:回写 CURRENT.outline =====
function syncOutlineFromDOM() {
const lis = getAllItems();
const phases = [];
const stepsByPhase = {};
let currentPhase = null;
for (const el of lis) {
if (isLvl1(el)) continue; // 忽略主题
if (isBtn(el)) continue; // 忽略新增按钮(如果按钮是 li 以外的元素)
if (isLvl2(el)) {
currentPhase = el.textContent.trim();
phases.push({ text: currentPhase });
stepsByPhase[currentPhase] = [];
} else if (isLvl3(el)) {
if (!currentPhase) continue; // 安全防御
stepsByPhase[currentPhase].push(el.textContent.trim());
}
}
CURRENT.outline.phases = phases;
CURRENT.outline.stepsByPhase = stepsByPhase;
// (可选)立即保存结构到后端
persistOutlineOrderByRewriting();
}
// let CURRENT = {
// material_id: null, chapter_name: null, lesson_name: null,
// links: { lesson:'', prompt:'', score:'' },
// raw: { lesson:'', prompt:'', score:'' },
// parsed:{ lesson:null, prompt:null, score:null },
// active:{ phase:null, step:null },
// outline:null
// };
async function persistOutlineOrderByRewriting() {
// 1) 按 outline 重写三份原文
const newLesson = rewriteMarkdownByOutline(CURRENT.raw.lesson, CURRENT.outline);
const newPrompt = rewriteMarkdownByOutline(CURRENT.raw.prompt, CURRENT.outline);
const newScore = rewriteMarkdownByOutline(CURRENT.raw.score, CURRENT.outline);
// 2) 更新本地内存(可选:重新解析 & 重绘)
CURRENT.raw.lesson = newLesson;
CURRENT.raw.prompt = newPrompt;
CURRENT.raw.score = newScore;
CURRENT.parsed.lesson = parseHeadings(newLesson);
CURRENT.parsed.prompt = parseHeadings(newPrompt);
CURRENT.parsed.score = parseHeadings(newScore);
// 可选renderOutline(CURRENT.outline); // outline 顺序已是最新
console.log(CURRENT.outline);
console.log(CURRENT.raw.lesson);
await saveToOrigin({
lesson: { cdn_link: CURRENT.links.lesson, content: newLesson },
prompt: { cdn_link: CURRENT.links.prompt, content: newPrompt },
score: { cdn_link: CURRENT.links.score, content: newScore },
});
}
/**
* 根据 outline 重排整篇 md
* - phases: [{text}]
* - stepsByPhase: { [phaseName]: [stepName, ...] }
* 规则:
* 1) 一级标题/前导(prelude)保持不变;
* 2) 按 outline.phases 的顺序重排各“## 阶段块”;
* 3) 阶段块内部:保持原 intro 文本;按 stepsByPhase 顺序重排所有 ###
* 4) 被移动到其它阶段的步骤会从原处“搬走”;未在新顺序中出现的旧步骤会作为“剩余”追加在末尾;
* 5) 缺少的步骤会生成占位骨架。
*/
function rewriteMarkdownByOutline(md, outline) {
const { lines, tokens, prelude, phases: oldPhaseTokens } = splitDoc(md);
// 索引phaseName -> 原始阶段块文本 & 解析
const oldPhaseMap = new Map();
oldPhaseTokens.forEach(pt => {
oldPhaseMap.set(pt.text, {
token: pt,
block: getPhaseBlock(lines, pt)
});
});
// 全局步骤索引(带原所属阶段前缀,避免重名冲突)
const globalStepIndex = buildGlobalStepIndex(lines, tokens);
const usedSteps = new Set();
function takeStepTextPreferAny(stepName) {
// 任意相同名(跨阶段搬运)
for (const [k, v] of globalStepIndex.entries()) {
const name = k.split('||')[1];
if (name === stepName && !usedSteps.has(k)) {
usedSteps.add(k);
return v;
}
}
// 不存在则返回骨架
return `### ${stepName}\n\n`;
}
// 按新顺序构建各阶段块
const newPhaseBlocks = [];
const desiredPhases = outline.phases.map(p => p.text);
for (const phName of desiredPhases) {
const old = oldPhaseMap.get(phName);
const h2Line = `## ${phName}`;
let intro = '';
let leftovers = []; // 本阶段原有但未被使用/未在新顺序中列出的步骤
if (old) {
const analyzed = analyzePhaseBlock(old.block);
intro = analyzed.intro;
// 先标记本阶段原有的步骤(用于“剩余”)
const oldStepNames = analyzed.steps.map(s => s.name);
// 目标顺序
const wantSteps = outline.stepsByPhase[phName] || [];
const orderedParts = wantSteps.map(stepName => takeStepTextPreferAny(stepName));
// 追加旧阶段中未被“使用”的步骤(保持内容不丢)
for (const st of analyzed.steps) {
// 若相同名的某个实例已被 usedSteps 吃掉,说明已搬走或已按需放置
// 需要判断具体 key本阶段::该名)
const key = `${phName}||${st.name}`;
if (!usedSteps.has(key) && !wantSteps.includes(st.name)) {
orderedParts.push(st.text);
usedSteps.add(key);
}
}
const body = [h2Line, '', intro].join('\n').trimEnd() + '\n\n'
+ orderedParts.join('\n\n').trimEnd() + '\n\n';
newPhaseBlocks.push(body);
} else {
// 新增的阶段(原文里不存在):生成一个空阶段骨架 + 目标步骤骨架
const wantSteps = outline.stepsByPhase[phName] || [];
const orderedParts = wantSteps.map(st => `### ${st}\n\n`);
const body = [h2Line, '', /* intro */ ''].join('\n').trimEnd() + '\n\n'
+ orderedParts.join('\n\n').trimEnd() + '\n\n';
newPhaseBlocks.push(body);
}
}
// 把未列入 outline 的“遗留阶段”也接到末尾,避免内容丢失
for (const pt of oldPhaseTokens) {
if (!desiredPhases.includes(pt.text)) {
const blk = getPhaseBlock(lines, pt);
newPhaseBlocks.push(blk.trimEnd() + '\n\n');
}
}
// 组装全文
const rebuilt = (prelude.trimEnd() + '\n\n' + newPhaseBlocks.join('')).replace(/\n{3,}/g, '\n\n');
return rebuilt.trimEnd() + '\n';
}

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/lesson.css">
<link rel="stylesheet" href="/static/css/teacher_course_setting.css">
<link rel="stylesheet" href="/static/css/lesson_setting.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@@ -39,6 +40,10 @@
<!-- 课时编辑页主体:左侧大纲 + 右侧编辑 -->
<div class="lesson-editor">
<aside class="outline">
<button id="toggle-outline-reorder" class="reorder-btn" title="排序模式" aria-pressed="false">
排序
</button>
<ul id="outline-tree"></ul>
</aside>