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

- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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('已添加搜索引擎');
+91 -36
View File
@@ -58,22 +58,40 @@ function toggle_form(string $action, int $id, int $state, string $p = ''): strin
. '</button></form>';
}
function move_form(string $action, int $id, string $dir, string $label, string $p): string
/** 上/下移按钮(无 JS 回退用;有 JS 时以拖拽为主) */
function move_form(string $action, int $id, string $dir, string $label, string $p, string $extra = ''): string
{
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline">'
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">'
. '<input type="hidden" name="id" value="' . $id . '">'
. '<input type="hidden" name="dir" value="' . $dir . '">'
. '<input type="hidden" name="p" value="' . e($p) . '">'
. $extra
. '<button class="ico-btn" title="' . e($label) . '">' . ($dir === 'up' ? '↑' : '↓') . '</button></form>';
}
/** 拖拽手柄(纯展示,真实拖拽由 admin.js 绑在整行上) */
function drag_handle(): string
{
return '<span class="drag-handle" title="按住拖动调整顺序" aria-hidden="true">'
. '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">'
. '<circle cx="9" cy="7" r="1.6"/><circle cx="15" cy="7" r="1.6"/>'
. '<circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/>'
. '<circle cx="9" cy="17" r="1.6"/><circle cx="15" cy="17" r="1.6"/>'
. '</svg></span>';
}
ob_start();
if ($p === 'sites'):
$rows = $pdo->query('SELECT s.*, g.name AS gname FROM sites s JOIN groups g ON g.id=s.group_id ORDER BY g.sort_order, g.id, s.sort_order, s.id')->fetchAll();
// 一次取全量,再按板块分桶(避免每板块一次查询)
$allRows = $pdo->query('SELECT s.*, g.name AS gname FROM sites s JOIN groups g ON g.id=s.group_id ORDER BY g.sort_order, g.id, s.sort_order, s.id')->fetchAll();
$byGroup = [];
foreach ($allRows as $rr) {
$byGroup[(int)$rr['group_id']][] = $rr;
}
$customCount = 0;
foreach ($rows as $rr) { if ((int)($rr['icon_custom'] ?? 0) === 1) { $customCount++; } }
foreach ($allRows as $rr) { if ((int)($rr['icon_custom'] ?? 0) === 1) { $customCount++; } }
?>
<?= flash_html($flash) ?>
@@ -137,41 +155,78 @@ if ($p === 'sites'):
<section class="card">
<div class="card-head">
<h2>网站列表(<?= count($rows) ?>)</h2>
<h2>网站列表(<?= count($allRows) ?>)</h2>
<span class="hint">其中 <?= $customCount ?> 个使用自定义图标(不会自动更新)</span>
</div>
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>图标</th><th>简称(展示)</th><th>名称</th><th>网址</th><th>板块</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): $label = $r['short_name'] !== '' ? $r['short_name'] : ($r['name'] !== '' ? $r['name'] : domain_of($r['url'])); $src = icon_src($r['icon']); ?>
<tr>
<td>
<?php if ($src): ?><img class="mini" src="<?= e($src) ?>" alt=""><?php else: ?><span class="mini letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span><?php endif; ?>
<?php if ((int)($r['icon_custom'] ?? 0) === 1): ?><span class="badge" title="用户自定义图标,不会被自动更新覆盖">自定义</span><?php endif; ?>
</td>
<td><?= e($label) ?></td>
<td class="muted"><?= e($r['name']) ?></td>
<td class="muted ell"><a href="<?= e($r['url']) ?>" target="_blank" rel="noopener"><?= e($r['url']) ?></a></td>
<td><?= e($r['gname']) ?></td>
<td><?= toggle_form('site_toggle', (int)$r['id'], (int)$r['is_visible']) ?></td>
<td class="nowrap"><?= move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-site"
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
data-url="<?= e($r['url']) ?>" data-name="<?= e($r['name']) ?>"
data-short="<?= e($r['short_name']) ?>" data-icon="<?= e($r['icon']) ?>"
data-icon-url="<?= e($src) ?>" data-custom="<?= (int)($r['icon_custom'] ?? 0) ?>"
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
<?= csrf_field() ?><input type="hidden" name="action" value="site_delete">
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="ico-btn danger" title="删除">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<p class="hint" style="margin-top:-6px">
按<b>板块</b>分成标签页,每个板块单独调整顺序:<b>按住某一行拖动</b>即可改顺序,松手即保存;
也可以用每行右侧的 ↑ ↓ 微调(不支持拖拽的环境用)。
<b>新添加的网站一律排在所属板块的最后</b>,直到你手动把它拖到别处。
</p>
<?php if (!$allRows): ?>
<p class="hint">还没有任何网站,先在上面的表单里添加一个。</p>
<?php else: ?>
<?php
// 板块标签页:只显示有网站的板块(空板块不占位)
$tabGroups = [];
foreach ($groups as $g) {
$gid = (int)$g['id'];
if (!empty($byGroup[$gid])) { $tabGroups[] = ['g' => $g, 'items' => $byGroup[$gid]]; }
}
?>
<div class="sites-tabs" id="sites-tabs" role="tablist" data-reorder-url="<?= e(url('admin/actions.php')) ?>">
<?php foreach ($tabGroups as $i => $t): $gid = (int)$t['g']['id']; ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="sgroup-<?= $gid ?>"><?= e($t['g']['name']) ?>
<span class="tab-count"><?= count($t['items']) ?></span>
</button>
<?php endforeach; ?>
</div>
<div class="sites-panels">
<?php foreach ($tabGroups as $i => $t): $gid = (int)$t['g']['id']; ?>
<div class="sites-panel<?= $i === 0 ? ' active' : '' ?>" id="sgroup-<?= $gid ?>" role="tabpanel"
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 ?>">
<?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>
<td>
<?php if ($src): ?><img class="mini" src="<?= e($src) ?>" alt=""><?php else: ?><span class="mini letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span><?php endif; ?>
<?php if ((int)($r['icon_custom'] ?? 0) === 1): ?><span class="badge" title="用户自定义图标,不会被自动更新覆盖">自定义</span><?php endif; ?>
<?= e($label) ?>
</td>
<td class="muted"><?= e($r['name']) ?></td>
<td class="muted ell"><a href="<?= e($r['url']) ?>" target="_blank" rel="noopener"><?= e($r['url']) ?></a></td>
<td><?= toggle_form('site_toggle', (int)$r['id'], (int)$r['is_visible'], 'sites') ?></td>
<td class="nowrap"><?= move_form('site_move', (int)$r['id'], 'up', '上移', 'sites', '<input type="hidden" name="reload" value="1"><input type="hidden" name="gid" value="' . $gid . '">') . move_form('site_move', (int)$r['id'], 'down', '下移', 'sites', '<input type="hidden" name="reload" value="1"><input type="hidden" name="gid" value="' . $gid . '">') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-site"
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
data-url="<?= e($r['url']) ?>" data-name="<?= e($r['name']) ?>"
data-short="<?= e($r['short_name']) ?>" data-icon="<?= e($r['icon']) ?>"
data-icon-url="<?= e($src) ?>" data-custom="<?= (int)($r['icon_custom'] ?? 0) ?>"
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
<?= csrf_field() ?><input type="hidden" name="action" value="site_delete">
<input type="hidden" name="p" value="sites">
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="ico-btn danger" title="删除">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<p class="hint sort-status" data-group-id="<?= $gid ?>"></p>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php