后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定
- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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:
+51
-5
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user