Files
SimpleNav/admin/actions.php
T
laoyang aac6d3e0f4 联想词键盘选词彻底修复:上下键 / 逗号句号 / 输入法组合上屏四条路径全通
背景:用户反馈「上下键和逗号句号都无法选择候选词,只能用鼠标」。逐条查下去
发现是**三个各自独立的原因**叠在一起,任何一个没修都会让人觉得「还是坏的」。

① 单组按键模式让另一组键静默失效
   线上 settings.suggest_keys 停在 'punct'(只认 , 和 .)—— 是该模式的正确
   行为,但用户不知道那是设置项在起作用,只当坏了。
   改法:默认值改为 'both'(↓/↑ 与 , . 都认)。'both' 是 'arrows' 与 'punct'
   各自行为的**严格超集**,升级不会让任何人的习惯失效,只是多了一组。
   老库用**一次性迁移**(哨兵键 mig_suggest_keys_both 把关)把已知单组值并成
   'both'。⚠️ 迁移必须只跑一次:db_init() 每次请求都执行,无条件 UPDATE 会把
   用户在后台特意选回的 'arrows' 于下一个请求改回去 —— 设置项直接失效。
   同时同步三处默认值:index.php / admin/index.php / admin/actions.php。

② 【元凶】beforeinput 只认 insertText,漏了 insertCompositionText
   输入法把标点当**组合内容**提交时,beforeinput 的 inputType 是
   insertCompositionText,旧代码直接 return → 这条路径完全失效。
   修法:两种 inputType 都认。

③ 【真机实测的硬限制】insertCompositionText 的 beforeinput cancelable = false
   Chromium 里这条事件**不允许** preventDefault(CDP 实测记录:
   beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false),
   所以「命中就拦」对这条路根本无效 —— 标点会被真的插进搜索框。
   修法:加「事后收口」——beforeinput 记下快照与该移到的目标下标,
   等这一帧事件洪峰过去(setTimeout 0)再把标点从输入框里撤掉、并补上选词移动。
   目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1`
   已经把选中清掉了,再从 active 起算会每次都回到第一条(不累加)。
   顺带把标点判定抽成共用函数 punctDir/keyPunctDir(e.key 与 e.code 互为兜底),
   并修掉「方向键也无差别置去重闸门」——方向键不产生 beforeinput,置了纯属副作用,
   反而会误挡紧随其后的真实标点。

测试与验证(全部实跑,非推断):
- jsdom 交互测试 89 项全过,新增「真实浏览器形态」场景(cancelable=false +
  组合上屏),并**纠正了旧版一条在断言 bug 本身的断言**(原写「组合上屏不该被拦」)。
- 反向验证 6 变体全部精确挂掉对应断言(含新增的「只认 insertText」与
  「方向键置闸门」两个变体);旧变体引用的 isComma/isPeriod 已随重构更新。
- **真实浏览器**(Playwright + msedge + CDP IME)21/21:上下键 / 半角 / 全角
  insertText / IME 组合上屏四条路径全部能选词。
- 真实浏览器反向验证:基准全绿,删掉「事后收口」或改回只认 insertText 都精确报红。
- 迁移语义测试 16 项全过(真实 db_init,含「用户改回 arrows 后不再被覆盖」)。
- 静态断言空跑 155 条全过;线上部署 0 FAIL,诊断页 57 条断言全过。
- 线上真机直连 n.yangyq.net 21/21。
2026-09-29 08:10:22 +08:00

352 lines
16 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;
}
// 图标文件的命名键:按整条网址算(同域名不同路径必须互不覆盖,见 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) {
$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;
}
/* ---------- 站点整体图标(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']]);
}