- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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 会落进已有间隙、插到用户排好的中间) - 改变所属板块时,该条目自动排到新板块的最后 - 后台页面 + 拖拽手柄做了配套样式,触屏设备降级为上下移按钮
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
/* 简航 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();
|
||
});
|
||
}
|
||
})();
|