Files
FamilyBook/assets/js/nav-show.js
T
laoyang 91c55038c9 v30 第 30 批(3 条):ICS 导入读完即弃 / 导航联想词根因修复 / 导航设置改纯图标
三条都是「静态检查与 e2e 恒绿、只有真机才现形」的,每条先写真机诊断探针
拿到真根因再改,并逐条反向验证(改回旧行为必须被抓住)。

1. 日程 ICS 导入:文件已上传仍提示「请选择一个文本」
   真根因:_stage_drop.php 是"拖进来即刻上传到暂存区"的唯一形态——
   拖放时文件走 upload_stage.php 落到 uploads/_staging/,而 <input>.files
   始终是空的;两个导入框(ics/通讯录)都带 required → 浏览器原生校验把提交
   拦下,弹它自己的文案。就算绕过校验,路由只读 $_FILES['ics']、从不看
   stage_token[],暂存文件永远没人认领。
   改:片段新增 $sdStage(默认 true)。导入型传 false → 框上不带
   data-drop-stage → app.js 走"写回 input.files、随表单提交"的老路,文件走
   PHP 临时文件,路由读完即弃;不落存储、不建附件行、不进 OSS。
   同时把 app.js 找框的选择器从 [data-drop-stage][data-sd-for=…] 放宽成
   [data-sd-for=…]——否则这些框点不开也接不住拖动。

2. 导航联想词"还是不好用"
   真根因:nav_show.php 给出的 suggest 地址已带 ?p=<短语>,而 nav-show.js 又
   硬拼 '?slug=…' → 实际是 …?p=abc?slug=bing&q=…;PHP 认为第一个 ? 之后整串
   都是查询值 → p 匹配不上短语 → 403 → 下拉永远空。服务端一直是对的(直连
   200 + 10 条词),e2e 探针自己拼对了参数,所以 HTTP 层恒绿、只有真页面能抓。
   改:nav-show.js 新增 withQuery(),按 base 里有没有 ? 决定分隔符;
   nav_suggest.php 加短语容错(切掉第一个 ?/& 之后的内容,救缓存里的旧 JS)。

3. 导航设置按钮改纯图标 ☰
   v29 只改成了下拉菜单,summary 仍是 btn-ghost btn-sm + 文字「设置」+ 一个
   向下箭头。改:换成与其他七个模块逐字节相同的 btn-icon + 三条横线。

验证:e2e 1464/0(新增 2 条)、升级 153/0、vCard 40 项、城市 32/0、
桌面几何全过、移动 680/0、交互 92/0、窄屏 30/0、b24_nav 41/0、b24_ui 26/0、
b28 39/0、b29 38/0、b27 34/0;新真机探针 _b30 22/0、反向验证 11/0;
部署静态断言新增 8 条守卫并逐条证伪通过(本轮抓到 2 条恒真守卫 + 1 处
_code_only 的 locale 差异)。线上 7 个文件 SHA256 与本地一致。
2026-10-08 14:46:16 +08:00

409 lines
22 KiB
JavaScript
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.
/* ============================================================
导航展示页脚本(v24 第 24 批 用户第 4 条)
—— 从 SimpleNav 的 assets/js/app.js 整份搬运,逻辑主体保留。
它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、
站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。
⚠️ 联想接口地址来自 window.SIMPLENAV.suggest(服务端按当前短语生成),
不在这个文件里写死。
============================================================
🔴🔴 v24 第 26 批 用户第 4 条:**搬砖时丢了两处选择器 id,导致两个功能全废**。
用户原话:「导航展示页面,选不同的搜索引擎没用。搜索条里面的联想也没有实现。」
根因(两处,都是"HTML 与 JS 对不上",且**都不报错、静默失效**):
① 引擎标签:JS 找 `#engine-tabs`,而 HTML 的容器只有 `class="search-type"`
(没有 id)→ `querySelectorAll` 返回空 → 点击事件**一个都没绑上** →
"选不同的搜索引擎没用"(点完标签视觉上会变吗?连高亮都不会变,
因为高亮也是这个函数做的)。
② 联想下拉:同一段代码继续处理 `#word` / `#search-text`,但**在 ① 挂掉之后
那半段其实仍在执行**(它们各查各的)—— 真正的第二处是 `activateEngine`
与 `askSuggest` 都依赖 `tabs`/`form.dataset.slug`,
而 `form` 的 slug 从来没被设过(因为 activateEngine 没跑),
于是请求 `/nav_suggest.php?slug=&q=…` —— `slug=` 是空的,
服务端虽然能兜底(它按 baidu→bing→360 试),但**前端拿不到东西时也不显示**:
因为 `showWord()` 依赖 `#word`,而 `#word` 是 `<ul>`,在 CSS 里
只有在 `display:block` 时才可见 —— 那段 CSS 依赖 `.s-search` 的一个后代选择器,
而容器 id 丢了之后**连 CSS 一起失配**(连锁反应)。
修法:**给 HTML 补 id**(`#engine-tabs`)而不是改 JS 的选择器 ——
理由是这份 JS 是从 SimpleNav 原样搬来的、将来还要对照原版维护,
改 JS 会让"两边不一样",改 HTML 只是把搬漏的属性补回去。
============================================================ */
(function () {
'use strict';
var cfg = window.SIMPLENAV || {};
/* ---------- 搜索引擎切换 ----------
⚠️ 选择器兼容两种写法(`#engine-tabs` 与老模板的 `.search-type`),
这样即使将来模板改回 class、这里也不会再静默失效一次。
**兜底写法不是"多余的"**:它就是这次事故的对策。 */
var tabHost = document.getElementById('engine-tabs')
|| document.querySelector('.search-type');
var tabs = tabHost
? tabHost.querySelectorAll('.s-engine')
: document.querySelectorAll('.s-engine');
var form = document.getElementById('search-form');
var input = document.getElementById('search-text');
function activateEngine(el) {
Array.prototype.forEach.call(tabs, function (t) { t.classList.remove('active'); });
el.classList.add('active');
var search = el.getAttribute('data-search');
var param = el.getAttribute('data-param');
var slug = el.getAttribute('data-slug');
var ph = el.getAttribute('data-placeholder') || '搜索';
if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); }
if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
hideWord();
// 已经输了字就顺手换一份建议(各引擎来源不同)
if (input && input.value.trim() !== '') { askSuggest(); }
}
/* ⚠️ 原版写的是 `tabs.forEach(...)` —— 那是 **NodeList.forEach**,
现代浏览器有、但 `querySelectorAll` 之外的集合(比如下面兜底分支返回的
也可能是数组)不一定有。改用 `Array.prototype.forEach.call` 更稳,
且与本站其它脚本的写法一致(全站都这么写,见 person_picker.js 等)。 */
Array.prototype.forEach.call(tabs, function (el) {
el.addEventListener('click', function () { activateEngine(el); if (input) { input.focus(); } });
});
/* ---------- 联想下拉 ---------- */
var word = document.getElementById('word');
var timer = null;
var lastQ = null;
var seq = 0; // 请求序号:防止先发的慢响应覆盖后发的快响应
var items = []; // 当前展示的建议词
var active = -1; // 选中项下标(键盘 ↓/↑、,/. 与鼠标划过共用);-1 = 没选中
var punctByKeyDown = false; // 本次标点是否已由 keydown 处理(防 keydown+beforeinput 双触发跳两格)
var pendingPunct = null; // 「拦不住的标点」信息 {ch, dir, before}(见下),null = 无
var pendingTimer = null; // 收口定时器(去抖:组合上屏会连发两次 input)
/* 联想词的选中按键(后台「设置 → 联想词按键」):
both(默认:两组都认)/ arrows(只认 ↓/↑)/ punct(只认 , 和 .)
⚠️ 默认已从 arrows 改为 both(2026-09-29):用户反馈「上下键和逗号句号都选不了词」,
而线上设置恰好是单组 punct —— 单组模式天然让**另一组键静默失效**,极易被当成 bug。
两组都认是它们的**严格超集**:想用哪组都行,没有任何损失。 */
var keyMode = cfg.suggestKeys || 'both';
function acceptsArrows() { return keyMode === 'arrows' || keyMode === 'both'; }
function acceptsPunct() { return keyMode === 'punct' || keyMode === 'both'; }
/* ⚠️ 中文输入法的标点坑(真实用户反馈,2026-09-28 / 2026-09-29):
中文输入法下按逗号/句号,上屏的常是**全角** `,`(U+FF0C) / `。`(U+3002),
而且此时 e.code **不一定**是 'Comma'/'Period'(取决于浏览器与输入法)——
早先只认半角的 e.code / e.key(半角逗号点号)就是因此漏判,
结果标点被当普通字符输进搜索框、没当成选词键。
修法:**e.key 与 e.code 两条都认**(互为兜底)—— 有的环境 e.key 是全角而 code 缺,
有的环境 e.key 仍半角而 code 稳;只取其一都会漏。
全角逗号 `,`(U+FF0C);全角句号常见 `。`(U+3002),另兼容全角句点 `.`(U+FF0E)。
返回:-1 = 逗号(上一条,同 ↑);+1 = 句号(下一条,同 ↓);0 = 不是标点。 */
function punctDir(ch) {
if (ch === ',' || ch === '\uFF0C') { return -1; }
if (ch === '.' || ch === '\u3002' || ch === '\uFF0E') { return 1; }
return 0;
}
function keyPunctDir(e) {
var d = punctDir(e.key);
if (d !== 0) { return d; }
if (e.code === 'Comma') { return -1; }
if (e.code === 'Period') { return 1; }
return 0;
}
function hideWord() {
if (word) { word.innerHTML = ''; word.style.display = 'none'; }
items = [];
active = -1;
}
function showWord(list) {
if (!word) return;
items = (list || []).slice();
active = -1; // 换了新建议就取消选中:此时回车应当用输入框原文
if (!items.length) { hideWord(); return; }
word.innerHTML = items.map(function (t) {
var d = document.createElement('li');
d.textContent = t;
return d.outerHTML;
}).join('');
word.style.display = 'block';
}
/** 把 .active 标到当前选中项上(键盘与鼠标共用这一个状态) */
function paintWord() {
if (!word) return;
Array.prototype.forEach.call(word.children, function (li, i) {
if (i === active) { li.classList.add('active'); } else { li.classList.remove('active'); }
});
}
/**
* 往一个 URL 追加查询串,**分隔符按 URL 里有没有 `?` 决定**。
*
* 🔴🔴 v30 用户第 2 条:「导航的联想词功能还是不好用」。真根因就在这一行。
*
* `window.SIMPLENAV.suggest` 是**服务端按当前短语生成**的,形态是
* `https://<站点>/nav_suggest.php?p=<短语>`
* —— 它**已经带了一个 `?`**(`p=` 是那个免登录接口的门禁,见 nav_suggest.php)。
* 而旧代码在这里硬拼 `'?slug=…&q=…'` → 实际发出的是
* `…/nav_suggest.php?p=abc?slug=bing&q=天气`
* PHP 对重复的 `?` 的处理:**第一个 `?` 之后整串都是查询值**,第二个 `?` 只是
* `p` 的值里一个普通字符 → `?p` 变成 `abc?slug=bing` → `Nav::ownerByPhrase()`
* 匹配不上任何短语 → **403 forbidden** → `data.items` 是 undefined → `showWord()`
* 收到空数组 → `hideWord()` → 下拉永远是空的。
*
* ⚠️ 这条**只在真机上才现形**:e2e 里直连 `/nav_suggest.php?p=…&slug=…&q=…`
* 是 200(探针自己拼对了),所以"接口是好的"一直成立,坏的只是前端那一步。
* (实测记录:前端发出的 URL 带 `?p=…?slug=…`,服务端回 403;服务端本身 200。)
* ⚠️ 不改成"服务端直接把 slug/q 一起拼好"是为了保持这份 JS 与 SimpleNav 原版的
* 可对照性 —— 原版 `suggest` 是一个不带问号的固定地址,参数确实是前端拼的。
* 这里只把"分隔符"这件事修正回来,其余一行不动。
*/
function withQuery(base, qs) {
if (!base) { return ''; }
return base + (base.indexOf('?') >= 0 ? '&' : '?') + qs;
}
function askSuggest() {
if (!input) return;
var q = input.value.trim();
if (q === '') { lastQ = null; hideWord(); return; }
if (q === lastQ) { return; }
lastQ = q;
var mine = ++seq;
var slug = form ? (form.getAttribute('data-slug') || '') : '';
fetch(withQuery(cfg.suggest, 'slug=' + encodeURIComponent(slug)
+ '&q=' + encodeURIComponent(q)))
.then(function (r) { return r.json(); })
.then(function (data) { if (mine === seq) { showWord(data && data.items); } })
.catch(function () { if (mine === seq) { hideWord(); } });
}
if (input) {
input.addEventListener('input', function () {
clearTimeout(timer);
active = -1; // 一开始打字就取消选中:之后回车应当用输入框原文
paintWord();
timer = setTimeout(askSuggest, 180);
});
input.addEventListener('keydown', function (e) {
// ⚠️ IME 组合态保护:用中文输入法**打拼音**时也会触发 keydown(isComposing=true,
// 或 keyCode=229),那些按键属于输入法自己的候选选择,不能当成选联想词的键拦下来。
// 组合结束(拼音上屏)后 isComposing=false,下面的 , / . 选词才生效。
// ⚠️ 这里的「返回」**不是**把这次标点丢掉 —— 输入法上屏标点会再走 beforeinput,
// 那条路径会接住(见下面 beforeinput 的 insertCompositionText 分支)。
// 所以「组合态里按标点」在用户看来照样能选词。
if (e.isComposing || e.keyCode === 229) { return; }
var open = !!word && word.style.display === 'block' && items.length > 0;
// 移动选中项:只动高亮、不动输入框(回车时才落到输入框)。
// 用哪组键由后台「联想词按键」决定:arrows = ↓/↑ / punct = , 和 . / both = 都行。
var dir = 0;
var isPunct = false; // 这次移动是否由**标点**触发(决定要不要标去重闸门)
if (acceptsArrows() && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
dir = e.key === 'ArrowDown' ? 1 : -1;
} else if (acceptsPunct()) {
dir = keyPunctDir(e); // , = 上一条(同 ↑)/ . = 下一条(同 ↓),半角全角都认
isPunct = dir !== 0;
}
if (dir !== 0) {
// ⚠️ 没有下拉时**必须放行** —— 这时 , 和 . 是正常输入字符(如搜「python, pandas」)。
if (!open) { return; }
e.preventDefault(); // 别让光标跳到文本首尾、也别让页面跟着滚
moveSelection(dir);
// ⚠️ 只有**标点**才需要标闸门:同一次按键会先走 keydown、再走 beforeinput,
// 不标就会「跳两格」。方向键压根不产生 beforeinput,标了纯属副作用
// —— 反而会把紧随其后的真实标点误挡掉(两者相隔可能不足 1ms)。
if (isPunct) {
punctByKeyDown = true;
setTimeout(function () { punctByKeyDown = false; }, 0);
}
return;
}
if (e.key === 'Escape') { hideWord(); return; }
if (e.key === 'Enter') {
// 只有**用 ↓/↑ 选中**了某条建议,回车才按建议词搜索。
// 没选中就一律用输入框里的原文 —— 老实现是「回车就套用第一条建议」,
// 会把用户自己输入、而建议里没有的词悄悄换掉。
if (open && active >= 0) {
input.value = items[active];
hideWord();
e.preventDefault();
if (form) { form.submit(); }
}
// active < 0:不拦截,交给浏览器默认的表单提交(即用输入框原文搜)
return;
}
});
/* ⚠️ beforeinput 兜底 —— 专治输入法的标点(用户实测反馈,2026-09-28)。
为什么需要它:按逗号/句号时,keydown 那一路可能整条接不住 ——
· 输入法上屏的是**全角**「,」「。」,e.key 不是半角、e.code 也不可靠 → 漏判;
· 或者按键发生在 isComposing 组合态里,被上面那道 IME 保护挡掉 → 漏判;
· 甚至**根本没有 keydown**(输入法把标点作为组合内容直接提交)→ 更接不住。
beforeinput 在**字符即将插入文本框之前**触发,e.data 就是要插入的字符,是最可靠的一道。
⚠️ 两种 inputType 都要认(2026-09-29 实测补齐,这正是「选了标点模式也没反应」的元凶):
· `insertText` —— 普通输入 / 部分输入法上屏标点;
· `insertCompositionText` —— 输入法把标点当**组合内容**提交(CDP `imeSetComposition` 复现)。
旧代码只认 insertText,于是这条路径完全失效。
命中就 preventDefault(不让它插入)并移动选中项。
只在「下拉打开 + punct/both 模式」下生效;没有下拉时不拦,逗号句号照常输入。
🔴🔴 【2026-09-29 真实浏览器实测】`insertCompositionText` 的 beforeinput 在 Chromium 里
**cancelable = false** —— preventDefault() 根本无效!所以「组合上屏标点」这条路
**只能事后撤销**:这里记住标点,等紧随的 input 事件把字符插进输入框后,再把它删掉
并移动选中项(见 undoPendingPunct)。只靠 beforeinput 是拦不住的。
(CDP 实测记录:beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false) */
input.addEventListener('beforeinput', function (e) {
var it = e.inputType;
if (it !== 'insertText' && it !== 'insertCompositionText') { return; }
if (punctByKeyDown) { return; } // keydown 已处理过本次标点,别再移一次(防跳两格)
var d = e.data;
if (typeof d !== 'string' || d.length === 0) { return; }
// 只在**恰好是一个标点字符**时当选词键。带别的内容(如整串组合文本)一律放行,
// 免得把输入法正常上屏的字吃掉。
var dir = punctDir(d);
if (dir === 0) { return; }
var open = !!word && word.style.display === 'block' && items.length > 0;
if (!open || !acceptsPunct()) { return; }
if (e.cancelable) {
// —— 能拦就拦:insertText 路径,preventDefault 立刻生效,字符根本不会进输入框。
e.preventDefault();
moveSelection(dir);
return;
}
// —— 拦不住:insertCompositionText 路径(Chromium 实测 cancelable=false)。
// 只能**事后收口**:先记下「这次标点进来之前」的输入框值,等事件洪峰过去
// 再统一撤销(见 flushPending)。用快照而不是记字符,是因为这条路径的
// 事件序列是「先插入标点 → 再回滚成原样」,只记字符会撤错。
// ⚠️ 目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1`
// 已经把选中清掉了,再从 active 起算就会每次都从第一条重新开始(不累加)。
pendingPunct = {
ch: d,
before: input.value,
target: nextActive(active, dir),
};
schedulePendingFlush();
});
}
/** 安排一次「拦不住的标点」收口(去抖:组合上屏会在一帧内连发两次 input) */
function schedulePendingFlush() {
clearTimeout(pendingTimer);
pendingTimer = setTimeout(flushPending, 0);
}
/**
* 收口「拦不住的标点」(beforeinput 里 cancelable=false 的那条路径)。
*
* 为什么必须**延迟到宏任务**而不是当场处理:这条路径的真实事件序列是
* ① beforeinput(insertCompositionText, cancelable=false) ← 拦不住
* ② input 值 = 原文 + 「,」 ← 标点真的进去了
* ③ input 值 = 原文(回滚) ← 又被自己撤掉了
* 若在 ② 当场处理,会被 ③ 冲掉。等这一帧的事件洪峰结束(setTimeout 0)再收口才稳。
*
* 处理方式:把输入框值**恢复成标点进来之前的快照**(若此刻值确实被标点污染了),
* 再把选中项设到 beforeinput 时算好的目标下标。
*/
function flushPending() {
if (!pendingPunct) { return; }
var info = pendingPunct;
pendingPunct = null;
if (!input) { return; }
// 恢复快照:抹掉「拦不住的标点」留下的痕迹。
// ⚠️ 只在值确实与快照不同、且**差值就是那个标点**时才动,避免抹掉用户真实输入。
var tv = input.value;
if (tv !== info.before && tv.indexOf(info.ch) >= 0) {
var tb = info.before;
if (tv.length > tb.length && tv.slice(0, tb.length) === tb) {
input.value = tb; // 标点被追加在末尾
} else if (tv.length === tb.length) {
input.value = tb; // 等长(插入后再回滚的中间态)
} else {
var idx = tv.lastIndexOf(info.ch);
if (idx >= 0) { input.value = tv.slice(0, idx) + tv.slice(idx + info.ch.length); }
}
// ⚠️ 这里**不要**重置 lastQ:值被恢复成「标点进来之前」的原文,而 lastQ 记的正是那份原文,
// 两者本来就相等 → 180ms 后的 askSuggest 会走 `q === lastQ` 直接 return,
// 不会重新请求、也就不会 showWord 把刚设好的选中清掉。重置反而会自找麻烦。
}
active = info.target;
paintWord();
}
/** 移动联想下拉的选中项(keydown 与 beforeinput 共用;dir=-1 上一条 / +1 下一条) */
function moveSelection(dir) {
active = nextActive(active, dir);
paintWord();
}
/** 算出「从 base 再按 dir 一步」应落在哪个下标(items 为空时返回 -1)。
* 独立出来是为了「拦不住的标点」那条路:它要在 beforeinput 时就算好目标下标,
* 因为收口跑在宏任务里,那时 active 已被 input 的 `active = -1` 清掉了。 */
function nextActive(base, dir) {
if (!items.length) { return -1; }
if (base < 0) { return dir > 0 ? 0 : items.length - 1; }
return (base + dir + items.length) % items.length;
}
/* 鼠标划过某条建议 → 同步高亮(与 ↓/↑ 共用 active,回车即可确认) */
document.addEventListener('mouseover', function (e) {
if (!word || !e.target.closest) { return; }
var hovered = e.target.closest('#word li');
if (!hovered) { return; }
Array.prototype.forEach.call(word.children, function (li, i) {
if (li === hovered) { active = i; }
});
paintWord();
});
document.addEventListener('click', function (e) {
if (word && !word.contains(e.target) && e.target !== input) { hideWord(); }
var li = e.target.closest ? e.target.closest('#word li') : null;
if (li && input) {
input.value = li.textContent;
hideWord();
if (form) { form.submit(); }
}
});
/* ---------- 合集标签页 ---------- */
var collTabs = document.querySelectorAll('#coll-tabs .tab');
collTabs.forEach(function (tab) {
tab.addEventListener('click', function () {
collTabs.forEach(function (t) {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
var panel = document.getElementById(tab.getAttribute('data-target'));
if (panel) { panel.classList.add('active'); }
});
});
/* ---------- 图标加载失败 → 退回首字母块(与 PHP 端配色一致) ---------- */
var PALETTE = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
function colorOf(s) {
var h = 0;
for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; }
return PALETTE[Math.abs(h) % PALETTE.length];
}
document.querySelectorAll('.mylist img.icon').forEach(function (img) {
img.addEventListener('error', function () {
var a = img.closest('a');
if (!a) { img.style.visibility = 'hidden'; return; }
var nameEl = a.querySelector('.name');
var label = (nameEl ? nameEl.textContent : '?').trim() || '?';
var span = document.createElement('span');
span.className = 'ico-letter';
span.style.background = colorOf(label);
span.textContent = label.charAt(0);
img.parentNode.replaceChild(span, img);
}, { once: true });
});
})();