Files
SimpleNav/admin/index.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

346 lines
20 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 —— 后台首页(网站 / 板块 / 搜索引擎 / 设置)
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
send_no_cache();
$pdo = db();
$p = isset($_GET['p']) ? (string)$_GET['p'] : 'sites';
if (!in_array($p, ['sites', 'groups', 'engines', 'settings'], true)) {
$p = 'sites';
}
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
$groups = $pdo->query('SELECT * FROM groups ORDER BY sort_order, id')->fetchAll();
$engines = $pdo->query('SELECT * FROM engines ORDER BY sort_order, id')->fetchAll();
$SUGGEST_TYPES = ['bing_osjson', 'baidu_sugrec', 'google_firefox', 'sogou_ajaj', 'so360', 'ddg'];
function flash_html(?array $f): string
{
if (!$f) return '';
$cls = ($f['t'] ?? 'ok') === 'err' ? 'alert err' : 'alert ok';
return '<div class="' . $cls . '">' . e($f['m']) . '</div>';
}
/** 眼睛(显示)/ 斜杠眼(隐藏)图标 —— 用内联 SVG,避免 emoji 字体差异导致显示成方块 */
function eye_svg(bool $on): string
{
$slash = $on ? '' : '<path d="M3 3l18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>';
return '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor">'
. '<path d="M12 5c-5.5 0-9.6 4.3-11 7 1.4 2.7 5.5 7 11 7s9.6-4.3 11-7c-1.4-2.7-5.5-7-11-7zm0 12a5 5 0 110-10 5 5 0 010 10z"/>'
. '<circle cx="12" cy="12" r="2.6"/>'
. $slash
. '</svg>';
}
/**
* 显隐切换按钮:表单自带 ajax=1,前端原地切换、不跳页;
* 即使浏览器禁用 JS,表单也会正常提交并回到当前列表页(p 已带上)。
*/
function toggle_form(string $action, int $id, int $state, string $p = ''): string
{
$on = $state === 1;
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline js-toggle" data-id="' . $id . '">'
. csrf_field()
. '<input type="hidden" name="action" value="' . e($action) . '">'
. '<input type="hidden" name="id" value="' . $id . '">'
. '<input type="hidden" name="p" value="' . e($p) . '">'
. '<input type="hidden" name="ajax" value="1">'
. '<button type="submit" class="ico-btn toggle' . ($on ? ' on' : '') . '"'
. ' data-on="' . ($on ? '1' : '0') . '"'
. ' title="' . ($on ? '当前:显示中(点击隐藏)' : '当前:已隐藏(点击显示)') . '">'
. eye_svg($on)
. '</button></form>';
}
function move_form(string $action, int $id, string $dir, string $label, string $p): 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) . '">'
. '<button class="ico-btn" title="' . e($label) . '">' . ($dir === 'up' ? '↑' : '↓') . '</button></form>';
}
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();
$customCount = 0;
foreach ($rows as $rr) { if ((int)($rr['icon_custom'] ?? 0) === 1) { $customCount++; } }
?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head">
<h2>新增 / 编辑网站</h2>
<button type="button" class="btn ghost" id="btn-update-all" data-url="<?= e(url('admin/update_all.php')) ?>">⟳ 全部更新(名称 + 自动图标)</button>
</div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="site-form" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_save">
<input type="hidden" name="id" id="f-id" value="0">
<input type="hidden" name="icon" id="f-icon" value="">
<label>板块
<select name="group_id" id="f-group" required>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>"><?= e($g['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="span2">网址
<span class="row">
<input type="text" name="url" id="f-url" placeholder="https://example.com" required>
<button type="button" class="btn ghost" id="btn-fetch" data-url="<?= e(url('admin/fetch_meta.php')) ?>">自动获取</button>
</span>
</label>
<label>网站名称(自动抓取)
<input type="text" name="name" id="f-name" placeholder="自动抓取">
</label>
<label>简称(页面显示,手动)
<input type="text" name="short_name" id="f-short" placeholder="如:知乎">
</label>
<div class="span2 icon-field">
<div class="icon-field-preview">
<span class="icon-preview" id="f-icon-preview"><span class="ph">无</span></span>
</div>
<div class="icon-field-body">
<div class="icon-field-title">图标</div>
<input type="text" name="icon_url" id="f-icon-url" placeholder="自定义图标图片地址(http/https),留空则用自动获取的">
<div class="row icon-row2">
<input type="file" name="icon_file" id="f-icon-file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.svg,.ico">
<label class="chk"><input type="checkbox" name="icon_auto" id="f-icon-auto" checked> 允许自动更新图标</label>
</div>
<p class="hint">
优先级:<b>上传文件</b> > <b>自定义地址</b> > 「自动获取」抓到的图。<br>
选择上传或填写地址后会自动取消勾选「允许自动更新图标」,该网站图标此后不再被「全部更新」覆盖;
勾回去即可恢复自动。
</p>
</div>
</div>
<label class="chk"><input type="checkbox" name="is_visible" id="f-visible" checked> 显示</label>
<div class="span2 form-actions">
<button type="submit" class="btn primary">保存</button>
<button type="button" class="btn ghost" id="btn-reset">清空</button>
<span class="hint" id="fetch-hint"></span>
</div>
</form>
</section>
<section class="card">
<div class="card-head">
<h2>网站列表(<?= count($rows) ?>)</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>
</section>
<?php
elseif ($p === 'groups'): ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>新增 / 编辑板块</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="group-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="group_save">
<input type="hidden" name="id" id="g-id" value="0">
<input type="hidden" name="p" value="groups">
<label>板块名称<input type="text" name="name" id="g-name" required></label>
<label>展示方式
<select name="display" id="g-display">
<option value="grid">普通网格板块(如:常用 / 工具)</option>
<option value="tabs">合集标签页</option>
</select>
</label>
<label class="chk"><input type="checkbox" name="is_visible" id="g-visible" checked> 显示</label>
<div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="g-reset">清空</button></div>
</form>
</section>
<section class="card">
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($groups as $g): $cnt = (int)$pdo->query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?>
<tr>
<td><?= e($g['name']) ?></td>
<td><?= $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?></td>
<td><?= $cnt ?></td>
<td><?= toggle_form('group_toggle', (int)$g['id'], (int)$g['is_visible'], 'groups') ?></td>
<td class="nowrap"><?= move_form('group_move', (int)$g['id'], 'up', '上移', 'groups') . move_form('group_move', (int)$g['id'], 'down', '下移', 'groups') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-group" data-id="<?= (int)$g['id'] ?>" data-name="<?= e($g['name']) ?>" data-display="<?= e($g['display']) ?>" data-visible="<?= (int)$g['is_visible'] ?>">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('删除板块会同时删除其下所有网站,确定?');">
<?= csrf_field() ?><input type="hidden" name="action" value="group_delete"><input type="hidden" name="p" value="groups"><input type="hidden" name="id" value="<?= (int)$g['id'] ?>">
<button class="ico-btn danger">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
</section>
<?php
elseif ($p === 'engines'): ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>新增 / 编辑搜索引擎</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="engine-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="engine_save">
<input type="hidden" name="id" id="e-id" value="0">
<input type="hidden" name="p" value="engines">
<label>名称<input type="text" name="name" id="e-name" required></label>
<label>标识 slug<input type="text" name="slug" id="e-slug" placeholder="bing" required></label>
<label class="span2">搜索地址<input type="text" name="search_url" id="e-search" placeholder="https://cn.bing.com/search" required></label>
<label>参数名<input type="text" name="param" id="e-param" placeholder="q / wd / query"></label>
<label>主题色<input type="text" name="color" id="e-color" placeholder="#60A504"></label>
<label class="span2">联想接口({q} 为关键词占位)<input type="text" name="suggest_url" id="e-sug" placeholder="https://api.bing.com/osjson.aspx?query={q}"></label>
<label>联想解析类型
<select name="suggest_type" id="e-sugtype">
<?php foreach ($SUGGEST_TYPES as $t): ?><option value="<?= e($t) ?>"><?= e($t) ?></option><?php endforeach; ?>
</select>
</label>
<label class="chk"><input type="checkbox" name="is_active" id="e-active"> 启用(显示在首页顶部)</label>
<p class="hint span2">
这两项只在「设置 → 联想词来源」选<b>各搜索引擎自己的接口</b>时才会用到;
默认是统一用百度出建议(搜索本身仍走各自的搜索地址)。
这里填的接口只要通了就会被优先使用,没通就自动回退到百度。
</p>
<div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="e-reset">清空</button></div>
</form>
</section>
<section class="card">
<div class="card-head">
<h2>搜索引擎列表</h2>
<span class="hint">点最右侧「眼睛」图标即在该行原地切换,不会跳页</span>
</div>
<div class="tbl-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>搜索地址</th><th>联想类型</th><th>前台显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($engines as $en): ?>
<tr>
<td><span style="color:<?= e($en['color']) ?>">■</span> <?= e($en['name']) ?> <span class="muted">(<?= e($en['slug']) ?>)</span></td>
<td class="muted ell"><?= e($en['search_url']) ?></td>
<td class="muted"><?= e($en['suggest_type']) ?></td>
<td class="nowrap">
<span class="state <?= $en['is_active'] ? 'on' : 'off' ?> js-state" data-on-text="已显示" data-off-text="已隐藏"><?= $en['is_active'] ? '已显示' : '已隐藏' ?></span>
<?= toggle_form('engine_toggle', (int)$en['id'], (int)$en['is_active'], 'engines') ?>
</td>
<td class="nowrap"><?= move_form('engine_move', (int)$en['id'], 'up', '上移', 'engines') . move_form('engine_move', (int)$en['id'], 'down', '下移', 'engines') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-engine"
data-id="<?= (int)$en['id'] ?>" data-name="<?= e($en['name']) ?>" data-slug="<?= e($en['slug']) ?>"
data-search="<?= e($en['search_url']) ?>" data-param="<?= e($en['param']) ?>" data-sug="<?= e($en['suggest_url']) ?>"
data-sugtype="<?= e($en['suggest_type']) ?>" data-color="<?= e($en['color']) ?>" data-active="<?= (int)$en['is_active'] ?>">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该引擎?');">
<?= csrf_field() ?><input type="hidden" name="action" value="engine_delete"><input type="hidden" name="p" value="engines"><input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<button class="ico-btn danger">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<p class="hint">提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。「前台显示」为该引擎是否出现在首页顶部搜索框上方的切换条里。</p>
</section>
<?php
else: // settings ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>站点设置</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="settings_save">
<label>站点名称<input type="text" name="site_name" value="<?= e(setting('site_name', '简航')) ?>"></label>
<label>副标题<input type="text" name="subtitle" value="<?= e(setting('subtitle', '个人网站导航')) ?>"></label>
<label>默认搜索引擎
<select name="default_engine">
<?php foreach ($engines as $en): ?>
<option value="<?= e($en['slug']) ?>" <?= setting('default_engine') === $en['slug'] ? 'selected' : '' ?>><?= e($en['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>联想词来源
<select name="suggest_source">
<?php
$cur = setting('suggest_source', 'baidu');
$opts = [['baidu', '百度(推荐,最快)'], ['bing', '必应'], ['so360', '360'], ['auto', '各搜索引擎自己的接口']];
$known = array_column($engines, 'slug');
foreach ($opts as $o):
if ($o[0] !== 'auto' && !in_array($o[0], $known, true)) { continue; }
?>
<option value="<?= e($o[0]) ?>" <?= $cur === $o[0] ? 'selected' : '' ?>><?= e($o[1]) ?></option>
<?php endforeach; ?>
</select>
</label>
<p class="hint span2">
搜索时仍然用你选中的那个引擎,这里只决定<b>下拉建议词</b>从哪儿来。
实测(2026-09-28):<b>百度 0.12s、360 0.20s、必应 0.5~1.6s</b> 可用;
<b>搜狗接口已失效</b>(返回 0 字节)、<b>谷歌与 DuckDuckGo 国内连不上</b>。
所以默认「统一用百度」——这样切到任何引擎下拉都有词。选「各搜索引擎自己的接口」则恢复成
各用各的(没恢复好的引擎会自动回退到百度的建议)。
</p>
<label>主题色<input type="text" name="primary_color" value="<?= e(setting('primary_color', '#2F6BF7')) ?>"></label>
<label class="chk"><input type="checkbox" name="open_blank" <?= setting('open_blank', '1') === '1' ? 'checked' : '' ?>> 搜索结果新窗口打开</label>
<label class="chk"><input type="checkbox" name="icon_services" <?= setting('icon_services', '1') === '1' ? 'checked' : '' ?>> 联网搜索高清图标(第三方聚合服务)</label>
<p class="hint span2">勾上后,当站点自身没有提供高清图标时,会依次尝试 icon.horse、Yandex 图标服务;关掉则只用站点自己声明的图标。</p>
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>修改管理员密码</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="password_save">
<label>原密码<input type="password" name="old_password" autocomplete="current-password" required></label>
<label>新密码<input type="password" name="new_password" autocomplete="new-password" required></label>
<div class="span2 form-actions"><button class="btn primary">修改密码</button></div>
</form>
</section>
<?php
endif;
$body = ob_get_clean();
admin_layout($p, '后台', $body);