Files
FamilyBook/templates/_person_picker.php
T
laoyang 864e281265 v24 第 8 条:凡「选人」都走同一套弹窗 + 拼音首字母(补上唯一漏做的一条)
用户原话:「名册详情页面,添加关系那里,选择一个人,应该也是和观影人一样,
点击弹窗,用拼音首字母选择,否则人多的时候,这个选择起来太麻烦了。」

- 成员详情页:①「添加关系 → 对方」 ②「分享他的名片给…」→ 全局选人组件(单选)
- 任务:③ 列表页「共享给(多选)」 ④ 列表页「负责人」 ⑤ 详情页「负责人」
  (共享候选人先把自己过滤掉 —— 共享给自己是空操作)
- 纪念日:「与谁相关」→ 单选,且保留原下拉第一项「不指定」(ppAllowEmpty)
- 「对方」是必填,但组件里是原生 radio(HTML 没有"必选一个 radio"的可靠约束)
  → 页面补一道 submit 拦截,否则用户点了按钮会静默无反应

顺带修一个用户没提、我自己抓到的真 bug:
_person_picker.php 原用**文件顶层**的 `static $ppSeq` 做 id 计数 —— PHP 里被
include 的文件,顶层 static 初值**每次 include 都重新绑定**(与函数体内的
static 不同)→ 同一页 include 两次都拿 pp1 → DOM id 撞车(getElementById /
<label for> 全抓到第一个)。实测 include 三次得到 pp1,pp1,pp1。改用
$GLOBALS['ppSeq']。这个坑只有同一页出现两个组件时才暴露,且资源页第二个组件
只有真有资源时才渲染 —— 所以写成**单元级**探针(同一请求 include 两次)。

另修一个构建层隐患(本轮部署时踩到,白查了一轮):
本机 core.autocrlf=true,一次 git stash 往返就把 6 个文件全改成 CRLF;而部署
打包取的是工作树 → 包里带 CRLF 上去 → 部署脚本里 grep -qxF '<整行 CSS 选择器>'
的校验在 Linux 上一条都匹配不到(报错却指向"CSS 少了某条规则",极易误诊)。
→ .gitattributes 加 `* text=auto eol=lf`(必须排在 -text 那几条之前),
  并在打包前加一道行尾体检 _fb_eol_check.py(失败时点名文件)。

验证:反向验证对着 git stash 出来的真·改动前状态跑 → 11 通过 / 17 失败;
新增 _fb_picker_unify.py 28/0;e2e 785/0;几何 428/0;移动端 688/0;交互 97/0;
升级 126/0;家谱 34/0;专项 91/0;导入 21/0;豆瓣 22/0;行尾体检 + 两道新静态
门禁均实测可失败。线上 6 文件 md5 一致、CR=0。
2026-10-03 21:19:34 +08:00

107 lines
6.7 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 同一份变量也不会撞)。
*/
$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-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>
<div class="pp-list" id="<?= e($ppId) ?>-list">
<?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; ?>
<?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 class="pp-empty" hidden>没有匹配的人。</div>
</div>
</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>