板块管理页支持拖拽排序(group_reorder)

- 板块列表与网站列表同款的拖拽交互:按住行首手柄拖动,实时重排预览,
  松手即保存(Ajax 动作 group_reorder,整组归一化为 index * SORT_STEP)
- 板块没有分组维度,group_reorder 不带 group_id、不做归属校验
  (apply_sort_order 不传 $groupId 时按给定 id 顺序整组重写)
- admin.js 的拖拽排序从「只认网站列表」泛化为「所有带 data-sortable 的
  tbody」:落库 action / group_id / 落库地址都改由 tbody 的 data-* 提供,
  网站列表行为不变(仍从 #sites-tabs 读地址、带 group_id)
- 新增板块仍排最后(MAX + SORT_STEP,上一轮已实现);板块上移/下移按钮
  保留作无 JS 回退;页面顶部补说明文案
- 验证:HTTP e2e 新增 S12 系列 18 条(结构 / 拖拽落库 / 前台同步 /
  新增排最后 / 上移回退),72/72;jsdom 新增板块页 19 项,26+19 全绿;
  反向验证:函数层 4 变体 + HTTP 层 7 变体(新增 G:group_reorder 失效)
  + jsdom 4 变体全部精确报红、基准全绿;全量既有回归 193/193
This commit is contained in:
2026-09-29 21:45:16 +08:00
parent 717a8877bb
commit e81e6900e2
3 changed files with 44 additions and 16 deletions
+14
View File
@@ -210,6 +210,20 @@ try {
move_sort('groups', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
case 'group_reorder': {
// 拖拽排序落库:板块没有分组维度(全表一套顺序),无需归属校验 ——
// apply_sort_order() 不带 $groupId 时按给定 id 顺序整组重写。
// 表单字段 `order` = 逗号分隔的 group id(按新顺序)。
$ids = [];
foreach (explode(',', (string)($_POST['order'] ?? '')) as $one) {
$one = (int)trim($one);
if ($one > 0) { $ids[] = $one; }
}
$n = apply_sort_order('groups', $ids);
$ajax = ['count' => $n];
if (!$isAjax) { flash('已保存排序(' . $n . ' 项)'); }
break;
}
/* ---------- 搜索引擎 ---------- */
case 'engine_save': {
+13 -5
View File
@@ -191,7 +191,7 @@ if ($p === 'sites'):
data-group-id="<?= $gid ?>">
<div class="tbl-wrap"><table class="tbl tbl-sortable">
<thead><tr><th class="th-drag"></th><th>简称(展示)</th><th>名称</th><th>网址</th><th>显示</th><th>微调</th><th>操作</th></tr></thead>
<tbody data-group-id="<?= $gid ?>">
<tbody data-group-id="<?= $gid ?>" data-sortable data-sort-action="site_reorder">
<?php foreach ($t['items'] as $r): $label = $r['short_name'] !== '' ? $r['short_name'] : ($r['name'] !== '' ? $r['name'] : domain_of($r['url'])); $src = icon_src($r['icon']); ?>
<tr draggable="true" data-id="<?= (int)$r['id'] ?>">
<td class="td-drag"><?= drag_handle() ?></td>
@@ -251,11 +251,18 @@ elseif ($p === 'groups'): ?>
</form>
</section>
<section class="card">
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<div class="card-head"><h2>板块列表(<?= count($groups) ?>)</h2></div>
<p class="hint" style="margin-top:-6px">
板块的先后顺序就是它们在<b>前台出现的顺序</b>:<b>按住某一行拖动</b>即可调整,松手即保存;
也可以用每行右侧的 ↑ ↓ 微调(不支持拖拽的环境用)。
<b>新添加的板块一律排在最后</b>,直到你手动把它拖到别处。
</p>
<div class="tbl-wrap"><table class="tbl tbl-sortable">
<thead><tr><th class="th-drag"></th><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>微调</th><th>操作</th></tr></thead>
<tbody data-sortable data-sort-action="group_reorder" data-reorder-url="<?= e(url('admin/actions.php')) ?>">
<?php foreach ($groups as $g): $cnt = (int)$pdo->query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?>
<tr>
<tr draggable="true" data-id="<?= (int)$g['id'] ?>">
<td class="td-drag"><?= drag_handle() ?></td>
<td><?= e($g['name']) ?></td>
<td><?= $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?></td>
<td><?= $cnt ?></td>
@@ -272,6 +279,7 @@ elseif ($p === 'groups'): ?>
<?php endforeach; ?>
</tbody>
</table></div>
<p class="hint sort-status"></p>
</section>
<?php
+17 -11
View File
@@ -244,24 +244,30 @@
/**
* 拖拽排序:按住某一行拖动即可改顺序,松手即保存。
* 用于「网站列表」(每板块一个 tbody)与「板块列表」(整表一个 tbody)。
*
* 实现要点:
* · 用**原生 HTML5 拖拽**(draggable=true + dragstart/dragover/drop),
* 不引任何第三方库(本项目零依赖);
* · 拖动过程中实时把被拖行**移动到光标位置**(真·所见即所得),
* 而不是只画一条插入线 —— 个人导航站的列表不长,实时重排体验更直观;
* · **只允许在本板块的 tbody 内拖动**:跨板块拖会被忽略(拖到别的板块前先改「板块」再排);
* · 松手后收集该 tbody 的 id 顺序 → POST 给 site_reorder → 服务端整组归一化。
* · 网站列表**只允许在本板块的 tbody 内拖动**:跨板块拖会被忽略(拖到别的板块前先改「板块」再排);
* · 松手后收集该 tbody 的 id 顺序 → POST 给对应 action(tbody 上的 data-sort-action)
* → 服务端整组归一化。
*/
function initDragSort() {
var bodies = $$('tbody[data-group-id]');
var bodies = $$('tbody[data-sortable]');
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;
var action = body.getAttribute('data-sort-action') || 'site_reorder';
var gid = body.getAttribute('data-group-id') || '';
// 落库地址:优先 tbody 自带;网站列表没有时回退到标签容器上的
var url = body.getAttribute('data-reorder-url')
|| (wrap ? wrap.getAttribute('data-reorder-url') : '');
body.addEventListener('dragstart', function (ev) {
var row = ev.target.closest ? ev.target.closest('tr') : null;
@@ -293,7 +299,7 @@
if (!dragRow) return;
dragRow.classList.remove('dragging');
dragRow = null;
saveOrder(body);
saveOrder(body, action, gid, url);
});
});
@@ -309,16 +315,16 @@
return null;
}
/** 收集顺序并提交;成功后更新状态提示 */
function saveOrder(body) {
/** 收集顺序并提交;成功后更新状态提示(action/gid/url 来自所属 tbody 的 data-*) */
function saveOrder(body, action, gid, url) {
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 scope = body.closest('.sites-panel') || body.closest('.card') || document;
var status = $('.sort-status', scope);
var fd = new FormData();
fd.append('action', 'site_reorder');
fd.append('group_id', gid);
fd.append('action', action);
if (gid) { fd.append('group_id', gid); }
fd.append('order', ids.join(','));
fd.append('ajax', '1');
fd.append('csrf', csrf);