diff --git a/Html/apps/static/css/lesson.css b/Html/apps/static/css/lesson.css index da38f7b..3b0b39f 100644 --- a/Html/apps/static/css/lesson.css +++ b/Html/apps/static/css/lesson.css @@ -55,13 +55,50 @@ } #outline-tree li.lvl2 { - padding-left: 20px; + padding-left: 1px; } #outline-tree li.lvl3 { - padding-left: 40px; + padding-left: 20px; } + + + +/* 删除模式下可删除的节点样式 */ + .outline.delete-mode #outline-tree .lvl2, + .outline.delete-mode #outline-tree .lvl3 { + border: 1px solid #dc2626; /* 红色边框 */ + border-radius: 6px; + padding: 4px 6px; + transition: background-color .15s ease, box-shadow .15s ease; + cursor: pointer; + } + .outline.delete-mode #outline-tree .lvl2:hover, + .outline.delete-mode #outline-tree .lvl3:hover { + background: rgba(220,38,38,.06); /* 微红背景 */ + box-shadow: 0 0 0 2px rgba(220,38,38,.15) inset; + } + + /* 删除按钮样式(可按需调整) */ + .delete-btn { + margin-left: 6px; + border: none; + background: transparent; + cursor: pointer; + padding: 6px; + border-radius: 8px; + } + .delete-btn[aria-pressed="true"] { + background: rgba(220,38,38,.12); + outline: 2px solid #dc2626; + } + .delete-btn:focus-visible { outline: 2px solid #4c9ffe; } + + + + + .details-header { display:flex; justify-content:space-between; align-items:center; } .close-btn { border:none; background:transparent; font-size:22px; cursor:pointer; } diff --git a/Html/apps/static/css/modelinput.css b/Html/apps/static/css/modelinput.css new file mode 100644 index 0000000..1b46657 --- /dev/null +++ b/Html/apps/static/css/modelinput.css @@ -0,0 +1,34 @@ +.help-icon { opacity: .7; cursor: help; } + .edit-btn { + border: none; background: transparent; cursor: pointer; + padding: 4px; border-radius: 6px; + } + .edit-btn:focus-visible { outline: 2px solid #4c9ffe; } + .edit-btn svg { width: 16px; height: 16px; display: block; } + + /* title_change_modal */ + .title_change_modal-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.35); + display: flex; align-items: center; justify-content: center; z-index: 9999; + } + .title_change_modal { + width: min(520px, 92vw); background: #fff; border-radius: 12px; padding: 16px; + box-shadow: 0 10px 30px rgba(0,0,0,.25); + } + .title_change_modal h3 { margin: 0 0 10px; font-size: 16px; } + .title_change_modal .field { display: flex; flex-direction: column; gap: 6px; } + .title_change_modal input[type="text"] { + padding: 8px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 14px; + } + .title_change_modal .error { + color: #c62828; font-size: 12px; min-height: 1.2em; /* 占位避免跳动 */ + } + .title_change_modal .actions { + margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; + } + .btn { + padding: 6px 12px; border-radius: 8px; border: 1px solid #d0d0d0; background: #f6f6f6; cursor: pointer; + } + .btn.primary { background: #2563eb; color: #fff; border-color: #2563eb; } + .btn:focus-visible { outline: 2px solid #4c9ffe; } + .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } diff --git a/Html/apps/static/js/lesson.js b/Html/apps/static/js/lesson.js index 86ab777..453d0f3 100644 --- a/Html/apps/static/js/lesson.js +++ b/Html/apps/static/js/lesson.js @@ -109,7 +109,7 @@ function parseHeadings(mdText) { links: { lesson:'', prompt:'', score:'' }, raw: { lesson:'', prompt:'', score:'' }, parsed:{ lesson:null, prompt:null, score:null }, - active:{ phase:null, step:null }, + active:{ phase:null, step:null, lessontxt:null, prompt:null, score:null }, outline:null }; function bustCache(url) { @@ -185,12 +185,40 @@ function parseHeadings(mdText) { function addThemeWithHelp(tree, themeText) { const li = document.createElement('li'); li.className = 'lvl1'; - li.textContent = themeText; + + // 标题文本容器(单独一个 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( @@ -214,7 +242,47 @@ function parseHeadings(mdText) { // 二级标题 const li2 = document.createElement('li'); li2.className = 'lvl2'; - li2.textContent = `${ph.text}`;//## + 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); // 渲染三级标题(步骤) @@ -406,6 +474,97 @@ function addStepToPhase(phaseText) { 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'); @@ -465,11 +624,88 @@ function addStepToPhase(phaseText) { 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; @@ -497,6 +733,9 @@ function addStepToPhase(phaseText) { //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); @@ -510,6 +749,13 @@ function addStepToPhase(phaseText) { 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) } // 更新对应步骤的内容 @@ -530,6 +776,121 @@ function addStepToPhase(phaseText) { 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 editors = 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(把当前值规范化一次) + editors.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 { + editors.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; + } + } +editors.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 的片段(不存在则回退) @@ -551,6 +912,21 @@ function loadStepToEditors(phaseText, stepText) { document.getElementById('editor-lesson').value = lText; document.getElementById('editor-prompt').value = pText; document.getElementById('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) + } // Tab 切换 @@ -572,7 +948,6 @@ function loadStepToEditors(phaseText, stepText) { await saveUpdatedStep(); } catch (err) { console.error('保存错误:', err); - alert('保存失败,请重试'); } }); diff --git a/Html/apps/static/js/modelinput.js b/Html/apps/static/js/modelinput.js new file mode 100644 index 0000000..4e2ae95 --- /dev/null +++ b/Html/apps/static/js/modelinput.js @@ -0,0 +1,115 @@ + // ======= 工具:创建编辑按钮(含铅笔 SVG) ======= + function createEditButton(onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'edit-btn'; + btn.setAttribute('aria-label', '编辑标题'); + btn.innerHTML = ` + + `; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + onClick && onClick(); + }); + return btn; + } +// ======= 模态框:打开标题编辑器 ======= + function openTitleEditor({ title = '修改标题', initialValue = '', onSubmit }) { + const backdrop = document.createElement('div'); + backdrop.className = 'title_change_modal-backdrop'; + + const title_change_modal = document.createElement('div'); + title_change_modal.className = 'title_change_modal'; + title_change_modal.setAttribute('role', 'dialog'); + title_change_modal.setAttribute('aria-title_change_modal', 'true'); + + title_change_modal.innerHTML = ` +
-+Auto-generated at 2025-09-16 05:03
-
项目组目前有两套备选的交通信号灯同步算法,它们将在不同性能的服务器上运行:
diff --git a/Html/apps/templates/lesson.html b/Html/apps/templates/lesson.html index 9f8d247..a443a5b 100644 --- a/Html/apps/templates/lesson.html +++ b/Html/apps/templates/lesson.html @@ -9,10 +9,12 @@ + + {% include 'navbar.html' %} @@ -41,7 +43,11 @@