Files
FamilyBook/templates/people/index.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

834 lines
45 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>
<?php /* data-group-n 上挂的是**数字节点**:无刷新删除后 JS 直接改它的文本,
不必重算/重绘整栏。 */ ?>
<span class="group-n"><span class="n" data-group-n="<?= $gid ?>"><?= 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">
<?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 ——
所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。
⚠️ 不能把整段文字都塞进去:后面的「,分 6 个关系分组」是**另一件事**,
删一个人不该动它。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $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 /* ==================== CardDAV 同步(v24 第 22 批)====================
用户原话:「名册,增加cardav同步功能。caldav和cardav,都增加一个创建口令的功能,
这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时自己的密码泄露。」
⚠️ 口令在**个人设置**里生成(一把通用 DAV 口令,日历与通讯录共用),
这里只给地址 + 一个指路按钮 —— 密码类的东西只该有一个管理入口,
两处各放一套按钮迟早对不上(这页重置了、那页还显示"已配置")。
⚠️ 同步范围 = **这一页看到的人**(同一个 Person::roster 口径):
下面那句里的人数就是名册页的人数,用户当场能对上,不必猜"到底同步了谁"。 */ ?>
<div class="card card-pad" style="margin-top:20px" id="carddav">
<h2 class="section-title">
通讯录同步(CardDAV)
<span class="hint">把这 <?= (int)$total ?> 个人放进手机通讯录</span>
</h2>
<p class="small muted" style="margin:0 0 12px">
在 iOS「通讯录」、安卓 <b>DAVx⁵</b>、Thunderbird、群晖 Contacts 里填下面这个地址 +
<b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。
<br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)——
谁加进你的名册、谁就会出现在手机通讯录里。
<br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回家书,要改请回到本站来改。
</p>
<div class="field">
<label>通讯录地址</label>
<input class="input mono" readonly value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/carddav/') ?>">
<span class="tip">填这一条即可;点进去之后 iOS/DAVx⁵ 会自动发现这本通讯录。</span>
</div>
<div class="btn-row">
<a class="btn btn-sm" href="<?= e(url('settings/me', [], 'dav')) ?>">
<?= !empty($davHas) ? 'DAV 口令已配置(去管理 / 重置)' : '去生成 DAV 口令' ?>
</a>
<?php /* ⚠️ 这句徽标原本写得很长("口令在「个人设置 → DAV 同步」里管理(日历也用同一把)"),
而 `.node-badge` 是 `white-space: nowrap`、`.btn-row` 不换行 ——
在 360/375/390px 上它把整页**撑出横向滚动**(真机几何实测:
scrollW 412 > clientW 360,越界元素就是它)。
页面变宽在手机上是最刺眼的问题(整页可以左右拖),
所以文案压到八个字以内、并允许在窄屏折行。 */ ?>
<span class="node-badge">日历与通讯录共用一把</span>
</div>
</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"
data-ajax-del>
<?= 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 第 21 批 第 1 条:删除**不刷新页面** ====================
用户原话:「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
从当前页面的 dom 中删除,页面保持不动。」
做法:拦掉 #rmDel 那个表单的提交(它带 data-ajax-del,见上面浮层),
改成 fetch POST;成功后**只把那一张卡片从 DOM 里摘掉**并就地更新计数,
页面滚动位置、搜索框里的字、展开的分栏全都保持原样。
⚠️ 四个必须踩准的点:
① **成功后必须真的重跑一次筛选**:摘掉卡片之后,如果搜索框里还有字,
或某一栏刚好空掉,分组栏的显隐/计数就与现状不符了。
这里派发一个 input 事件让 filter.js 自己重算 —— 不重复实现它的逻辑。
(filter.js 的那套判据只在它自己那儿,抄一份必然飘。)
② **删的是"当前看的人"时不能只摘卡片**:还是留给他自己那颗"不能删自己"的红字提示
(服务端会回 ok=false + 原因),所以走的是底下那条"失败就退回原生提交"。
③ **失败一律退回原生提交**:网络断了 / 会话过期 / 权限不足 ——
任何一种都不能静默失败("点了删除没反应"比"刷一下"糟得多)。
退回原生提交,用户至少能看到服务端给的红字提示。
④ **提交期间禁用按钮**:防连点(两次 DELETE 第二次会拿到"找不到这个人",
弹出来会让人以为第一次失败了)。
============================================================ */ ?>
(function () {
var form = document.querySelector('form[data-ajax-del]');
if (!form || !window.fetch || !window.FormData) { return; }
var wrap = document.getElementById('rosterMenus');
var panel = document.getElementById('rmDel');
// 一次性把要用的计数节点找好(删完要就地改它们,别再全页扫)
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 摘掉一个人 = 摘卡片 + 更新"共 N 人" + 更新他那一栏的人数 */
function removePerson(pid) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var block = card.closest('.group-block');
card.parentNode.removeChild(card);
if (totalEl) {
var n = parseInt(totalEl.textContent || '0', 10) || 0;
totalEl.textContent = String(Math.max(0, n - 1));
}
if (block) {
var gid = block.getAttribute('data-group');
var nEl = document.querySelector('[data-group-n="' + gid + '"]');
if (nEl) {
var m = parseInt(nEl.textContent || '0', 10) || 0;
nEl.textContent = String(Math.max(0, m - 1));
}
}
// ① 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)。
// 没有 filter.js(极老浏览器)时什么也不做 —— 只是栏头人数会略旧,
// 卡片本身已经摘掉了,不影响用户看到的结果。
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
return true;
}
/** 提示一句(用页面自己的 flash 样式,不弹 alert) */
function say(ok, msg) {
// ⚠️ 结构与 layout.php 里那段完全一致(.flash > .flash-item[.err])——
// 自己另发明一套 class 会得不到那条淡红/淡绿的样式。
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
}
form.addEventListener('submit', function (ev) {
// 已经退回原生提交了就别再拦(避免打转)
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="id"]') || {}).value || '';
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
// ① 先关浮层(它盖在页面上,不收掉会让人以为还在等)
if (wrap) { wrap.hidden = true; }
if (panel) { panel.hidden = true; }
removePerson(String(pid));
say(true, d.msg || '已删除。');
return;
}
// ③ 服务端明确说不行(删自己 / 唯一管理员)→ 把原因说出来,但不刷新
if (d && d.msg) {
if (wrap) { wrap.hidden = true; }
if (panel) { panel.hidden = true; }
say(false, d.msg);
return;
}
// ④ 拿不到 JSON(会话过期 / 500 / 网络坏)→ 退回原生提交,让服务端说话
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
<?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(); }
});
})();
<?php /* ==================== v24 第 25 批 第 1 条:改分组**不刷新页面** ====================
用户原话:「名册模块在列表中,对列表中的某一个人进行分组,也不应该刷新界面,
而是应该动态调整页面。」
原来:`change` 一下 → `requestSubmit()` → 服务端 302 回 people 页 → **整页重载**
(搜索框里的字没了、滚动位置回到顶部、正在拖的栏也收起来了)。
现在:拦下那个提交,改 `fetch` POST;成功后**就地把他挪到目标分组栏里**,
并更新两栏的人数与「共 N 人」不变,最后重跑一次筛选(复用 filter.js)。
⚠️ 四个必须踩准的点(与删除那段的四条同源,但多了一条"挪卡片"):
① **卡片真的要在栏之间移动**,不能只改按钮上的文字 ——
用户改分组的目的是"看到他在新栏里",只换文字等于没做。
② 移动要**插到目标栏末尾**,而不是 append 到 .roster(那会掉到所有栏外面)。
③ 目标栏原本是空的(`is-empty` + `hidden`)→ 必须把 `is-empty` 去掉、
`hidden` 也要清掉。只去 is-empty 没用:作者样式里 `.group-block` 没写 display,
浏览器默认的 `[hidden]{display:none}` 照样生效,那一栏连同刚挪进去的人一起看不见
(app.js 的 refreshCounts() 里记过同一个坑)。
④ 失败一律**退回原生提交**:网络断/会话过期/权限不足 ——
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var form = gPanel ? gPanel.querySelector('form.pg-menu') : null;
if (!form || !window.fetch || !window.FormData) { return; }
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 就地把他挪到目标分组栏里 + 两栏人数跟着变 */
function moveCard(pid, gid, gname) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var from = card.closest('.group-block');
var toSec = document.querySelector('.group-block[data-group="' + gid + '"]');
if (!toSec) { return false; } // 目标栏不存在(分组被删了)→ 别乱挪
var grid = toSec.querySelector('.people-grid');
if (!grid) { return false; }
// ① 先记下"他在哪一栏"(要减人数的那一栏)
var fromGid = from ? from.getAttribute('data-group') : null;
// ② 挪过去(插到末尾:新归到一组的人在那一组最后,与拖拽的语义一致)
grid.appendChild(card);
// ③ 目标栏:清 is-empty 与 hidden(空栏平时是收起来的)
toSec.classList.remove('is-empty');
toSec.hidden = false;
// ④ 两栏的人数都重数一遍(比 +1/-1 稳:筛选着的时候"可见数"≠"总数")
[fromGid, String(gid)].forEach(function (g) {
if (g === null) { return; }
var sec = document.querySelector('.group-block[data-group="' + g + '"]');
var nEl = document.querySelector('[data-group-n="' + g + '"]');
if (!sec || !nEl) { return; }
var n = sec.querySelectorAll('.people-grid .person').length;
nEl.textContent = String(n);
sec.classList.toggle('is-empty', n === 0);
});
// ⑤ 按钮上显示的文字跟着换(用户第 1 条:那颗按钮**显示当前分组**)
var btn = card.querySelector('.person-group-btn');
if (btn) {
btn.textContent = gname !== '' ? gname : '分组';
btn.classList.toggle('is-none', gname === '');
btn.setAttribute('data-pg-cur', String(gid));
btn.setAttribute('title', '改 ' + (btn.getAttribute('data-pg-name') || '') + ' 的分组');
}
// ⑥ 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
return true;
}
function say(ok, msg) {
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
}
form.addEventListener('submit', function (ev) {
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="person_id"]') || {}).value || '';
var checked = form.querySelector('input[name="group_id"]:checked');
var gid = checked ? (parseInt(checked.value || '0', 10) || 0) : 0;
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
moveCard(String(pid), gid, String(d.gname || ''));
say(true, d.msg || '分组已改。');
return;
}
if (d && d.msg) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
say(false, d.msg);
return;
}
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
</script>
<?php endif; ?>