Files
FamilyBook/templates/people/index.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

226 lines
11 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);
$pNick = (string)($p['my_nickname'] ?? '');
$pid = (int)$p['id'];
?>
<div class="person" data-person="<?= $pid ?>" <?= $canMove ? 'draggable="true"' : '' ?>>
<?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">
<?= e($pshow) ?>
<?php if ($pid === (int)$meId): ?><span class="badge badge-me">我</span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div>
<div class="pmeta">
<?php if (!empty($p['label']) && $p['label'] !== '我'): ?>
<span class="rel-label"><?= e($p['label']) ?></span>
<?php endif; ?>
<?php
$r = $p['account_role'] ?? '';
if ($r === 'admin' && ($p['account_status'] ?? '') === 'active') {
echo '<span class="badge badge-admin">管理员</span>';
} elseif ($r === 'guest') {
echo '<span class="badge">访客</span>';
} elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>';
}
// 我叫他别的名字时,把本名再用小字附上,免得自己都认不出这人是谁
if ($pNick !== '' && $pNick !== (string)$p['name']) {
echo ' · <span class="muted">' . e((string)$p['name']) . '</span>';
}
// v7:因家谱关系自动进名册的人,标出来历 ——
// 不然"我没加过他,他怎么在我名册里"会让人怀疑数据坏了
if (($p['roster_source'] ?? '') === 'kin') {
echo ' <span class="badge" title="他在你的家谱树上(配偶/父母/子女/祖孙…),自动加入你的名册">家谱</span>';
}
?>
</div>
</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 ?>">
<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 if ($canMove && $q === '' && $total > 0): ?>
<p class="small muted roster-tip" style="margin:0 0 12px">
拖动卡片(左边那个 <span class="drag-handle-inline">⠿</span>)就能改顺序,
拖到别的一栏里就是改分组。改的是<b>你自己那份名册</b>,不影响家里其他人。
</p>
<?php endif; ?>
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="people">
<input class="input" name="q" value="<?= e($q) ?>" placeholder="按姓名、称呼或邮箱搜索">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($q !== ''): ?>
<a class="link-quiet" href="<?= e(url('people')) ?>">清除</a>
<?php endif; ?>
</form>
<?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 elseif ($q !== ''): ?>
<?php /* 搜索时按"命中的人"平铺 —— 分栏会把一次搜索拆成一堆只有一两个人的碎块,反而更难看 */ ?>
<p class="small muted" style="margin:0 0 12px">
找到 <?= count($people) ?> 个匹配「<?= e($q) ?>」的人(搜索时不分组)。
</p>
<?php $renderGrid($people); ?>
<?php else: ?>
<div class="roster<?= $canMove ? ' is-droppable' : '' ?>"
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; ?>