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。
This commit is contained in:
2026-10-03 21:19:34 +08:00
parent 4377fa995f
commit 864e281265
7 changed files with 133 additions and 56 deletions
+20 -9
View File
@@ -1,10 +1,21 @@
# 第三方库原样保存:不做行尾转换
# 行尾规则(2026-10-03)
#
# 为什么要写这个:本机 core.autocrlf 开着,`.min.js` 这类文本文件被 Git 检出时
# 会被转成 CRLF(`git add` 时会警告"LF will be replaced by CRLF")。
# 对第三方库这是**无谓的改写** —— 一来哈希与上游不一致,
# 二来部署包里带的就是改写后的版本,将来想核对「是不是原版」就没了基准。
# 标记成 binary(-text)后 Git 原样存、原样取。
assets/vendor/** -text
*.min.js -text
*.min.css -text
# 🔴 默认:所有文本文件一律 **LF**,且**检出/还原时也不许转成 CRLF**。
#
# 为什么必须写死:本机 `core.autocrlf=true`。不写这条的话,任何一次 git 检出/还原
# (`git stash pop`、`git checkout`、`git restore`…)都会把源码变成 CRLF。
# 而部署打包取的是**工作树**,于是包里带着 CRLF 上去,部署脚本里那些
# `grep -qxF '<整行 CSS 选择器>'` 的校验在 Linux 上一条都匹配不到 ——
# 症状是「本地全绿、部署却卡在一条莫名其妙的 CSS 断言上」,代码本身完全没问题。
# (本轮实测踩到:一次 git stash 往返就把 6 个文件全变成了 CRLF。)
* text=auto eol=lf
# 第三方库原样保存:不做任何行尾转换(字节与上游一致,便于日后核对是否原版)。
# ⚠️ 这几条**必须排在 `*` 之后** —— gitattributes 是"后面的规则覆盖前面的",
# 写在前面会被上面那条 `* text=auto eol=lf` 顶掉(本轮当场抓到一次)。
# ⚠️ 除了 -text 还要**显式 unset eol**:-text 只关掉"文本规范化",
# 上面那条 `*` 里设的 `eol=lf` 仍会挂着(`git check-attr` 可见),
# 换行策略由哪个属性说了算会变得含糊。
assets/vendor/** -text -eol
*.min.js -text -eol
*.min.css -text -eol
+4
View File
@@ -1007,6 +1007,10 @@ textarea.input { min-height: 76px; resize: vertical; }
.share-wrap > summary { list-style: none; cursor: pointer; display: inline-block; }
.share-wrap > summary::-webkit-details-marker { display: none; }
.share-wrap .inline-form { margin-top: 10px; }
/* 分享改用弹窗选人组件后,里面的 chips + 按钮要能在这个窄条里排开;
组件默认按"整页宽"设计(.pp-chips 自带 flex-wrap),这里给它一个合理上限,
免得点开"分享他的名片给…"时弹窗按钮被挤成半行。 */
.share-wrap .share-to { flex: 1 1 220px; min-width: 0; }
/* ---------------- 「从家里已有的人里挑」 ---------------- */
.pick-list { list-style: none; margin: 0 0 4px; padding: 0; display: grid; gap: 8px; }
+9 -4
View File
@@ -32,10 +32,15 @@ $ppTitle = $ppTitle ?? '选择';
$ppEmptyText = $ppEmptyText ?? '未选择';
$ppAllowEmpty = !empty($ppAllowEmpty);
// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个
static $ppSeq = 0;
$ppSeq++;
$ppId = 'pp' . $ppSeq;
// 同一页多个实例要各有一套 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] 是个非空数组,
+14 -9
View File
@@ -76,15 +76,20 @@ if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
</div>
<div class="field">
<label for="an-person">与谁相关</label>
<select class="select" id="an-person" name="person_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>" <?= (int)$v('person_id', 0) === (int)$p['id'] ? 'selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
<label>与谁相关</label>
<?php /* 第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想;
保留「不指定」这一项(原下拉的第一项就是它,不能丢)。 */ ?>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [(int)$v('person_id', 0)];
$ppMode = 'single';
$ppOpenBtn = '选择人';
$ppTitle = '这个纪念日与谁相关';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
+49 -14
View File
@@ -372,15 +372,27 @@ $shortKind = \App\Birthday::KINDS;
</select>
</div>
<div class="field">
<?php /* 用户 2026-10-03 第 8 条:「添加关系那里,选择一个人,应该也是和观影人一样,
点击弹窗,用拼音首字母选择,否则人多的时候,这个选择起来太麻烦了。」
—— 原来是个 <select>,名册一长就得在几百个选项里瞪眼找。
换成全局选人组件(与观影人同一套:弹窗 + 头像 + 首字母联想)。
⚠️ 这里**不能**写 `required`:组件在无 JS 时退化成一组原生 radio,
「必选一个 radio」HTML 没有这个约束(radio 组的 required 也不可靠)。
改成提交时用 JS 拦一下,把"没选人"挡在客户端;
万一真漏过去,服务端 kin/relate 也会判「选的人不对」并给人话提示。 */ ?>
<label>对方</label>
<select class="select" name="to_id" required>
<option value="">— 选择一个人 —</option>
<?php foreach ($options as $o): ?>
<option value="<?= (int)$o['id'] ?>">
<?= e((string)$o['name']) ?><?= !empty($o['my_nickname']) ? '(' . e((string)$o['my_nickname']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
<?php
$ppName = 'to_id';
$ppPeople = $options;
$ppSelected = []; // 每次进页面都不预选:这是"新记一条关系",不是改旧的
$ppMode = 'single';
$ppOpenBtn = '选择对方';
$ppTitle = '对方是谁';
$ppEmptyText = '还没有选人';
$ppAllowEmpty = false;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field" style="justify-content:flex-end">
<button class="btn btn-sm" type="submit">记下这条关系</button>
@@ -481,12 +493,20 @@ $shortKind = \App\Birthday::KINDS;
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<input type="hidden" name="back" value="<?= $pid ?>">
<label class="sr-only" for="shareTo">分享给谁</label>
<select class="select" id="shareTo" name="to_id">
<?php foreach ($options as $o): $oid = (int)$o['id']; ?>
<option value="<?= $oid ?>"><?= e((string)$o['name']) ?></option>
<?php endforeach; ?>
</select>
<?php /* 与「添加关系」同一套:既然这一页已经在用弹窗选人了,
就别让分享还留一个上百项的下拉(同页两种手感更别扭)。 */ ?>
<div class="share-to">
<?php
$ppName = 'to_id';
$ppPeople = $options;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择分享给谁';
$ppTitle = '把他的名片分享给谁';
$ppEmptyText = '还没有选人';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<button class="btn btn-sm" type="submit">分享</button>
</form>
</details>
@@ -608,4 +628,19 @@ $shortKind = \App\Birthday::KINDS;
kind.addEventListener('change', sync);
sync();
})();
// 「添加关系」的「对方」换成了弹窗选人组件(第 8 条),它没有原生 required ——
// 这里补一道客户端拦截,别让用户点了「记下这条关系」却什么都没发生(服务端会拒,
// 但来回一趟还看不到错在哪)。选人组件里勾中的那个 radio 才是真正的值。
(function () {
var form = document.querySelector('.rel-form');
if (!form) return;
form.addEventListener('submit', function (ev) {
var picked = form.querySelector('.pp-list input[name="to_id"]:checked');
if (!picked || picked.value === '0') {
ev.preventDefault();
alert('还没选「对方」——点上面的按钮,在弹出的列表里选一个人。');
}
});
})();
</script>
+24 -12
View File
@@ -78,12 +78,17 @@ $lastColIdx = count($colList) - 1;
</div>
<div class="field">
<label for="tk-assign">负责人</label>
<select class="select" id="tk-assign" name="assignee_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
<?php
$ppName = 'assignee_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label for="tk-repeat">循环</label>
@@ -113,12 +118,19 @@ $lastColIdx = count($colList) - 1;
</div>
<div class="field">
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
<select class="select" id="tk-share" name="shares[]" multiple size="3">
<?php foreach ($people as $p): ?>
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?>
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
<?php endforeach; ?>
</select>
<?php /* 第 8 条的同一诉求:人多时不要下拉里瞪眼找。换成弹窗 + 首字母联想。
⚠️ 候选里去掉**自己**——「共享给自己」是空操作;组件没有"排除项",
所以在传进去之前先过滤掉。 */ ?>
<?php
$ppName = 'shares[]';
$ppPeople = array_values(array_filter($people,
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
$ppSelected = [];
$ppMode = 'multi';
$ppOpenBtn = '+ 添加人';
$ppTitle = '共享给谁';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="tk-note">备注</label>
+13 -8
View File
@@ -95,14 +95,19 @@ $isChild = !empty($t['parent_id']);
</div>
<div class="field">
<label>负责人</label>
<select class="select" name="assignee_id">
<option value="0">不指定</option>
<?php foreach ($people as $p): ?>
<option value="<?= (int)$p['id'] ?>"<?= (int)($t['assignee_id'] ?? 0) === (int)$p['id'] ? ' selected' : '' ?>>
<?= e(person_name($p)) ?>
</option>
<?php endforeach; ?>
</select>
<?php /* 第 8 条同一诉求:负责人也是「选人」,人多时用弹窗 + 首字母更快。
保留原下拉的「不指定」(pp-item-none + value=0)。 */ ?>
<?php
$ppName = 'assignee_id';
$ppPeople = $people;
$ppSelected = [(int)($t['assignee_id'] ?? 0)];
$ppMode = 'single';
$ppOpenBtn = '更换负责人';
$ppTitle = '谁负责这件事';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label>循环</label>