Files
FamilyBook/templates/_person_picker.php
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

197 lines
12 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
/**
* 全局人员选择器(v24 用户第 11 条)
*
* 「所有要查找人的地方」统一走这一个片段:chips + 弹窗 + 拼音首字母联想。
* 之前只有观影表单有这套(.att-*),旅程是平铺的 checkbox 网格、共享/归属是
* <select> 下拉 —— 名册一多就变成"在一长串里瞪眼找",而且三种地方三种手感。
*
* 用法(在模板里先准备好变量再 include):
* $ppName 表单字段名。多选传 'attendees[]';单选传 'owner_id' 等
* $ppPeople 候选的人(Person::roster() 的行,至少要有 id / name)
* $ppSelected 已选的 id 数组(单选传 0 或 1 个)
* $ppMode 'multi'(默认)| 'single'
* $ppOpenBtn 打开按钮文案(默认 multi=「+ 添加人」/ single=「选择」)
* $ppTitle 弹窗标题(默认「选择人」)
* $ppAllowEmpty 单选模式下是否给一个「不选」选项(筛选表单用;值为 0)
* $ppEmptyText 单选且未选时的占位(默认「未选择」)
* $ppTip 可选,渲染在按钮下面的一句话
*
* ⚠️ 无 JS 时也能用:选项就是弹窗列表里的原生 checkbox / radio,
* noscript 样式把弹窗摊平成一个普通列表,提交语义与有 JS 时完全一致。
*
* ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号,
* 调用方不用管(但同一页重复 include 同一份变量也不会撞)。
*
* ────────────────────────────────────────────────────────────────
* v24 第 18 批(用户第 18 条):**多选改成"暂存 + 确定"两段式**。
*
* 用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字,
* 把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入
* 另外一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回
* 原来的界面儿,把他选中的多个人都放入到之前的页面儿里。」
*
* 与旧行为的差别(这是本批的核心):
* · 旧:勾一个 = 立刻出 chip **并关掉弹窗** → 选 5 个人要开合 5 次;
* · 新:勾一个 = 进弹窗**上半部分的暂存区**、弹窗**不关**、输入框清空继续搜下一个;
* 全部选完点「确定」才关闭并把结果写回原页面。
* · 关掉弹窗(× / Esc / 点遮罩)**不会**丢掉改动 —— 见下面"取消即还原"的说明。
*
* ⚠️ 单选($ppMode = 'single')**不改**:单选语义就是"只能有一个",
* 再加一个确定按钮等于让用户白点一次。它的字段名(person_id / owner_id…)
* 在后端是一个值,多选会直接改坏数据。所以暂存区与确定按钮**只在 multi 下渲染**。
*/
$ppMode = $ppMode ?? 'multi';
$ppPeople = $ppPeople ?? [];
$ppSelected = array_map('intval', (array)($ppSelected ?? []));
$ppOpenBtn = $ppOpenBtn ?? ($ppMode === 'single' ? '选择' : '+ 添加人');
$ppTitle = $ppTitle ?? '选择';
$ppEmptyText = $ppEmptyText ?? '未选择';
$ppAllowEmpty = !empty($ppAllowEmpty);
// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个。
//
// ⚠️⚠️ 别用文件顶层的 `static $ppSeq` 做这个计数 —— PHP 里**被 include 的文件**顶层的
// `static` 初值会**每次 include 都重新绑定**(与函数体内的 static 不同,那个才跨调用)。
// 实测:同一页 include 三次,拿到的都是 pp1。资源页今天就有两处(归属人 + 共享给谁),
// DOM 里两套 pp1 撞在一起,getElementById / `<label for>` / 任何按 id 取的元素都会指错。
// 改成 $GLOBALS 计数:它才是**一次请求内真正跨 include 持续**的。
$GLOBALS['ppSeq'] = ($GLOBALS['ppSeq'] ?? 0) + 1;
$ppId = 'pp' . $GLOBALS['ppSeq'];
$ppIsMulti = $ppMode === 'single' ? false : true;
$ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
// ⚠️ 「已选」要按**非零**判断:筛选表单常用 0 表示「全部」,[0] 是个非空数组,
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
$ppHasSel = count(array_filter($ppSelected)) > 0;
?>
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
页面后半段的 $p['xxx'] 就全成了 Warning。_datetime.php 踩过同款。 */ ?>
<?php foreach ($ppPeople as $ppRow): ?>
<?php if (in_array((int)$ppRow['id'], $ppSelected, true)): ?>
<span class="pp-chip" data-id="<?= (int)$ppRow['id'] ?>"><img src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt=""><span class="pp-name"><?= e(person_name($ppRow)) ?></span><?php if ($ppIsMulti): ?><button type="button" class="pp-x" data-id="<?= (int)$ppRow['id'] ?>" title="移除">×</button><?php endif; ?></span>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!$ppIsMulti && !$ppHasSel): ?>
<span class="pp-chip pp-chip-empty"><?= e($ppEmptyText) ?></span>
<?php endif; ?>
</div>
<button type="button" class="btn btn-ghost btn-sm pp-open" id="<?= e($ppId) ?>-open"><?= e($ppOpenBtn) ?></button>
<?php if (!empty($ppTip)): ?><span class="tip"><?= e($ppTip) ?></span><?php endif; ?>
<div class="pp-modal" id="<?= e($ppId) ?>-modal" hidden>
<div class="pp-modal-box">
<div class="pp-modal-head">
<b><?= e($ppTitle) ?></b>
<button type="button" class="pp-close" id="<?= e($ppId) ?>-close" title="关闭">×</button>
</div>
<?php /* ⚠️ 这个 div 原来是个 <form>,看着更"正规",实际有两个坑:
① 选人组件常被嵌在**别的表单里**(新建资源 / 记一笔 / 共享账本…),
HTML 不允许 form 嵌套 —— 浏览器会把外层表单切两半,
提交时丢字段("保存了但选的人没存上"就是这么来的);
② 在里面按回车会提交那个表单、整页刷新,而列表本来就实时筛过了。
改成 div:回车不再有任何副作用,筛选照样是即时的。 */ ?>
<div class="pp-searchline">
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
</div>
<?php /* ---- 暂存区(**只有多选**才有;v24 第 18 批)----
用户原话:「把选出来的人暂存到**选择框的上半部分**…等所有人都选完之后,
点击确定按钮完成多选」。所以这里就是"已经点了头像/名字、还没点确定"
的那批人。它由 JS 渲染(点一个 append 一个),服务端不预置内容 ——
服务端渲染的 chips(弹窗外面那个)才是"已经确定过"的人。 */ ?>
<?php if ($ppIsMulti): ?>
<div class="pp-stage" id="<?= e($ppId) ?>-stage" hidden>
<span class="pp-stage-label">已选 <b id="<?= e($ppId) ?>-stage-n">0</b> 人</span>
<div class="pp-stage-chips" id="<?= e($ppId) ?>-stage-chips"></div>
</div>
<?php endif; ?>
<?php
/* ⚠️ v24 第 11 批 #7 续:候选人清单**首屏不建 DOM**。
线上真实数据下,每页 1~3 个选人组件 × 1184 人 = 几千个 <label>+<input>,
账目页因此到 6.9 MB / 16788 个 input,密码管理器逐个扫描把页面拖死。
现在按 data-pp-people(JSON)在**打开弹框时**建清单;
无 JS 时由下面的 <noscript> 兜底(启用脚本时它只是纯文本,不产生节点)。 */
$ppJson = [];
foreach ($ppPeople as $ppRow) {
$ppJson[] = [
'id' => (int)$ppRow['id'],
'name' => (string)$ppRow['name'],
'shown' => person_name($ppRow),
'avatar' => avatar_url($ppRow['avatar'] ?? null),
];
}
unset($ppRow);
?>
<div class="pp-list" id="<?= e($ppId) ?>-list"
data-pp-people="<?= e(json_encode($ppJson, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-type="<?= e($ppInputType) ?>"
data-pp-selected="<?= e(implode(',', array_map('intval', $ppSelected))) ?>"
data-pp-allow-empty="<?= (!$ppIsMulti && $ppAllowEmpty) ? '1' : '0' ?>">
<?php /* 「不选」很轻(1 个 input)且是"默认不指定"语义的载体 → 留在服务端渲染。
⚠️ 它必须留在**普通容器**里(不是 noscript),否则 ppSetSelected 找不到它。 */ ?>
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
<span class="pp-name">不选</span>
</label>
<?php endif; ?>
<div class="pp-empty" hidden>没有匹配的人。</div>
</div>
<?php /* 🔴🔴 **承运字段**(v24 第 24 批 用户第 1 条修的真 bug)—— 别删!
未在弹框里用过时,提交表单**必须**把"当前已选"一起带上。 */ ?>
<?php /* ⚠️ 我第一版在这里凭空写了 `<?= $ppName ?>_ver` / `_nickname` 两个"旁路字段",
以为 chips 会把它们一起送上去 —— 那是**猜的**,实际 chips 只渲染
`<span class="pp-chip" data-id>`,**一个 input 都不产生**。
已核实后才写成现在这样:按 $ppName 原样把已选 id 各写一个 hidden。
(教训:写代码前先把"实际的渲染产物"读出来,别照着自己脑补的结构写。) */ ?>
<?php if ($ppSelected): ?>
<?php foreach ($ppSelected as $ppSelId): ?>
<?php if ((int)$ppSelId > 0): ?>
<input type="hidden" class="pp-carry"
name="<?= e($ppName) ?>" value="<?= (int)$ppSelId ?>">
<?php endif; ?>
<?php endforeach; ?>
<?php unset($ppSelId); ?>
<?php endif; ?>
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
不会变成 DOM 节点、也就不会造成上面的膨胀;
禁用脚本时才渲染出来,选人照样能用。 */ ?>
<noscript>
<div class="pp-list pp-list-noscript">
<?php foreach ($ppPeople as $ppRow): ?>
<label class="pp-item"
data-name="<?= e((string)$ppRow['name']) ?>"
data-shown="<?= e(person_name($ppRow)) ?>">
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
<span class="pp-initials" hidden></span>
</label>
<?php endforeach; ?>
<?php unset($ppRow); ?>
</div>
</noscript>
<?php /* ---- 底部按钮(v24 第 18 批)----
⚠️ multi 才有「确定」;single 保持旧行为(点一个即选、立即关闭),
因为单选语义就是"只能有一个",多一步确定是白让人点。 */ ?>
<?php if ($ppIsMulti): ?>
<div class="pp-actions">
<button type="button" class="btn btn-ghost pp-cancel" id="<?= e($ppId) ?>-cancel">取消</button>
<button type="button" class="btn pp-confirm" id="<?= e($ppId) ?>-confirm">确定</button>
</div>
<?php endif; ?>
</div>
</div>
<noscript><style>.pp-open { display: none; } .pp-modal { position: static; display: block; background: none; padding: 0; } .pp-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
</div>