diff --git a/admin/actions.php b/admin/actions.php index 21969c5..535f3ac 100644 --- a/admin/actions.php +++ b/admin/actions.php @@ -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-` 锚点,由 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('已添加搜索引擎'); diff --git a/admin/index.php b/admin/index.php index 84e7b25..cf13eb9 100644 --- a/admin/index.php +++ b/admin/index.php @@ -58,22 +58,40 @@ function toggle_form(string $action, int $id, int $state, string $p = ''): strin . ''; } -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 '
' . csrf_field() . '' . '' . '' . '' + . $extra . '
'; } +/** 拖拽手柄(纯展示,真实拖拽由 admin.js 绑在整行上) */ +function drag_handle(): string +{ + return ''; +} + 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++; } } ?> @@ -137,41 +155,78 @@ if ($p === 'sites'):
-

网站列表()

+

网站列表()

其中 个使用自定义图标(不会自动更新)
-
- - - - - - - - - - - - - - - -
图标简称(展示)名称网址板块显示排序操作
- - 自定义 - - -
- - - -
-
+

+ 按板块分成标签页,每个板块单独调整顺序:按住某一行拖动即可改顺序,松手即保存; + 也可以用每行右侧的 ↑ ↓ 微调(不支持拖拽的环境用)。 + 新添加的网站一律排在所属板块的最后,直到你手动把它拖到别处。 +

+ + +

还没有任何网站,先在上面的表单里添加一个。

+ + $g, 'items' => $byGroup[$gid]]; } + } + ?> +
+ $t): $gid = (int)$t['g']['id']; ?> + + +
+ +
+ $t): $gid = (int)$t['g']['id']; ?> +
+
+ + + + + + + + + + + + + + +
简称(展示)名称网址显示微调操作
+ + 自定义 + + ') . move_form('site_move', (int)$r['id'], 'down', '下移', 'sites', '') ?> + +
+ + + + +
+
+

+
+ +
+
`(上移/下移按钮提交后回跳时用), + * 以及无 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'); if (upBtn) { diff --git a/config.php b/config.php index 43e5804..6c42e10 100644 --- a/config.php +++ b/config.php @@ -18,6 +18,20 @@ define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可 define('ICON_URL', 'uploads/icons'); // 相对站点根 define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利) +/** + * 排序步长(稀疏排序)。 + * + * 为什么不用「连续整数 + 每次全表重排」: + * · 拖拽排序只需要改**被拖动那一条**(在两邻居之间取中间值),不必重写整组; + * · 新增条目一律 `同组 MAX + SORT_STEP`,天然排在最后,**永远不会插进用户已排好的 + * 顺序中间**(这是用户明确要求的行为); + * · 只有「间隙用尽」或「用户拖完一次」时才做一次归一化(见 apply_sort_order())。 + * + * 取值 1000:一个小组最多能容纳约 1000 次「往同两邻居之间插」而不归一化, + * 个人导航站的规模下基本永远不会退化。 + */ +define('SORT_STEP', 1000); + /** * 「站点整体图标」(浏览器标签页 favicon + 首页品牌图标)—— 用户上传后自动生成的一整套尺寸。 * diff --git a/db.php b/db.php index 03683da..4c7a4be 100644 --- a/db.php +++ b/db.php @@ -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