Files
hsa/Html/apps/static/js/lesson.js
2025-11-03 16:14:57 +08:00

1612 lines
58 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, lessontxt:null, prompt:null, score: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';
// 标题文本容器(单独一个 span避免更新 textContent 时把子元素清掉)
const titleSpan = document.createElement('span');
titleSpan.className = 'title-text';
titleSpan.textContent = normalizeTitle(themeText);
li.appendChild(titleSpan);
const help = document.createElement('span');
help.className = 'help-icon';
help.textContent = ' ?';
li.appendChild(help);
// 新增:铅笔编辑按钮
const editBtn = createEditButton(() => {
openTitleEditor({
title: '修改标题',
initialValue: titleSpan.textContent,
onSubmit: (value) => {
const res = validateTitle(value);
if (!res.ok) return res; // 返回 {ok:false, message:'...'} => 在模态框里显示错误
titleSpan.textContent = res.value; // 合法后的值写回
const newLine = "# " + res.value;
CURRENT.raw.lesson = setFirstLine(CURRENT.raw.lesson || "", newLine);
CURRENT.raw.prompt = setFirstLine(CURRENT.raw.prompt || "", newLine);
CURRENT.raw.score = setFirstLine(CURRENT.raw.score || "", newLine);
let confirmMsg = "是否立即保存?注意当前编辑内容";
// —— 二次确认 —— //
if (window.confirm(confirmMsg)) {
saveUpdatedStep()
}
return { ok: true }; // 关闭模态框
}
});
});
li.appendChild(editBtn);
// 绑定即时 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}`;
// 新增:铅笔编辑按钮
const editBtn = createEditButton(() => {
openTitleEditor({
title: '修改阶段标题',
initialValue: ph.text,
onSubmit: (value) => {
const res = validateTitle(value);
if (!res.ok) return res; // 返回 {ok:false, message:'...'} => 在模态框里显示错误
const oldH2 = (ph.text || "").trim();
const newH2 = (res.value || "").trim();
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const hasH2 = (text, h2) => {
const re = new RegExp(`^##\\s*${escapeRegExp(h2)}\\s*$`, 'm');
return re.test(text || '');
};
const replaceH2Once = (text, oldT, newT) => {
const re = new RegExp(`^##\\s*${escapeRegExp(oldT)}\\s*$`, 'm');
return (text || '').replace(re, `## ${newT}`);
};
['lesson', 'prompt', 'score'].forEach((key) => {
const src = CURRENT.raw[key] || '';
// 已经有 "## newH2" 就不动;否则把第一处 "## oldH2" 换成新标题
if (!hasH2(src, newH2)) {
CURRENT.raw[key] = replaceH2Once(src, oldH2, newH2);
li2.textContent = res.value; // 合法后的值写回
let confirmMsg = "是否立即保存?注意当前编辑内容";
// —— 二次确认 —— //
if (window.confirm(confirmMsg)) {
saveUpdatedStep()
}
}else{
// 已存在新标题:保持原文不变
alert("新标题已存在")
}
});
return { ok: true }; // 关闭模态框
}
});
});
li2.appendChild(editBtn);
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');
}
const outlineAside = document.querySelector('.outline');
const outlineTree = document.getElementById('outline-tree');
const btnDeleteMode = document.getElementById('toggle-outline-delete');
// 切换删除模式
function setDeleteMode(enabled) {
btnDeleteMode.setAttribute('aria-pressed', String(enabled));
outlineAside.classList.toggle('delete-mode', enabled);
}
// 点击按钮:进入/退出删除模式
btnDeleteMode.addEventListener('click', (e) => {
e.preventDefault();
const enabled = btnDeleteMode.getAttribute('aria-pressed') !== 'true';
setDeleteMode(enabled);
});
// 工具:判断是否是 lvl2 / lvl3 节点(允许点到它的内部子元素)
function findDeletableNode(el) {
if (!el) return null;
if (el.classList?.contains('lvl2') || el.classList?.contains('lvl3')) return el;
return el.closest?.('.lvl2, .lvl3') || null;
}
// 事件代理:删除模式下,点击二/三级标题进行删除确认
outlineTree.addEventListener('click', (e) => {
if (btnDeleteMode.getAttribute('aria-pressed') !== 'true') return; // 非删除模式,忽略
const targetNode = findDeletableNode(e.target);
if (!targetNode) return;
// 阻止与其他点击逻辑(比如展开/跳转)冲突
e.preventDefault();
e.stopPropagation();
// 取层级与文案
const level = targetNode.classList.contains('lvl2') ? 2 : 3;
// 标题文本:如果内部结构复杂,优先拿一个有标题含义的元素,否则退回整个文本
const text = (targetNode.querySelector('.title-text')?.textContent
|| targetNode.firstChild?.textContent
|| targetNode.textContent || '').trim();
const ok = window.confirm(`确认删除 ${level} 级标题「${text || '(未命名)'}」吗?`);
if (!ok) return;
// 执行删除:从大纲中移除该节点
targetNode.remove();
// 可选如果你需要同步删除到正文内容CURRENT.raw.xxx可以在这里调用你的同步逻辑
// 例:把正文中第一处行首 "## 标题" 或 "### 标题" 删除(仅示例,按你项目规则调整):
deleteSectionInRaw(level, text);
});
/**
* 删除指定 level(2/3) 与 title 的小节:
* 从 "^## title$" 或 "^### title$" 那一行开始,
* 一直到“下一个同级标题”(不含)或文末。
*/
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function deleteSectionInRaw(level, title) {
const hashes = '#'.repeat(level); // "##" 或 "###"
const escTitle = escapeRegExp(String(title).trim());
// 精确匹配:行首同级标题 + 可选空格 + 标题文本 + 行尾
const headingRe = new RegExp(`^${hashes}\\s*${escTitle}\\s*$`, 'm');
// 下一个同级标题(任意文本)
const nextSameLevelRe = new RegExp(`^${hashes}\\s+.*$`, 'm');
['lesson', 'prompt', 'score'].forEach((key) => {
let text = CURRENT.raw[key] || '';
const m = headingRe.exec(text);
if (!m) return; // 这份原文里没有该标题,跳过
const start = m.index;
const afterHeading = m.index + m[0].length;
const sub = text.slice(afterHeading);
const m2 = nextSameLevelRe.exec(sub);
const end = m2 ? (afterHeading + m2.index) : text.length;
let updated = text.slice(0, start) + text.slice(end);
updated = updated.replace(/\r?\n{3,}/g, '\n\n');
CURRENT.raw[key] = updated;
});
if (window.confirm("确认删除标题(含内部所有内容)并保存?")) {
saveUpdatedStep()
}
}
// 解析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(()=> ({}));
}
// Markdown 标题1~3 个 #)匹配:仅行首、最多 3 个 #,后跟空格与内容
const HEADING_RE = /^\s{0,3}#{1,3}\s+\S/;
const FIRSTLINE_RE = /^#{3}\s+\S/;
/**
* 扫描一个文本找出从第2行开始的非法标题# / ## / ###
* @return {Array<{line: number, text: string}>}
*/
function findIllegalHeadings(text = "") {
const lines = String(text).split(/\r?\n/);
const issues = [];
if (!FIRSTLINE_RE.test(lines[0] || "")) {
issues.push({ line: 1, text: lines[0] + " 首行必须是 '### 标题'" });
}
for (let i = 1; i < lines.length; i++) { // 跳过首行 i=0
if (HEADING_RE.test(lines[i])) {
issues.push({ line: i + 1, text: lines[i] }); // 人类可读行号 = 下标+1
}
}
return issues;
}
/**
* 校验三个编辑器:除首行外不允许 # / ## / ###
* @param {HTMLTextAreaElement} lEditor
* @param {HTMLTextAreaElement} pEditor
* @param {HTMLTextAreaElement} sEditor
* @returns {{ok: boolean, report: string, details: object}}
*/
function validateEditorsBeforeCloudSave(lEditor, pEditor, sEditor) {
const buckets = {
lesson: findIllegalHeadings(lEditor?.value || ""),
prompt: findIllegalHeadings(pEditor?.value || ""),
score: findIllegalHeadings(sEditor?.value || ""),
};
const parts = [];
for (const [key, arr] of Object.entries(buckets)) {
if (arr.length) {
const name = key === 'lesson' ? 'lesson' : key === 'prompt' ? 'prompt' : 'score';
parts.push(
`${name} 存在非法标题:\n` +
arr.map(x => ` - 第 ${x.line} 行:${x.text}`).join('\n')
);
}
}
return {
ok: parts.length === 0,
report: parts.join('\n\n'),
details: buckets
};
}
/**
* (可选)自动修复:去掉除首行外行首的 # / ## / ### 前缀
*/
function sanitizeHeadingsBeyondFirst(text = "") {
const lines = String(text).split(/\r?\n/);
for (let i = 1; i < lines.length; i++) {
lines[i] = lines[i].replace(/^\s{0,3}#{1,3}\s+/, ''); // 去掉前缀与一个以上空格
}
return lines.join('\n');
}
// 保存某个步骤的更新
async function saveUpdatedStep() {
const lEditor = document.getElementById('editor-lesson');
const pEditor = document.getElementById('editor-prompt');
const sEditor = document.getElementById('editor-score');
const check = validateEditorsBeforeCloudSave(lEditor, pEditor, sEditor);
if (!check.ok) {
alert("保存被阻止:" + check.report);
return ;
}
// 获取原始的文档内容
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 {
console.log("saveToOrigin", CURRENT.links.lesson, CURRENT.links.prompt, CURRENT.links.score);
console.log("updatedLesson", updatedLesson);
console.log("updatedPrompt", updatedPrompt);
console.log("updatedScore", updatedScore);
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('已保存');
CURRENT.active.lesson = lEditor.value
CURRENT.active.prompt = pEditor.value
CURRENT.active.score = sEditor.value
}
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);
renderOutline(CURRENT.outline);
let btn = document.getElementById("tab-btn-lesson");
btn.innerText = ClearModifiedStat(btn.innerText)
btn = document.getElementById("tab-btn-prompt");
btn.innerText = ClearModifiedStat(btn.innerText)
btn = document.getElementById("tab-btn-score");
btn.innerText = ClearModifiedStat(btn.innerText)
}
// 更新对应步骤的内容
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];
console.log("lines length", lines.length);
console.log("startLine", startLine);
console.log("endLine", endLine);
console.log("endLine - startLine + 1", endLine - startLine + 1);
console.log("newContent", newContent);
lines.splice(startLine, endLine - startLine + 1, newContent); // 用新内容替换
return lines.join('\n'); // 返回更新后的完整内容
}
// 获取所有 textarea
function AddModifiedStat(str){
return str.charAt(str.length - 1) !== '*' ? str + '*' : str;
}
function ClearModifiedStat(str){
return str.charAt(str.length - 1) !== '*' ? str:str.slice(0, str.length-1);
}
function ValidTextareaNoBlankHead(textarea) {
let lines = textarea.value.split("\n");
// 如果第一行是空的,则删掉
while (lines.length > 0 && lines[0].trim() === "") {
lines.shift(); // 删除第一行
}
// 更新 textarea 的值(如果有变化)
textarea.value = lines.join("\n");
}
const editorsDoc = document.querySelectorAll("textarea");
const PREFIX = "### ";
// 记录每个 textarea 最新的“标题内容”(不含前缀)
const lastTitleMap = new Map();
// 防止循环触发
let isProgrammaticUpdate = false;
function getFirstLine(text) {
const idx = text.indexOf("\n");
return idx === -1 ? text : text.slice(0, idx);
}
function setFirstLine(text, newFirstLine) {
const idx = text.indexOf("\n");
return idx === -1 ? newFirstLine : newFirstLine + text.slice(idx);
}
// 工具:规范化首行 -> 始终以 '### ' 开头,并返回 { line, title }
function normalizeFirstLine(line) {
// 去掉前导的 # 和空格,保留后面的标题内容
const title = line.replace(/^#+\s*/,''); // 如果用户没写#,也直接当作标题
const normalized = PREFIX + title;
return { line: normalized, title };
}
// 初始化 lastTitleMap把当前值规范化一次
editorsDoc.forEach(ed => {
const rawFirst = getFirstLine(ed.value || "");
const { line, title } = normalizeFirstLine(rawFirst);
if (line !== rawFirst) {
isProgrammaticUpdate = true;
ed.value = setFirstLine(ed.value || "", line);
isProgrammaticUpdate = false;
}
lastTitleMap.set(ed, title);
});
// 同步到其他两个 textarea 的首行
function syncOthers(sourceEditor, newTitle) {
isProgrammaticUpdate = true;
try {
editorsDoc.forEach(ed => {
if (ed === sourceEditor) return;
const first = getFirstLine(ed.value || "");
const { line } = normalizeFirstLine(first);
const newLine = PREFIX + newTitle;
if (line !== newLine) {
ed.value = setFirstLine(ed.value || "", newLine);
}
lastTitleMap.set(ed, newTitle);
});
} finally {
isProgrammaticUpdate = false;
}
}
// ==== 前置:你的三编辑器实例与 id 映射(按你实际为准)====
const ID_LESSON = 'editor-lesson';
const ID_PROMPT = 'editor-prompt';
const ID_SCORE = 'editor-score';
// editors: { [textareaId]: EasyMDEInstance }
const editorIds = [ID_LESSON, ID_PROMPT, ID_SCORE];
// 全局防抖/重入保护:避免程序化更新再触发同步
// // 记录上一次各编辑器的标题文本(不含 "### " 前缀)
// const lastTitleMap = new Map();
// —— 工具:从 EasyMDE 拿值 / 设值(会同步 textarea因为 forceSync:true——
const getMD = (id) => editors[id].value();
const setMD = (id, text) => editors[id].value(text);
// —— 工具:仅替换“首行”为 normalizedLine尽量不破坏光标与撤销栈 ——
// line 是完整首行文本(含 "### "
function setFirstLineInEditor(ed, normalizedLine) {
const cm = ed.codemirror;
cm.operation(() => {
const doc = cm.getDoc();
const firstLine = doc.getLine(0) ?? "";
if (firstLine === normalizedLine) return;
// 记住用户光标
const cursors = doc.listSelections();
// 用 replaceRange 仅替换首行
doc.replaceRange(
normalizedLine,
{ line: 0, ch: 0 },
{ line: 0, ch: firstLine.length }
);
// 尽力恢复光标(如果原先在首行,偏移可能要校正)
doc.setSelections(cursors);
});
}
// —— 工具:从 Markdown 文本取首行(兼容你现有函数)
function getFirstLineFromText(text) {
const idx = text.indexOf('\n');
return idx === -1 ? text : text.slice(0, idx);
}
// —— 工具:把 normalizedLine 写回文本首行(备用:整段 setValue——
function setFirstLineInText(text, normalizedLine) {
const idx = text.indexOf('\n');
return idx === -1 ? normalizedLine : (normalizedLine + text.slice(idx));
}
// —— 同步另外两个编辑器的首行(仅标题行),避免全文覆盖 ——
function syncOthersTitle(fromId, titleText /*不含前缀*/, normalizedLine /*含### */) {
editorIds.forEach((id) => {
if (id === fromId) return;
const ed = editors[id];
if (!ed) return;
const currentFirst = ed.codemirror.getDoc().getLine(0) ?? "";
if (currentFirst !== normalizedLine) {
setFirstLineInEditor(ed, normalizedLine);
lastTitleMap.set(ed, titleText);
}
});
}
// —— 更新 tab 按钮上的 “*” 修改状态(保持你原逻辑)——
function refreshTabDirtyBadges() {
let btn = document.getElementById("tab-btn-lesson");
if (CURRENT.active.lesson !== getMD(ID_LESSON)) {
btn.innerText = AddModifiedStat(btn.innerText);
} else {
btn.innerText = ClearModifiedStat(btn.innerText);
}
btn = document.getElementById("tab-btn-prompt");
if (CURRENT.active.prompt !== getMD(ID_PROMPT)) {
btn.innerText = AddModifiedStat(btn.innerText);
} else {
btn.innerText = ClearModifiedStat(btn.innerText);
}
btn = document.getElementById("tab-btn-score");
if (CURRENT.active.score !== getMD(ID_SCORE)) {
btn.innerText = AddModifiedStat(btn.innerText);
} else {
btn.innerText = ClearModifiedStat(btn.innerText);
}
}
// —— 单个编辑器的 change 处理器工厂 ——
function bindEditorChangeHandler(textareaId) {
const ed = editors[textareaId];
const cm = ed.codemirror;
// 初始化 lastTitleMap
lastTitleMap.set(ed, "");
cm.on('change', () => {
if (isProgrammaticUpdate) return;
const value = ed.value();
const rawFirst = getFirstLineFromText(value);
// 你自己的校验(如果要继续保留)
// ValidTextareaNoBlankHead(ed.element.nextSibling /* textarea DOM? */);
// 归一化首行,要求 "### " 前缀
const { line: normalizedLine, title: currentTitle } = normalizeFirstLine(rawFirst);
// 若首行不规范,程序化改回去(仅替换首行)
if (normalizedLine !== rawFirst) {
isProgrammaticUpdate = true;
try {
setFirstLineInEditor(ed, normalizedLine);
} finally {
isProgrammaticUpdate = false;
}
}
// 标题变化则同步到其他两个编辑器(只同步首行)
const lastTitle = lastTitleMap.get(ed) ?? "";
if (currentTitle !== lastTitle) {
lastTitleMap.set(ed, currentTitle);
isProgrammaticUpdate = true;
try {
syncOthersTitle(textareaId, currentTitle, normalizedLine);
} finally {
isProgrammaticUpdate = false;
}
}
// 刷新三个 Tab 的“修改中 * ”标识
refreshTabDirtyBadges();
// 如果你还有右侧预览:
if (typeof renderToPreview === 'function' && textareaId === activeId) {
renderToPreview(ed.value());
}
});
}
// 绑定三个编辑器
editorIds.forEach(bindEditorChangeHandler);
// editorsDoc.forEach(editor => {
// editor.addEventListener("input", (event) => {
// const textarea = event.target
// const value = event.target.value;
// const type = event.target.dataset.type;
// // 在这里做提示或校验
// console.log(`内容已更改: ${event.target.id} => ${value}`);
// ValidTextareaNoBlankHead(event.target)
// const rawFirst = getFirstLine(value);
// const { line: normalizedLine, title: currentTitle } = normalizeFirstLine(rawFirst);
// if (normalizedLine !== rawFirst) {
// isProgrammaticUpdate = true;// 把首行改回以 '### ' 开头
// try {
// textarea.value = setFirstLine(value, normalizedLine);
// } finally {
// isProgrammaticUpdate = false;
// }
// }
// const lastTitle = lastTitleMap.get(textarea) ?? "";// —— 2) 如标题内容有变化,则同步到其他两个 —— //
// if (currentTitle !== lastTitle) {
// lastTitleMap.set(textarea, currentTitle);// 更新自身记录
// syncOthers(textarea, currentTitle);
// }
// let btn = document.getElementById("tab-btn-lesson");
// if (CURRENT.active.lesson != document.getElementById("editor-lesson").value){
// btn.innerText = AddModifiedStat(btn.innerText)
// }else{
// btn.innerText = ClearModifiedStat(btn.innerText)
// }
// btn = document.getElementById("tab-btn-prompt");
// if (CURRENT.active.prompt != document.getElementById("editor-prompt").value){
// btn.innerText = AddModifiedStat(btn.innerText)
// }else{
// btn.innerText = ClearModifiedStat(btn.innerText)
// }
// btn = document.getElementById("tab-btn-score");
// if (CURRENT.active.score != document.getElementById("editor-score").value){
// btn.innerText = AddModifiedStat(btn.innerText)
// }else{
// btn.innerText = ClearModifiedStat(btn.innerText)
// }
// });
// });
// 把“某个步骤”对应的三个片段载入到三个编辑器
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;
editors['editor-lesson'].value(lText);
editors['editor-prompt'].value(pText);
editors['editor-score'].value(sText);
CURRENT.active={ //保存打开时的三个文本
phase:phaseText,
step:stepText,
lesson:lText,
prompt:pText,
score:sText
}
let btn = document.getElementById("tab-btn-lesson");
btn.innerText = ClearModifiedStat(btn.innerText)
btn = document.getElementById("tab-btn-prompt");
btn.innerText = ClearModifiedStat(btn.innerText)
btn = document.getElementById("tab-btn-score");
btn.innerText = ClearModifiedStat(btn.innerText)
}
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');
// 广播 tab 变更(告诉别人当前激活的 tab id / 以及要用哪个 editorId
const editorIdMap = {
'tab-lesson' : 'editor-lesson',
'tab-prompt' : 'editor-prompt',
'tab-score' : 'editor-score',
};
const activeBtnId = e.target.id; // 如 'tab-btn-lesson'
const editorId =
activeBtnId === 'tab-btn-lesson' ? 'editor-lesson' :
activeBtnId === 'tab-btn-prompt' ? 'editor-prompt' :
'editor-score';
document.dispatchEvent(new CustomEvent('tab:change', {
detail: { tabId: id, editorId }
}));
}
});
// 保存全部:将三个编辑器中的文本保存回相应的 markdown 文件
document.getElementById('save-all-btn')?.addEventListener('click', async () => {
try {
// 将各自的步骤更新保存回文件
await saveUpdatedStep();
} catch (err) {
console.error('保存错误:', err);
}
});
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';
}