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

- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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
+104
View File
@@ -111,6 +111,110 @@ SQL);
if ($isEmpty) {
seed_groups_and_sites($pdo);
}
// 老库升级:把排序号**归一化成稀疏值**(第 n 条 = n*SORT_STEP),只跑一次。
//
// 为什么要归一化:旧库的 sort_order 多是连续的 0,1,2…(相邻只差 1,没有空隙),
// 而「拖拽把某条插到另两条之间」需要算出**整数**中间值 —— 1 与 2 之间没有整数。
// 归一化后每两条之间留出 SORT_STEP,拖拽时任意插入点都有余量。
//
// 为什么必须**只跑一次**:db_init() 是**每次请求**都会执行的。若无条件重排,
// 用户在后台刚拖好的顺序,会在下一个请求里被按「旧顺序」覆盖回去 ——
// 与上面「联想词按键」那次是同一个坑。用 settings 里的哨兵键把关,跑过就永不再跑。
//
// 为什么这次归一化**不会打乱**用户既有顺序:重排用的排序键仍是 `sort_order, id`,
// 是**稳定排序** —— 相对次序不变,只把「差值」放大成整齐的间隙。
$migSortDone = (int)$pdo->query("SELECT COUNT(*) FROM settings WHERE k='mig_sort_sparse'")->fetchColumn() > 0;
if (!$migSortDone) {
normalize_sort_orders($pdo);
$pdo->exec("INSERT INTO settings (k,v) VALUES ('mig_sort_sparse','1')
ON CONFLICT(k) DO UPDATE SET v='1'");
}
}
/**
* 把三张表的排序号重写为 (序号 × SORT_STEP),同组内按「原 sort_order, id」稳定排序。
* 供一次性迁移调用(也是 apply_sort_order() 的整组归一化语义)。
*/
function normalize_sort_orders(PDO $pdo): void
{
// 无分组维度的两张表
foreach (['groups', 'engines'] as $tbl) {
$rows = $pdo->query("SELECT id FROM {$tbl} ORDER BY sort_order, id")->fetchAll(PDO::FETCH_COLUMN);
$upd = $pdo->prepare("UPDATE {$tbl} SET sort_order=? WHERE id=?");
foreach ($rows as $i => $rid) {
$upd->execute([($i + 1) * SORT_STEP, (int)$rid]);
}
}
// sites 按板块分别归一化(不同板块之间互不影响)
$gids = $pdo->query("SELECT id FROM groups ORDER BY sort_order, id")->fetchAll(PDO::FETCH_COLUMN);
$upd = $pdo->prepare('UPDATE sites SET sort_order=? WHERE id=?');
$sel = $pdo->prepare('SELECT id FROM sites WHERE group_id=? ORDER BY sort_order, id');
foreach ($gids as $gid) {
$sel->execute([(int)$gid]);
foreach ($sel->fetchAll(PDO::FETCH_COLUMN) as $i => $rid) {
$upd->execute([($i + 1) * SORT_STEP, (int)$rid]);
}
}
}
/**
* 取「同组最后一条的 sort_order + SORT_STEP」——**新增条目专用**。
*
* 这是用户明确要求的行为:新增的链接一律排在**最后**(得到一个很大的顺序号),
* 直到用户手动把它拖到别处,它才拥有一个「正常」的顺序。
* ⚠️ 不要改回 `MAX+1`:那只在「旧数据恰好是连续整数」时才等于「排最后」,
* 一旦 sort_order 之间有间隙(拖拽后必然如此),MAX+1 会**撞进已有间隙**里,
* 让新条目落到用户排好的中间 —— 这正是用户抱怨的「排序被打乱」。
*
* @param int|null $groupId 该表的分组值;null = 该表没有分组维度(groups / engines)
*/
function next_sort_order(string $table, ?int $groupId = null, string $groupCol = 'group_id'): int
{
$pdo = db();
if ($groupId === null) {
$max = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0) FROM {$table}")->fetchColumn();
} else {
$st = $pdo->prepare("SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE {$groupCol}=?");
$st->execute([$groupId]);
$max = (int)$st->fetchColumn();
}
return $max + SORT_STEP;
}
/**
* 按给定 id 顺序**整组重写**排序号(第 n 条 = n*SORT_STEP),返回实际写入行数。
*
* 这是拖拽排序的落库方式:一次拖动后把整个组的顺序提交上来、整组归一化。
* 好处是每次拖完间隙都恢复整齐,之后新增仍天然排在最后。
*
* @param int[] $ids 该组按新顺序排列的 id(未列出/不属于本组的会被忽略)
* @param int|null $groupId 非 null 时做**归属校验**:不属于该组的 id 直接跳过
* (防止一个被篡改的请求把别的板块的条目拖进来)
*/
function apply_sort_order(string $table, array $ids, ?int $groupId = null, string $groupCol = 'group_id'): int
{
$pdo = db();
$upd = $pdo->prepare("UPDATE {$table} SET sort_order=? WHERE id=?");
$ck = $groupId === null ? null : $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE id=? AND {$groupCol}=?");
$n = 0;
$i = 0;
foreach ($ids as $rid) {
$rid = (int)$rid;
if ($rid <= 0) {
continue;
}
if ($ck !== null) {
$ck->execute([$rid, $groupId]);
if ((int)$ck->fetchColumn() === 0) {
continue; // 不属于本组 → 忽略
}
}
$i++;
$upd->execute([$i * SORT_STEP, $rid]);
$n++;
}
return $n;
}
function seed_settings(PDO $pdo): void