Files
hsa/Html/apps/static/js/lesson.js
2025-08-31 17:16:28 +08:00

612 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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