Files
SimpleNav/assets/js/admin.js
T
laoyang 717a8877bb 后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定
- 后台网站列表按板块分标签页,每个板块单独调顺序;用 URL hash
  (#sgroup-<gid>) 定位,上移/下移提交后回跳到原来的板块
- 支持鼠标拖拽排序(原生 HTML5 拖拽,零库依赖):拖动时实时重排预览,
  松手后整组落库(Ajax 动作 site_reorder,带 CSRF 校验与板块归属校验,
  防止跨板块搬条目)
- 稀疏排序:新增链接的顺序号 = 同板块 MAX + SORT_STEP(1000),天然排最后;
  拖拽落库、历史数据迁移时整组归一化为 index * SORT_STEP
- 修复真根因:旧机制靠「交换两条 sort_order」上移/下移,一旦历史数据里
  出现重复值,交换即无效、位置退化为按 id 排序 —— 这正是「排了顺序又被
  打乱」的来源。新增一次性归一化迁移,用 settings.mig_sort_sparse 作哨兵,
  避免每次请求重复覆盖用户设置
- 新增/修改链接不再用 MAX+1(MAX+1 会落进已有间隙、插到用户排好的中间)
- 改变所属板块时,该条目自动排到新板块的最后
- 后台页面 + 拖拽手柄做了配套样式,触屏设备降级为上下移按钮
2026-09-29 09:21:51 +08:00

370 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 简航 SimpleNav —— 后台交互 */
(function () {
'use strict';
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
/* ---------- 显示图标预览 ---------- */
function setPreview(src) {
var box = $('#f-icon-preview');
if (!box) return;
box.innerHTML = src ? '<img src="' + src + '" alt="">' : '<span class="ph">无</span>';
}
/* ---------- 自动获取网站信息 ---------- */
var fetchBtn = $('#btn-fetch');
if (fetchBtn) {
fetchBtn.addEventListener('click', function () {
var url = ($('#f-url') || {}).value || '';
var hint = $('#fetch-hint');
if (!url) { if (hint) hint.textContent = '请先填写网址'; return; }
if (hint) hint.textContent = '正在获取标题与高清图标…';
fetchBtn.disabled = true;
fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url))
.then(function (r) { return r.json(); })
.then(function (d) {
fetchBtn.disabled = false;
if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; }
if ($('#f-name')) $('#f-name').value = d.name || '';
if ($('#f-short') && !$('#f-short').value) $('#f-short').value = d.short || d.name || '';
var auto = $('#f-icon-auto');
var locked = auto && !auto.checked;
if (!locked) {
if ($('#f-icon')) $('#f-icon').value = d.icon || '';
setPreview(d.icon_url || '');
}
var parts = ['已获取名称'];
parts.push(d.icon ? ('已获取图标(候选 ' + (d.candidates || 1) + ' 个)') : '未找到图标,将回退为首字母块');
if (locked) parts.push('(当前为自定义图标,未覆盖)');
if (hint) hint.textContent = parts.join(',');
})
.catch(function () { fetchBtn.disabled = false; if (hint) hint.textContent = '网络错误'; });
});
}
/* ---------- 图标:上传文件 / 自定义地址 ---------- */
var fileInput = $('#f-icon-file');
if (fileInput) {
fileInput.addEventListener('change', function () {
var f = fileInput.files && fileInput.files[0];
if (!f) return;
var auto = $('#f-icon-auto');
if (auto) auto.checked = false; // 一旦自定义,自动取消「允许自动更新」
if (window.URL && URL.createObjectURL) { setPreview(URL.createObjectURL(f)); }
var hint = $('#fetch-hint');
if (hint) hint.textContent = '已选择本地图标:' + f.name + '(保存后生效,并将锁定不再自动更新)';
});
}
var urlInput = $('#f-icon-url');
if (urlInput) {
urlInput.addEventListener('change', function () {
var v = urlInput.value.trim();
if (!v) return;
var auto = $('#f-icon-auto');
if (auto) auto.checked = false;
setPreview(v);
});
}
var autoBox = $('#f-icon-auto');
if (autoBox) {
autoBox.addEventListener('change', function () {
var hint = $('#fetch-hint');
if (!hint) return;
hint.textContent = autoBox.checked
? '已开启自动更新:该网站图标会在「全部更新」时刷新'
: '已关闭自动更新:该网站图标将被锁定';
});
}
/* ---------- 清空表单 ---------- */
var resetBtn = $('#btn-reset');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
['f-id', 'f-url', 'f-name', 'f-short', 'f-icon', 'f-icon-url'].forEach(function (id) {
var el = $('#' + id); if (el) el.value = id === 'f-id' ? '0' : '';
});
var fi = $('#f-icon-file'); if (fi) fi.value = '';
var v = $('#f-visible'); if (v) v.checked = true;
var a = $('#f-icon-auto'); if (a) a.checked = true;
var h = $('#fetch-hint'); if (h) h.textContent = '';
setPreview('');
});
}
/* ---------- 编辑网站:回填表单 ---------- */
$$('.edit-site').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#f-id').value = d.id;
$('#f-group').value = d.group;
$('#f-url').value = d.url;
$('#f-name').value = d.name;
$('#f-short').value = d.short;
$('#f-icon').value = d.icon;
if ($('#f-icon-url')) $('#f-icon-url').value = '';
if ($('#f-icon-file')) $('#f-icon-file').value = '';
if ($('#f-icon-auto')) $('#f-icon-auto').checked = d.custom !== '1';
$('#f-visible').checked = d.visible === '1';
setPreview(d.iconUrl || '');
var hint = $('#fetch-hint');
if (hint) hint.textContent = d.custom === '1' ? '该网站图标为自定义,不会被「全部更新」覆盖' : '';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
/* ---------- 编辑板块 / 引擎 ---------- */
$$('.edit-group').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#g-id').value = d.id; $('#g-name').value = d.name;
$('#g-display').value = d.display; $('#g-visible').checked = d.visible === '1';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
var gReset = $('#g-reset');
if (gReset) gReset.addEventListener('click', function () { $('#g-id').value = '0'; $('#g-name').value = ''; $('#g-display').value = 'grid'; $('#g-visible').checked = true; });
$$('.edit-engine').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#e-id').value = d.id; $('#e-name').value = d.name; $('#e-slug').value = d.slug;
$('#e-search').value = d.search; $('#e-param').value = d.param; $('#e-sug').value = d.sug;
$('#e-sugtype').value = d.sugtype; $('#e-color').value = d.color; $('#e-active').checked = d.active === '1';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
var eReset = $('#e-reset');
if (eReset) eReset.addEventListener('click', function () {
['e-id', 'e-name', 'e-slug', 'e-search', 'e-sug', 'e-color'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'e-id' ? '0' : ''; });
$('#e-param').value = 'q'; $('#e-active').checked = false;
});
/* ---------- 列表内「显示 / 隐藏」原地切换(不跳页) ---------- */
$$('form.js-toggle').forEach(function (form) {
form.addEventListener('submit', function (ev) {
var btn = $('button.toggle', form);
if (btn) btn.disabled = true;
ev.preventDefault();
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (btn) btn.disabled = false;
if (!d || !d.ok) { alert((d && d.msg) || '切换失败'); return; }
applyToggleState(form, d.state);
})
.catch(function () {
if (btn) btn.disabled = false;
// AJAX 失败时退回普通提交,保证功能可用(表单已带 p,会回到当前页)
var ajax = form.querySelector('input[name="ajax"]');
if (ajax) { ajax.parentNode.removeChild(ajax); }
form.submit();
});
});
});
function applyToggleState(form, state) {
var on = state === 1;
var btn = $('button.toggle', form);
if (btn) {
btn.classList.toggle('on', on);
btn.setAttribute('data-on', on ? '1' : '0');
btn.setAttribute('title', on ? '当前:显示中(点击隐藏)' : '当前:已隐藏(点击显示)');
var svg = btn.querySelector('path[stroke]');
var circle = btn.querySelector('circle');
if (on) {
if (svg) svg.remove();
} else if (circle && !svg) {
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', 'M3 3l18 18');
p.setAttribute('stroke', 'currentColor');
p.setAttribute('stroke-width', '2');
p.setAttribute('stroke-linecap', 'round');
btn.querySelector('svg').appendChild(p);
}
}
// 同行的状态文字(如搜索引擎的「已显示 / 已隐藏」)
var cell = form.closest('td') || form.parentNode;
var st = cell ? $('.js-state', cell) : null;
if (st) {
st.textContent = on ? (st.dataset.onText || '已显示') : (st.dataset.offText || '已隐藏');
st.classList.toggle('on', on);
st.classList.toggle('off', !on);
}
var row = form.closest('tr');
if (row) {
row.classList.toggle('row-off', !on);
}
}
/* ---------- 网站列表:板块标签页 + 拖拽排序 ---------- */
initSitesTabs();
initDragSort();
/**
* 板块标签页:点标签切换对应面板。
* 也支持 URL 锚点 `#sgroup-<gid>`(上移/下移按钮提交后回跳时用),
* 以及无 hash 时默认激活第一个。
*/
function initSitesTabs() {
var wrap = $('#sites-tabs');
if (!wrap) return;
var tabs = $$('.tab', wrap);
var panels = $$('.sites-panel');
function activate(targetId) {
var found = false;
panels.forEach(function (p) {
var on = p.id === targetId;
p.classList.toggle('active', on);
if (on) found = true;
});
if (!found) return false;
tabs.forEach(function (t) {
var on = t.getAttribute('data-target') === targetId;
t.classList.toggle('active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
return true;
}
tabs.forEach(function (t) {
t.addEventListener('click', function () { activate(t.getAttribute('data-target')); });
});
// 载入时:优先用 hash(上移/下移回来时定位到原板块),否则保持服务端渲染的第一个
var hash = (location.hash || '').replace(/^#/, '');
if (hash) { activate(hash); }
}
/**
* 拖拽排序:按住某一行拖动即可改顺序,松手即保存。
*
* 实现要点:
* · 用**原生 HTML5 拖拽**(draggable=true + dragstart/dragover/drop),
* 不引任何第三方库(本项目零依赖);
* · 拖动过程中实时把被拖行**移动到光标位置**(真·所见即所得),
* 而不是只画一条插入线 —— 个人导航站的列表不长,实时重排体验更直观;
* · **只允许在本板块的 tbody 内拖动**:跨板块拖会被忽略(拖到别的板块前先改「板块」再排);
* · 松手后收集该 tbody 的 id 顺序 → POST 给 site_reorder → 服务端整组归一化。
*/
function initDragSort() {
var bodies = $$('tbody[data-group-id]');
if (!bodies.length) return;
var wrap = $('#sites-tabs');
var url = wrap ? wrap.getAttribute('data-reorder-url') : '';
var csrf = (document.querySelector('input[name="csrf"]') || {}).value || '';
bodies.forEach(function (body) {
var dragRow = null;
body.addEventListener('dragstart', function (ev) {
var row = ev.target.closest ? ev.target.closest('tr') : null;
if (!row || row.parentNode !== body) { ev.preventDefault(); return; }
dragRow = row;
row.classList.add('dragging');
try {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || '');
} catch (e) { /* 某些浏览器不允许写 dataTransfer,忽略 */ }
});
body.addEventListener('dragover', function (ev) {
if (!dragRow) return;
ev.preventDefault();
if (ev.dataTransfer) { ev.dataTransfer.dropEffect = 'move'; }
// 找到光标下最近的一行,把被拖行插到它前面/后面
var after = rowAfterPoint(body, ev.clientY);
if (after === null) {
body.appendChild(dragRow);
} else if (after !== dragRow) {
body.insertBefore(dragRow, after);
}
});
body.addEventListener('drop', function (ev) { ev.preventDefault(); });
body.addEventListener('dragend', function () {
if (!dragRow) return;
dragRow.classList.remove('dragging');
dragRow = null;
saveOrder(body);
});
});
/** 返回「光标位置之后的第一行」;光标在最后一行下方时返回 null(表示追加到末尾) */
function rowAfterPoint(body, y) {
var rows = $$('tr', body);
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var box = r.getBoundingClientRect();
if (r.classList.contains('dragging')) continue;
if (y < box.top + box.height / 2) return r;
}
return null;
}
/** 收集顺序并提交;成功后更新状态提示 */
function saveOrder(body) {
if (!url) return;
var gid = body.getAttribute('data-group-id');
var ids = $$('tr', body).map(function (r) { return r.getAttribute('data-id'); }).filter(Boolean);
var status = document.querySelector('.sort-status[data-group-id="' + gid + '"]');
var fd = new FormData();
fd.append('action', 'site_reorder');
fd.append('group_id', gid);
fd.append('order', ids.join(','));
fd.append('ajax', '1');
fd.append('csrf', csrf);
if (status) status.textContent = '正在保存顺序…';
fetch(url, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || !d.ok) {
if (status) status.textContent = '保存失败:' + ((d && d.msg) || '未知错误') + ',请刷新后重试';
return;
}
if (status) status.textContent = '顺序已保存(' + (d.count || ids.length) + ' 项)';
})
.catch(function () {
if (status) status.textContent = '保存失败(网络错误),请刷新后重试';
});
}
}
/* ---------- 一键「全部更新」(分批循环) ---------- */
var upBtn = $('#btn-update-all');
if (upBtn) {
upBtn.addEventListener('click', function () {
if (!confirm('将批量刷新所有网站的名称与自动图标。\n用户自定义简称不受影响;标记为「自定义」的图标会被跳过。\n确定继续?')) return;
var base = upBtn.getAttribute('data-url');
var total = 0, updated = 0, failed = 0, skipped = 0, offset = 0;
upBtn.disabled = true;
function step() {
fetch(base + '?offset=' + offset + '&limit=3', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
total = d.total; updated += d.updated; failed += d.failed; skipped += (d.skipped || 0); offset = d.offset;
upBtn.textContent = '⟳ 更新中 ' + Math.min(offset, total) + '/' + total;
if (!d.done) { step(); }
else {
upBtn.disabled = false;
upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)';
alert('更新完成:\n成功 ' + updated + ' 个\n跳过(自定义图标)' + skipped + ' 个\n失败/无变化 ' + failed + ' 个\n共 ' + total + ' 个网站。');
location.reload();
}
})
.catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(名称 + 自动图标)'; alert('更新中断,请重试。'); });
}
step();
});
}
})();