Files
FamilyBook/assets/js/nav-show.js
T
laoyang 09e30d27d4 v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图
用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
2026-10-07 05:52:40 +08:00

348 lines
18 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(服务端按当前短语生成),
不在这个文件里写死。
============================================================ */
/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */
(function () {
'use strict';
var cfg = window.SIMPLENAV || {};
/* ---------- 搜索引擎切换 ---------- */
var tabs = document.querySelectorAll('#engine-tabs .s-engine');
var form = document.getElementById('search-form');
var input = document.getElementById('search-text');
function activateEngine(el) {
tabs.forEach(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();
// 已经输了字就顺手换一份建议(auto 模式下各引擎来源不同)
if (input && input.value.trim() !== '') { askSuggest(); }
}
tabs.forEach(function (el) {
el.addEventListener('click', function () { activateEngine(el); 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'); }
});
}
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(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 });
});
})();