1559 lines
56 KiB
JavaScript
1559 lines
56 KiB
JavaScript
window.addEventListener('load', ()=>{
|
||
console.log(window.material);
|
||
console.log(window.lesson);
|
||
editLesson(window.material.material_id, window.chapter.chapter_name, window.lesson.lesson_name);
|
||
});
|
||
|
||
|
||
|
||
|
||
|
||
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';
|
||
}
|