From ace98caff66f32c8628035721b5d47dd7ba34f63 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Wed, 29 Jul 2026 11:22:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=A1=E7=89=87=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE=20+=20dnfs=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnfs.js: 卡片底部新增编辑/删除按钮,复用下方表单支持编辑模式 (startEdit/cancelEdit/updateNode/deleteChild),stopPropagation 防误触 - theme.css: 新增 card-actions/act-btn 按钮样式,编辑橙色/删除红色软底 - dnfs.py: ADMIN_USERS 统一管理豁免名单(admin, admin@qsont.me), update_note 新增管理员豁免,delete_note 同步改用名单 - dnfs/README.md: 同步更新所有权豁免说明 --- dnfs/README.md | 4 +- dnfs/dnfs.py | 12 ++- static/public/css/theme.css | 26 ++++++ static/public/js/dnfs.js | 158 +++++++++++++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/dnfs/README.md b/dnfs/README.md index 923c0f0..f0ad6ef 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -44,7 +44,7 @@ Authorization: Bearer _ - payload 中取 `user` 字段作为操作者,写入 `owner`。 - `delete_note` 与 `update_note` 校验所有权: - 节点 `owner` 必须等于当前 `user`,否则 `403`; - - **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。 + - **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。 - `get_notes` 不需要鉴权。 ## 接口文档 @@ -154,7 +154,7 @@ curl -X POST http://localhost:8006/delete_note \ 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。 -仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。 +仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。 **Request** diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 45b54d5..8a559c8 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -37,6 +37,9 @@ from pymsg import MsgClient # noqa: E402 _DBV2_ADDR = '127.0.0.1' _DBV2_PORT = 8005 +# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据) +ADMIN_USERS = ('admin', 'admin@qsont.me') + def _call_dbv2(payload: dict, timeout: int = 10) -> dict: """通过 pymsg 短连接调用 dbv2 数据库服务。 自动注入 ``_route: "execute"``。 @@ -325,7 +328,7 @@ def delete_note(): data = _call_dbv2(select_payload) notes = data['data'] - if not info['user'] == 'admin': + if info['user'] not in ADMIN_USERS: if not len(notes) == 1 or not notes[0]['owner'] == info['user']: logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 @@ -400,9 +403,10 @@ def update_note(): data = _call_dbv2(select_payload) notes = data['data'] - if not len(notes) == 1 or not notes[0]['owner'] == info['user']: - logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) - return jsonify({"error": 'It\'s not your note'}), 403 + if info['user'] not in ADMIN_USERS: + if not len(notes) == 1 or not notes[0]['owner'] == info['user']: + logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) + return jsonify({"error": 'It\'s not your note'}), 403 # 内容审查 review = review_content(title + '\n' + content, 10) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index e6c6dfb..0ace46c 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -302,6 +302,32 @@ input[type="button"]:disabled { font-variant-numeric: tabular-nums; } +/* ===== Card Actions (dnfs 编辑/删除按钮) ===== */ +.card-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.6rem; +} +.act-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.7rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-sm); + border: 1px solid transparent; + background: var(--accent-soft); + color: var(--accent-dark); + cursor: pointer; + transition: border-color 0.15s, opacity 0.15s, transform 0.1s; +} +.act-btn svg { width: 12px; height: 12px; flex-shrink: 0; } +.act-btn:hover { border-color: var(--accent); } +.act-del { background: var(--error-bg); color: var(--error); } +.act-del:hover { border-color: var(--error); } + /* ===== Minimap (dnfs) ===== */ .minimap { position: fixed; diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 6f617d5..aa27232 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -168,6 +168,118 @@ async function deleteNode(id) { } } +/* 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(); + 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(); + 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 shufeng2012 */ function renderMarkdown(element, content) { /* @@ -181,6 +293,8 @@ function renderMarkdown(element, content) { const ICON_EYE = ''; const ICON_HEART = ''; const ICON_SHARE = ''; +const ICON_EDIT = ''; +const ICON_TRASH = ''; /* by qing — 生成一个统计项:图标 + 数字,count 缺省显示 0 */ function makeStatItem(iconSvg, count) { @@ -206,6 +320,7 @@ var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var is_expand = true; +var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ input_note_submit.addEventListener('click', function(event) { if (input_note_submit.disabled) return; // 防重复点击 @@ -218,7 +333,9 @@ input_note_submit.addEventListener('click', function(event) { input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; - addNode(pid, title, content) + // 编辑模式下保存修改,否则新增节点 + var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); + Promise.resolve(req) .finally(() => { input_note_submit.disabled = false; input_note_clear.disabled = false; @@ -229,6 +346,7 @@ input_note_submit.addEventListener('click', function(event) { input_note_clear.addEventListener('click', function(event) { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); // 重置时同时退出编辑模式 }) delete_note.addEventListener('click', (event) => { @@ -246,6 +364,22 @@ delete_note.addEventListener('click', (event) => { } }) +/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */ +function startEdit(item) { + editing_id = item.id; + input_note_title.value = item.title; + input_note_content.value = item.content; + input_note_submit.textContent = "保存修改"; + input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); + input_note_title.focus(); +} + +/* by qing — 退出编辑模式,表单恢复为新增状态 */ +function cancelEdit() { + editing_id = null; + input_note_submit.textContent = "提交"; +} + function load_notes(main_node) { /* 这个不能设置为空的 */ @@ -256,6 +390,7 @@ function load_notes(main_node) { if (confirm("是否放弃当前输入的内容?") == true) { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); } else { if (stack.length != 0) { return; @@ -264,6 +399,7 @@ function load_notes(main_node) { } else { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); } @@ -303,11 +439,31 @@ function load_notes(main_node) { 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 防止触发卡片自身的点入跳转) + 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 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(del_btn); ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); renderMarkdown(ctt, item.content); card.appendChild(ctt) + card.appendChild(act_div); recommend_list.appendChild(card) recommend_list.appendChild(document.createElement('br')) }