后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定

- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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:
2026-09-29 09:21:51 +08:00
parent aac6d3e0f4
commit 717a8877bb
6 changed files with 443 additions and 41 deletions
+51 -5
View File
@@ -102,11 +102,28 @@ try {
$now = date('Y-m-d H:i:s');
if ($id > 0) {
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, icon_custom=?, is_visible=?, updated_at=? WHERE id=?');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
// 若这次改了「所属板块」,条目要落到**新板块的最后**(同组 MAX + SORT_STEP)。
// ⚠️ 否则它会带着旧板块的 sort_order 混进新板块的中间 ——
// 这正是用户抱怨的「新链接混入其中、打乱原有排序」的另一种形态:
// 旧板块用稀疏号(如 12000),挪进一个只有 1000/2000 的板块后,
// 它会按 sort_order 插到那些条目之间,而不是排到最后。
$old = $pdo->prepare('SELECT group_id FROM sites WHERE id=?');
$old->execute([$id]);
$oldGid = (int)$old->fetchColumn();
if ($oldGid !== $gid) {
$max = next_sort_order('sites', $gid);
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, icon_custom=?, sort_order=?, is_visible=?, updated_at=? WHERE id=?');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $id]);
} else {
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, icon_custom=?, is_visible=?, updated_at=? WHERE id=?');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
}
flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM sites WHERE group_id=' . $gid)->fetchColumn();
// 新增条目:一律排在该板块**最后**(同组 MAX + SORT_STEP)。
// ⚠️ 不要改回 `MAX+1`:拖拽后 sort_order 之间必然有间隙,
// MAX+1 会落进既有间隙里、跑到用户排好的中间去(详见 next_sort_order 注释)。
$max = next_sort_order('sites', $gid);
$st = $pdo->prepare('INSERT INTO sites (group_id,url,name,short_name,icon,icon_custom,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $now]);
flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
@@ -127,6 +144,33 @@ try {
}
case 'site_move': {
move_sort('sites', post_id(), (string)($_POST['dir'] ?? 'up'), 'group_id');
// 非 AJAX 提交后回到「该板块」那个标签页,而不是跳回第一个标签。
// 表单里带了 gid;回退用 `#sgroup-<gid>` 锚点,由 admin.js 在载入时激活对应标签。
$gid = (int)($_POST['gid'] ?? 0);
if ($gid > 0) {
$back = 'admin/?p=sites#sgroup-' . $gid;
}
break;
}
case 'site_reorder': {
// 拖拽排序落库:一次拖动后把**该板块**的完整新顺序提交上来。
// 表单字段 `order` = 逗号分隔的 site id(按新顺序)。
// 归属校验在 apply_sort_order() 里做 —— 不属于该板块的 id 会被忽略,
// 防止被篡改的请求把别的板块的条目混进来。
$gid = (int)($_POST['group_id'] ?? 0);
if ($gid <= 0) {
if ($isAjax) { json_out(['ok' => false, 'msg' => '缺少板块']); }
flash('排序失败:缺少板块', 'err');
break;
}
$ids = [];
foreach (explode(',', (string)($_POST['order'] ?? '')) as $one) {
$one = (int)trim($one);
if ($one > 0) { $ids[] = $one; }
}
$n = apply_sort_order('sites', $ids, $gid);
$ajax = ['count' => $n];
if (!$isAjax) { flash('已保存排序(' . $n . ' 项)'); }
break;
}
@@ -142,7 +186,8 @@ try {
->execute([$name, $display, $visible, $id]);
flash('已保存板块');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM groups')->fetchColumn();
// 新增板块:排最后(稀疏步长,理由同 next_sort_order 注释)
$max = next_sort_order('groups');
$pdo->prepare('INSERT INTO groups (name,slug,display,sort_order,is_visible) VALUES (?,?,?,?,?)')
->execute([$name, slugify($name), $display, $max, $visible]);
flash('已添加板块');
@@ -183,7 +228,8 @@ try {
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
flash('已保存搜索引擎');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM engines')->fetchColumn();
// 新增引擎:排最后(稀疏步长,理由同 next_sort_order 注释)
$max = next_sort_order('engines');
$pdo->prepare('INSERT INTO engines (name,slug,search_url,param,suggest_url,suggest_type,color,is_active,sort_order) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $max]);
flash('已添加搜索引擎');