feat: 卡片编辑/删除按钮 + dnfs 管理员豁免

- 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: 同步更新所有权豁免说明
This commit is contained in:
qing committed 2026-07-29 11:22:30 +08:00
1 parent a0f6a14b5d
commit ace98caff6
4 files changed
+193 -7

No files matched your search

+2 -2
View File
@@ -44,7 +44,7 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
- 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**
+8 -4
View File
@@ -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)
+26
View File
@@ -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;
+157 -1
View File
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
const ICON_SHARE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
const ICON_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
const ICON_TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
/* 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'))
}