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`
This commit is contained in:
2026-10-07 05:52:40 +08:00
parent ae3060f552
commit 09e30d27d4
21 changed files with 5034 additions and 180 deletions
+347
View File
@@ -0,0 +1,347 @@
/* ============================================================
导航展示页脚本(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 });
});
})();
+83 -3
View File
@@ -372,6 +372,14 @@
var type = list.getAttribute('data-pp-type') || 'radio';
var fname = list.getAttribute('data-pp-name') || '';
// 🔴🔴 先把**承运字段**撤掉(v24 第 24 批 用户第 1 条修的真 bug)。
// 它是服务端渲染的隐藏 input,承载"当前已选";一旦真的清单建出来,
// 真值的输入项就在 DOM 里了,承运字段必须**立刻移除** —— 否则提交时
// 同一个 name 会带上两份值(承运一份 + 真值一份),后端收到重复项。
// 详见 templates/_person_picker.php 里那段长注释。
Array.prototype.forEach.call(
root.querySelectorAll('.pp-carry'), function (el) { el.parentNode.removeChild(el); });
var frag = document.createDocumentFragment();
people.forEach(function (p) {
var lab = document.createElement('label');
@@ -555,9 +563,81 @@
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(false); }
// 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然)
if (e.key === 'Enter' && !modal.hidden && stagedMode
&& e.target === search) {
if (modal.hidden || e.target !== search) { return; }
/* ============================================================
键盘流畅流程(v24 第 24 批 用户第 1 条后半,用户原话):
「当我输入了一个人的拼音、首字母的几个字母之后,如果第一个列表里面,
第一个人就是我要选的这个人。这时候我按空格就直接把这个人选中…
只要是完成了选中一个人,这个操作之后,就把输入框里面的用户已经输入的
字母全部给它清空。同时光标回到输入框里面,让用户等待的选出下一个人的
字母。…这时候如果它回车,就是确定,就是和点击那个框上的确定按钮
是一个效果。」
⇒ 所以这里要三件事(按用户说的顺序):
① 空格 = 选中**当前筛选结果里的第一个人**;
② 选中之后:清空输入框 + 光标回到输入框(接着打下一个人的拼音);
③ 回车 = 等于点「确定」(把已选的人写回页面)。
⚠️ 只在**输入框里**按才生效(`e.target !== search` 直接返回):
否则用户在弹框其它地方按空格(比如点完按钮后焦点在按钮上)会误选人。
⚠️ 空格**必须** preventDefault:`<input type="text">` 里空格默认会打出一个空格
字符,不挡的话输入框会变成 "yyq "(多一个空格,看着像拼音打错了)。
⚠️ 「第一个人」取的是**当前可见**列表的第一个(applyFilter 已经算好 `hidden`),
而不是 DOM 里的第一个 —— 用户看到的顺序才是他心里的"第一个"。
这也是用户那句"如果第一个列表里面,第一个人就是我要选的这个人"的意思。
============================================================ */
if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
// 清单可能还没建(懒建)—— 空格时先补建,否则一个候选都找不到
buildList();
// 🔴 「第一个人」要取**当前可见、且还没被选中的**那一个。
//
// 为什么(这是本轮真机跑出来的一处交互缺陷):
// 用户描述的是「输拼音 → 按空格 → 就选中这个人」。而如果**第一个可见的**恰好
// **已经选过**(比如他没清筛选、接着按空格),`!checked` 会把它**取消掉** ——
// 与他"我要选人"的意图正好相反,而且他多半看不出来(人少了以为是没选上)。
// ⇒ 跳过已选中的,选"第一个还没选的";一个都没得选时**什么都不做**
// (不要静默取消任何人)。
var first = null;
var items = list.querySelectorAll('.pp-item');
for (var i = 0; i < items.length; i++) {
if (items[i].hidden) { continue; }
var _b = items[i].querySelector('input');
if (!_b || _b.value === '0') { continue; }
if (!_b.checked) { first = items[i]; break; } // 只认"还没选的"
}
if (!first) {
// 可见的都选过了(或一个候选都没有)→ 什么都不做,
// 只把"没有匹配的人"的提示按需亮一下,**绝不取消任何已选**
var _any = false;
for (var j = 0; j < items.length; j++) { if (!items[j].hidden) { _any = true; break; } }
if (!_any && emptyTip) { emptyTip.hidden = false; }
return;
}
var fbox = first.querySelector('input');
if (!fbox || fbox.value === '0') { return; }
if (stagedMode) {
fbox.checked = true; // 见上:到这里它一定是**未选中**的
// 用户明确要求:**选完一个人就清空输入框 + 光标回来**(见上面的说明)
search.value = '';
applyFilter();
refresh();
search.focus();
} else {
// 单选:空格 = 选中并关闭(与"点一个人"同义)
fbox.checked = true;
refresh();
closeModal(true);
}
return;
}
// 回车 = 确定(多选;单选没有确定这一步,浏览器里没有可提交的表单,
// 所以回车**不**做任何事,避免"不小心把弹窗关了却没选上")
if (e.key === 'Enter' && stagedMode) {
e.preventDefault();
refresh();
closeModal(true);