Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012
This commit is contained in:
commit
121e155248
357 files changed
+44067
-17806
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 清空;回车不提交 ===== */
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user