Files
FamilyBook/templates/people/index.php
T

874 lines
49 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
/**
* 家庭名册 —— v28 起**平铺成一张列表**,顶部用「标签」筛选,卡片可拖动排序。
*
* 🔴 v28 用户第 5 条:「名册的分组,用下来不太好,我感觉应该改成打标签,
* 就是说,一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
* 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
*
* ⇒ 从「一人一组的**分栏**」改成「**一人多标签** + 平铺 + 顶部标签筛选」。
* 为什么必须取消分栏:一个人挂 2 个标签时,分栏会让他在两栏里各出现一次,
* 看起来像"名册里有两个人";而且"未分类"那栏会越积越多(挂 2 个标签的人
* 反而更难归类)。平铺 + 筛选是唯一自洽的做法(手机通讯录也是这么做的)。
*
* 三处刻意的设计:
* ① **名册是每人一份的**(v5 起,见 App\PersonRoster):只显示"我加的人 +
* 别人分享给我的";标签也只影响我自己那份(我给他打"球友"不该让全家都变)。
* ② **拖动仍然只改顺序**(用户明确要求保留拖动排序):不再表示"改分组",
* 拖动只调整"我看到的先后"。标签在卡片上点一下就能改。
* ③ **没有 JS 也完全能用**:卡片上的「编辑」是普通链接;标签用 `?tag=` 链接筛选
* (服务端渲染,不依赖 JS)。
*
* ⚠️ 卡片结构是 div > a + div,**不能写成 a > form**:
* 表单套在链接里是非法 HTML,浏览器会把 DOM 拆开(本仓踩过)。
*/
$canMove = \App\Auth::canWrite();
$pickable = $pickable ?? 0;
// ⚠️ v54(用户 2026-10-16 第 2 条):两个变量**不是一个东西**,别合并:
// · $tagCloud —— **真有人的**标签(筛选条用;人的数为 0 的词不上筛选条)
// · $tagChoices —— **打标签的候选**(词表 ∪ 用过的词;浮层里的 checkbox 用它)
$tagCloud = $tagCloud ?? [];
$tagChoices = $tagChoices ?? [];
$tagFilter = $tagFilter ?? '';
// v28:平铺列表,人数直接数行数(不再按分组累加)
$total = count($people ?? []);
/**
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
* 复制一遍迟早会改漏一处。
*
* ⚠️ `$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): 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[] = ['待确认', '']; }
/* v28 用户第 5 条:原来这里算「当前分组名」给按钮显示。
改成标签后不再需要 —— 标签直接从 `$p['tags']` 取(见下面按钮那段),
而且**一个人可能有好几个标签**,没法再塞进一个"当前值"里。 */
?>
<div class="person" data-person="<?= $pid ?>"
data-tags="<?= e(implode(' ', (array)($p['tags'] ?? []))) ?>"
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
/* 🔴 v28 用户第 5 条:分组 → 标签。
原按钮显示"当前分组"(一个人只有一个);现在显示**他身上的标签**
(可能好几个,也可能一个都没有)。点开就是标签编辑浮层:
可以勾已有的、也可以直接打新词 —— 这正是「一个人可以既是小学同学、
又是初中同学」在界面上的样子。
⚠️ 按钮文案:有标签就列出来,没有就显示「标签」,
与原来的「分组 / is-none」形态保持一致的心智。 */
$pTags = (array)($p['tags'] ?? []);
$tagLabel = $pTags ? implode(' ', $pTags) : '标签';
?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-tag-btn<?= $pTags ? '' : ' is-none' ?>"
data-pt-open="<?= $pid ?>"
data-pt-name="<?= e($pshow) ?>"
data-pt-tags="<?= e(implode(' ', $pTags)) ?>"
title="改 <?= e($pshow) ?> 的标签"><?= e($tagLabel) ?></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><?php /* /.person-actions */ ?>
</div><?php /* /.person
🔴🔴 v29 第 3 条「名册页面完全错乱」的**真根因**就在这里:
这一行 `</div>`(闭合 `.person`)在 v29 重写平铺列表时**漏掉了**。
浏览器遇到"卡片没闭合"会**把下一张卡塞进上一张里**(DOM 修复行为),
于是每张卡的宽度被前一张的按钮吃掉 —— 实测第一张的 `.person-main`
只剩 24~151px(本该 ~800px),姓名与徽标全被挤碎,
整页看起来就是"错乱"。
⚠️ 这类"少一个闭合标签"的 bug,`php -l` 与静态断言**全绿**
(PHP 语法没错、PHP 的 if/endif 也平衡),只有真机量 DOM 才现形。
定位手法:量 `.person` 的 `children`,发现里面**有另一个 `.person`**
—— 嵌套的同类元素 = 上一张没收尾。 */ ?>
<?php endforeach; ?>
</div>
<?php
};
/* v28 用户第 5 条:`$renderBlock`(画"一栏")**整个删掉** ——
名册不再分栏,改成一整张平铺列表(见下面 `$renderGrid($people)`)。
留着它只会让后人以为"还有个分栏路径"。 */
?>
<div class="page-head">
<h1>家庭名册</h1>
<span class="sub">
<?php /* v24 第 21 批 第 1 条:无刷新删除后要就地更新这个数 ——
所以「共 N 人」那个 N 单独包一个 <b>(加 data-roster-total 供 JS 定位)。
v28:原来后面跟着「,分 6 个关系分组」—— 分组已成标签,
改成说"打了几个标签"(情报更贴近新模型)。 */ ?>
共 <b data-roster-total><?= $total ?></b> 人<?= $tagCloud && $q === '' ? ',有 ' . count($tagCloud) . ' 个标签' : '' ?>
</span>
<div class="actions">
<?php if ($canMove): ?>
<?php /* 🔴🔴 v52(用户 2026-10-15 第 6 条):「名册右侧,**只保留一个添加成员按钮**,
右侧再加一个设置按钮,点击后下拉设置,第一个就是导入联系人,
第二个就是原来个人设置那里的名册分组,这样个人设置中就不显示名册分组的内容了。」
⇒ 右上角只剩两颗:**添加成员** + **☰ 设置**(设置恒在最右,全站统一)。
⚠️ 原来那两颗去哪了:
· 「从家里已有的人里挑」→ **整颗删掉**(用户第 5 条:不再让人手动挑,
改由"新增/导入时自动按姓名/手机号/身份证匹配并问一句"来替代);
· 「导入联系人」→ 收进 ☰ 设置里(第一行)。 */ ?>
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
<?php $plSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?php elseif (\App\Auth::isAdmin()): ?>
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
<?php $plSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<?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 else: ?>
<b>名册还是空的</b>
<?php if (\App\Auth::isAdmin()): ?>
<?php /* 🔴 v52(用户第 6 条):这里原来挂着「从家里已有的人里挑」按钮 ——
那条入口已按用户第 5 条的要求**整颗删掉**(改成"新增/导入时自动匹配")。
现在只留"添加成员",以及一句说明:家里已有的人会被自动认出来。 */ ?>
把家里人加进来,之后观影、旅行的同行人就可以直接从名册里选。
<br>家里已经有的人(别人先录的)不用重录 —— 录入时填上姓名或手机号,
系统会认出来并问你要不要直接加进你的名册。
<div class="btn-row" style="justify-content:center;margin-top:14px">
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
<a class="btn btn-ghost" href="<?= e(url('people/import')) ?>">导入联系人</a>
</div>
<?php else: ?>
等家里人把你加进来,或者让管理员给你开个账号。
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* ==================== v28:顶部标签筛选条 ====================
用户第 5 条把「分组」换成「标签」之后,名册**不再分栏**(一个人挂 2 个标签
会在两栏里各出现一次,看着像两个人)。改成一整张列表 + 这条筛选:
· 「全部」= 不筛;点某个标签 = 只看挂了这个标签的人。
· 用**链接**(`?tag=xxx`)而不是 JS 切换 —— 服务端渲染,无 JS 也能筛,
而且"筛选状态"在 URL 里,刷新/分享/回退都不会丢。
⚠️ 只列出**真有人的**标签(PersonTag::cloud 已经 JOIN 了 roster)——
否则会出现"标签写着 5 个、点进去只有 3 个"。 */ ?>
<?php if ($tagCloud): ?>
<div class="roster-tagbar">
<span class="roster-tagbar-k">标签</span>
<a class="note-tag<?= $tagFilter === '' ? ' is-on' : '' ?>"
href="<?= e(url('people', $q !== '' ? ['q' => $q] : [])) ?>">全部 <b><?= (int)$total ?></b></a>
<?php foreach ($tagCloud as $t): ?>
<a class="note-tag<?= $tagFilter === (string)$t['tag'] ? ' is-on' : '' ?>"
href="<?= e(url('people', ['tag' => (string)$t['tag']] + ($q !== '' ? ['q' => $q] : []))) ?>">
#<?= e((string)$t['tag']) ?> <b><?= (int)$t['n'] ?></b>
</a>
<?php endforeach; ?>
<?php if ($tagFilter !== ''): ?>
<a class="link-quiet roster-tagbar-clear" href="<?= e(url('people', $q !== '' ? ['q' => $q] : [])) ?>">清除筛选</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php else: ?>
<?php /* v28:**平铺**成一张列表(原来按分组分成好几栏)。
⚠️ 拖动仍然可用,但语义已变:拖动只调整**我看到的先后**,
不再表示"改分组"(标签在卡片上点一下就能改)。 */ ?>
<div class="roster is-flat<?= $canMove ? ' is-droppable' : '' ?>" id="rosterBox"
data-reorder="<?= e(url('people/reorder')) ?>"
data-csrf="<?= e(csrf_token()) ?>">
<?php $renderGrid($people); ?>
<?php if ($tagFilter !== '' && !$people): ?>
<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>
<?php /* 🔴 v28 用户第 5 条:这个浮层从「选一个分组(radio 单选)」改成
「**打标签(多选 + 自由输入)**」。
为什么用 checkbox + 一个文本框而不是纯文本输入:
· checkbox 列出**我已有过的标签**,点一下就能加/去 —— 日常最高频的动作;
· 文本框允许**当场打新词**("小学同学"这种第一次出现的),
两种情况都要支持,否则用户第一次打新标签时无处可打。
⚠️ 提交的是一个**逗号分隔的字符串**(`tags`),服务端 `PersonTag::setFor`
做整组替换 —— 与笔记标签同一套归一规则(去 #、去空白、去重、限长)。
⚠️ 保留原生「保存」按钮:JS 出错时手动点它也能提交,不掉进"点了没反应"的黑洞。 */ ?>
<div class="rm-panel" id="rmTags" role="dialog" aria-modal="true" aria-label="编辑标签" hidden>
<div class="rm-head"><b>「<span class="pt-who"></span>」的标签</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('people/tags')) ?>" class="pt-form">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<div class="rm-body">
<?php /* 🔴 v54(用户 2026-10-16 第 2 条):候选**不再是"我用过的词"**,
而是 `$tagChoices` = **设置页配的词表 ∪ 我用过的词**。
用户原话:「打标签的时候,**却不是从这些分组里读出来的**……
应该从这里面把所有标签都读出来吧?但是现在标签里面却只有几个,
也不知道是从哪儿读出来的。」
⇒ 词表里的词**还没用过**也能一点就打上 —— 这正是词表的意义
(否则每个新标签都得手打一遍,词表就等于没有)。 */ ?>
<?php if ($tagChoices): ?>
<div class="pt-picks" id="ptPicks">
<?php foreach ($tagChoices as $tg): ?>
<label class="pt-pick">
<input type="checkbox" value="<?= e((string)$tg) ?>">
<span>#<?= e((string)$tg) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="field" style="margin-top:12px">
<label for="ptInput">标签(用空格分开,可直接打新的)</label>
<input class="input" id="ptInput" name="tags" maxlength="300"
placeholder="如:小学同学 初中同学 球友" autocomplete="off">
</div>
<p class="small muted" style="margin:8px 0 0">
一个人可以有多个标签 —— 他既是小学同学、又是初中同学,两个都打上就行。
</p>
</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 人"
* ⚠️ v28:名册已**平铺**(不再分栏),所以这里删掉了原来"更新他那一栏人数"
* 的两行(`.group-block` / `[data-group-n]` 在新结构里都不存在了)。 */
function removePerson(pid) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
card.parentNode.removeChild(card);
if (totalEl) {
var n = parseInt(totalEl.textContent || '0', 10) || 0;
totalEl.textContent = String(Math.max(0, n - 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 tPanel = document.getElementById('rmTags');
var dPanel = document.getElementById('rmDel');
var mPanel = document.getElementById('rmMore');
function closeAll() {
wrap.hidden = true;
[tPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
}
function open(panel) {
[tPanel, 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(); }
}
// ---- 打开:标签(v28 用户第 5 条,原先是"分组")----
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var tb = t.closest('[data-pt-open]');
if (tb) {
e.preventDefault();
var form = tPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = tb.getAttribute('data-pt-open');
tPanel.querySelector('.pt-who').textContent = tb.getAttribute('data-pt-name') || '';
// 预填他现在的标签(勾选框回勾 + 文本框回填)——
// 点开就知道他打了什么,不用去卡片上找。
// ⚠️ 回填函数由下面那段 IIFE 挂到 window.__ptFill(两段脚本解耦,
// 顺序变了也不会"回填静默失效")。
if (typeof window.__ptFill === 'function') {
window.__ptFill(tb.getAttribute('data-pt-tags') || '');
} else {
var inp0 = form.querySelector('#ptInput');
if (inp0) { inp0.value = tb.getAttribute('data-pt-tags') || ''; }
}
open(tPanel);
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 /* ==================== v28 用户第 5 条:改标签**不刷新页面** ====================
原型是 v24 第 25 批的"改分组不刷新"(用户原话:「名册模块在列表中,对列表中的
某一个人进行分组,也不应该刷新界面,而是应该动态调整页面」)——
现在分组换成了标签,这条诉求一字不变地适用:改完标签**就地更新卡片**,
不整页重载(否则搜索框的字没了、滚动位置回到顶部)。
原来还要"把卡片挪到目标分组栏里";名册已平铺(不再分栏),
所以现在只做两件事:① 换掉卡片上那颗标签按钮的显示;② 更新卡片的 data-tags。
⚠️ 失败一律**退回原生提交**:网络断 / 会话过期 / 权限不足 ——
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var tPanel = document.getElementById('rmTags');
var form = tPanel ? tPanel.querySelector('form.pt-form') : null;
if (!form || !window.fetch || !window.FormData) { return; }
var searchInput = document.getElementById('rosterSearch');
/* 勾选框 ↔ 文本框**双向同步**:
· 勾/取消一个已有标签 → 把它增删进文本框(文本框是唯一提交源);
· 文本框里手打的新词 → 打开时按它回勾(见 fillPanel)。
为什么用"文本框当唯一真相"而不是"提交时拼两部分":
拼两部分就要处理"同一个标签既勾了又在文本框里"(重复)、
"文本框里删了但勾还在"(该算删还是算加)—— 全是边界 bug 的温床。 */
function syncFromPicks() {
if (!tPanel) { return; }
var inp = tPanel.querySelector('#ptInput');
if (!inp) { return; }
var on = [];
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input:checked'), function (c) {
on.push(c.value);
});
// 保留文本框里**手打的、不在已有标签里**的那些(它们没有勾选框)
var known = {};
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input'), function (c) {
known[c.value] = true;
});
var keep = [];
String(inp.value || '').split(/[\s,,]+/).forEach(function (w) {
w = w.replace(/^#/, '');
if (w !== '' && !known[w]) { keep.push(w); }
});
inp.value = on.concat(keep).join(' ');
}
/** 打开面板时:把这个人已有的标签灌进去(勾选框回勾 + 文本框回填) */
function fillPanel(tagsStr) {
if (!tPanel) { return; }
var tags = String(tagsStr || '').split(/[\s,,]+/).filter(function (x) { return x !== ''; });
var inp = tPanel.querySelector('#ptInput');
if (inp) { inp.value = tags.join(' '); }
Array.prototype.forEach.call(tPanel.querySelectorAll('.pt-pick input'), function (c) {
c.checked = tags.indexOf(c.value) >= 0;
});
}
window.__ptFill = fillPanel;
if (tPanel) {
tPanel.addEventListener('change', function (e) {
if (e.target && e.target.closest && e.target.closest('.pt-pick')) { syncFromPicks(); }
});
}
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 input = form.querySelector('#ptInput');
var want = input ? input.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) {
wrap.hidden = true;
if (tPanel) { tPanel.hidden = true; }
var tags = (d.tags || []);
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (card) {
card.setAttribute('data-tags', tags.join(' '));
var b = card.querySelector('.person-tag-btn');
if (b) {
b.textContent = tags.length ? tags.join(' ') : '标签';
b.classList.toggle('is-none', tags.length === 0);
b.setAttribute('data-pt-tags', tags.join(' '));
}
}
say(true, d.msg || '标签已改。');
return;
}
if (d && d.msg) {
wrap.hidden = true;
if (tPanel) { tPanel.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(); }
});
});
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);
}
})();
</script>
<?php endif; ?>