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:
1 parent
a0f6a14b5d
commit
ace98caff6
4 files changed
+193
-7
No files matched your search
+2
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user