Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012

This commit is contained in:
shufeng2012 committed 2026-08-18 15:39:16 +08:00
commit 121e155248
357 files changed
+44067 -17806

No files matched your search

+28 -1
View File
@@ -366,6 +366,11 @@ input[type="button"]:disabled {
text-overflow: ellipsis;
white-space: nowrap;
}
.owner-time {
font-size: 0.7rem;
color: var(--fg-tertiary);
white-space: nowrap;
}
/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */
.card-stats {
@@ -570,8 +575,30 @@ input[type="button"]:disabled {
background: var(--accent-soft);
border-color: var(--accent);
}
/* 折叠态用户面板:圆形头像按钮,与 owner-avatar 同风格 */
.userpanel .user-avatar-fold {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-dark);
border: 2px solid var(--accent);
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
user-select: none;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.userpanel .user-avatar-fold:hover {
border-color: var(--accent-dark);
transform: scale(1.08);
box-shadow: 0 0 0 4px var(--accent-glow);
}
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
/* ===== Special Nodes (dnfs) ===== */
.sp-link { display: flex; align-items: center; gap: 0.5rem; }
.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; }
.sp-anchor { color: var(--accent); word-break: break-all; }
+4 -4
View File
@@ -26,15 +26,15 @@
<div class="card node-form">
<h3>节点操作</h3>
<label>新增节点</label><input id="input-note-title">
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
<!-- 特殊节点面板:登录用户可见(dnfs.js 按 /whoami login 显示;发布权限由后端按节点拥有者/管理员强制) -->
<div id="sp-panel" style="display:none">
<label>节点类型</label>
<select id="sp-type">
<option value="text">普通文本</option>
<option value="link">链接(自动显示站点图标)</option>
<option value="js">前端代码(可审计/可运行)</option>
<option value="image">图片(自动显示)</option>
<option value="file">文件(可下载)</option>
<option value="style">界面样式(CSS 注入,控制整个界面)</option>
<option value="theme">主题变量(换肤,仅 CSS 变量)</option>
</select>
<input type="file" id="sp-file" style="display:none">
<span id="sp-file-tip"></span>
@@ -43,7 +43,7 @@
<div class="form-actions">
<button id="input-note-submit">提交</button>
<button id="input-note-clear">重置</button>
<button id="delete-note">删除当前节点</button>
<button id="delete-note" style="display:none">删除当前节点</button>
</div>
</div>
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script> -->
+343 -95
View File
@@ -102,14 +102,13 @@ async function addNode(pid, title, content) {
}
if (!res.ok) {
throw new Error(`添加节点失败(HTTP ${res.status})`);
throw new Error(result.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)
getNode(stack[stack.length - 1]).then(function(r) { load_notes(r); loadMap(r); })
//if (result.success) {
alert(`添加节点成功`, result);
@@ -167,8 +166,8 @@ async function deleteNode(id) {
}
stack.pop();
getNode(stack[stack.length - 1]).then(load_notes);
getNode(stack[stack.length - 1]).then(loadMap)
stack_operate.pop();
getNode(stack[stack.length - 1]).then(function(r) { load_notes(r); loadMap(r); })
console.log(`删除节点成功`);
return result
@@ -225,8 +224,7 @@ async function deleteChild(id) {
input_note_content.value = "";
}
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
console.log(`删除节点成功`);
return result;
@@ -281,8 +279,7 @@ async function updateNode(id, pid, title, content) {
input_note_content.value = "";
cancelEdit();
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
alert('修改成功');
return result;
@@ -334,8 +331,7 @@ async function moveNode(id, pid, title, content) {
}
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(load_notes);
getNode(cur).then(loadMap);
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
alert('移动成功');
return result;
@@ -429,9 +425,9 @@ function renderMarkdown(element, content) {
element.innerHTML = md.render(content);
}
/* ===== by qing — 特殊节点(管理员专属)=====
* 信封:content 为 {"_sp":"link|js|image|file", ...} JSON,普通内容走 Markdown。
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
/* ===== by qing — 特殊节点 =====
* 信封:content 为 {"_sp":"js|file|style|theme", ...} JSON,普通内容走 Markdown。
* 发布权限由后端 dnfs 强制(节点拥有者或管理员),前端仅做展示与表单切换。 */
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024;
@@ -445,7 +441,7 @@ function parseSp(content) {
} catch (e) {
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
}
if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
if (obj && typeof obj === 'object' && ['js', 'file', 'style', 'theme'].indexOf(obj._sp) !== -1) return obj;
return null;
}
@@ -464,32 +460,9 @@ function renderContent(container, item) {
container.appendChild(note);
}
}
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
else if (sp._sp === 'js') renderSpJs(container, sp, appendNote);
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
if (sp._sp === 'js') renderSpJs(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();
else if (sp._sp === 'style' || sp._sp === 'theme') renderSpCss(container, sp, appendNote, item);
}
/* 前端代码节点:含前端内容的节点在渲染到当前页面时自动执行(隔离 iframe 沙箱)。
@@ -499,14 +472,19 @@ function renderSpLink(container, sp, appendNote) {
* - 全屏特效 iframe 挂载到 document.body(z-index:9999 在根层叠上下文生效,
* 不被后续卡片盖住),pointer-events:none 不拦截父页面点击,不影响卡片点击/节点导航;
* - 不使用 new Function/eval(CSP 未开 unsafe-eval),srcdoc 内联脚本由 CSP 'unsafe-inline' 放行。
* 仅管理员可发布(后端强制)。 */
* 仅节点拥有者或管理员可发布(后端强制)。 */
/* 无特效模式:true 为关闭自动执行(用户面板开关控制,localStorage 持久化) */
var sp_effects_disabled = (function() {
try { return localStorage.getItem('sp_effects_disabled') === '1'; } catch (e) { return false; }
})();
/* 清除当前页面已运行的全部特效沙箱(进入新节点 / 关闭特效时调用) */
/* __ 隐藏节点可见:true 时 __ 前缀节点正常显示(用户面板开关控制,localStorage 持久化) */
var __show_hidden = (function() {
try { return localStorage.getItem('dnfs_show_hidden') === '1'; } catch (e) { return false; }
})();
/* 清除当前页面已运行的全部特效沙箱与注入样式(进入新节点 / 关闭特效时调用) */
function clearAllEffects() {
document.querySelectorAll('.sp-effect-frame').forEach(function(f) { f.remove(); });
document.querySelectorAll('.sp-injected-style').forEach(function(s) { s.remove(); });
}
function renderSpJs(container, sp, appendNote) {
var bar = document.createElement('div');
@@ -579,16 +557,6 @@ function renderSpJs(container, sp, appendNote) {
if (!sp_effects_disabled) startEffect();
}
/* 图片节点: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');
@@ -619,6 +587,114 @@ function renderSpFile(container, sp, appendNote) {
appendNote();
}
/* ===== 样式类节点(style/theme):CSS 注入 <style> 控制整个界面 =====
* 生命周期:节点作用域——进入该节点所在层时注入生效,切换/返回节点时随
* clearAllEffects 移除,界面自动还原;卡片上可逐个停用/启用。
* 安全:节点拥有者或管理员可发布(后端强制)且经 AI 送审;CSS 无法执行脚本,风险面仅为界面外观。
* theme 是 style 的克制子集——仅允许覆盖 CSS 变量(:root 块),
* 变量名/值合法性由后端 validate_sp 强制,前端这里只做兜底过滤。 */
function themeVarsToCss(vars) {
var lines = [':root {'];
for (var k in vars) {
if (!Object.prototype.hasOwnProperty.call(vars, k)) continue;
if (!/^--[A-Za-z0-9_-]+$/.test(k)) continue;
var v = String(vars[k]);
if (/[{}<>]/.test(v)) continue;
lines.push(' ' + k + ': ' + v + ';');
}
lines.push('}');
return lines.join('\n');
}
/* vars 对象 → 编辑/审计用的行文本(每行 "--var: value;"),与 theme 提交输入格式一致;
* 非 -- 前缀 key 后端不会接受,这里同口径过滤防止原型链属性混入展示 */
function themeVarsToText(vars) {
var lines = [];
for (var k in vars) {
if (!Object.prototype.hasOwnProperty.call(vars, k)) continue;
if (k.indexOf('--') !== 0) continue;
lines.push(k + ': ' + vars[k] + ';');
}
return lines.join('\n');
}
/* 将 theme/style 信封注入为 <style> 元素到 <head>(供卡片渲染和祖先继承复用)。
* 返回创建的 styleEl,cssText 为空时返回 null。 */
function injectSpCss(sp, noteId) {
var is_theme = (sp._sp === 'theme');
var cssText = is_theme ? themeVarsToCss(sp.vars || {}) : String(sp.css || '');
if (!cssText) return null;
/* 同一 noteId 的旧注入先移除 */
if (noteId) {
document.querySelectorAll('.sp-injected-style[data-note-id="' + noteId + '"]')
.forEach(function(s) { s.remove(); });
}
var styleEl = document.createElement('style');
styleEl.className = 'sp-injected-style';
if (noteId) styleEl.setAttribute('data-note-id', noteId);
styleEl.textContent = cssText;
document.head.appendChild(styleEl);
return styleEl;
}
function renderSpCss(container, sp, appendNote, item) {
var is_theme = (sp._sp === 'theme');
var kindLabel = is_theme ? '主题' : '样式';
var cssText = is_theme ? themeVarsToCss(sp.vars || {}) : String(sp.css || '');
var displayText = is_theme ? themeVarsToText(sp.vars || {}) : String(sp.css || '');
var noteId = (item && item.id != null) ? String(item.id) : null;
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 toggle_btn = document.createElement('button');
toggle_btn.className = 'act-btn act-move';
toggle_btn.textContent = '停用' + kindLabel;
var pre = document.createElement('pre');
pre.className = 'sp-code';
pre.style.display = 'none';
pre.textContent = displayText;
var out = document.createElement('div');
out.className = 'sp-run';
out.style.display = 'none';
var styleEl = null; /* 当前注入的 <style> 实例,null 表示未生效 */
audit_btn.addEventListener('click', function(e) {
e.stopPropagation();
var hidden = (pre.style.display === 'none');
pre.style.display = hidden ? 'block' : 'none';
audit_btn.textContent = hidden ? '收起源码' : '审计源码';
});
function applyCss() {
if (styleEl) return;
styleEl = injectSpCss(sp, noteId);
if (!styleEl) return;
toggle_btn.textContent = '停用' + kindLabel;
out.style.display = 'block';
out.textContent = kindLabel + '已生效(本层)';
}
function stopCss() {
if (styleEl) { styleEl.remove(); styleEl = null; }
toggle_btn.textContent = '启用' + kindLabel;
out.style.display = 'none';
}
toggle_btn.addEventListener('click', function(e) {
e.stopPropagation();
if (styleEl) stopCss(); else applyCss();
});
bar.appendChild(audit_btn);
bar.appendChild(toggle_btn);
container.appendChild(bar);
container.appendChild(pre);
container.appendChild(out);
appendNote();
/* 渲染即生效 */
applyCss();
}
function formatBytes(n) {
var units = ['B', 'KB', 'MB', 'GB'];
var i = 0;
@@ -626,6 +702,65 @@ function formatBytes(n) {
return n.toFixed(1) + units[i];
}
/* ===== 祖先主题/样式继承 =====
* 当前节点不存在 theme/style 子节点时,沿 stack 向根搜索最近祖先的对应节点,
* 自动注入其 CSS。已存在的不重复搜索(hasTheme/hasStyle 分别控制)。
* 用静默 fetch 替代 getNode(不弹 alert、不抛异常),仅日志输出。 */
var _inherit_gen = 0;
function inheritThemeStyle() {
var hasTheme = false, hasStyle = false;
for (var i = 0; i < current_children.length; i++) {
var sp = parseSp(current_children[i].content);
if (!sp) continue;
if (sp._sp === 'theme') hasTheme = true;
if (sp._sp === 'style') hasStyle = true;
}
if (hasTheme && hasStyle) return;
console.log('[继承] stack=', JSON.stringify(stack),
'current_children.length=', current_children.length,
'hasTheme=', hasTheme, 'hasStyle=', hasStyle);
var gen = ++_inherit_gen;
(async function() {
for (var i = stack.length - 1; i >= 0; i--) {
if (hasTheme && hasStyle) break;
if (gen !== _inherit_gen) return;
var pid = (i === 0) ? 0 : stack[i - 1];
console.log('[继承] 查询 pid=', pid, '(stack[' + i + '] 的父节点)');
try {
var res = await fetch('/get_notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pid: pid, noread: true })
});
if (gen !== _inherit_gen) return;
if (!res.ok) { console.log('[继承] pid=', pid, 'HTTP', res.status); continue; }
var data = await res.json();
if (gen !== _inherit_gen) return;
if (!Array.isArray(data)) { console.log('[继承] pid=', pid, '非数组'); continue; }
console.log('[继承] pid=', pid, '返回', data.length, '个子节点');
for (var j = 0; j < data.length; j++) {
var sp = parseSp(data[j].content);
if (!sp) continue;
if (!hasTheme && sp._sp === 'theme') {
console.log('[继承] 找到主题:', data[j].title, 'id=', data[j].id);
injectSpCss(sp, 'inh-' + data[j].id);
hasTheme = true;
}
if (!hasStyle && sp._sp === 'style') {
console.log('[继承] 找到样式:', data[j].title, 'id=', data[j].id);
injectSpCss(sp, 'inh-' + data[j].id);
hasStyle = true;
}
}
} catch (e) {
console.error('[继承] 异常 pid=', pid, e);
}
}
console.log('[继承] 完成 hasTheme=', hasTheme, 'hasStyle=', hasStyle);
})();
}
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
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>';
@@ -648,6 +783,9 @@ function makeStatItem(iconSvg, count) {
/* ---- by qing ---- */
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
/* 与 stack 平行的权限栈:进入每个节点时记录其 can_operate(后端按 owner/is_admin 判定,
* 与 delete_note 鉴权口径一致),用于"删除当前节点"按钮显隐;入栈/出栈必须与 stack 同步 */
const stack_operate = [];
var article_title = document.getElementById('article-title');
var article_content = document.getElementById('article-content');
var input_note_title = document.getElementById('input-note-title');
@@ -663,7 +801,7 @@ 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; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
var sp_edit_original = null; /* 编辑文件节点时的原信封字符串:未选新文件则原样提交 */
/* 搜索框:当前层卡片标题/内容关键词过滤 */
var searchtext = document.getElementById('searchtext');
var searchsubmit = document.getElementById('searchsubmit');
@@ -675,12 +813,26 @@ var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
/* ===== 输入框自动高度调整 ===== */
/* 中文输入法 composition 期间每个拼音字母都会触发 input;若每次都做塌陷测量,
* 页面总高度反复变化,滚到页面底部输入时 scrollTop 被浏览器钳制又回弹,
* 表现为页面位置反复横跳。故 composition 中跳过,compositionend 后统一调整一次。 */
var _content_composing = false;
function autoResizeTextarea(el) {
var doc = document.scrollingElement || document.documentElement;
var prevTop = doc.scrollTop;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 400) + 'px';
el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden';
/* 塌陷测量会触发整页同步布局:若页面瞬时变矮导致滚动位置被钳制,恢复原位 */
if (doc.scrollTop < prevTop) doc.scrollTop = prevTop;
}
input_note_content.addEventListener('input', function() {
input_note_content.addEventListener('compositionstart', function() { _content_composing = true; });
input_note_content.addEventListener('compositionend', function() {
_content_composing = false;
autoResizeTextarea(input_note_content);
});
input_note_content.addEventListener('input', function(e) {
if (_content_composing || (e && e.isComposing)) return;
autoResizeTextarea(input_note_content);
});
function resetTextareaHeight() {
@@ -692,7 +844,7 @@ autoResizeTextarea(input_note_content); /* 初始化高度 */
/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */
sp_type.addEventListener('change', function() {
var t = sp_type.value;
var is_file = (t === 'image' || t === 'file');
var is_file = (t === 'file');
sp_file.style.display = is_file ? '' : 'none';
input_note_content.style.display = is_file ? 'none' : '';
sp_file_tip.textContent = '';
@@ -701,8 +853,9 @@ sp_type.addEventListener('change', function() {
sp_file.value = '';
sp_edit_original = null;
}
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
else if (t === 'js') input_note_content.placeholder = '请输入完整前端代码(可含 HTML/CSS/JS)';
if (t === 'js') input_note_content.placeholder = '请输入完整前端代码(可含 HTML/CSS/JS)';
else if (t === 'style') input_note_content.placeholder = '请输入 CSS(如 .card { border-radius: 16px }),发布后全界面生效';
else if (t === 'theme') input_note_content.placeholder = '每行一个变量,如 --accent: #e8853a';
else input_note_content.placeholder = '';
});
@@ -715,25 +868,51 @@ function readFileAsDataURL(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 });
/* 解析主题变量输入:每行一条 "--变量名: 值"(末尾分号可选),返回 vars 对象;非法返回 null(已弹提示) */
function parseThemeVars(text) {
var vars = {};
var lines = text.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
if (line.indexOf('--') !== 0) { alert('第 ' + (i + 1) + ' 行不是 CSS 变量(须以 -- 开头):' + line); return null; }
var idx = line.indexOf(':');
if (idx <= 0) { alert('第 ' + (i + 1) + ' 行缺少冒号:' + line); return null; }
var k = line.substring(0, idx).trim();
var v = line.substring(idx + 1).trim().replace(/;+$/, '');
if (!/^--[A-Za-z0-9_-]+$/.test(k)) { alert('非法变量名:' + k); return null; }
if (!v) { alert('变量缺少值:' + k); return null; }
if (/[{}<>]/.test(v)) { alert('变量值含非法字符({}<>):' + k); return null; }
vars[k] = v;
}
if (Object.keys(vars).length === 0) { alert('请至少输入一个 CSS 变量,如 --accent: #e8853a'); return null; }
return vars;
}
/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。
* 校验失败返回 null(已弹提示);未登录永远按普通文本提交。 */
async function buildSubmitContent() {
var t = (current_user && current_user.login) ? sp_type.value : 'text';
if (t === 'text') return input_note_content.value;
if (t === 'js') {
var code = input_note_content.value;
if (!code.trim()) { alert('请输入前端代码'); return null; }
return JSON.stringify({ _sp: 'js', code: code });
}
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
if (t === 'style') {
var css = input_note_content.value;
if (!css.trim()) { alert('请输入 CSS 样式'); return null; }
return JSON.stringify({ _sp: 'style', css: css });
}
if (t === 'theme') {
var vars = parseThemeVars(input_note_content.value);
if (!vars) return null;
return JSON.stringify({ _sp: 'theme', vars: vars });
}
/* file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
if (sp_file.files.length === 0) {
if (sp_edit_original) return sp_edit_original;
alert('请选择要上传的' + (t === 'image' ? '图片' : '文件'));
alert('请选择要上传的文件');
return null;
}
var f = sp_file.files[0];
@@ -742,10 +921,6 @@ async function buildSubmitContent() {
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] });
}
@@ -798,21 +973,29 @@ delete_note.addEventListener('click', (event) => {
})
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
* 特殊节点:link/js 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
* 特殊节点:js/style/theme 回填对应文本到文本域;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 === 'js')) {
sp_type.value = sp._sp;
if (sp && sp._sp === 'js') {
sp_type.value = 'js';
sp_type.dispatchEvent(new Event('change'));
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
input_note_content.value = sp.code || '';
} else if (sp && sp._sp === 'style') {
sp_type.value = 'style';
sp_type.dispatchEvent(new Event('change'));
input_note_content.value = sp.css || '';
} else if (sp && sp._sp === 'theme') {
sp_type.value = 'theme';
sp_type.dispatchEvent(new Event('change'));
input_note_content.value = themeVarsToText(sp.vars || {});
} 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' ? '图片' : '文件');
sp_file_tip.textContent = '不选择新文件则保持原文件';
input_note_content.value = '';
} else {
sp_type.value = 'text';
@@ -851,6 +1034,18 @@ function confirmLeaveIfDirty() {
return true;
}
/* by qing — "删除当前节点"按钮显隐:仅当前节点的 can_operate 为真(本人或管理员,
* 后端 get_notes 判定,与 delete_note 鉴权口径一致)时显示;
* 根节点(无主人)/未登录/非本人节点一律隐藏。
* 所有改变 stack 的导航(进入/返回/删除当前节点)最终都会走到 load_notes,在此统一刷新 */
function updateDeleteBtnVisibility() {
if (stack.length == 0) {
delete_note.style.display = 'none';
return;
}
delete_note.style.display = (stack_operate[stack_operate.length - 1] === true) ? '' : 'none';
}
function load_notes(main_node) {
/* 这个不能设置为空的 */
@@ -878,13 +1073,37 @@ function load_notes(main_node) {
if (searchtext) searchtext.value = "";
clearAllEffects();
renderCards(current_children);
inheritThemeStyle();
updateDeleteBtnVisibility();
};
/* 对 __ 隐藏节点应用特效(注入 CSS / 运行 js),卡片不渲染 */
function applyHiddenNodeEffects(item) {
var sp = parseSp(item.content);
if (!sp) return;
var noteId = (item.id != null) ? 'hid-' + String(item.id) : null;
if (sp._sp === 'style' || sp._sp === 'theme') {
injectSpCss(sp, noteId);
} else if (sp._sp === 'js' && !sp_effects_disabled) {
/* 仅全屏模式(非 in-node):无卡片容器,in-node 无法合理放置,忽略 */
if (/<!--\s*in-node\s*-->/.test(sp.code || '')) return;
var frame = document.createElement('iframe');
frame.className = 'sp-effect-frame';
frame.setAttribute('sandbox', 'allow-scripts');
frame.srcdoc = sp.code;
frame.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;pointer-events:none;z-index:9999;';
document.body.appendChild(frame);
}
}
/* 把卡片列表渲染到 recommend_list(从 load_notes/搜索过滤复用) */
function renderCards(list) {
recommend_list.replaceChildren();
for (let i in list) {
let item = list[i]
if (item.title && typeof item.title === 'string' && item.title.indexOf('__') === 0) {
if (!__show_hidden) { applyHiddenNodeEffects(item); continue; }
}
console.log(item)
let card = document.createElement('div');
card.className = "card";
@@ -898,8 +1117,8 @@ function renderCards(list) {
if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不跳转,stack 保持原样 */
var id = Number(item.id)
stack.push(id)
getNode(id).then(load_notes)
getNode(id).then(loadMap)
stack_operate.push(item.can_operate === true)
getNode(id).then(function(r) { load_notes(r); loadMap(r); })
});
ttl.appendChild(ttl1);
card.appendChild(ttl);
@@ -915,12 +1134,20 @@ function renderCards(list) {
owner_name.textContent = owner_text;
wrt_div.appendChild(owner_avatar);
wrt_div.appendChild(owner_name);
// 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0)
// 最后编辑时间(新增时 created_at = updated_at,仅更新时 updated_at 变化)
let time_span = document.createElement('span');
time_span.className = "owner-time";
if (item.updated_at) {
time_span.textContent = ' · ' + item.updated_at.substring(0, 16);
}
wrt_div.appendChild(time_span);
// 互动统计:眼睛=观看数(后端 views 列)、爱心=点赞、转发=子节点数(后端 child_count)。
// views / child_count 由 dnfs2 get_notes 返回;like_count 后端暂无字段,缺省显示 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_EYE, item.views));
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
sts_div.appendChild(makeStatItem(ICON_SHARE, item.child_count));
// 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
// 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定,
// 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note);
@@ -974,13 +1201,16 @@ function itemSearchText(item) {
var sp = parseSp(c);
if (!sp) {
parts.push(c);
} else if (sp._sp === 'link') {
if (sp.url) parts.push(sp.url);
if (sp.note) parts.push(sp.note);
} else if (sp._sp === 'js') {
if (sp.code) parts.push(sp.code);
if (sp.note) parts.push(sp.note);
} else { /* image / file:不把 base64 数据纳入搜索 */
} else if (sp._sp === 'style') {
if (sp.css) parts.push(sp.css);
if (sp.note) parts.push(sp.note);
} else if (sp._sp === 'theme') {
parts.push(themeVarsToText(sp.vars || {}));
if (sp.note) parts.push(sp.note);
} else { /* file:不把 base64 数据纳入搜索 */
if (sp.name) parts.push(sp.name);
if (sp.note) parts.push(sp.note);
}
@@ -1050,17 +1280,18 @@ function loadMap(children_json) {
back.addEventListener("click", () => {
if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不返回,stack 保持原样 */
stack.pop();
stack_operate.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);
getNode(id).then(function(r) { load_notes(r); loadMap(r); });
});
minimap.appendChild(back);
}
for (let i in children_json) {
let item = children_json[i];
let tag = document.createElement("div");
let title = item.title;
if (title && typeof title === 'string' && title.indexOf('__') === 0 && !__show_hidden) continue;
let tag = document.createElement("div");
tag.className = "tag";
tag.textContent = title;
tag.addEventListener("click", () => {
@@ -1086,8 +1317,9 @@ async function loadUserPanel() {
});
if (!res.ok) return;
current_user = await res.json();
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
if (current_user.login && current_user.is_admin) {
/* 登录用户显示特殊节点面板(发布权限由后端按"节点拥有者或管理员"强制,
* 非拥有者在他人节点下提交特殊节点会被 403 拒绝并提示) */
if (current_user.login) {
sp_panel.style.display = '';
}
renderUserPanel();
@@ -1100,11 +1332,12 @@ function renderUserPanel() {
if (current_user == null) return;
userpanel.replaceChildren();
let fold = document.createElement("div");
fold.className = "tag fold-btn";
if (userpanel_expand) {
fold.className = "tag fold-btn";
fold.textContent = "v 折叠";
} else {
fold.textContent = "> 展开";
fold.className = "user-avatar-fold";
fold.textContent = current_user.login ? (current_user.user || "?").trim().charAt(0).toUpperCase() : "?";
}
fold.addEventListener("click", () => {
userpanel_expand = !userpanel_expand;
@@ -1145,6 +1378,22 @@ function renderUserPanel() {
});
userpanel.appendChild(effect_btn);
/* __ 系统节点显隐开关(所有用户) */
let hidden_btn = document.createElement("div");
hidden_btn.className = "tag";
hidden_btn.textContent = __show_hidden ? "隐藏系统节点" : "显示系统节点";
hidden_btn.addEventListener("click", () => {
__show_hidden = !__show_hidden;
try { localStorage.setItem('dnfs_show_hidden', __show_hidden ? '1' : '0'); } catch (e) {}
/* 切换时重新渲染当前层卡片和 minimap */
clearAllEffects();
renderCards(current_children);
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
getNode(cur).then(function(r) { loadMap(r); });
renderUserPanel();
});
userpanel.appendChild(hidden_btn);
/* 管理员专属入口:运维机器人 / 进程管理器 */
if (current_user.is_admin) {
let ops_btn = document.createElement("div");
@@ -1170,8 +1419,7 @@ function renderUserPanel() {
userpanel.appendChild(logout_tag);
}
getNode(0).then(load_notes);
getNode(0).then(loadMap)
getNode(0).then(function(r) { load_notes(r); loadMap(r); })
loadUserPanel();
/* ===== 搜索框事件绑定:实时过滤当前层卡片;ESC 清空;回车不提交 ===== */
-3
View File
@@ -37,9 +37,6 @@
</div>
</form>
<a href="forgot.html" style="display:block;text-align:center;color:#999;font-size:13px;margin:8px 0">忘记密码?</a>
<div style="text-align:center;margin:12px 0;position:relative"><div style="position:absolute;top:50%;left:0;right:0;height:1px;background:#e0d6cc"></div><span style="background:#fff;padding:0 12px;position:relative;color:#999;font-size:13px">或</span></div>
<a href="https://yhdet.top/yhdet_auth?callback=https://qsont.me/yhdet_callback" class="yhdet-login-btn">开源社区 快捷登录</a>
<style>.yhdet-login-btn{display:block;width:100%;padding:12px;border:1.5px solid #1a73e8;border-radius:8px;background:white;color:#1a73e8;font-size:15px;font-weight:600;text-decoration:none;text-align:center;box-sizing:border-box;margin-bottom:12px}.yhdet-login-btn:hover{background:#e8f0fe}</style>
<!-- 用户使用条例弹窗 -->
<div id="licenseModal" class="license-modal-overlay" style="display:none">