Files
FamilyBook/templates/people/index.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

288 lines
15 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 (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
*/
$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[] = ['待确认', '']; }
?>
<div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>
data-search="<?= e($pshow . ' ' . $pReal . ' ' . (string)($p['email'] ?? '') . ' ' . (string)($p['org'] ?? '') . ' ' . (string)($p['phone_plain'] ?? '')) ?>"
data-initials="<?= e(\App\Pinyin::initials($pReal . ' ' . $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">
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt="" draggable="false"></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> 都不画** —— 留一个空标签只会让每行都长一截。
// 生日优先阳历、其次农历,用 [阳历]/[农历] 小字标明是哪一个。
$facts = [];
if (($p['phone_plain'] ?? '') !== '') {
$facts[] = ['手机', (string)$p['phone_plain']];
}
if (($p['birthday_show'] ?? '') !== '') {
$facts[] = [(string)$p['birthday_kind'], (string)$p['birthday_show']];
}
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 if ($canMove): ?>
<details class="person-more" data-no-drag>
<summary aria-label="调整分组">⋯</summary>
<form method="post" action="<?= e(url('people/group')) ?>" class="person-move">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<label class="sr-only" for="pg<?= $pid ?>">分组</label>
<select class="select" id="pg<?= $pid ?>" name="group_id" data-no-drag>
<option value="">未分类</option>
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
<option value="<?= $gid ?>"
<?= (int)($p['group_id'] ?? 0) === $gid ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>移动</button>
</form>
<?php if (($p['roster_source'] ?? '') === 'kin'): ?>
<p class="small muted person-move" style="margin:4px 0 0">因家谱关系自动加入;解除关系后自动移出。</p>
<?php else: ?>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
</form>
<?php endif; ?>
</details>
<?php endif; ?>
</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 (\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 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();
})();
</script>
<?php endif; ?>