Files
hsa/Html/apps/static/js/lesson.js
2025-09-18 08:38:18 +00:00

1063 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

window.addEventListener('load', ()=>{
console.log(window.material);
console.log(window.lesson);
editLesson(window.material.material_id, window.chapter.chapter_name, window.lesson.lesson_name);
});
// 在页面里复用一个全局 tooltip 节点
let __globalTooltip;
function ensureTooltip() {
if (!__globalTooltip) {
__globalTooltip = document.createElement('div');
__globalTooltip.className = 'tooltip-portal';
document.body.appendChild(__globalTooltip);
}
return __globalTooltip;
}
// 绑定到任意图标:移入→显示;移动→跟随;移出→隐藏
function attachImmediateTooltip(anchorEl, text) {
const tip = ensureTooltip();
let shown = false;
function show(e) {
tip.textContent = text;
tip.style.display = 'block';
tip.style.opacity = '1';
position(e);
shown = true;
}
function position(e) {
// 以鼠标位置为准(也可用 anchorEl.getBoundingClientRect()
const padding = 8;
const tipRect = tip.getBoundingClientRect();
let x = e.clientX + 12;
let y = e.clientY - tipRect.height - 12;
// 简易防出界
const vw = window.innerWidth, vh = window.innerHeight;
if (x + tipRect.width + padding > vw) x = vw - tipRect.width - padding;
if (y < padding) y = e.clientY + 16; // 放到下面
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
function hide() {
tip.style.opacity = '0';
// 用小延迟避免闪烁
setTimeout(() => { if (!shown) return; tip.style.display = 'none'; }, 100);
shown = false;
}
anchorEl.addEventListener('mouseenter', show);
anchorEl.addEventListener('mousemove', position);
anchorEl.addEventListener('mouseleave', hide);
}
// 你的 renderOutline 里调用:
// if (outline.theme) addThemeWithHelp(tree, outline.theme);
function parseHeadings(mdText) {
const lines = mdText.split('\n');
const tokens = [];
for (let i=0;i<lines.length;i++) {
const m = lines[i].match(/^(#{1,3})\s+(.*)$/);
if (m) tokens.push({ level: m[1].length, text: m[2].trim(), line: i });
}
// 确定每个heading的区间到下一个同级或上级前一行
for (let i=0;i<tokens.length;i++) {
let j = i+1;
while (j<tokens.length && tokens[j].level > tokens[i].level) j++;
// 找到第一个 >= 当前level 的 heading 或到文末
let k = i+1;
while (k<tokens.length && tokens[k].level > tokens[i].level) k++;
const nextSameOrHigher = k<tokens.length ? tokens[k].line : lines.length;
tokens[i].start = tokens[i].line;
tokens[i].end = nextSameOrHigher - 1;
}
return { lines, tokens };
}
// 从解析结果中抽取一个 heading 段落文本
function sliceSection(parsed, headingText, level) {
const t = parsed.tokens.find(x => x.level===level && x.text===headingText);
if (!t) return '';
return parsed.lines.slice(t.start, t.end+1).join('\n');
}
// 根据三个 md构造统一大纲树一级唯一“主题”其下多个阶段##,其下多个步骤###
function buildUnifiedOutline(parsedLesson, parsedPrompt, parsedScore) {
// 取 lesson 的结构为主(也可做三者交集/并集策略,这里简单以 lesson 为基准)
const root1 = parsedLesson.tokens.find(t => t.level===1);
const phases = parsedLesson.tokens.filter(t => t.level===2);
const stepsByPhase = {};
phases.forEach(ph => {
stepsByPhase[ph.text] = parsedLesson.tokens.filter(t => t.level===3 && t.start>ph.start && t.end<=ph.end)
.map(s => s.text);
});
return { theme: root1 ? root1.text : '主题', phases, stepsByPhase };
}
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
};
function bustCache(url) {
const sep = url.includes("?") ? "&" : "?";
return url + sep + "_ts=" + Date.now();
}
// 入口:从你的列表调用
async function editLesson(material_id, chapter_name, lesson_name) {
CURRENT.material_id = material_id;
CURRENT.chapter_name = chapter_name;
CURRENT.lesson_name = lesson_name;
// 打开右侧课程详情先占位
const details = document.getElementById('courseDetails');
const detailsContent = document.getElementById('details-content');
detailsContent.innerHTML = '<p>...</p>';
details.classList.add('open');
// 拉课时详情
metaRes = window.lesson;
const meta = metaRes;
document.getElementById('course-title').textContent = meta.name || meta.material_name || lesson_name;
// 右侧面板描述/目录(简化)
detailsContent.innerHTML = `<h4>描述</h4><p>${meta.description || meta.material_description || '暂无描述'}</p>`;
const chapterList = document.getElementById('chapter-list');
chapterList.innerHTML = '';
(meta.chapters||[]).forEach(ch=>{
const li = document.createElement('li');
li.textContent = ch.chapter_name;
chapterList.appendChild(li);
});
// 保存三份链接
CURRENT.links.lesson = meta.markdown_lesson_file_link;
CURRENT.links.prompt = meta.markdown_prompt_file_name;
CURRENT.links.score = meta.markdown_score_prompt_file_link;
console.log(CURRENT.links);
// 拉三份 Markdown
const [t1, t2, t3] = await Promise.all([
fetch(bustCache(CURRENT.links.lesson), { cache: "no-store" }).then(r => r.text()),
fetch(bustCache(CURRENT.links.prompt), { cache: "no-store" }).then(r => r.text()),
fetch(bustCache(CURRENT.links.score), { cache: "no-store" }).then(r => r.text())
]);
CURRENT.raw.lesson = t1; CURRENT.raw.prompt = t2; CURRENT.raw.score = t3;
CURRENT.parsed.lesson = parseHeadings(t1);
CURRENT.parsed.prompt = parseHeadings(t2);
CURRENT.parsed.score = parseHeadings(t3);
// 构建大纲并渲染
CURRENT.outline = buildUnifiedOutline(CURRENT.parsed.lesson, CURRENT.parsed.prompt, CURRENT.parsed.score);
renderOutline(CURRENT.outline);
// 默认选中第一个“步骤”(若无步骤则整个文档)
const firstPhase = CURRENT.outline.phases?.[0]?.text;
const firstStep = firstPhase && CURRENT.outline.stepsByPhase[firstPhase]?.[0];
if (firstPhase && firstStep) {
loadStepToEditors(firstPhase, firstStep);
highlightActive(firstPhase, firstStep);
} else {
// 无###时,直接把全文放到 Lesson 标签
document.getElementById('editor-lesson').value = CURRENT.raw.lesson;
document.getElementById('editor-prompt').value = CURRENT.raw.prompt;
document.getElementById('editor-score').value = CURRENT.raw.score;
}
}
// ===== 在渲染一级标题时调用 =====
function addThemeWithHelp(tree, themeText) {
const li = document.createElement('li');
li.className = 'lvl1';
li.textContent = themeText;
const help = document.createElement('span');
help.className = 'help-icon';
help.textContent = ' ?';
li.appendChild(help);
// 绑定即时 tooltip挂到 body不会被裁切
attachImmediateTooltip(
help,
`提示“阶段”和“步骤”是为了统一大模型中的提示词表述满足所在Markdown标题层级即可。\n\r
(即阶段为##,步骤为###,在正文编辑中不要使用#、##、###(可使用####、#####`
);
tree.appendChild(li);
}
function renderOutline(outline) {
const tree = document.getElementById('outline-tree');
tree.innerHTML = ''; // 清空大纲内容
if (outline.theme) addThemeWithHelp(tree, outline.theme);
// 渲染每个二级标题
outline.phases.forEach(ph => {
// 二级标题
const li2 = document.createElement('li');
li2.className = 'lvl2';
li2.textContent = `${ph.text}`;//##
tree.appendChild(li2);
// 渲染三级标题(步骤)
const steps = outline.stepsByPhase[ph.text] || [];
steps.forEach(st => {
const li3 = document.createElement('li');
li3.className = 'lvl3';
li3.textContent = `${st}`;//###
li3.dataset.phase = ph.text;
li3.dataset.step = st;
li3.onclick = () => {
loadStepToEditors(ph.text, st);
highlightActive(ph.text, st);
};
tree.appendChild(li3);
});
// 在二级标题下添加 "新增三级标题" 按钮
const addStepBtn = document.createElement('button');
addStepBtn.className = 'add-step-btn';
addStepBtn.textContent = '新增步骤';
addStepBtn.onclick = () => addStepToPhase(ph.text);
tree.appendChild(addStepBtn);
});
// 在大纲底部添加 "新增二级标题" 按钮
const addPhaseBtn = document.createElement('button');
addPhaseBtn.className = 'add-phase-btn';
addPhaseBtn.textContent = '新增阶段';
addPhaseBtn.onclick = () => addPhaseToOutline();
tree.appendChild(addPhaseBtn);
}
// 新增二级标题(章节)
// 新增二级标题(章节 / 阶段)
function addPhaseToOutline() {
const phaseName = (prompt('请输入新的章节(阶段)名称:') || '').trim();
if (!phaseName) return;
// 重名校验(防止三份文档与大纲不同步)
const dup = (CURRENT.outline.phases || []).some(p => p.text === phaseName);
if (dup) { alert('该阶段名称已存在'); return; }
// 2) 向三份原文真正写入该阶段骨架
CURRENT.raw.lesson = insertPhaseIntoMarkdown(
CURRENT.raw.lesson, phaseName, PHASE_TEMPLATES.lesson
);
CURRENT.raw.prompt = insertPhaseIntoMarkdown(
CURRENT.raw.prompt, phaseName, PHASE_TEMPLATES.prompt
);
CURRENT.raw.score = insertPhaseIntoMarkdown(
CURRENT.raw.score, phaseName, PHASE_TEMPLATES.score
);
// 3) 重新解析并构建统一大纲
CURRENT.parsed.lesson = parseHeadings(CURRENT.raw.lesson);
CURRENT.parsed.prompt = parseHeadings(CURRENT.raw.prompt);
CURRENT.parsed.score = parseHeadings(CURRENT.raw.score);
CURRENT.outline = buildUnifiedOutline(CURRENT.parsed.lesson, CURRENT.parsed.prompt, CURRENT.parsed.score);
// 4) 重绘大纲 & 默认高亮新阶段(若你希望直接进入某个步骤,可在此自动新增第一个###
renderOutline(CURRENT.outline);
}
/**
* 在 mdText 文末追加一个新的 "## phaseText" 块(若已存在则不重复插入)
* skeleton 为该阶段下的默认正文
*/
function insertPhaseIntoMarkdown(mdText, phaseText, skeleton) {
const parsed = parseHeadings(mdText);
const exists = parsed.tokens.some(t => t.level === 2 && t.text === phaseText);
if (exists) return mdText; // 已存在则原样返回
const trimmed = mdText.trimEnd();
const block = [
'', '', // 与上一段隔两行,更稳妥
`## ${phaseText}`,
'', // 标题与正文留空行
...(skeleton ? skeleton.trimEnd().split('\n') : []),
'' // 末尾再留一空行
].join('\n');
return trimmed + block + '\n';
}
function addStepToPhase(phaseText) {
const stepName = prompt('请输入新的步骤名称:');
if (!stepName) return;
CURRENT.raw.lesson = insertStepIntoMarkdown(
CURRENT.raw.lesson, phaseText, stepName, STEP_TEMPLATES.lesson
);
CURRENT.raw.prompt = insertStepIntoMarkdown(
CURRENT.raw.prompt, phaseText, stepName, STEP_TEMPLATES.prompt
);
CURRENT.raw.score = insertStepIntoMarkdown(
CURRENT.raw.score, phaseText, stepName, STEP_TEMPLATES.score
);
// 重新解析三份文档并重建统一大纲
CURRENT.parsed.lesson = parseHeadings(CURRENT.raw.lesson);
CURRENT.parsed.prompt = parseHeadings(CURRENT.raw.prompt);
CURRENT.parsed.score = parseHeadings(CURRENT.raw.score);
CURRENT.outline = buildUnifiedOutline(CURRENT.parsed.lesson, CURRENT.parsed.prompt, CURRENT.parsed.score);
// 重新渲染左侧树,并默认选中新加的步骤(加载到三个编辑器)
renderOutline(CURRENT.outline);
loadStepToEditors(phaseText, stepName);
highlightActive(phaseText, stepName);
}
function highlightActive(phase, step) {
document.querySelectorAll('#outline-tree li').forEach(li=>li.classList.remove('active'));
const target = Array.from(document.querySelectorAll('#outline-tree li.lvl3'))
.find(li => li.dataset.phase===phase && li.dataset.step===step);
if (target) target.classList.add('active');
CURRENT.active.phase=phase
CURRENT.active.step=step
}
/**
* 在 mdText 中,将 `### stepName` 插入到 `## phaseText` 阶段的末尾。
* 若 phase 不存在,则创建 `## phaseText` 再插入。
* skeleton 为插入文本模板(含### 标题本身及正文)。
*/
function insertStepIntoMarkdown(mdText, phaseText, stepName, skeleton, { allowDuplicate = false } = {}) {
const parsed = parseHeadings(mdText);
const { lines, tokens } = parsed;
// 找到目标阶段(##
const phaseToken = tokens.find(t => t.level === 2 && t.text === phaseText);
// 生成插入块(### 标题 + 正文骨架),不强制前后空行,由下面逻辑按需补
const blockLines = [
`### ${stepName}`,
'', // 标题后留一行
...(skeleton ? skeleton.trimEnd().split('\n') : [])
];
// 如果没找到该阶段:在文末新建阶段并追加该步骤
if (!phaseToken) {
const endIsBlank = mdText.endsWith('\n\n');
const out = [
mdText.trimEnd(),
endIsBlank ? '' : '', // 保持原末尾,不多加
'', // 与原文隔一空行更安全
`## ${phaseText}`,
'', // 阶段标题后空行
...blockLines,
'' // 末尾再空一行
].join('\n');
return out;
}
// 收集该阶段内的所有 ### 步骤
const stepsInPhase = tokens.filter(t =>
t.level === 3 && t.start > phaseToken.start && t.end <= phaseToken.end
);
// 已存在同名步骤?
const dup = stepsInPhase.some(s => s.text === stepName);
if (dup && !allowDuplicate) {
// 已有同名步骤则直接返回原文本(也可改为抛错/提示)
return mdText;
}
// 计算插入位置:
// - 若有步骤:插入到最后一个步骤“之后”
// - 若无步骤:插入到该阶段“末尾”(不打乱引言)
let insertPos; // 行号(在其前插入)
if (stepsInPhase.length > 0) {
const lastStep = stepsInPhase[stepsInPhase.length - 1];
insertPos = lastStep.end + 1;
} else {
insertPos = phaseToken.end + 1;
}
// 按需补空行:在插入块前后各确保至少一条空行,避免粘连
const beforeLine = insertPos - 1 >= 0 ? lines[insertPos - 1] : '';
const needLeadingBlank = beforeLine && beforeLine.trim() !== '';
// 插入块组装
const insertChunk = [
...(needLeadingBlank ? [''] : []), // 前置空行(若上一行非空)
...blockLines,
'' // 块后空一行
];
const newLines = lines.slice(0, insertPos)
.concat(insertChunk)
.concat(lines.slice(insertPos));
return newLines.join('\n');
}
// 解析Markdown并定位章节/步骤
function parseHeadings(mdText) {
const lines = mdText.split('\n');
const tokens = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(#{1,3})\s+(.*)$/);
if (m) tokens.push({ level: m[1].length, text: m[2].trim(), line: i });
}
// 确定每个heading的区间到下一个同级或上级前一行
for (let i = 0; i < tokens.length; i++) {
let j = i + 1;
while (j < tokens.length && tokens[j].level > tokens[i].level) j++;
let k = i + 1;
while (k < tokens.length && tokens[k].level > tokens[i].level) k++;
const nextSameOrHigher = k < tokens.length ? tokens[k].line : lines.length;
tokens[i].start = tokens[i].line;
tokens[i].end = nextSameOrHigher - 1;
}
return { lines, tokens };
}
// 从解析结果中抽取一个 heading 段落文本
function sliceSection(parsed, headingText, level) {
const t = parsed.tokens.find(x => x.level === level && x.text === headingText);
if (!t) return '';
return parsed.lines.slice(t.start, t.end + 1).join('\n');
}
function buildOriginSaveUrl() {
return `/api/materials/save/${encodeURIComponent(CURRENT.material_id)}/${encodeURIComponent(CURRENT.chapter_name)}/${encodeURIComponent(CURRENT.lesson_name)}`;
}
/**
* 把三份文档lesson/prompt/score的 "CDN 外链 + 原文内容" 一次性提交给源站。
* @param {Object} payload 形如:
* {
* lesson: { cdn_link, content },
* prompt: { cdn_link, content },
* score: { cdn_link, content }
* }
*/
async function saveToOrigin(payload) {
const res = await fetch(buildOriginSaveUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// 你也可以改为 docs: [] 的数组结构,后端按需解析
lesson: payload.lesson,
prompt: payload.prompt,
score: payload.score,
}),
credentials: 'include' // 若需要带 cookie/会话,保留;不需要可移除
});
if (!res.ok) {
const text = await res.text().catch(()=>'');
throw new Error(`保存失败(${res.status}): ${text}`);
}
return res.json().catch(()=> ({}));
}
// 保存某个步骤的更新
async function saveUpdatedStep() {
const lEditor = document.getElementById('editor-lesson');
const pEditor = document.getElementById('editor-prompt');
const sEditor = document.getElementById('editor-score');
// 获取原始的文档内容
const originalLesson = CURRENT.raw.lesson;
const originalPrompt = CURRENT.raw.prompt;
const originalScore = CURRENT.raw.score;
// 解析各自的文档结构
const parsedLesson = parseHeadings(originalLesson);
const parsedPrompt = parseHeadings(originalPrompt);
const parsedScore = parseHeadings(originalScore);
phaseText = CURRENT.active.phase
stepText = CURRENT.active.step;
// 更新具体步骤
const updatedLesson = updateMarkdownContent(parsedLesson, phaseText, stepText, lEditor.value);
const updatedPrompt = updateMarkdownContent(parsedPrompt, phaseText, stepText, pEditor.value);
const updatedScore = updateMarkdownContent(parsedScore, phaseText, stepText, sEditor.value);
// PUT 请求保存更新
try {
const result = await saveToOrigin({
lesson: { cdn_link: CURRENT.links.lesson, content: updatedLesson },
prompt: { cdn_link: CURRENT.links.prompt, content: updatedPrompt },
score: { cdn_link: CURRENT.links.score, content: updatedScore },
});
//result 可包含新的 CDN 链接或版本号,更新到 CURRENT.links / UI
if (result?.links) { CURRENT.links = { ...CURRENT.links, ...result.links }; }
alert('已保存');
}
catch (err) {
console.error(err);
alert('保存失败,请重试');
}
CURRENT.raw.lesson = updatedLesson
CURRENT.raw.prompt = updatedPrompt
CURRENT.raw.score = updatedScore
CURRENT.parsed.lesson = parseHeadings(CURRENT.raw.lesson)
CURRENT.parsed.prompt = parseHeadings(CURRENT.raw.prompt)
CURRENT.parsed.score = parseHeadings(CURRENT.raw.score)
CURRENT.outline = buildUnifiedOutline(CURRENT.parsed.lesson, CURRENT.parsed.prompt, CURRENT.parsed.score);
}
// 更新对应步骤的内容
function updateMarkdownContent(parsed, phaseText, stepText, newContent) {
const phaseIndex = parsed.tokens.findIndex(token => token.level === 2 && token.text === phaseText);
if (phaseIndex === -1) return parsed.lines.join('\n'); // 找不到阶段
const stepIndex = parsed.tokens.findIndex(token => token.level === 3 && token.text === stepText);
if (stepIndex === -1) return parsed.lines.join('\n'); // 找不到步骤
const startLine = parsed.tokens[stepIndex].start;
const endLine = parsed.tokens[stepIndex].end;
// 替换该步骤内容
const lines = [...parsed.lines];
lines.splice(startLine, endLine - startLine + 1, newContent); // 用新内容替换
return lines.join('\n'); // 返回更新后的完整内容
}
// 把“某个步骤”对应的三个片段载入到三个编辑器
function loadStepToEditors(phaseText, stepText) {
// 三份文档里都取对应 phase/step 的片段(不存在则回退)
const L = CURRENT.parsed.lesson, P = CURRENT.parsed.prompt, S = CURRENT.parsed.score;
const lessonPhase = sliceSection(L, phaseText, 2) || CURRENT.raw.lesson;
const promptPhase = sliceSection(P, phaseText, 2) || CURRENT.raw.prompt;
const scorePhase = sliceSection(S, phaseText, 2) || CURRENT.raw.score;
// 在各 phase 片段里再找 ### step
const subL = parseHeadings(lessonPhase);
const subP = parseHeadings(promptPhase);
const subS = parseHeadings(scorePhase);
const lText = sliceSection(subL, stepText, 3) || lessonPhase;
const pText = sliceSection(subP, stepText, 3) || promptPhase;
const sText = sliceSection(subS, stepText, 3) || scorePhase;
document.getElementById('editor-lesson').value = lText;
document.getElementById('editor-prompt').value = pText;
document.getElementById('editor-score').value = sText;
}
// Tab 切换
document.addEventListener('click', (e)=>{
if (e.target.classList.contains('tab-btn')) {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
e.target.classList.add('active');
const id = e.target.dataset.target;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
});
// 保存全部:将三个编辑器中的文本保存回相应的 markdown 文件
document.getElementById('save-all-btn')?.addEventListener('click', async () => {
try {
// 将各自的步骤更新保存回文件
await saveUpdatedStep();
} catch (err) {
console.error('保存错误:', err);
alert('保存失败,请重试');
}
});
const STEP_TEMPLATES = {
lesson: `在这里编写本步骤的课程内容...`,
prompt: `#### 指令
请严格遵循本步骤的教学目标,输出所需内容。
#### 约束
- 使用简明、结构化的格式
- 语言:中文
#### 输出格式
- 小结
- 关键要点(列表)`,
score: `#### 评分标准
- 维度A...
- 维度B...
- 维度C...
#### 打分
请输出 JSON
{"score": 0-100, "reasons": ["...","..."]}`
};
const PHASE_TEMPLATES = {
lesson: `### 新步骤
本阶段描述:请在此补充阶段说明。
`,
prompt: `### 新步骤
阶段提示
为该阶段的任务撰写总体引导。
# 约束
- 输出语言:中文
`,
score: `### 新步骤
阶段评分参考
- 维度A...
- 维度B...
`
};
// 假设已存在 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';
}