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