Files
FamilyBook/templates/people/index.php
T
laoyang 5d80b9480b v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条:
- #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签
  从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。
- #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效"
  ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮
  的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」
  (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。
- #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签)
  + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。
- #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、
  每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框
  (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。
  修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild)
  把控件插到了 label 之上 —— 改成插在第一个原生控件之前。
- #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta):
  弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、
  viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。
  结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。
- #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成
  [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步);
  跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。
- #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天";
  图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例**
  (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理
  (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。

## 顺带修的真 bug(本批最要紧的三条)
1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。
   HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成
   主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生
   (Chrome: invalid form control with name='name' is not focusable)。
   → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。
2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口
   传 true 清空;否则"点了取消"仍会把没裁的原图传上去。
3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画,
   toDataURL 写回预览,恢复"所见即所得"。

## 连带修复:选人组件懒渲染(线上实测的必要性)
线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input ——
根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。
改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList();
无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。
新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。
实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。

验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、
crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、
upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。
线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
2026-10-04 17:42:21 +08:00

526 lines
29 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
/**
* 家庭名册 —— 按「关系分组」分栏显示,卡片可以拖动。
*
* 三处刻意的设计:
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):这里只显示"我加的人 +
* 别人分享给我的",归到哪一组、排第几也都只影响我自己。所以"改分组"不再是
* 改全家的信息,而是改我自己那份 —— 于是顺手就能做成拖动。
* ② **拖动改分组、拖动改顺序**(需求原话:「拖动一个人的卡片进行排序…通过拖动
* 改变分组」):拖动本身是 JS,但**每个分组栏也是空的时候照样存在**,
* 拖动时统一露出来当落点,否则"想拖到一个现在没人的分组里"就没地方放。
* ③ **没有 JS 也完全能用**:卡片上的 `<details>调整分组</details>` 还在,
* 而且手机上的 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
*
* ⚠️ 卡片结构是 div > a + details,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开,表单里那个「移动」按钮
* 可能连提交机会都没有(或者把整张卡片点成跳转)。
*/
$canMove = \App\Auth::canWrite();
$pickable = $pickable ?? 0;
$total = 0;
foreach ($grouped as $list) {
$total += count($list);
}
/**
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
* 复制一遍迟早会改漏一处。
*
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「分组」按钮要用它(见下)。
* (历史:这个下拉原来长在卡片里,漏掉它会让下拉只剩「未分类」一项,
* 看着像"分组功能没做",其实是变量没传进来。)
*
* 🔴🔴 v24 第 10 批 #6:**卡片里不再内联弹层与表单**。
*
* 用户报「名册列表页面仍然很慢,Firefox 提示这个页面和 Bitwarden 插件有冲突」。
* 线上真实数据实测(1184 人)把这件事量出来是什么样:
* · 全页 `<form>` **2369 个**、`<input>` **27234 个**、`radio` **22496 个**;
* · 单张卡片 83 个 DOM 节点,其中**分组菜单独占 64 个**;
* · 于是总 DOM 98100 个、解压后 7.7 MB。
* 密码管理器(Bitwarden/1Password/LastPass…)会**逐个 form/input 去判定是不是登录框**,
* 2369 个表单足够把主线程占住 —— 这就是"和 Bitwarden 冲突"的真实来源,
* 也是"明明没报错却卡得没法用"的原因。
*
* 修法(不是调 CSS 缓存,而是**把节点数砍掉一个数量级**):
* ① 每卡的「分组」按钮只留一个 **<button data-pg-open="…">**(1 个节点,无表单无 radio);
* ② 每卡的「⋯」只留一个 **<button data-pm-open="…">**;
* ③ 「分组」「移出名册」需要的 **radio / 表单 / csrf** 全页只渲染**一份**,
* 放在列表末尾的共享浮层里(见文件末尾 `#rosterMenus`),点哪个人的按钮就把
* 该按钮的数据灌进去 —— 语义没变,服务端路由也没变(还是 people/group 与 people/forget)。
*
* 代价:没有 JS 时这两个按钮点不动。但**改分组/移出名册本来就只有 JS 可用场景**
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
*/
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
?>
<div class="people-grid">
<?php foreach ($list as $p): ?>
<?php
// 显示名走「我怎么称呼他」:同一个孩子,爸爸看到"儿子"、妈妈看到"小宝"。
// ⚠️ 不要再读 $p['nickname'] —— 那是已废弃的旧列(全家共用一个小名)。
// ⚠️ 一律走 person_name():它按 shown → my_nickname → name 的顺序取,
// 这里自己拼一次迟早漏掉 my_nickname(v14 前就是漏的,于是设了称呼也不显示)。
$pshow = person_name($p);
// 用户 2026-10-03 第 3 条:「如果有昵称,就不要显示真实姓名了」。
// 所以这里只在**没有昵称/称呼**、显示名就是本名时,本名才等于 $pshow(不重复画)。
$pNick = (string)($p['my_nickname'] ?? '');
$pReal = (string)$p['name'];
$pid = (int)$p['id'];
// 用户第 3 条:行高要矮。姓名行右边的徽标只留最必要的几个,
// 「已开通 / 访客 / 家谱 / 待确认」这些收成小徽标(有就显示,没有就不占位)。
// ⚠️「家谱」那枚必须带 title 说明来历 —— 不然"我没加过他,他怎么在我名册里"
// 会让人以为数据坏了(v7 加它就是为了回答这个问题,别在改版时弄丢)。
$metaBits = [];
if ($pid === (int)$meId) { $metaBits[] = ['我', '']; }
$r = $p['account_role'] ?? '';
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
$metaBits[] = ['管理员', ''];
} elseif ($r === 'guest') {
$metaBits[] = ['访客', ''];
} elseif (!empty($p['account_id'])) {
$metaBits[] = ['已开通', ''];
}
if (($p['roster_source'] ?? '') === 'kin') {
$metaBits[] = ['家谱', '他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册'];
}
if (!empty($p['is_placeholder'])) { $metaBits[] = ['待确认', '']; }
// 当前分组:行上那个「分组」按钮要**显示它**(用户 2026-10-04 第 1 条)。
// ⚠️ 只认「我能用的分组」里那几个 —— 挂在一个别人家的私自分组上时,
// 这里取不到名字,就按"未分类"显示(按钮照样能改成一个正常的分组)。
$curGid = (int)($p['group_id'] ?? 0);
$grpName = '';
foreach ($groups as $g) {
if ((int)$g['id'] === $curGid) { $grpName = (string)$g['name']; break; }
}
?>
<div class="person" data-person="<?= $pid ?>"
data-search="<?= e($pshow . ' ' . $pReal . ' ' . (string)($p['email'] ?? '') . ' ' . (string)($p['org'] ?? '') . ' ' . (string)($p['phone_plain'] ?? '')) ?>"
<?php /* ⚠️ 首字母**分字段**、用空格隔开(前端逐段独立判子序列)。
把"姓名+称呼"拼成一串会造出跨字段巧合:用户实测「输 yy 搜出岳父」
—— 洪勇的称呼是"岳父" → hy + yf = hyyf → yy 跨边界命中。
服务端 Pinyin::matches 本来就是逐字段判的,这样两边口径一致。 */ ?>
data-initials="<?= e(trim(\App\Pinyin::initials($pReal) . ' ' . \App\Pinyin::initials($pNick))) ?>">
<?php if ($canMove): ?>
<span class="drag-handle" title="拖到我想要的位置 / 那一栏" aria-hidden="true">⠿</span>
<?php endif; ?>
<?php /* draggable="false" 很重要:链接与图片浏览器默认就是可拖的,
不关掉的话拖起来会变成"拖一个网址",父卡片反而拿不到这次拖动。 */ ?>
<a class="person-main" href="<?= e(url('people/show', ['id' => $pid])) ?>" draggable="false">
<?php /* ⚠️ loading="lazy" 是名册加载一千人还不卡的**关键**:
头像全部走 media.php(每个都要启一次 PHP、查一次库、读一次存储),
一千个并发拉下来页面就是"点了没反应"。懒加载之后只拉视口里那十几个。
⚠️ 别写成 JS 的 data-src 懒加载:那样没有 JS 就一张头像都没有。 */ ?>
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt="" draggable="false"
loading="lazy" decoding="async" width="52" height="52"></div>
<div class="pb">
<div class="pname">
<?php /* ⚠️ 姓名必须是 .pname 里的**第一段裸文本**,不要包 <span>。
① 现有的 e2e 断言就是按这个形状读「这一行显示的是谁」
(person_names():只认 <div class="pname"> 内的第一段文本)——
包一层 span 会让它读到空串,6 条断言齐刷刷变红,
而页面看着完全正常(这种"改样式把测试打挂"最难查);
② 包起来也没有任何用处:.pname 本身已经带字重与字号,
下面的关系标签/徽标都在姓名**之后**,不需要靠 span 定位。 */ ?>
<?= e($pshow) ?>
<?php /* 用户第 4 条:与我的关系放姓名右边(列表里一眼能看到) */ ?>
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
<span class="rel-label"><?= e($p['label']) ?></span>
<?php endif; ?>
<?php foreach ($metaBits as [$b, $bTitle]): ?>
<span class="badge<?= $b === '我' ? ' badge-me' : ($b === '已开通' ? ' badge-open' : '') ?>"
<?= $bTitle !== '' ? 'title="' . e($bTitle) . '"' : '' ?>><?= e($b) ?></span>
<?php endforeach; ?>
</div>
<?php
// 用户第 3 条:一行放齐「手机号 / 生日 / 邮箱 / 工作单位」。
// 没填的项**整个 <span> 都不画** —— 留一个空标签只会让每行都长一截。
// 用户 2026-10-04 第 3 条:生日**阴历阳历都列**,且只显示月和日(不显示年)。
$facts = [];
if (($p['phone_plain'] ?? '') !== '') {
$facts[] = ['手机', (string)$p['phone_plain']];
}
foreach ((array)($p['birthdays'] ?? []) as [$bk, $bv]) {
$facts[] = [(string)$bk, (string)$bv];
}
if (trim((string)($p['email'] ?? '')) !== '') {
$facts[] = ['邮箱', (string)$p['email']];
}
if (trim((string)($p['org'] ?? '')) !== '') {
$facts[] = ['单位', (string)$p['org']];
}
?>
<?php if ($facts): ?>
<div class="pfacts">
<?php foreach ($facts as [$k, $v]): ?>
<span class="pfact"><span class="pfact-k"><?= e($k) ?></span><?= e($v) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</a>
<?php /* 用户 2026-10-03 第 7 条:在 ⋯ 前面加一个「编辑」按钮,快速进这个人的编辑页。
为什么不做进 ⋯ 菜单里:改资料是**高频**操作(补生日、补电话),
藏进 ⋯ 里要点两下;而 ⋯ 里放的是"调整分组/移出名册"这类低频动作。
⚠️ 它是 <a>,且必须在 person-main 这个 <a> **外面**
—— a 套 a 是非法 HTML,浏览器会把 DOM 拆开。 */ ?>
<?php /* ⚠️ 行右侧三个动作收在一个 flex 容器里,**不再各自绝对定位**:
原来「编辑」写死 right:46px、「⋯」写死 right:8px,再来第三个按钮
就得继续猜偏移;而且「分组」按钮的宽度随分组名变化,写死必然重叠。
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
<div class="person-actions">
<?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮,
**直接显示当前分组**,点开就是分组列表,选中即改。
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio,
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?>
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
data-pg-open="<?= $pid ?>"
data-pg-name="<?= e($pshow) ?>"
data-pg-cur="<?= $curGid ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
<?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「在编辑后面,放一个按钮,删除,
用来删除一个人。」
⚠️ 它与「⋯→从我的名册移走」不是一件事,别混:
· 「从我的名册移走」= 只是不在**我的**名册里显示,人还在家里;
· 「删除」= 真的把这个人删掉(账号与亲属关系一起走,不可恢复)。
所以它必须是显式的红色按钮 + 二次确认,不能收进 ⋯ 里。
没有删除权限(非管理员)时不画,并给出原因提示。 */ ?>
<?php if (\App\Auth::isAdmin()): ?>
<button type="button" class="btn btn-danger btn-sm person-del"
data-pd-open="<?= $pid ?>" data-pd-name="<?= e($pshow) ?>"
title="删除 <?= e($pshow) ?>">删除</button>
<?php endif; ?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
};
/** 一栏(含空栏)。空栏平时收起来,拖动时由 JS 加 is-dragging 露出来当落点。 */
$renderBlock = function (int $gid, string $name, array $list) use ($renderGrid): void {
?>
<section class="group-block<?= $list ? '' : ' is-empty' ?>" data-group="<?= $gid ?>" data-filter-group>
<h2 class="group-head">
<span class="group-title"><?= e($name) ?></span>
<span class="group-n"><span class="n"><?= count($list) ?></span> 人</span>
<span class="group-drop-hint">放到这里</span>
</h2>
<?php $renderGrid($list); ?>
</section>
<?php
};
?>
<div class="page-head">
<h1>家庭名册</h1>
<span class="sub">
共 <?= $total ?> 人<?= $groups && $q === '' ? ',分 ' . count($groups) . ' 个关系分组' : '' ?>
</span>
<div class="actions">
<?php if ($canMove && $pickable > 0): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/pick')) ?>">
从家里已有的人里挑(<?= $pickable ?>)
</a>
<?php endif; ?>
<?php if ($canMove): ?>
<?php /* 用户 2026-10-03 第 1 条:名册导入入口。
⚠️ 放在「添加成员」**左边**(次要动作在左、主要动作在右)——
日常还是一个个加,导入是换手机/换号时的一次性动作。 */ ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/import')) ?>">导入联系人</a>
<?php endif; ?>
<?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
<?php endif; ?>
</div>
</div>
<?php /* 用户第 4 条:这句拖动提示**去掉**(用户直接点名要删)。
功能本身不变,只是不再占版面。 */ ?>
<?php /* 用户 2026-10-03 第 2 条:**输入即筛选**,不需要按回车。
第 3 条:搜索后的「清除」按钮要与搜索保持一致(同款 .btn 尺寸、同一行)。
⚠️ 这里仍然是个 form(没有 JS 时按回车照样能搜)——有 JS 时 filter.js 会把
提交拦下来,改成即时筛,避免整页刷新把焦点弹走。 */ ?>
<form class="search" method="get" action="index.php" data-roster-search>
<input type="hidden" name="r" value="people">
<input class="input" name="q" id="rosterSearch" data-filter="#rosterBox"
data-filter-empty="#rosterEmpty" data-filter-clear=".js-roster-clear"
value="<?= e($q) ?>" autocomplete="off"
placeholder="姓名 / 称呼 / 邮箱 / 手机号,或姓名首字母(如 yyq)">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<button class="btn btn-ghost btn-sm js-roster-clear<?= $q === '' ? ' is-hidden' : '' ?>"
type="button" data-href="<?= e(url('people')) ?>">清除</button>
</form>
<p class="small muted search-count" id="rosterCount" style="margin:-8px 0 10px"></p>
<p class="small muted" id="rosterEmpty" hidden>没有匹配的人。</p>
<?php if (!$people): ?>
<div class="empty">
<?php if ($q !== ''): ?>
<b>没有匹配的人</b>
换个词试试。
<?php elseif ($pickable > 0): ?>
<b>你的名册还是空的</b>
家里已经有 <?= (int)$pickable ?> 个人了 —— 直接挑过来就行,不用重新录一遍。
<div class="btn-row" style="justify-content:center;margin-top:14px">
<a class="btn" href="<?= e(url('people/pick')) ?>">从家里已有的人里挑</a>
<?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-ghost" href="<?= e(url('people/new')) ?>">添加一个新人</a>
<?php endif; ?>
</div>
<?php else: ?>
<b>名册还是空的</b>
<?php if (\App\Auth::isAdmin()): ?>
把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。
<div class="btn-row" style="justify-content:center;margin-top:14px">
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
</div>
<?php else: ?>
等家里人把你加进来,或者让管理员给你开个账号。
<?php endif; ?>
<?php endif; ?>
</div>
<?php else: ?>
<?php /* ⚠️ 搜索与否**都走同一张列表**(v24 用户第 2 条之后)。
原来搜索时会把分组拆掉、平铺成一堆卡片,看着"搜的时候样式变了";
现在一律按分组呈现,filter.js 会把没命中的行藏起来,
整栏都不剩人时连栏一起收 —— 搜索结果与平时长得一样,只少了不匹配的。 */ ?>
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
data-reorder="<?= e(url('people/reorder')) ?>"
data-csrf="<?= e(csrf_token()) ?>">
<?php foreach ($groups as $g): ?>
<?php $gid = (int)$g['id']; ?>
<?php $renderBlock($gid, (string)$g['name'], $grouped[$gid] ?? []); ?>
<?php endforeach; ?>
<?php
// 「未分类」栏永远画出来(哪怕是空的)—— 它是把卡片从某个分组里拖出来的落点。
// 提示语按需求删掉了(用户原话:「这些人还没归到任何分组里…这些提示,都去掉」)。
$renderBlock(0, '未分类', $grouped[0] ?? []);
?>
<?php if (!$groups): ?>
<p class="small muted" style="margin:0">
还没有任何分组。管理员可以在「系统设置 → 公共分组」里建一套全家共用的,
你也可以在「个人设置 → 我的分组」里加只给自己看的。
</p>
<?php endif; ?>
</div>
<?php /* ==================== 卡片动作的**共享浮层**(v24 第 10 批 #6)====================
这里只有**一份**「分组」表单和**一份**「移出名册」表单,全页共用。
每一行的按钮只带数据(data-pg-open / data-pm-open),点开时由 JS 把数据灌进来。
目的:把 2369 个 form / 27234 个 input / 22496 个 radio 压回个位数 ——
这正是 Bitwarden 那类密码管理器把页面拖卡的根源(它们要逐个 form 扫描)。
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
不会掉进"按钮点了没反应"的黑洞。 */ ?>
<?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「名册的分组功能,点击一次之后,再次点击就失效了,
应该**弹出一个框,就像选择观影人一样**,选择分组。」
所以分组从"贴在按钮下方的浮层"改成**居中弹框 + 遮罩**(与选人弹窗同一形态)。
⚠️ "第二次点击失效"的根因:旧实现把浮层贴在按钮下方,并在**捕获阶段**监听
文档点击来"点外面就关"——于是点第二次时,mousedown 先被关闭逻辑吃掉、
随后的 click 又把面板打开,视觉上就是"闪一下没反应/要用第三次才行"。
改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?>
<div class="rm-modal" id="rosterMenus" hidden>
<div class="rm-mask" data-rm-close></div>
<div class="rm-panel" id="rmGroup" role="dialog" aria-modal="true" aria-label="选择分组" hidden>
<div class="rm-head"><b>把「<span class="pg-who"></span>」分到</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<div class="rm-body">
<label class="pg-item">
<input type="radio" name="group_id" value="">
<span>未分类</span>
</label>
<?php foreach ($groups as $g): ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<div class="rm-foot">
<button class="btn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
</div>
</form>
</div>
<div class="rm-panel" id="rmDel" role="dialog" aria-modal="true" aria-label="删除成员" hidden>
<div class="rm-head"><b>删除「<span class="pd-who"></span>」</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)——
不要写成 people/delete,那会 404 且看起来像"点了没反应"。 */ ?>
<form method="post" action="<?= e(url('people/destroy')) ?>" class="pd-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="rm-body">
<p class="rm-warn">
会把他从名册中删除,<b>并且</b>删掉他的登录账号与全部亲属关系连线。
<b>不可恢复。</b>
</p>
<p class="small muted" style="margin:8px 0 0">
只是"不想在名册里看到他"?那用卡片上的「⋯ → 从我的名册移走」,人还留在家里。
</p>
</div>
<div class="rm-foot">
<button class="btn btn-danger" type="submit">确认删除</button>
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
</div>
</form>
</div>
<div class="rm-panel" id="rmMore" role="dialog" aria-modal="true" aria-label="更多操作" hidden>
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
因家谱关系自动加入;解除关系后自动移出。
</p>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move is-self">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<button class="btn btn-ghost btn-sm" type="submit">从我的名册移走</button>
</form>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($q !== '' || $canMove): ?>
<script>
(function () {
/* 「清除」按钮:有 JS 时不动页面、只清空输入并立刻重筛
(data-href 是没有 JS 时的兜底:直接跳回不带 q 的名册)。 */
var btn = document.querySelector('.js-roster-clear');
var input = document.getElementById('rosterSearch');
if (!btn || !input) { return; }
function syncClear() {
btn.classList.toggle('is-hidden', (input.value || '').trim() === '');
}
input.addEventListener('input', syncClear);
btn.addEventListener('click', function () {
// filter.js 里已经挂了"点它就清空重筛",这里只负责同步按钮显隐;
// 没有 filter.js(极老浏览器)时退化成跳转。
if (!window.__fbFilterLoaded) { window.location.href = btn.getAttribute('data-href'); }
setTimeout(syncClear, 0);
});
syncClear();
})();
<?php /* ==================== 卡片动作弹框的交互(v24 第 11 批 #2 重写)====================
一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
「应该弹出一个框,就像选择观影人一样」。
⚠️ 为什么旧版"点第二次会失效":
旧实现在**捕获阶段**监听 document 的 click,只要目标不在浮层里就 closeAll()。
点第二次时事件顺序是 mousedown → (捕获阶段 closeAll)→ click(又打开)→ …
于是按钮像是"时灵时不灵"。模态框把"关闭"收敛到遮罩/关闭键/取消键上,
按钮自己只负责"打开",语义不再打架。
⚠️ 外部点击用**冒泡**阶段监听,且只认遮罩本身(data-rm-close),
不用"点哪儿都关"那种一刀切 —— 那正是上一版出问题的写法。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var dPanel = document.getElementById('rmDel');
var mPanel = document.getElementById('rmMore');
function closeAll() {
wrap.hidden = true;
[gPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
}
function open(panel) {
[gPanel, dPanel, mPanel].forEach(function (p) { if (p && p !== panel) { p.hidden = true; } });
panel.hidden = false;
wrap.hidden = false;
// 焦点进面板:点开就能用键盘选(Esc 也能关,见下)
var first = panel.querySelector('input:not([type=hidden]), button');
if (first) { first.focus(); }
}
// ---- 打开:分组 ----
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var gb = t.closest('[data-pg-open]');
if (gb) {
e.preventDefault();
var form = gPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = gb.getAttribute('data-pg-open');
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
// 预选当前分组(点开就知道他现在在哪一栏)
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
});
open(gPanel);
return;
}
// ---- 打开:删除 ----
var db = t.closest('[data-pd-open]');
if (db) {
e.preventDefault();
dPanel.querySelector('form input[name="id"]').value = db.getAttribute('data-pd-open');
dPanel.querySelector('.pd-who').textContent = db.getAttribute('data-pd-name') || '';
open(dPanel);
return;
}
// ---- 打开:⋯ ----
var mb = t.closest('[data-pm-open]');
if (mb) {
e.preventDefault();
var kin = mb.getAttribute('data-pm-hint') === '1';
mPanel.querySelector('.is-kin').hidden = !kin;
mPanel.querySelector('form.is-self').hidden = kin;
if (!kin) {
mPanel.querySelector('form.is-self input[name="person_id"]').value =
mb.getAttribute('data-pm-open');
}
open(mPanel);
return;
}
// ---- 关闭:只有遮罩 / ✕ / 取消 会关(不做"点哪儿都关")----
if (t.closest('[data-rm-close]')) {
e.preventDefault();
closeAll();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(); }
});
/* 选中即提交(面板里那个「保存」按钮一直都在,这条只是顺手) */
document.addEventListener('change', function (e) {
var inp = e.target;
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
var form = inp.form;
if (!form || !form.classList || !form.classList.contains('pg-menu')) { return; }
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
})();
</script>
<?php endif; ?>