Files
laoyang e81e6900e2 板块管理页支持拖拽排序(group_reorder)
- 板块列表与网站列表同款的拖拽交互:按住行首手柄拖动,实时重排预览,
  松手即保存(Ajax 动作 group_reorder,整组归一化为 index * SORT_STEP)
- 板块没有分组维度,group_reorder 不带 group_id、不做归属校验
  (apply_sort_order 不传 $groupId 时按给定 id 顺序整组重写)
- admin.js 的拖拽排序从「只认网站列表」泛化为「所有带 data-sortable 的
  tbody」:落库 action / group_id / 落库地址都改由 tbody 的 data-* 提供,
  网站列表行为不变(仍从 #sites-tabs 读地址、带 group_id)
- 新增板块仍排最后(MAX + SORT_STEP,上一轮已实现);板块上移/下移按钮
  保留作无 JS 回退;页面顶部补说明文案
- 验证:HTTP e2e 新增 S12 系列 18 条(结构 / 拖拽落库 / 前台同步 /
  新增排最后 / 上移回退),72/72;jsdom 新增板块页 19 项,26+19 全绿;
  反向验证:函数层 4 变体 + HTTP 层 7 变体(新增 G:group_reorder 失效)
  + jsdom 4 变体全部精确报红、基准全绿;全量既有回归 193/193
2026-09-29 21:45:16 +08:00

412 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 简航 SimpleNav —— 后台操作处理(全部 POST + CSRF)
* 支持 AJAX:请求带 ajax=1 时返回 JSON(用于列表内原地切换显隐/排序)。
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
send_no_cache();
$isAjax = !empty($_POST['ajax']);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . url('admin/'));
exit;
}
csrf_check();
$pdo = db();
$action = (string)($_POST['action'] ?? '');
$back = 'admin/';
if (!empty($_POST['p'])) {
$back = 'admin/?p=' . preg_replace('/[^a-z]/', '', (string)$_POST['p']);
}
function flash(string $m, string $t = 'ok'): void { $_SESSION['flash'] = ['m' => $m, 't' => $t]; }
function post_id(): int { return (int)($_POST['id'] ?? 0); }
/** AJAX 直出 JSON 并结束 */
function json_out(array $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/** 读取某表单状态字段的当前值(用于把最新状态回给前端) */
function state_of(string $table, int $id, string $col): int
{
$st = db()->prepare("SELECT {$col} FROM {$table} WHERE id=?");
$st->execute([$id]);
return (int)$st->fetchColumn();
}
$ajax = null; // 需要在 AJAX 模式下返回的附加数据
try {
switch ($action) {
/* ---------- 网站 ---------- */
case 'site_save': {
$id = post_id();
$gid = (int)($_POST['group_id'] ?? 0);
$url = norm_url((string)($_POST['url'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$short = trim((string)($_POST['short_name'] ?? ''));
$visible = isset($_POST['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0) {
flash('网址和板块不能为空', 'err');
break;
}
// 图标文件的命名键:按整条网址算(同域名不同路径必须互不覆盖,见 icon_key())
$iconKey = icon_key($url);
// ---- 图标 ----
// icon_auto 勾选 = 允许「全部更新」自动刷新图标(icon_custom=0)
// 取消勾选 / 上传文件 / 给地址 = 自定义固定(icon_custom=1)
$iconAuto = isset($_POST['icon_auto']);
$custom = $iconAuto ? 0 : 1;
$iconFile = trim((string)($_POST['icon'] ?? ''));
$uploaded = !empty($_FILES['icon_file']['tmp_name']) && is_uploaded_file((string)$_FILES['icon_file']['tmp_name']);
if ($uploaded) {
$bin = (string)@file_get_contents((string)$_FILES['icon_file']['tmp_name']);
if ($bin === '' || strlen($bin) > 2 * 1024 * 1024) {
flash('图标文件过大或为空(限 2MB)', 'err');
} else {
$new = save_custom_icon($bin, $iconKey);
if ($new !== '') {
$iconFile = $new;
$custom = 1;
} else {
flash('图标文件无法解析,请换一张(PNG / JPG / SVG,建议正方形)', 'err');
}
}
} else {
$iconUrl = trim((string)($_POST['icon_url'] ?? ''));
if ($iconUrl !== '') {
$new = download_best_icon([norm_url($iconUrl)], $iconKey, 1);
if ($new !== '') {
$iconFile = $new;
$custom = 1;
} else {
flash('自定义图标地址下载失败,请检查地址是否可直接访问', 'err');
}
}
}
// 网址换了但没换图标:清掉旧图标,避免张冠李戴
if ($iconFile !== '' && $custom === 0 && !is_file(ICON_PATH . '/' . $iconFile)) {
$iconFile = '';
}
$now = date('Y-m-d H:i:s');
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->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
}
flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
} else {
// 新增条目:一律排在该板块**最后**(同组 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 ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
}
break;
}
case 'site_delete': {
$pdo->prepare('DELETE FROM sites WHERE id=?')->execute([post_id()]);
flash('已删除网站');
break;
}
case 'site_toggle': {
$id = post_id();
$pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('sites', $id, 'is_visible')];
flash('已切换显示状态');
break;
}
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;
}
/* ---------- 板块 ---------- */
case 'group_save': {
$id = post_id();
$name = trim((string)($_POST['name'] ?? ''));
$display = ($_POST['display'] ?? 'grid') === 'tabs' ? 'tabs' : 'grid';
$visible = isset($_POST['is_visible']) ? 1 : 0;
if ($name === '') { flash('板块名称不能为空', 'err'); break; }
if ($id > 0) {
$pdo->prepare('UPDATE groups SET name=?, display=?, is_visible=? WHERE id=?')
->execute([$name, $display, $visible, $id]);
flash('已保存板块');
} else {
// 新增板块:排最后(稀疏步长,理由同 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('已添加板块');
}
break;
}
case 'group_delete': {
$pdo->prepare('DELETE FROM groups WHERE id=?')->execute([post_id()]);
flash('已删除板块(含其下网站)');
break;
}
case 'group_toggle': {
$id = post_id();
$pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('groups', $id, 'is_visible')];
flash('已切换显示状态');
break;
}
case 'group_move': {
move_sort('groups', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
case 'group_reorder': {
// 拖拽排序落库:板块没有分组维度(全表一套顺序),无需归属校验 ——
// apply_sort_order() 不带 $groupId 时按给定 id 顺序整组重写。
// 表单字段 `order` = 逗号分隔的 group id(按新顺序)。
$ids = [];
foreach (explode(',', (string)($_POST['order'] ?? '')) as $one) {
$one = (int)trim($one);
if ($one > 0) { $ids[] = $one; }
}
$n = apply_sort_order('groups', $ids);
$ajax = ['count' => $n];
if (!$isAjax) { flash('已保存排序(' . $n . ' 项)'); }
break;
}
/* ---------- 搜索引擎 ---------- */
case 'engine_save': {
$id = post_id();
$name = trim((string)($_POST['name'] ?? ''));
$slug = preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['slug'] ?? ''));
$search = trim((string)($_POST['search_url'] ?? ''));
$param = trim((string)($_POST['param'] ?? 'q'));
$sugUrl = trim((string)($_POST['suggest_url'] ?? ''));
$sugType = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['suggest_type'] ?? ''));
$color = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['color'] ?? '')) ? $_POST['color'] : '#2F6BF7';
$active = isset($_POST['is_active']) ? 1 : 0;
if ($name === '' || $slug === '' || $search === '') { flash('名称/标识/搜索地址不能为空', 'err'); break; }
if ($id > 0) {
$pdo->prepare('UPDATE engines SET name=?,slug=?,search_url=?,param=?,suggest_url=?,suggest_type=?,color=?,is_active=? WHERE id=?')
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
flash('已保存搜索引擎');
} else {
// 新增引擎:排最后(稀疏步长,理由同 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('已添加搜索引擎');
}
break;
}
case 'engine_delete': {
$pdo->prepare('DELETE FROM engines WHERE id=?')->execute([post_id()]);
flash('已删除搜索引擎');
break;
}
case 'engine_toggle': {
$id = post_id();
$pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('engines', $id, 'is_active')];
flash('已切换启用状态');
break;
}
case 'engine_move': {
move_sort('engines', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
/* ---------- 站点整体图标(favicon + 首页品牌图标) ---------- */
case 'site_icon_save': {
$back = 'admin/?p=settings';
$f = $_FILES['site_icon_file'] ?? null;
if (!$f || empty($f['tmp_name']) || !is_uploaded_file((string)$f['tmp_name'])) {
flash('请选择要上传的图片', 'err');
break;
}
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
flash('上传失败(错误码 ' . (int)$f['error'] . ',可能是文件超过服务器上限)', 'err');
break;
}
$size = (int)($f['size'] ?? 0);
if ($size <= 0) {
flash('上传的文件是空的', 'err');
break;
}
if ($size > 5 * 1024 * 1024) {
flash('图片太大了(' . round($size / 1048576, 1) . 'MB),请压缩到 5MB 以内', 'err');
break;
}
$bin = (string)@file_get_contents((string)$f['tmp_name']);
if ($bin === '') {
flash('读取上传文件失败', 'err');
break;
}
// 只认图片:SVG 不收(矢量图当站点图标会带来 XSS 面,且栅格化各端不一致)
$im = gd_load($bin);
if ($im === null) {
flash('这不是一张能识别的图片(支持 PNG / JPG / GIF / WebP)', 'err');
break;
}
$iw = imagesx($im);
$ih = imagesy($im);
imagedestroy($im);
$err = save_site_logo($bin);
if ($err !== '') {
flash($err, 'err');
} else {
flash('站点图标已更新(' . $iw . '×' . $ih
. ' → 已生成 favicon 与手机主屏图标等一整套尺寸)');
}
break;
}
case 'site_icon_reset': {
$back = 'admin/?p=settings';
reset_site_logo();
flash('已恢复默认图标');
break;
}
/* ---------- 设置 ---------- */
case 'settings_save': {
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
$src = in_array($srcRaw, ['auto', 'baidu', 'bing', 'so360'], true) ? $srcRaw : 'baidu';
// 合集区显示方式:flat = 全部平铺;其余一律回到 tabs(标签页折叠)
$coll = (string)($_POST['collections_layout'] ?? 'tabs') === 'flat' ? 'flat' : 'tabs';
// 打开方式:'0' = 当前页面;其余(含字段缺失、被篡改成怪值)一律回到 '1'(新标签页)
// ——默认必须等于改造前的旧行为,这样老库里已有的值上线后不会突然变样。
$open = (string)($_POST['open_blank'] ?? '1') === '0' ? '0' : '1';
// 联想词按键:both(都认,默认)/ arrows(↓/↑)/ punct(, 和 .);非白名单一律回到 both
$keys = (string)($_POST['suggest_keys'] ?? 'both');
if (!in_array($keys, ['arrows', 'punct', 'both'], true)) { $keys = 'both'; }
$map = [
'site_name' => trim((string)($_POST['site_name'] ?? '')),
'subtitle' => trim((string)($_POST['subtitle'] ?? '')),
'default_engine' => preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['default_engine'] ?? '')),
'primary_color' => preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['primary_color'] ?? '')) ? $_POST['primary_color'] : '#2F6BF7',
'open_blank' => $open,
'suggest_keys' => $keys,
'icon_services' => isset($_POST['icon_services']) ? '1' : '0',
'suggest_source' => $src,
'collections_layout' => $coll,
];
$st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v');
foreach ($map as $k => $v) { $st->execute([$k, $v]); }
flash('设置已保存');
$back = 'admin/?p=settings';
break;
}
case 'password_save': {
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['new_password'] ?? '');
$u = current_user();
$st = $pdo->prepare('SELECT password_hash FROM users WHERE id=?');
$st->execute([$u['id']]);
$hash = (string)$st->fetchColumn();
if (!password_verify($old, $hash)) { flash('原密码不正确', 'err'); $back = 'admin/?p=settings'; break; }
if (mb_strlen($new) < 4) { flash('新密码至少 4 位', 'err'); $back = 'admin/?p=settings'; break; }
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([password_hash($new, PASSWORD_DEFAULT), $u['id']]);
flash('密码已更新');
$back = 'admin/?p=settings';
break;
}
default:
flash('未知操作', 'err');
}
} catch (Throwable $e) {
if ($isAjax) {
json_out(['ok' => false, 'msg' => $e->getMessage()]);
}
flash('操作失败:' . $e->getMessage(), 'err');
}
if ($isAjax) {
unset($_SESSION['flash']); // AJAX 不产生页面提示,避免下次进后台弹出过期提示
if ($ajax === null) {
json_out(['ok' => false, 'msg' => '该操作不支持 AJAX']);
}
json_out(['ok' => true] + $ajax);
}
header('Location: ' . url($back));
exit;
/** 上/下移动排序(同组内交换 sort_order) */
function move_sort(string $table, int $id, string $dir, ?string $groupCol): void
{
if ($id <= 0) return;
$pdo = db();
$st = $pdo->prepare("SELECT * FROM {$table} WHERE id=?");
$st->execute([$id]);
$cur = $st->fetch();
if (!$cur) return;
$where = $groupCol ? " AND {$groupCol}=" . (int)$cur[$groupCol] : '';
if ($dir === 'up') {
$sql = "SELECT * FROM {$table} WHERE sort_order < ?{$where} ORDER BY sort_order DESC LIMIT 1";
} else {
$sql = "SELECT * FROM {$table} WHERE sort_order > ?{$where} ORDER BY sort_order ASC LIMIT 1";
}
$st2 = $pdo->prepare($sql);
$st2->execute([$cur['sort_order']]);
$nb = $st2->fetch();
if (!$nb) return;
$upd = $pdo->prepare("UPDATE {$table} SET sort_order=? WHERE id=?");
$upd->execute([$nb['sort_order'], $cur['id']]);
$upd->execute([$cur['sort_order'], $nb['id']]);
}