Files
SimpleNav/admin/actions.php
T
laoyang 34473d0120 fix: 联想建议词改成「统一来源 + 自动兜底」,任何引擎都能出词
问题:除百度/360 外,其它引擎的联想下拉基本是空的。

实测(2026-09-28 在站点服务器上逐个探测):
  百度 sugrec        200 / 0.12s / 1137B  ✅ 最快最稳
  360 sug.so.360.cn  200 / 0.20s /  714B  ✅
  必应 api.bing.com  200 / 0.5~1.6s      ✅ 可用但明显慢
  搜狗 ajajjson      200 / **0 字节**     ❌ 联想域名 w.sugg.sogou.com 已不解析
  谷歌 suggestqueries  连接超时            ❌ 国内不可达
  DuckDuckGo          连接超时            ❌ 国内不可达

按「联想统一取百度、搜索仍走各自引擎」实现:
- 新增设置 suggest_source(设置页可选 百度/必应/360/各引擎自己的接口),默认百度。
- 任何情况下都带自动兜底链:首选来源取不到词(接口挂/超时/返回空)
  就依次回退 百度 → 必应 → 360;单个候选 3 秒超时,绝不让下拉框卡住。
- 候选来源不过滤 is_active —— 作为联想来源的引擎可能在前台被隐藏。
- 响应里带 src 字段标明这次实际用的是谁,便于排查。

顺带修一个更隐蔽的 bug:前台 <form id="search-form"> 原本**没有 data-slug**,
而 JS 只在「点击引擎标签」时才把它写上 → 一进站、没点过引擎时 slug 为空,
suggest.php 查不到引擎,联想**全都不出词**。现在表单自带默认引擎的 slug。

其它:
- 清掉种子里已失效的搜狗联想地址;加一条幂等迁移,只匹配默认种子值,
  用户自己改过的地址不动。谷歌/DDG 也不再预设(前端不可达)。
- app.js:请求加序号,防止先发的慢响应覆盖后发的快响应;切换引擎时
  若输入框已有内容就顺带换一份建议。
- e2e 新增「联想来源」场景(本地桩服务器 + 独立端口,不依赖外网):
  8 条断言覆盖 默认来源 / auto 模式 / 回退 / 空关键词 / 响应头。

验证:本地 e2e 57/57;部署 38 文件哈希逐字节一致、双版本 php -l、37 条
静态断言全过;线上实测 google/sogou/ddg 三个「自家接口不可用」的引擎
均返回 10 条百度建议(src=baidu),中英文都正常。
2026-09-28 17:13:10 +08:00

289 lines
13 KiB
PHP
Raw 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;
}
$domain = domain_of($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, $domain);
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)], $domain, 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) {
$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();
$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');
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 {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM groups')->fetchColumn();
$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 '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 {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM engines')->fetchColumn();
$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;
}
/* ---------- 设置 ---------- */
case 'settings_save': {
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
$src = in_array($srcRaw, ['auto', 'baidu', 'bing', 'so360'], true) ? $srcRaw : 'baidu';
$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' => isset($_POST['open_blank']) ? '1' : '0',
'icon_services' => isset($_POST['icon_services']) ? '1' : '0',
'suggest_source' => $src,
];
$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']]);
}