Files
SimpleNav/admin/index.php
T
laoyang 13b12a3d67 后台新增「联想词按键」设置:可用 , 和 . 代替 ↑/↓ 选联想词
输入法手感 —— 习惯中文输入法的话,手不用离开主键区就能在联想下拉里选词。

- 设置项 suggest_keys:arrows(默认,等同改造前)/ punct(, 和 .)/ both。
  后台为三选一下拉,保存走白名单回退(非白名单一律 arrows),默认值 = 旧行为。
- app.js 认**物理键**(e.code 的 Comma / Period)而不是字符 —— 中文输入法下按逗号
  打出来的是全角「,」,e.key 会变成「,」,但物理键不变;「跟输入法一样」就该这么认。
- ⚠️ 逗号和句号同时是搜索框的合法输入字符(如「python, pandas」)—— 所以只在
  **联想下拉打开时**才把它们当选词键;没有下拉时放行,照常输入。这条边界写进了
  后台说明(想输入逗号本身先按 Esc 关掉下拉)。
- 「,」= 上一条(同 ↑)、「.」= 下一条(同 ↓),首尾循环、与方向键行为完全一致。
- 前台输出两处同源标记:#search-form 的 data-keys(供断言/排查)与
  window.SIMPLENAV.suggestKeys(JS 实际读它决定用哪组键)。

验证:
- jsdom 交互测试 54/54 —— arrows / punct / both 三种模式各跑一遍;覆盖全角「,」「。」
  (中文输入法场景)、「没有下拉时 ,/. 不被拦截」(关键回归)、
  以及上一轮的核心回归「直接回车用原文」在三种模式下都成立。
- 反向验证两个变体:把回车改回旧行为 → 「直接回车不改动输入框」精确失败(27 项);
  把「没下拉放行」改坏 → 恰好只失败「没有下拉时按 ./, 不被拦截」那两条(2 项),
  其余全过 —— 两条核心断言各自有效、定位精准。
- e2e 128/128(新增联想按键场景:保存 → data-keys / suggestKeys 同步、
  非法值回退 arrows、保存不动其它设置)。
- 线上部署 0 FAIL(77 条静态断言、38 文件哈希逐字节一致);线上几何 25/25;
  直连线上核验 data-keys / suggestKeys / app.js 内容均已就位。
2026-09-28 20:25:33 +08:00

385 lines
22 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>联想词按键
<?php $sk = setting('suggest_keys', 'arrows'); ?>
<select name="suggest_keys">
<option value="arrows" <?= $sk === 'arrows' ? 'selected' : '' ?>>↑ / ↓ 方向键(默认)</option>
<option value="punct" <?= $sk === 'punct' ? 'selected' : '' ?>>, 和 .(输入法手感)</option>
<option value="both" <?= $sk === 'both' ? 'selected' : '' ?>>两者都可以</option>
</select>
</label>
<p class="hint span2">
决定用哪组键在联想下拉里<b>选词</b>:选中某条后回车才按该词搜索;不选就直接回车 = 用你输入的原文。
「, 和 .」跟输入法一样,手不用离开主键区,习惯中文输入法的话更顺手。
⚠️ 选它之后,<b>联想下拉打开时</b>逗号与句号会当作选词键,不再直接输入 ——
想搜「python, pandas」这类带逗号的词,先按 <b>Esc</b> 关掉下拉再输。
</p>
<label>合集显示方式
<?php $cl = setting('collections_layout', 'tabs'); ?>
<select name="collections_layout">
<option value="tabs" <?= $cl === 'flat' ? '' : 'selected' ?>>标签页折叠(合集多时用)</option>
<option value="flat" <?= $cl === 'flat' ? 'selected' : '' ?>>全部平铺(每组各占一块)</option>
</select>
</label>
<p class="hint span2">
只影响底部<b>「合集」</b>那一区,中间那些普通网格板块(常用 / 工具…)不受影响。
<b>每个合集里条目不多</b>时选「全部平铺」,一眼看全、不用点;
<b>合集很多、条目很杂</b>时选「标签页折叠」,默认只展开当前那一个。
某个板块是进「合集」还是做成普通网格板块,由「板块 → 展示方式」决定。
</p>
<label>主题色<input type="text" name="primary_color" value="<?= e(setting('primary_color', '#2F6BF7')) ?>"></label>
<label>搜索与链接的打开方式
<?php $ob = setting('open_blank', '1'); ?>
<select name="open_blank">
<option value="1" <?= $ob === '0' ? '' : 'selected' ?>>新标签页打开(推荐)</option>
<option value="0" <?= $ob === '0' ? 'selected' : '' ?>>当前页面打开</option>
</select>
</label>
<p class="hint span2">
同时管两处:<b>顶部搜索</b>(提交后到哪儿看结果)和<b>所有网站格 / 合集链接</b>。
「新标签页打开」(默认):点完当前导航页还在,方便连着开好几个;
「当前页面打开」:在同一个标签里跳走,适合把本站当浏览器起始页、不想堆一堆标签的场景。
<b>不影响</b>后台管理界面里的链接。
</p>
<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);