From fd6e12528cab35f3e7529520e5395944043a4de2 Mon Sep 17 00:00:00 2001 From: CakeCN Date: Sun, 31 Aug 2025 17:16:28 +0800 Subject: [PATCH] =?UTF-8?q?lesson=20edit=20(add=20=E9=98=B6=E6=AE=B5/?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Html/apps/services/course_service.py | 87 +++++ Html/apps/static/css/lesson.css | 73 +++- Html/apps/static/css/teacherboard.css | 19 +- Html/apps/static/js/lesson.js | 532 +++++++++++++++++++++++--- Html/apps/static/js/teacherboard.js | 3 + Html/apps/templates/lesson.html | 10 +- Html/apps/templates/teacherboard.html | 2 +- Html/apps/views/lessons.py | 73 +++- 8 files changed, 704 insertions(+), 95 deletions(-) diff --git a/Html/apps/services/course_service.py b/Html/apps/services/course_service.py index b335053..df1fbcc 100644 --- a/Html/apps/services/course_service.py +++ b/Html/apps/services/course_service.py @@ -2,6 +2,7 @@ import re from typing import Dict, List from flask import current_app +from urllib.parse import urlparse from datetime import datetime from bson import ObjectId @@ -111,7 +112,93 @@ def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teach # 如果删除操作成功,res.modified_count 应为 1 return res.modified_count == 1 +def save_materials_markdown_cos( + material_id: str, + chapter_name: str, + lesson_name: str, + docs: Dict[str, Dict[str, str]], + version: str | None = None, # 目前不做并发控制,保留参数 + actor=None # 可记录操作者到审计日志 +) -> Dict[str, str]: + """ + docs 结构: + { + "lesson": {"cdn_link": "...lesson.md", "content": "..."}, + "prompt": {"cdn_link": "...prompt.md", "content": "..."}, + "score": {"cdn_link": "...score_prompt.md", "content": "..."} + } + 约定:cdn_link 与对象存储的 key 一一映射(同路径/同文件名), + 我们通过解析 URL 的 path 作为 key 进行覆盖写入(不改文件名)。 + """ + def _cdn_to_key(cdn_link: str) -> str: + """ + 把 CDN URL 映射成对象存储的 key: + - 若 CDN 与存储是“同 key 直出”,则直接取 path(去掉前导 /) + - 若你有自定义映射规则,在此处改写 + """ + p = urlparse(cdn_link) + key = p.path.lstrip("/") + if not key: + raise ValueError(f"非法 cdn_link:{cdn_link}") + return key + + # 1) 覆盖写入三份文件 + results: Dict[str, Dict[str, str]] = {} + for kind in ("lesson", "prompt", "score"): + item = docs.get(kind) + if not item or "cdn_link" not in item or "content" not in item: + raise ValueError(f"缺少 {kind} 的 cdn_link 或 content") + + key = _cdn_to_key(item["cdn_link"]) + content = item["content"] + # 覆盖写入(注意:upload_file 内部需实现覆盖同 key) + try: + # 约定:upload_file 返回可访问的 URL(覆盖后可与原相同) + url = upload_file(content.encode("utf-8"), key) + except Exception as e: + current_app.logger.exception("覆盖写入失败 kind=%s key=%s", kind, key) + raise + + # 简单返回:保留原 cdn_link(一般与 url 相同),以及 md5 作为伪版本 + import hashlib + etag = hashlib.md5(content.encode("utf-8")).hexdigest() + results[kind] = {"cdn_link": url or item["cdn_link"], "version": etag} + + # 2) 写库:更新该 lesson 的更新时间(不改文件名/链接) + _touch_material_lesson_updated_at(material_id, chapter_name, lesson_name, actor=actor) + + results["updated_at"] = datetime.now().isoformat() + "Z" + return results + + +def _touch_material_lesson_updated_at(material_id: str, chapter_name: str, lesson_name: str, actor=None) -> None: + """仅刷新 materials 文档中该课时的更新时间戳(以及顶层 updated_at)""" + mongo = current_app.extensions["mongo"] + now = datetime.now() + + # 更新顶层 updated_at + mongo.db.materials.update_one( + {"_id": ObjectId(material_id)}, + {"$set": {"updated_at": now}} + ) + + # 更新嵌套 lesson 的更新时间(如果你在 lesson 内有该字段) + # 需要使用 arrayFilters 精确定位到对应章节/课时 + mongo.db.materials.update_one( + {"_id": ObjectId(material_id)}, + { + "$set": { + "chapters.$[c].lessons.$[l].updated_at": now, + # 可选:记录最近编辑人 + "chapters.$[c].lessons.$[l].last_editor": getattr(actor, "id", None) if actor else None + } + }, + array_filters=[ + {"c.chapter_name": chapter_name}, + {"l.lesson_name": lesson_name} + ] + ) def add_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id): """ 1) 生成占位 Markdown(lesson_prompt_score_prompt) diff --git a/Html/apps/static/css/lesson.css b/Html/apps/static/css/lesson.css index 3c60155..da38f7b 100644 --- a/Html/apps/static/css/lesson.css +++ b/Html/apps/static/css/lesson.css @@ -1,10 +1,67 @@ -/* 侧边栏 */ -.course-details { - position: fixed; top:0; right:-360px; width:360px; height:100%; - background:#fff; box-shadow:-2px 0 10px rgba(0,0,0,.1); - transition:right .3s ease; padding:16px; overflow:auto; z-index: 1000; - } - .course-details.open { right:0; } +/* 问号图标 */ +.help-icon { + display: inline-block; + margin-left: 8px; + color: #888; + font-weight: bold; + cursor: help; +} +.help-icon:hover { color: #4CAF50; } + +/* Portal Tooltip(挂在 body 下) */ +.tooltip-portal { + position: fixed; /* 不受父级 overflow 影响 */ + left: 0; top: 0; + z-index: 999999; /* 足够高,覆盖侧栏等 */ + display: none; + opacity: 0; + transition: opacity .08s linear; + + background: rgba(0,0,0,.9); + color: #fff; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + pointer-events: none; /* 鼠标穿透,避免闪烁 */ + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0,0,0,.25); +} + + + +.add-step-btn, .add-phase-btn { + background-color: #4CAF50; + color: white; + display: block; + border: none; + padding: 5px 10px; + font-size: 14px; + border-radius: 5px; + cursor: pointer; + margin-top: 5px; + margin-left: 10px; +} +.add-step-btn{ + margin-left: 30px; +} +.add-step-btn:hover, .add-phase-btn:hover { + background-color: #45a049; +} + +/* 增加按钮与大纲列表的间距 */ +#outline-tree li { + margin: 5px 0; +} + +#outline-tree li.lvl2 { + padding-left: 20px; +} + +#outline-tree li.lvl3 { + padding-left: 40px; +} + .details-header { display:flex; justify-content:space-between; align-items:center; } .close-btn { border:none; background:transparent; font-size:22px; cursor:pointer; } @@ -20,7 +77,7 @@ #outline-tree .lvl1 { font-weight:700; } #outline-tree .lvl2 { margin-left:12px; } #outline-tree .lvl3 { margin-left:24px; } - #outline-tree li.active { background:#eaf6ff; border-radius:6px; padding:2px 6px; } + #outline-tree li.active { background:#eaf6ff; border-radius:6px; font-weight: bold; } .editor-pane { flex:1; display:flex; flex-direction:column; min-width:0; diff --git a/Html/apps/static/css/teacherboard.css b/Html/apps/static/css/teacherboard.css index ccc1a35..0915ba5 100644 --- a/Html/apps/static/css/teacherboard.css +++ b/Html/apps/static/css/teacherboard.css @@ -52,20 +52,24 @@ } /* 课程目录弹出框 */ - .course-details { position: fixed; top: 0; - right: -400px; + left: -400px; /* 从左侧滑入 */ width: 300px; height: 100%; background-color: #fff; - box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1); /* 修改阴影方向 */ padding: 20px; - transition: right 0.3s ease; + transition: left 0.3s ease; /* 过渡效果改为 left */ z-index: 9999; - border-radius: 15px; /* 添加圆角 */ + border-radius: 15px; } + +.course-details.open { + left: 0; /* 打开时显示 */ +} + /* 基本的课程卡片样式 */ .course-card { width: 150px; @@ -133,7 +137,6 @@ display: flex; justify-content: space-between; } -/* 基本样式,保证输入框和下拉框占满整行 */ input[type="text"], select { width: 100%; /* 占满一行 */ padding: 12px; /* 增加内边距 */ @@ -267,10 +270,6 @@ label[for="course-description"] { color: #333; /* 标签颜色 */ } -.course-details.open { - right: 0; /* 打开时显示 */ -} - .details-header { display: flex; justify-content: space-between; diff --git a/Html/apps/static/js/lesson.js b/Html/apps/static/js/lesson.js index 6f8b598..e0e839b 100644 --- a/Html/apps/static/js/lesson.js +++ b/Html/apps/static/js/lesson.js @@ -1,9 +1,67 @@ window.addEventListener('load', ()=>{ console.log(window.material); console.log(window.lesson); - editLesson(window.material.material_id, window.lesson.chapter_name, window.lesson.lesson_name); + 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'); @@ -51,8 +109,13 @@ function parseHeadings(mdText) { 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) { @@ -88,7 +151,11 @@ function parseHeadings(mdText) { console.log(CURRENT.links); // 拉三份 Markdown - const [t1, t2, t3] = await Promise.all([ fetch(CURRENT.links.lesson).then(r=>r.text()), fetch(CURRENT.links.prompt).then(r=>r.text()), fetch(CURRENT.links.score ).then(r=>r.text()) ]); + 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; @@ -114,62 +181,370 @@ function parseHeadings(mdText) { } } - // 渲染左侧大纲 + // ===== 在渲染一级标题时调用 ===== + 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) { - const li = document.createElement('li'); li.className='lvl1'; li.textContent = `# ${outline.theme}`; tree.appendChild(li); - } - 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); - }); + 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; - } - +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')) { @@ -181,24 +556,57 @@ function parseHeadings(mdText) { } }); - // 保存全部(把当前三个编辑器的文本整体 PUT 回各自文件) - document.getElementById('save-all-btn')?.addEventListener('click', async ()=>{ - const l = document.getElementById('editor-lesson').value; - const p = document.getElementById('editor-prompt').value; - const s = document.getElementById('editor-score').value; + // 保存全部:将三个编辑器中的文本保存回相应的 markdown 文件 + document.getElementById('save-all-btn')?.addEventListener('click', async () => { + try { - const [r1, r2, r3] = await Promise.all([ - fetch(CURRENT.links.lesson, {method:'PUT', body:l}), - fetch(CURRENT.links.prompt, {method:'PUT', body:p}), - fetch(CURRENT.links.score , {method:'PUT', body:s}), - ]); - if (r1.ok && r2.ok && r3.ok) alert('已保存'); - else alert('保存失败,请重试'); + // 将各自的步骤更新保存回文件 + await saveUpdatedStep(); } catch (err) { - console.error(err); alert('保存出错'); + console.error('保存错误:', err); + alert('保存失败,请重试'); } }); + + + const STEP_TEMPLATES = { + lesson: `在这里编写本步骤的课程内容...`, + prompt: `#### 指令 + 请严格遵循本步骤的教学目标,输出所需内容。 - // 右侧详情开/关 - function closeCourseDetails(){ document.getElementById('courseDetails').classList.remove('open'); } - function showInputForNewChapter(){ /* 省略:你的新增逻辑 */ } + #### 约束 + - 使用简明、结构化的格式 + - 语言:中文 + + #### 输出格式 + - 小结 + - 关键要点(列表)`, + score: `#### 评分标准 + - 维度A:... + - 维度B:... + - 维度C:... + + #### 打分 + 请输出 JSON: + {"score": 0-100, "reasons": ["...","..."]}` + }; + const PHASE_TEMPLATES = { + lesson: `### 新步骤 + 本阶段描述:请在此补充阶段说明。 + + `, + prompt: `### 新步骤 + 阶段提示 + 为该阶段的任务撰写总体引导。 + + # 约束 + - 输出语言:中文 + + `, + score: `### 新步骤 + 阶段评分参考 + - 维度A:... + - 维度B:... + ` + }; + \ No newline at end of file diff --git a/Html/apps/static/js/teacherboard.js b/Html/apps/static/js/teacherboard.js index b1a6cf8..bf97673 100644 --- a/Html/apps/static/js/teacherboard.js +++ b/Html/apps/static/js/teacherboard.js @@ -260,6 +260,7 @@ function closeAddCourseModal() { document.getElementById('add-course-modal').style.display = 'none'; } +function onTeacherboardPageLoad(){ // 提交表单处理 document.getElementById('add-course-form').addEventListener('submit', function(event) { event.preventDefault(); @@ -335,3 +336,5 @@ document.getElementById("cover-image-input").addEventListener("change", function }); } }); + +} diff --git a/Html/apps/templates/lesson.html b/Html/apps/templates/lesson.html index 4b98057..a70397d 100644 --- a/Html/apps/templates/lesson.html +++ b/Html/apps/templates/lesson.html @@ -12,13 +12,11 @@ - - + {% include 'navbar.html' %} - -
+
@@ -36,7 +34,6 @@
@@ -69,9 +66,12 @@ diff --git a/Html/apps/templates/teacherboard.html b/Html/apps/templates/teacherboard.html index d9b49b7..3f3df94 100644 --- a/Html/apps/templates/teacherboard.html +++ b/Html/apps/templates/teacherboard.html @@ -9,7 +9,7 @@ - + {% include 'navbar.html' %} diff --git a/Html/apps/views/lessons.py b/Html/apps/views/lessons.py index 3603980..36a3d5a 100644 --- a/Html/apps/views/lessons.py +++ b/Html/apps/views/lessons.py @@ -1,9 +1,10 @@ # views/lesson.py from flask import Blueprint, jsonify, request, render_template, session, current_app +from werkzeug.exceptions import BadRequest from bson import ObjectId from datetime import datetime from ..auth.decorators import require_role -from ..services.course_service import load_material +from ..services.course_service import load_material,save_materials_markdown_cos bp = Blueprint('lesson', __name__) @@ -18,7 +19,7 @@ def edit_lesson_detail(material_id, chapter_name, lesson_name): if chapter.chapter_name == chapter_name: for lesson in chapter.lessons: if lesson.lesson_name == lesson_name: - return render_template('lesson.html', lesson=lesson.model_dump(), role="teacher", material=material.model_dump()) + return render_template('lesson.html', lesson=lesson.model_dump(), role="teacher", material={**material.model_dump(), "material_id": material_id}, chapter=chapter.model_dump()) return jsonify({"message": "课时不存在"}), 404 # 返回某份 markdown 文件内容(你也可以直接返回 S3/OSS 的直链) @@ -32,10 +33,64 @@ def get_markdown_file(material_id, chapter_name, lesson_name, filename): } return demo_map.get(filename, ""), 200 -# 保存(更新)某一份 markdown(前端会把编辑后的文本整体传回) -@bp.route('/api/lesson_file////', methods=['PUT']) -def save_markdown_file(material_id, chapter_name, lesson_name, filename): - text = request.get_data(as_text=True) - # TODO: 写入存储,并做版本/审计 - # 返回更新时间等 - return jsonify({"saved": True, "updated_at": datetime.utcnow().isoformat()}), 200 +@bp.route("/api/materials/save///", methods=['POST']) +@require_role(roles='teacher') +def save_materials_markdown(material_id, chapter_name, lesson_name): + payload = request.get_json(silent=True, force=True) + if not payload: + raise BadRequest("缺少 JSON payload") + + # 基础校验 + required_keys = ("lesson", "prompt", "score") + for k in required_keys: + if k not in payload or not isinstance(payload[k], dict): + raise BadRequest(f"缺少或非法字段: {k}") + if "cdn_link" not in payload[k] or "content" not in payload[k]: + raise BadRequest(f"{k} 需要包含 cdn_link 与 content 字段") + if not isinstance(payload[k]["cdn_link"], str) or not isinstance(payload[k]["content"], str): + raise BadRequest(f"{k}.cdn_link 与 {k}.content 必须为字符串") + + # 可选:版本/并发控制(若你前端传了) + version = payload.get("version") + + docs = { + "lesson": { + "cdn_link": payload["lesson"]["cdn_link"].strip(), + "content" : payload["lesson"]["content"] + }, + "prompt": { + "cdn_link": payload["prompt"]["cdn_link"].strip(), + "content" : payload["prompt"]["content"] + }, + "score": { + "cdn_link": payload["score"]["cdn_link"].strip(), + "content" : payload["score"]["content"] + } + } + + # 轻量防御:限制单次大小(自行按需调整) + max_size = 4 * 1024 * 1024 # 2MB/份 + for name, doc in docs.items(): + if len(doc["content"].encode("utf-8")) > max_size: + raise BadRequest(f"{name} 文档过大,超过 {max_size} 字节限制") + + # 调用服务层保存:写源站、刷新/回写 CDN、更新索引等 + # 建议服务层返回新版本/新链接等元信息 + result = save_materials_markdown_cos( + material_id=material_id, + chapter_name=chapter_name, + lesson_name=lesson_name, + docs=docs, + version=version, # 可选,用于并发检测 + actor=getattr(request, "user", None) # 若你的鉴权中注入了当前教师 + ) + + # 统一响应 + return jsonify({ + "ok": True, + "material_id": material_id, + "chapter_name": chapter_name, + "lesson_name": lesson_name, + "result": result # e.g. {"lesson":{"cdn_link": "...", "version":"..."}, ...} + }), 200 +