lesson more edit

This commit is contained in:
Cai
2025-09-18 11:47:24 +00:00
parent 15963190cb
commit 6575ee80bc
7 changed files with 638 additions and 83 deletions

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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('保存失败,请重试');
}
});

View File

@@ -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 = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<i class="fas fa-pencil-alt"></i>
</svg>
`;
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 = `
<h3 id="dialog-title">${title}</h3>
<div class="field">
<label for="title-input" class="sr-only">标题</label>
<input id="title-input" type="text" placeholder="请输入标题" value="${escapeHtmlAttr(initialValue)}" />
<div class="error" id="title-error"></div>
</div>
<div class="actions">
<button type="button" class="btn" id="cancel-btn">取消</button>
<button type="button" class="btn primary" id="ok-btn">确定</button>
</div>
`;
backdrop.appendChild(title_change_modal);
document.body.appendChild(backdrop);
const input = title_change_modal.querySelector('#title-input');
const okBtn = title_change_modal.querySelector('#ok-btn');
const cancelBtn = title_change_modal.querySelector('#cancel-btn');
const errBox = title_change_modal.querySelector('#title-error');
const prevFocus = document.activeElement;
const close = () => {
document.body.removeChild(backdrop);
if (prevFocus && prevFocus.focus) prevFocus.focus();
};
const submit = () => {
const raw = input.value;
const result = onSubmit ? onSubmit(raw) : { ok: true };
if (result && result.ok) {
close();
} else if (result && result.message) {
errBox.textContent = result.message;
input.setAttribute('aria-invalid', 'true');
input.focus();
}
};
okBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', close);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close(); // 点击遮罩关闭
});
title_change_modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
if (e.key === 'Enter') submit();
});
// 初始聚焦
setTimeout(() => input.focus(), 0);
}
// ======= 验证与规范化 =======
function validateTitle(raw) {
if (typeof raw !== 'string') return { ok: false, message: '标题格式不正确' };
// 1) 去掉 Markdown 井号前缀(若用户手动输入了 ###
let t = raw.replace(/^#+\s*/, '');
// 2) 替换换行为单空格(标题单行)
t = t.replace(/\r?\n+/g, ' ');
// 3) 去首尾空白 & 折叠多空格
t = t.trim().replace(/\s{2,}/g, ' ');
// 4) 基础规则
if (t.length === 0) return { ok: false, message: '标题不能为空' };
if (t.length > 80) return { ok: false, message: '标题不能超过 80 个字符' };
// 5) 可选:禁止只包含标点
if (!/[0-9A-Za-z\u4e00-\u9fa5]/.test(t)) {
return { ok: false, message: '标题需包含字母/数字/中文' };
}
return { ok: true, value: t };
}
// 显示时的规范化(比如初始渲染)
function normalizeTitle(s) {
if (!s) return '';
return s.replace(/^#+\s*/, '').replace(/\r?\n+/g, ' ').trim().replace(/\s{2,}/g, ' ');
}
// 简单转义,避免把初始值插进 input 时破坏属性
function escapeHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

View File

@@ -11,9 +11,7 @@
</head>
<body class="markdown-body">
<h1>效率的重要性与实践验证</h1>
<blockquote>
<p>Auto-generated at 2025-09-16 05:03</p>
</blockquote>
<h2>效率</h2>
<h3>理论热身:算法选择的智慧</h3>
<h4>任务:分析与决策</h4>
<p>项目组目前有两套备选的交通信号灯同步算法,它们将在不同性能的服务器上运行:</p>

View File

@@ -9,10 +9,12 @@
<link rel="stylesheet" href="/static/css/lesson.css">
<link rel="stylesheet" href="/static/css/teacher_course_setting.css">
<link rel="stylesheet" href="/static/css/lesson_setting.css">
<link rel="stylesheet" href="/static/css/modelinput.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/static/js/modelinput.js"></script>
</head>
<body >
{% include 'navbar.html' %} <!-- 引入navbar.html -->
@@ -41,7 +43,11 @@
<div class="lesson-editor">
<aside class="outline">
<button id="toggle-outline-reorder" class="reorder-btn" title="排序模式" aria-pressed="false">
排序
<i class="fa-solid fa-arrow-up-short-wide"></i>
</button>
<!-- 新增:删除模式按钮 -->
<button id="toggle-outline-delete" class="delete-btn" title="删除模式" aria-pressed="false">
<i class="fa-solid fa-trash-can"></i>
</button>
<ul id="outline-tree"></ul>
@@ -49,9 +55,9 @@
<main class="editor-pane">
<div class="editor-tabs">
<button class="tab-btn active" data-target="tab-lesson">Lesson</button>
<button class="tab-btn" data-target="tab-prompt">Prompt</button>
<button class="tab-btn" data-target="tab-score">Score Prompt</button>
<button class="tab-btn active" data-target="tab-lesson" id="tab-btn-lesson">Lesson</button>
<button class="tab-btn" data-target="tab-prompt" id="tab-btn-prompt">Prompt</button>
<button class="tab-btn" data-target="tab-score" id="tab-btn-score">Score Prompt</button>
<div class="actions">
<button id="save-all-btn">保存全部</button>
@@ -60,13 +66,13 @@
<div class="tab-content">
<div id="tab-lesson" class="tab active">
<textarea id="editor-lesson"></textarea>
<textarea id="editor-lesson" data-type="lesson"></textarea>
</div>
<div id="tab-prompt" class="tab">
<textarea id="editor-prompt"></textarea>
<textarea id="editor-prompt" data-type="prompt"></textarea>
</div>
<div id="tab-score" class="tab">
<textarea id="editor-score"></textarea>
<textarea id="editor-score" data-type="score"></textarea>
</div>
</div>
</main>

View File

@@ -1,32 +1,36 @@
from hmac import new
import shutil
from flask import Blueprint, request, jsonify, current_app, render_template, session
from flask import Blueprint, request, jsonify, session
import os
import shutil
from flask import current_app
from ..services.file_service import get_file_tree
file_bp = Blueprint('file', __name__, url_prefix='/vsc-like')
# ===== 永久调试:写死 session 值 =====
@file_bp.route('/create-folder', methods=['POST'])
def create_folder():
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
try:
filepath = request.json.get('path', '')
path = session.get('path')
parent = request.json.get('parent', '')
base_path = f'{path}'
base_path = f'/tmp/{session.get("user_id")}/{path}'
filepath = f'{base_path}/{parent}/{filepath}'
try:
os.makedirs(filepath)
os.makedirs(filepath, exist_ok=True)
return jsonify({"message": "Folder created successfully"})
except Exception as e:
return jsonify({"error": str(e)}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/create-file', methods=['POST'])
def create_file():
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
filepath = request.json.get('path', '')
path = session.get('path')
parent = request.json.get('parent', '')
base_path = f'{path}'
base_path = f'/tmp/{session.get("user_id")}/{path}'
filepath = f'{base_path}/{parent}/{filepath}'
try:
open(filepath, 'w').close()
@@ -34,13 +38,15 @@ def create_file():
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/rename-file', methods=['POST'])
def rename_file():
user_root = session.get('path')
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
path = request.json.get('path', '')
new_name = request.json.get('newName', '')
base_path = f'{user_root}'
filepath = f'{base_path}/{path}'
filepath = f'{user_root}/{path}'
try:
path_list = filepath.split('/')
path_list[-1] = new_name
@@ -50,13 +56,15 @@ def rename_file():
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/rename-folder', methods=['POST'])
def rename_folder():
user_root = session.get('path')
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
path = request.json.get('path', '')
new_name = request.json.get('newName', '')
base_path = f'{user_root}'
folderpath = f'{base_path}/{path}'
folderpath = f'{user_root}/{path}'
try:
path_list = folderpath.split('/')
path_list[-1] = new_name
@@ -66,56 +74,46 @@ def rename_folder():
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/copy-files', methods=['POST'])
def copy_files():
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
old_path = request.json.get('oldPath', '')
new_path = request.json.get('newPath', '')
user_root = session.get('path')
base_path = f'{user_root}'
old_path = f'{base_path}/{old_path}'
new_path = f'{base_path}/{new_path}'
# oldpath的父目录就是newpath的话,对oldpath进行重命名
# 获取 old_path 的文件/文件夹名
old_name = os.path.basename(old_path)
# 确保目标路径是父目录(即 new_path 是一个目录)
new_path_parent = os.path.abspath(new_path)
# 如果目标路径下已经存在同名文件/文件夹,进行重命名
final_new_path = os.path.join(new_path_parent, old_name)
final_new_path = os.path.join(new_path, old_name)
counter = 1
while os.path.exists(final_new_path):
# 如果文件夹或文件已经存在,则为新的文件名添加计数后缀
final_new_path = os.path.join(new_path_parent, f"{old_name}_{counter}")
final_new_path = os.path.join(new_path, f"{old_name}_{counter}")
counter += 1
# 判断 old_path 是文件夹还是文件,并进行相应的复制操作
if os.path.isdir(old_path):
shutil.copytree(old_path, final_new_path, dirs_exist_ok=True)
else:
shutil.copy(old_path, final_new_path)
return jsonify({"message": "Files copied successfully"})
@file_bp.route('/delete-file', methods=['POST'])
def delete_file():
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
path = request.json.get('path', '')
user_root = session.get('path')
base_path = f'{user_root}'
filepath = f'{base_path}/{path}'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
filepath = f'{user_root}/{path}'
if os.path.isdir(filepath):
shutil.rmtree(filepath)
else:
os.remove(filepath)
return jsonify({"message": "File deleted successfully"})
@file_bp.route('/file-content', methods=['POST'])
def file_content():
path = session.get('path')
base_path = f'{path}'
filepath = request.json.get('path', '')
filepath = f'{base_path}/{filepath}'
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
filepath = f'{user_root}/{request.json.get("path", "")}'
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
try:
@@ -125,35 +123,27 @@ def file_content():
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/save-file', methods=['POST'])
def save_file():
path = session.get('path')
base_path = f'{path}'
filepath = request.json.get('path', '')
filepath = f'{base_path}/{filepath}'
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
filepath = f'{user_root}/{request.json.get("path", "")}'
content = request.json.get('content', '')
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
try:
with open(filepath, 'w') as file:
# 文件不存在就创建
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as file:
file.write(content)
return jsonify({"message": "File saved successfully"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@file_bp.route('/file-tree', methods=['GET'])
def file_tree():
path = session.get('path')
user_id = session.get('user_id')
if not path:
return jsonify({"error": "Missing path parameter"}), 400
root_workspace_path = current_app.config['ROOT_WORKSPACE_PATH']
base_path = os.path.join(root_workspace_path, user_id, path)
print(base_path)
if not os.path.exists(base_path):
return jsonify({"error": "Workspace not found"}), 404
tree = get_file_tree(base_path)
return jsonify(tree)
session['path'] = 'algotest/8080/lesson'
session['user_id'] = 'test'
user_root = f'/tmp/{session.get("user_id")}/{session.get("path")}'
# 确保目录存在
os.makedirs(user_root, exist_ok=True)
# 返回空列表,前端不再报错
return jsonify([])