diff --git a/Html/apps/static/css/lesson_setting.css b/Html/apps/static/css/lesson_setting.css new file mode 100644 index 0000000..fe36c45 --- /dev/null +++ b/Html/apps/static/css/lesson_setting.css @@ -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; } diff --git a/Html/apps/static/js/lesson.js b/Html/apps/static/js/lesson.js index e0e839b..9a75150 100644 --- a/Html/apps/static/js/lesson.js +++ b/Html/apps/static/js/lesson.js @@ -609,4 +609,446 @@ function loadStepToEditors(phaseText, stepText) { - 维度B:... ` }; - \ No newline at end of file + + + + + + + + + + + +// 假设已存在 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'; +} diff --git a/Html/apps/templates/lesson.html b/Html/apps/templates/lesson.html index 95c1755..9f8d247 100644 --- a/Html/apps/templates/lesson.html +++ b/Html/apps/templates/lesson.html @@ -8,6 +8,7 @@ + @@ -39,6 +40,10 @@