// 认证改为 HttpOnly Cookie(gateway 下发 q4_token),浏览器同源请求自动携带,JS 不再经手 Token /* by qing. * * 用于获取一个节点下的所有子节点信息,0为根节点。 */ async function getNode(id) { try { const obj = { "pid": id } const jsonBody = JSON.stringify(obj) console.log(jsonBody) const res = await fetch('/get_notes', { method: 'POST', headers: { 'Content-Type': 'application/json', // 告诉服务器内容为json 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); // WAF 拦截:弹提示并中止,不能落入 catch(其会向上抛出) if (window.WafTip && WafTip.isBlocked(res, responseText)) { alert(WafTip.MESSAGE); return; } let result; try { result = JSON.parse(responseText); } catch { console.error(`获取笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),仅此一种情况跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(result.message || `获取节点失败(HTTP ${res.status})`); } //if (result.success) { console.log(`获取节点成功`); return result //... //} else { // alert(result.message || '获取节点失败'); //} } catch (err) { // 非 401 失败(网络异常 / 5xx / 非 JSON 响应):提示真实原因并向上抛出, // 调用方 .then(load_notes/loadMap) 不再执行,当前页面内容保持不变; // 不再把任意错误误判为凭证失效而跳登录页 console.error('获取节点请求异常:', err); alert(err.message || '获取节点失败,请检查网络后重试'); throw err; } } /* by qing * * 用于向dnfs添加节点 */ async function addNode(pid, title, content) { try { obj = { "pid": pid, "title": title, "content": content } jsonBody = JSON.stringify(obj) console.log(jsonBody) const res = await fetch('/add_note', { method: 'POST', headers: { 'Content-Type': 'application/json', // 告诉服务器内容为json 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { console.error(`添加笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(`添加节点失败(HTTP ${res.status})`); } input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); /* 退出编辑模式并重置高度 */ getNode(stack[stack.length - 1]).then(load_notes); getNode(stack[stack.length - 1]).then(loadMap) //if (result.success) { alert(`添加节点成功`, result); return result //... //} else { // alert(result.message || '获取节点失败'); //} } catch (err) { alert(err.message || '接口请求失败,请检查后端服务是否启动'); console.error('添加节点请求异常:', err); } finally { } } async function deleteNode(id) { if (confirm("确认删除此节点?") == false) { return; } try { obj = {"id": id}; jsonBody = JSON.stringify(obj); console.log(jsonBody); const res = await fetch('/delete_note', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { console.error(`删除笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(`删除节点失败(HTTP ${res.status})`); } stack.pop(); getNode(stack[stack.length - 1]).then(load_notes); getNode(stack[stack.length - 1]).then(loadMap) console.log(`删除节点成功`); return result } catch (err) { alert(err.message || '接口请求失败,请检查后端服务是否启动'); console.error('删除节点请求异常:', err); } } /* by qing — 卡片上的"删除":删除子节点后停留在当前层,仅刷新列表(不弹栈) */ async function deleteChild(id) { if (confirm("确认删除此节点?") == false) { return; } try { obj = {"id": id}; jsonBody = JSON.stringify(obj); const res = await fetch('/delete_note', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { console.error(`删除笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(result.error === "It's not your note" ? '只能删除自己的节点' : (result.error || `删除节点失败(HTTP ${res.status})`)); } // 若删掉的正是编辑中的节点,顺带退出编辑模式 if (editing_id == id) { cancelEdit(); input_note_title.value = ""; input_note_content.value = ""; } var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; getNode(cur).then(load_notes); getNode(cur).then(loadMap); console.log(`删除节点成功`); return result; } catch (err) { alert(err.message || '接口请求失败,请检查后端服务是否启动'); console.error('删除节点请求异常:', err); } } /* by qing — 保存编辑:调用 /update_note,成功后刷新当前层并退出编辑模式 */ async function updateNode(id, pid, title, content) { try { obj = { "id": Number(id), "pid": pid, "title": title, "content": content } jsonBody = JSON.stringify(obj) const res = await fetch('/update_note', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(result.error === "It's not your note" ? '只能编辑自己的节点' : (result.error || `更新节点失败(HTTP ${res.status})`)); } input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; getNode(cur).then(load_notes); getNode(cur).then(loadMap); alert('修改成功'); return result; } catch (err) { alert(err.message || '接口请求失败,请检查后端服务是否启动'); console.error('更新节点请求异常:', err); } } /* by qing — 节点移动:本质是 pid 变更的 update_note,title/content 原样携带 */ async function moveNode(id, pid, title, content) { try { obj = { "id": Number(id), "pid": pid, "title": title, "content": content } jsonBody = JSON.stringify(obj) const res = await fetch('/update_note', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Target-Url': 'http://127.0.0.1:8006' }, body: jsonBody }); const responseText = await res.text(); if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE); let result; try { result = JSON.parse(responseText); } catch { console.error(`更新笔记接口返回非 JSON 内容(HTTP ${res.status}):`, responseText); throw new Error('服务器响应格式异常,请稍后重试'); } if (res.status === 401) { // 凭证失效(过期或网关重启),跳转重新登录 location.href = '/login.html'; return; } if (!res.ok) { throw new Error(result.error === "It's not your note" ? '只能移动自己的节点' : (result.error || `移动节点失败(HTTP ${res.status})`)); } var cur = (stack.length == 0) ? 0 : stack[stack.length - 1]; getNode(cur).then(load_notes); getNode(cur).then(loadMap); alert('移动成功'); return result; } catch (err) { alert(err.message || '接口请求失败,请检查后端服务是否启动'); console.error('移动节点请求异常:', err); } } /* by qing — 点击卡片"移动":暂存节点信息,底部弹出移动条 */ function startMove(item) { moving_node = { id: Number(item.id), pid: item.pid, title: item.title, content: item.content }; renderMoveBar(); } /* by qing — 渲染底部移动条:显示待移动节点,提供"移动到当前层"和"取消" */ function renderMoveBar() { movebar.replaceChildren(); if (moving_node == null) { return; } let tip = document.createElement('span'); tip.className = "move-title"; tip.textContent = "移动中:" + moving_node.title; let ok_btn = document.createElement('button'); ok_btn.className = "act-btn act-edit"; ok_btn.textContent = "移动到当前层"; ok_btn.addEventListener('click', confirmMove); let cancel_btn = document.createElement('button'); cancel_btn.className = "act-btn act-del"; cancel_btn.textContent = "取消"; cancel_btn.addEventListener('click', function() { moving_node = null; renderMoveBar(); }); movebar.appendChild(tip); movebar.appendChild(ok_btn); movebar.appendChild(cancel_btn); } /* by qing — 执行移动:把待移动节点挂到当前层(stack 顶,根为 0)。 * 后端 update_note 会走 AI 内容审核,耗时较长,期间禁用移动条与表单按钮防止重复提交 */ async function confirmMove() { if (moving_node == null) { return; } var target_pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; if (target_pid == moving_node.id) { alert('不能移动到节点自身内部'); return; } if (target_pid == moving_node.pid) { alert('该节点已在当前层'); return; } // 防重复点击:请求进行中禁用按钮(成功/失败都在 finally 恢复) movebar.querySelectorAll('button').forEach(function(b) { b.disabled = true; }); input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; try { var result = await moveNode(moving_node.id, target_pid, moving_node.title, moving_node.content); if (result != null) { moving_node = null; renderMoveBar(); } } finally { movebar.querySelectorAll('button').forEach(function(b) { b.disabled = false; }); input_note_submit.disabled = false; input_note_clear.disabled = false; delete_note.disabled = false; } } /* by shufeng2012 */ function renderMarkdown(element, content) { /* Markdown渲染 */ const md = window.markdownit({ html: false, highlight: function(code, lang) { if (lang && hljs.getLanguage(lang)) { return `
${hljs.highlight(code, {language: lang}).value}
` } else { return `
${md.utils.escapeHtml(code)}
` } } }); element.innerHTML = md.render(content); } /* ===== by qing — 特殊节点(管理员专属)===== * 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。 * 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */ /* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */ var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024; function parseSp(content) { if (typeof content !== 'string') return null; var s = content.trim(); if (s.indexOf('{"_sp"') !== 0) return null; try { var obj = JSON.parse(s); } catch (e) { return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) } if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; return null; } /* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */ function renderContent(container, item) { var sp = parseSp(item.content); if (!sp) { renderMarkdown(container, item.content); return; } function appendNote() { if (typeof sp.note === 'string' && sp.note.trim()) { var note = document.createElement('div'); note.className = 'sp-note'; note.textContent = sp.note; container.appendChild(note); } } if (sp._sp === 'link') renderSpLink(container, sp, appendNote); else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote); else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); } /* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */ function renderSpLink(container, sp, appendNote) { var box = document.createElement('div'); box.className = 'sp-link'; var icon = document.createElement('img'); icon.className = 'sp-favicon'; icon.alt = ''; icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url); icon.addEventListener('error', function() { icon.style.display = 'none'; }); var anchor = document.createElement('a'); anchor.className = 'sp-anchor'; anchor.href = sp.url; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.textContent = sp.url; anchor.addEventListener('click', function(e) { e.stopPropagation(); }); box.appendChild(icon); box.appendChild(anchor); container.appendChild(box); appendNote(); } /* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */ function renderSpHtml(container, sp, appendNote) { var bar = document.createElement('div'); bar.className = 'sp-actions'; var audit_btn = document.createElement('button'); audit_btn.className = 'act-btn act-edit'; audit_btn.textContent = '审计源码'; var run_btn = document.createElement('button'); run_btn.className = 'act-btn act-move'; run_btn.textContent = '运行'; var pre = document.createElement('pre'); pre.className = 'sp-code'; pre.style.display = 'none'; pre.textContent = sp.code; audit_btn.addEventListener('click', function(e) { e.stopPropagation(); var hidden = (pre.style.display === 'none'); pre.style.display = hidden ? 'block' : 'none'; audit_btn.textContent = hidden ? '收起源码' : '审计源码'; }); run_btn.addEventListener('click', function(e) { e.stopPropagation(); var win = window.open('', '_blank'); if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; } win.document.open(); win.document.write(sp.code); win.document.close(); }); bar.appendChild(audit_btn); bar.appendChild(run_btn); container.appendChild(bar); container.appendChild(pre); appendNote(); } /* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */ function renderSpImage(container, sp, appendNote) { var img = document.createElement('img'); img.className = 'sp-image'; img.src = sp.data; img.alt = sp.name || '图片'; container.appendChild(img); appendNote(); } /* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */ function renderSpFile(container, sp, appendNote) { var bar = document.createElement('div'); bar.className = 'sp-actions'; var btn = document.createElement('button'); btn.className = 'act-btn act-edit'; btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')'; btn.addEventListener('click', function(e) { e.stopPropagation(); try { var bin = atob(sp.data); var bytes = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = sp.name || 'download'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000); } catch (err) { alert('文件解码失败:' + err.message); } }); bar.appendChild(btn); container.appendChild(bar); appendNote(); } function formatBytes(n) { var units = ['B', 'KB', 'MB', 'GB']; var i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return n.toFixed(1) + units[i]; } /* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */ const ICON_EYE = ''; const ICON_HEART = ''; const ICON_SHARE = ''; const ICON_EDIT = ''; const ICON_TRASH = ''; const ICON_MOVE = ''; /* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */ function makeStatItem(iconSvg, count) { let span = document.createElement('span'); span.className = "stat-item"; span.innerHTML = iconSvg; // 上方固定常量字符串,不含用户输入 let num = document.createElement('span'); num.className = "stat-num"; num.textContent = (count == null) ? "0" : String(count); span.appendChild(num); return span; } /* ---- by qing ---- */ const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/ var article_title = document.getElementById('article-title'); var article_content = document.getElementById('article-content'); var input_note_title = document.getElementById('input-note-title'); var input_note_content = document.getElementById('input-note-content'); var input_note_submit = document.getElementById('input-note-submit'); var input_note_clear = document.getElementById('input-note-clear'); var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var movebar = document.getElementById("movebar"); /* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */ var sp_panel = document.getElementById('sp-panel'); var sp_type = document.getElementById('sp-type'); var sp_file = document.getElementById('sp-file'); var sp_file_tip = document.getElementById('sp-file-tip'); var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */ /* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */ var PANEL_EXPAND_MIN_WIDTH = 768; var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */ /* ===== 输入框自动高度调整 ===== */ function autoResizeTextarea(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 400) + 'px'; el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden'; } input_note_content.addEventListener('input', function() { autoResizeTextarea(input_note_content); }); function resetTextareaHeight() { input_note_content.style.height = '120px'; input_note_content.style.overflowY = 'hidden'; } autoResizeTextarea(input_note_content); /* 初始化高度 */ /* ===== 特殊节点表单:类型切换控制输入区形态 ===== */ sp_type.addEventListener('change', function() { var t = sp_type.value; var is_file = (t === 'image' || t === 'file'); sp_file.style.display = is_file ? '' : 'none'; input_note_content.style.display = is_file ? 'none' : ''; sp_file_tip.textContent = ''; sp_file_tip.className = is_file ? 'tip-yellow' : ''; if (!is_file) { sp_file.value = ''; sp_edit_original = null; } if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)'; else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码'; else input_note_content.placeholder = ''; }); function readFileAsDataURL(f) { return new Promise(function(resolve, reject) { var r = new FileReader(); r.onload = function() { resolve(r.result); }; r.onerror = function() { reject(new Error('文件读取失败')); }; r.readAsDataURL(f); }); } /* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。 * 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */ async function buildSubmitContent() { var t = (current_user && current_user.is_admin) ? sp_type.value : 'text'; if (t === 'text') return input_note_content.value; if (t === 'link') { var url = input_note_content.value.trim(); if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; } return JSON.stringify({ _sp: 'link', url: url }); } if (t === 'html') { var code = input_note_content.value; if (!code.trim()) { alert('请输入 HTML 代码'); return null; } return JSON.stringify({ _sp: 'html', code: code }); } /* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */ if (sp_file.files.length === 0) { if (sp_edit_original) return sp_edit_original; alert('请选择要上传的' + (t === 'image' ? '图片' : '文件')); return null; } var f = sp_file.files[0]; if (f.size > SP_MAX_FILE_BYTES) { alert('文件过大(上限约 2.5MB):' + f.name); return null; } var dataUrl = await readFileAsDataURL(f); if (t === 'image') { if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; } return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl }); } return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] }); } input_note_submit.addEventListener('click', function(event) { if (input_note_submit.disabled) return; // 防重复点击 var title = input_note_title.value var pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; //alert(stack.length) //alert(pid) // 发布期间同时禁用重置/删除,避免误触干扰 input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; // 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件) buildSubmitContent() .then(function(content) { if (content == null) return null; // 校验失败,已弹提示 return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); }) .catch(function(err) { alert(err.message || '内容处理失败'); console.error('构造提交内容异常:', err); }) .finally(() => { input_note_submit.disabled = false; input_note_clear.disabled = false; delete_note.disabled = false; }); }) input_note_clear.addEventListener('click', function(event) { input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); // 重置时同时退出编辑模式 }) delete_note.addEventListener('click', (event) => { if (delete_note.disabled) return; // 防重复点击 var id = (stack.length == 0) ? 0 : stack[stack.length - 1]; if (id == 0) { alert('无法删除根节点'); console.log("节点未删除"); } else { delete_note.disabled = true; // confirm 取消时 deleteNode 直接返回 undefined,统一用 Promise.resolve 兜底恢复按钮 Promise.resolve(deleteNode(id)) .finally(() => { delete_note.disabled = false; }); } }) /* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。 * 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */ function startEdit(item) { editing_id = item.id; input_note_title.value = item.title; var sp = parseSp(item.content); sp_edit_original = null; if (sp && (sp._sp === 'link' || sp._sp === 'html')) { sp_type.value = sp._sp; sp_type.dispatchEvent(new Event('change')); input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || ''); } else if (sp) { sp_type.value = sp._sp; sp_type.dispatchEvent(new Event('change')); sp_edit_original = item.content; sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件'); input_note_content.value = ''; } else { sp_type.value = 'text'; sp_type.dispatchEvent(new Event('change')); input_note_content.value = item.content; } input_note_submit.textContent = "保存修改"; autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */ input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); input_note_title.focus(); } /* by qing — 退出编辑模式,表单恢复为新增状态 */ function cancelEdit() { editing_id = null; sp_edit_original = null; sp_file.value = ''; sp_file_tip.textContent = ''; if (sp_type.value !== 'text') { sp_type.value = 'text'; sp_type.dispatchEvent(new Event('change')); } input_note_submit.textContent = "提交"; resetTextareaHeight(); /* 恢复初始高度 */ } /* 导航守卫:表单有未提交内容时先 confirm;确认放弃则清空表单放行,否则本次导航不执行。 * 必须在调用方修改 stack 之前调用(点卡片 push 前 / 返回上一层 pop 前), * 否则用户选择"不放弃"时 stack 已被改动,后续导航与新增节点的 pid 全部错位 */ function confirmLeaveIfDirty() { if (input_note_title.value == "" && input_note_content.value == "") return true; if (confirm("是否放弃当前输入的内容?") == false) return false; input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); return true; } function load_notes(main_node) { /* 这个不能设置为空的 */ //article_title.textContent = ""; //article_content.textContent = ""; if (input_note_title.value != "" || input_note_content.value != "") { if (confirm("是否放弃当前输入的内容?") == true) { input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); } else { if (stack.length != 0) { return; } } } else { input_note_title.value = ""; input_note_content.value = ""; cancelEdit(); } recommend_list.replaceChildren(); for (let i in main_node) { let item = main_node[i] console.log(item) let card = document.createElement('div'); card.className = "card"; card.addEventListener('click', function(event) { if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不跳转,stack 保持原样 */ var id = Number(item.id) stack.push(id) getNode(id).then(load_notes) getNode(id).then(loadMap) }); let ttl = document.createElement('center'); let ttl1 =document.createElement('h3'); ttl1.textContent = item.title; card.id = item.title; ttl.appendChild(ttl1); card.appendChild(ttl); let wrt_div = document.createElement('p'); // 发帖用户:头像圆点(取昵称首字符)+ 昵称,样式见 theme.css 的 Owner Chip 段 wrt_div.className = "card-owner"; let owner_text = (item.owner == null || item.owner === "") ? "匿名" : String(item.owner); let owner_avatar = document.createElement('span'); owner_avatar.className = "owner-avatar"; owner_avatar.textContent = owner_text.trim().charAt(0).toUpperCase() || "?"; let owner_name = document.createElement('span'); owner_name.className = "owner-name"; owner_name.textContent = owner_text; wrt_div.appendChild(owner_avatar); wrt_div.appendChild(owner_name); // 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0) let sts_div = document.createElement('div'); sts_div.className = "card-stats"; sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count)); sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count)); sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count)); // 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转) // 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定, // 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note); // 未登录或无权时不渲染整个按钮组 ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); renderContent(ctt, item); card.appendChild(ctt) if (item.can_operate === true) { let act_div = document.createElement('div'); act_div.className = "card-actions"; let edit_btn = document.createElement('button'); edit_btn.className = "act-btn act-edit"; edit_btn.innerHTML = ICON_EDIT + "编辑"; edit_btn.addEventListener('click', function(event) { event.stopPropagation(); startEdit(item); }); let move_btn = document.createElement('button'); move_btn.className = "act-btn act-move"; move_btn.innerHTML = ICON_MOVE + "移动"; move_btn.addEventListener('click', function(event) { event.stopPropagation(); startMove(item); }); let del_btn = document.createElement('button'); del_btn.className = "act-btn act-del"; del_btn.innerHTML = ICON_TRASH + "删除"; del_btn.addEventListener('click', function(event) { event.stopPropagation(); deleteChild(item.id); }); act_div.appendChild(edit_btn); act_div.appendChild(move_btn); act_div.appendChild(del_btn); card.appendChild(act_div); } recommend_list.appendChild(card) recommend_list.appendChild(document.createElement('br')) } }; function loadMap(children_json) { let need_return = false; minimap.replaceChildren(); var fold_div = document.createElement("div"); fold_div.className = "tag fold-btn"; if (is_expand == true) { fold_div.textContent = "v 折叠"; minimap.appendChild(fold_div); } else { fold_div.textContent = "> 展开"; minimap.appendChild(fold_div); need_return = true; } fold_div.addEventListener("click", () => { if (is_expand == true) { is_expand = false; getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap); } else { is_expand = true; getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap); } }); if (need_return == true) { return; } if (stack.length > 0) { let back = document.createElement("div"); back.className = "tag back"; back.textContent = "← 返回上一层"; back.addEventListener("click", () => { if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不返回,stack 保持原样 */ stack.pop(); /* 栈空(回到根)时取 0:原判断 stack.length >= 0 恒真,空栈取 stack[-1] 得 undefined */ var id = (stack.length > 0) ? stack[stack.length - 1] : 0; getNode(id).then(load_notes); getNode(id).then(loadMap); }); minimap.appendChild(back); } for (let i in children_json) { let item = children_json[i]; let tag = document.createElement("div"); let title = item.title; tag.className = "tag"; tag.textContent = title; tag.addEventListener("click", () => { window.location.href = `#${title}`; console.log(`跳转到${title}`); }) minimap.appendChild(tag); } } /* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。 * 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */ var userpanel = document.getElementById("userpanel"); var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠 var current_user = null; async function loadUserPanel() { try { const res = await fetch('/whoami', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' }); if (!res.ok) return; current_user = await res.json(); /* 管理员显示特殊节点面板(普通用户始终普通文本) */ if (current_user.login && current_user.is_admin) { sp_panel.style.display = ''; } renderUserPanel(); } catch (e) { console.error('获取用户信息失败:', e); } } function renderUserPanel() { if (current_user == null) return; userpanel.replaceChildren(); let fold = document.createElement("div"); fold.className = "tag fold-btn"; if (userpanel_expand) { fold.textContent = "v 折叠"; } else { fold.textContent = "> " + (current_user.login ? current_user.user : "未登录"); } fold.addEventListener("click", () => { userpanel_expand = !userpanel_expand; renderUserPanel(); }); userpanel.appendChild(fold); if (!userpanel_expand || !current_user.login) return; let back_to_index = document.createElement("div"); back_to_index.className = "tag"; back_to_index.textContent = "回到主页"; back_to_index.addEventListener("click", () => { window.location.href = "/"; }) userpanel.appendChild(back_to_index); let name_tag = document.createElement("div"); name_tag.className = "tag no-hover"; name_tag.textContent = current_user.user; userpanel.appendChild(name_tag); let role_tag = document.createElement("div"); role_tag.className = "tag no-hover"; role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户"; userpanel.appendChild(role_tag); let logout_tag = document.createElement("div"); logout_tag.className = "tag back"; logout_tag.textContent = "退出登录"; logout_tag.addEventListener("click", async () => { try { await fetch('/logout', {method: 'POST'}); } catch (e) {} location.href = '/login.html'; }); userpanel.appendChild(logout_tag); } getNode(0).then(load_notes); getNode(0).then(loadMap) loadUserPanel();