后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定
- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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 会落进已有间隙、插到用户排好的中间) - 改变所属板块时,该条目自动排到新板块的最后 - 后台页面 + 拖拽手柄做了配套样式,触屏设备降级为上下移按钮
This commit is contained in:
@@ -202,6 +202,143 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 网站列表:板块标签页 + 拖拽排序 ---------- */
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user