后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定
- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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:
+49
-3
@@ -102,11 +102,28 @@ try {
|
|||||||
|
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
|
// 若这次改了「所属板块」,条目要落到**新板块的最后**(同组 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 = $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]);
|
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
|
||||||
|
}
|
||||||
flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
|
flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
|
||||||
} else {
|
} 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 = $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]);
|
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $now]);
|
||||||
flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
|
flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
|
||||||
@@ -127,6 +144,33 @@ try {
|
|||||||
}
|
}
|
||||||
case 'site_move': {
|
case 'site_move': {
|
||||||
move_sort('sites', post_id(), (string)($_POST['dir'] ?? 'up'), 'group_id');
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +186,8 @@ try {
|
|||||||
->execute([$name, $display, $visible, $id]);
|
->execute([$name, $display, $visible, $id]);
|
||||||
flash('已保存板块');
|
flash('已保存板块');
|
||||||
} else {
|
} 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 (?,?,?,?,?)')
|
$pdo->prepare('INSERT INTO groups (name,slug,display,sort_order,is_visible) VALUES (?,?,?,?,?)')
|
||||||
->execute([$name, slugify($name), $display, $max, $visible]);
|
->execute([$name, slugify($name), $display, $max, $visible]);
|
||||||
flash('已添加板块');
|
flash('已添加板块');
|
||||||
@@ -183,7 +228,8 @@ try {
|
|||||||
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
|
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
|
||||||
flash('已保存搜索引擎');
|
flash('已保存搜索引擎');
|
||||||
} else {
|
} 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 (?,?,?,?,?,?,?,?,?)')
|
$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]);
|
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $max]);
|
||||||
flash('已添加搜索引擎');
|
flash('已添加搜索引擎');
|
||||||
|
|||||||
+68
-13
@@ -58,22 +58,40 @@ function toggle_form(string $action, int $id, int $state, string $p = ''): strin
|
|||||||
. '</button></form>';
|
. '</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">'
|
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline">'
|
||||||
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">'
|
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">'
|
||||||
. '<input type="hidden" name="id" value="' . $id . '">'
|
. '<input type="hidden" name="id" value="' . $id . '">'
|
||||||
. '<input type="hidden" name="dir" value="' . $dir . '">'
|
. '<input type="hidden" name="dir" value="' . $dir . '">'
|
||||||
. '<input type="hidden" name="p" value="' . e($p) . '">'
|
. '<input type="hidden" name="p" value="' . e($p) . '">'
|
||||||
|
. $extra
|
||||||
. '<button class="ico-btn" title="' . e($label) . '">' . ($dir === 'up' ? '↑' : '↓') . '</button></form>';
|
. '<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();
|
ob_start();
|
||||||
|
|
||||||
if ($p === 'sites'):
|
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;
|
$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) ?>
|
<?= flash_html($flash) ?>
|
||||||
|
|
||||||
@@ -137,24 +155,55 @@ if ($p === 'sites'):
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>网站列表(<?= count($rows) ?>)</h2>
|
<h2>网站列表(<?= count($allRows) ?>)</h2>
|
||||||
<span class="hint">其中 <?= $customCount ?> 个使用自定义图标(不会自动更新)</span>
|
<span class="hint">其中 <?= $customCount ?> 个使用自定义图标(不会自动更新)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tbl-wrap"><table class="tbl">
|
<p class="hint" style="margin-top:-6px">
|
||||||
<thead><tr><th>图标</th><th>简称(展示)</th><th>名称</th><th>网址</th><th>板块</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
|
按<b>板块</b>分成标签页,每个板块单独调整顺序:<b>按住某一行拖动</b>即可改顺序,松手即保存;
|
||||||
<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']); ?>
|
<b>新添加的网站一律排在所属板块的最后</b>,直到你手动把它拖到别处。
|
||||||
<tr>
|
</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>
|
<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 ($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; ?>
|
<?php if ((int)($r['icon_custom'] ?? 0) === 1): ?><span class="badge" title="用户自定义图标,不会被自动更新覆盖">自定义</span><?php endif; ?>
|
||||||
|
<?= e($label) ?>
|
||||||
</td>
|
</td>
|
||||||
<td><?= e($label) ?></td>
|
|
||||||
<td class="muted"><?= e($r['name']) ?></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 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'], 'sites') ?></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', '上移', '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"><?= move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?></td>
|
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
<button type="button" class="ico-btn edit-site"
|
<button type="button" class="ico-btn edit-site"
|
||||||
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
|
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
|
||||||
@@ -164,6 +213,7 @@ if ($p === 'sites'):
|
|||||||
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
|
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
|
||||||
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
|
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
|
||||||
<?= csrf_field() ?><input type="hidden" name="action" value="site_delete">
|
<?= 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'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||||
<button class="ico-btn danger" title="删除">✕</button>
|
<button class="ico-btn danger" title="删除">✕</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -172,6 +222,11 @@ if ($p === 'sites'):
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
|
<p class="hint sort-status" data-group-id="<?= $gid ?>"></p>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,52 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; }
|
|||||||
/* 已隐藏的行稍作弱化 */
|
/* 已隐藏的行稍作弱化 */
|
||||||
.tbl tr.row-off td:first-child, .tbl tr.row-off td:nth-child(2) { color: #98a2b3; }
|
.tbl tr.row-off td:first-child, .tbl tr.row-off td:nth-child(2) { color: #98a2b3; }
|
||||||
|
|
||||||
|
/* ===== 网站列表:板块标签页 + 拖拽排序 ===== */
|
||||||
|
|
||||||
|
/* 板块标签条(后台自己的配色,不复用前台的 .tabs —— 前台的底色是半透明,
|
||||||
|
放在后台白卡上会显得发灰) */
|
||||||
|
.sites-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.sites-tabs .tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
border: 1px solid #dfe3e8; background: #f7f9fb; color: #4a5568; cursor: pointer;
|
||||||
|
padding: 7px 14px; border-radius: 999px; font-size: 13px; transition: all .15s;
|
||||||
|
}
|
||||||
|
.sites-tabs .tab:hover { background: #eef2f7; }
|
||||||
|
.sites-tabs .tab.active { background: #2F6BF7; border-color: #2F6BF7; color: #fff; }
|
||||||
|
.sites-tabs .tab .tab-count {
|
||||||
|
display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,.08); font-size: 11px; line-height: 16px; text-align: center;
|
||||||
|
}
|
||||||
|
.sites-tabs .tab.active .tab-count { background: rgba(255,255,255,.28); }
|
||||||
|
|
||||||
|
/* 面板:一次只显示一个板块 */
|
||||||
|
.sites-panel { display: none; }
|
||||||
|
.sites-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* 拖拽手柄列 */
|
||||||
|
.tbl-sortable .th-drag, .tbl-sortable .td-drag { width: 30px; padding-right: 0; }
|
||||||
|
.drag-handle {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 22px; height: 26px; border-radius: 5px; color: #b6bec9; cursor: grab;
|
||||||
|
vertical-align: middle; user-select: none;
|
||||||
|
}
|
||||||
|
.drag-handle:hover { background: #eef2f7; color: #6b7684; }
|
||||||
|
tr[draggable="true"] { cursor: default; }
|
||||||
|
|
||||||
|
/* 正在被拖动的那一行 */
|
||||||
|
.tbl tr.dragging { opacity: .45; background: #eef3ff !important; }
|
||||||
|
.tbl tr.dragging td { background: #eef3ff !important; }
|
||||||
|
|
||||||
|
/* 拖拽/保存状态提示(每个板块一条) */
|
||||||
|
.sort-status { min-height: 18px; margin: 8px 0 0; }
|
||||||
|
.sort-status:empty { display: none; }
|
||||||
|
|
||||||
|
/* 触屏等不支持 HTML5 拖拽的环境:手柄给个提示色,微调按钮仍可用 */
|
||||||
|
@media (hover: none) {
|
||||||
|
.drag-handle { color: #c8cfd8; }
|
||||||
|
.sort-status { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* 登录页 */
|
/* 登录页 */
|
||||||
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
.login-card { width: 340px; background: #fff; border-radius: 14px; padding: 30px 26px; box-shadow: 0 10px 30px rgba(0,0,0,.08); text-align: center; }
|
.login-card { width: 340px; background: #fff; border-radius: 14px; padding: 30px 26px; box-shadow: 0 10px 30px rgba(0,0,0,.08); text-align: center; }
|
||||||
|
|||||||
@@ -202,6 +202,143 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 网站列表:板块标签页 + 拖拽排序 ---------- */
|
||||||
|
initSitesTabs();
|
||||||
|
initDragSort();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板块标签页:点标签切换对应面板。
|
||||||
|
* 也支持 URL 锚点 `#sgroup-<gid>`(上移/下移按钮提交后回跳时用),
|
||||||
|
* 以及无 hash 时默认激活第一个。
|
||||||
|
*/
|
||||||
|
function initSitesTabs() {
|
||||||
|
var wrap = $('#sites-tabs');
|
||||||
|
if (!wrap) return;
|
||||||
|
var tabs = $$('.tab', wrap);
|
||||||
|
var panels = $$('.sites-panel');
|
||||||
|
|
||||||
|
function activate(targetId) {
|
||||||
|
var found = false;
|
||||||
|
panels.forEach(function (p) {
|
||||||
|
var on = p.id === targetId;
|
||||||
|
p.classList.toggle('active', on);
|
||||||
|
if (on) found = true;
|
||||||
|
});
|
||||||
|
if (!found) return false;
|
||||||
|
tabs.forEach(function (t) {
|
||||||
|
var on = t.getAttribute('data-target') === targetId;
|
||||||
|
t.classList.toggle('active', on);
|
||||||
|
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(function (t) {
|
||||||
|
t.addEventListener('click', function () { activate(t.getAttribute('data-target')); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 载入时:优先用 hash(上移/下移回来时定位到原板块),否则保持服务端渲染的第一个
|
||||||
|
var hash = (location.hash || '').replace(/^#/, '');
|
||||||
|
if (hash) { activate(hash); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽排序:按住某一行拖动即可改顺序,松手即保存。
|
||||||
|
*
|
||||||
|
* 实现要点:
|
||||||
|
* · 用**原生 HTML5 拖拽**(draggable=true + dragstart/dragover/drop),
|
||||||
|
* 不引任何第三方库(本项目零依赖);
|
||||||
|
* · 拖动过程中实时把被拖行**移动到光标位置**(真·所见即所得),
|
||||||
|
* 而不是只画一条插入线 —— 个人导航站的列表不长,实时重排体验更直观;
|
||||||
|
* · **只允许在本板块的 tbody 内拖动**:跨板块拖会被忽略(拖到别的板块前先改「板块」再排);
|
||||||
|
* · 松手后收集该 tbody 的 id 顺序 → POST 给 site_reorder → 服务端整组归一化。
|
||||||
|
*/
|
||||||
|
function initDragSort() {
|
||||||
|
var bodies = $$('tbody[data-group-id]');
|
||||||
|
if (!bodies.length) return;
|
||||||
|
var wrap = $('#sites-tabs');
|
||||||
|
var url = wrap ? wrap.getAttribute('data-reorder-url') : '';
|
||||||
|
var csrf = (document.querySelector('input[name="csrf"]') || {}).value || '';
|
||||||
|
|
||||||
|
bodies.forEach(function (body) {
|
||||||
|
var dragRow = null;
|
||||||
|
|
||||||
|
body.addEventListener('dragstart', function (ev) {
|
||||||
|
var row = ev.target.closest ? ev.target.closest('tr') : null;
|
||||||
|
if (!row || row.parentNode !== body) { ev.preventDefault(); return; }
|
||||||
|
dragRow = row;
|
||||||
|
row.classList.add('dragging');
|
||||||
|
try {
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || '');
|
||||||
|
} catch (e) { /* 某些浏览器不允许写 dataTransfer,忽略 */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
body.addEventListener('dragover', function (ev) {
|
||||||
|
if (!dragRow) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (ev.dataTransfer) { ev.dataTransfer.dropEffect = 'move'; }
|
||||||
|
// 找到光标下最近的一行,把被拖行插到它前面/后面
|
||||||
|
var after = rowAfterPoint(body, ev.clientY);
|
||||||
|
if (after === null) {
|
||||||
|
body.appendChild(dragRow);
|
||||||
|
} else if (after !== dragRow) {
|
||||||
|
body.insertBefore(dragRow, after);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
body.addEventListener('drop', function (ev) { ev.preventDefault(); });
|
||||||
|
|
||||||
|
body.addEventListener('dragend', function () {
|
||||||
|
if (!dragRow) return;
|
||||||
|
dragRow.classList.remove('dragging');
|
||||||
|
dragRow = null;
|
||||||
|
saveOrder(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 返回「光标位置之后的第一行」;光标在最后一行下方时返回 null(表示追加到末尾) */
|
||||||
|
function rowAfterPoint(body, y) {
|
||||||
|
var rows = $$('tr', body);
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var r = rows[i];
|
||||||
|
var box = r.getBoundingClientRect();
|
||||||
|
if (r.classList.contains('dragging')) continue;
|
||||||
|
if (y < box.top + box.height / 2) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集顺序并提交;成功后更新状态提示 */
|
||||||
|
function saveOrder(body) {
|
||||||
|
if (!url) return;
|
||||||
|
var gid = body.getAttribute('data-group-id');
|
||||||
|
var ids = $$('tr', body).map(function (r) { return r.getAttribute('data-id'); }).filter(Boolean);
|
||||||
|
var status = document.querySelector('.sort-status[data-group-id="' + gid + '"]');
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('action', 'site_reorder');
|
||||||
|
fd.append('group_id', gid);
|
||||||
|
fd.append('order', ids.join(','));
|
||||||
|
fd.append('ajax', '1');
|
||||||
|
fd.append('csrf', csrf);
|
||||||
|
|
||||||
|
if (status) status.textContent = '正在保存顺序…';
|
||||||
|
fetch(url, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || !d.ok) {
|
||||||
|
if (status) status.textContent = '保存失败:' + ((d && d.msg) || '未知错误') + ',请刷新后重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status) status.textContent = '顺序已保存(' + (d.count || ids.length) + ' 项)';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (status) status.textContent = '保存失败(网络错误),请刷新后重试';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 一键「全部更新」(分批循环) ---------- */
|
/* ---------- 一键「全部更新」(分批循环) ---------- */
|
||||||
var upBtn = $('#btn-update-all');
|
var upBtn = $('#btn-update-all');
|
||||||
if (upBtn) {
|
if (upBtn) {
|
||||||
|
|||||||
+14
@@ -18,6 +18,20 @@ define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可
|
|||||||
define('ICON_URL', 'uploads/icons'); // 相对站点根
|
define('ICON_URL', 'uploads/icons'); // 相对站点根
|
||||||
define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利)
|
define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序步长(稀疏排序)。
|
||||||
|
*
|
||||||
|
* 为什么不用「连续整数 + 每次全表重排」:
|
||||||
|
* · 拖拽排序只需要改**被拖动那一条**(在两邻居之间取中间值),不必重写整组;
|
||||||
|
* · 新增条目一律 `同组 MAX + SORT_STEP`,天然排在最后,**永远不会插进用户已排好的
|
||||||
|
* 顺序中间**(这是用户明确要求的行为);
|
||||||
|
* · 只有「间隙用尽」或「用户拖完一次」时才做一次归一化(见 apply_sort_order())。
|
||||||
|
*
|
||||||
|
* 取值 1000:一个小组最多能容纳约 1000 次「往同两邻居之间插」而不归一化,
|
||||||
|
* 个人导航站的规模下基本永远不会退化。
|
||||||
|
*/
|
||||||
|
define('SORT_STEP', 1000);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「站点整体图标」(浏览器标签页 favicon + 首页品牌图标)—— 用户上传后自动生成的一整套尺寸。
|
* 「站点整体图标」(浏览器标签页 favicon + 首页品牌图标)—— 用户上传后自动生成的一整套尺寸。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -111,6 +111,110 @@ SQL);
|
|||||||
if ($isEmpty) {
|
if ($isEmpty) {
|
||||||
seed_groups_and_sites($pdo);
|
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
|
function seed_settings(PDO $pdo): void
|
||||||
|
|||||||
Reference in New Issue
Block a user