三条都是「静态检查与 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 与本地一致。
409 lines
22 KiB
JavaScript
409 lines
22 KiB
JavaScript
/* ============================================================
|
||
导航展示页脚本(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 });
|
||
});
|
||
})();
|