Files
FamilyBook/app/Person.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

423 lines
19 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
/**
* 家庭名册(家庭成员 + 外部联系人共用一张表)。
*
* 之所以不拆成两张表:两者字段几乎一样;而且需要互相转换 ——
* 一个「认识的人」随时可能变成家庭成员,一个家庭成员也可能只作为联系人存在。
*
* 三处刻意的设计(都是被纠正过的):
* · **小名不在这里**。称呼是"谁在叫"的属性,存在 nicknames 表(见 Nickname 类)。
* · 生日不是一个字段,是三个(身份证 / 阳历 / 农历),见 Birthday 类。
* · 姓名与称呼的读取都要过 `Nickname::display()`,页面不要直接 echo name。
*/
declare(strict_types=1);
namespace App;
final class Person
{
/**
* 名册列表(连带账号信息与「我叫他什么」)。
*
* `my_nickname` 是**当前登录者**给这个人起的称呼 —— 同一份名册,
* 爸爸看到的是"儿子",妈妈看到的是"小宝",靠的就是这个字段。
*/
public static function all(int $familyId, string $q = '', string $kind = 'member', int $viewerId = 0): array
{
$sql = 'SELECT p.*, a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE p.family_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $kind;
if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
$sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* **我的名册**:只列我自己的名册里的家庭成员(带分组与顺序)。
*
* 与 all() 的分工(v5 起两者不再是一回事):
* · all() —— 全家庭的成员,不管是谁的名册(首页徽标、邀请访客等内部用途);
* · roster() —— 名册页该显示的那些:我加的人 + 别人分享给我的,顺序是我的。
*
* 分组与顺序都从 person_roster 取(列名故意与老库的 persons.group_id /
* persons.sort_order 保持一致,调用方几乎不用改)。
*/
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
{
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM person_roster r
JOIN persons p ON p.id = r.person_id
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $viewerId;
$args[] = $kind;
if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
public static function find(int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
return $row ?: null;
}
/** 带账号信息的详情(详情页用;$viewerId 用来取「我叫他什么」) */
public static function findWithAccount(int $id, int $viewerId = 0): ?array
{
$st = Database::get()->prepare(
'SELECT p.*, a.id AS account_id, a.role AS account_role,
a.status AS account_status, a.last_login_at, n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.id = ?'
);
$st->execute([$viewerId, $id]);
$row = $st->fetch();
return $row ?: null;
}
public static function findByPhoneHash(string $hash): ?array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE phone_hash = ? LIMIT 1');
$st->execute([$hash]);
$row = $st->fetch();
return $row ?: null;
}
/**
* 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。
* @return int 新 id
*/
public static function create(int $familyId, array $d): int
{
$phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'INSERT INTO persons
(family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
phone_hash, phone_encrypted, id_card_encrypted, email, address, org, title, note,
is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql);
$st->execute([
$familyId,
$d['kind'] ?? 'member',
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
]);
return (int)Database::get()->lastInsertId();
}
public static function update(int $id, array $d): void
{
$phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'UPDATE persons SET
name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?, org = ?, title = ?, note = ?, is_placeholder = ?
WHERE id = ?';
Database::get()->prepare($sql)->execute([
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
$id,
]);
}
/**
* 自助改自己的资料。
*
* 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高;
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
* 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
*
* ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上,
* 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。
*/
public static function updateSelf(int $id, array $d): void
{
$bd = Birthday::normalize($d);
Database::get()->prepare(
'UPDATE persons SET
name = ?, gender = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
email = ?, address = ?, org = ?, title = ?, note = ?
WHERE id = ?'
)->execute([
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
$id,
]);
}
// ---------------- 补充联系方式(vCard 对齐,v6 起) ----------------
//
// 「主手机号」不在这里:它是登录凭据(phone_hash 唯一约束、按它找人),
// 永远留在 persons.phone_*。contacts 存的是**额外的**电话 / 邮箱 / 地址 / 网址。
// 电话与主手机号同一把钥匙加密存(value 是密文),展示时用 Crypto::maskPhone 打码。
/** 某个人的全部补充联系方式,按 kind 分组;kind=phone 的 value 是**密文** */
public static function contactsOf(int $personId): array
{
$st = Database::get()->prepare(
'SELECT kind, label, value FROM person_contacts
WHERE person_id = ? ORDER BY kind, position, id'
);
$st->execute([$personId]);
$out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']][] = ['label' => (string)$r['label'], 'value' => (string)$r['value']];
}
return $out;
}
/**
* 整组替换一个人的补充联系方式(delete + insert,幂等且不留下悬旧行)。
*
* @param string[][][] $byKind kind => 行列表,每行 ['label' => .., 'value' => ..]
* value 一律传**明文**;kind=phone 在这里统一加密。
* value 为空的行直接丢弃(表单会带出一排空行)。
*/
public static function replaceContacts(int $personId, array $byKind): void
{
$db = Database::get();
$ins = $db->prepare(
'INSERT INTO person_contacts (person_id, kind, label, value, position) VALUES (?,?,?,?,?)'
);
$db->prepare('DELETE FROM person_contacts WHERE person_id = ?')->execute([$personId]);
foreach (['phone', 'email', 'address', 'url'] as $kind) {
$pos = 100;
foreach (($byKind[$kind] ?? []) as $row) {
$value = trim((string)($row['value'] ?? ''));
if ($value === '') {
continue; // 空行是表单带出来的占位,不是数据
}
$label = mb_substr(trim((string)($row['label'] ?? '')), 0, 20, 'UTF-8');
if ($kind === 'phone') {
$value = Crypto::encrypt(mb_substr($value, 0, 60, 'UTF-8'));
} else {
$value = mb_substr($value, 0, 500, 'UTF-8');
}
$ins->execute([$personId, $kind, $label, $value, $pos]);
$pos += 10;
}
}
}
/** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
public static function setAvatar(int $id, ?string $avatar): void
{
Database::get()->prepare('UPDATE persons SET avatar = ? WHERE id = ?')
->execute([$avatar, $id]);
}
/**
* 只改头像主色。
*
* ⚠️ 空串**不写库**:算不出主色(灰片/纯色证件照)时应该让它保持 NULL,
* 让 AvatarColor::for() 落到「按姓名取色」的兜底上 ——
* 这时候才不会每次换图颜色乱跳。
*/
public static function setAvatarColor(int $id, ?string $hex): void
{
$hex = AvatarColor::normalize($hex);
if ($hex === '') {
return;
}
Database::get()->prepare('UPDATE persons SET avatar_color = ? WHERE id = ?')
->execute([$hex, $id]);
}
public static function delete(int $id): void
{
Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]);
}
/** 全部家庭成员,以 id 为键 —— 家谱推导要按 id 频繁取人,这样最省事 */
public static function map(int $familyId, string $kind = 'member'): array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE family_id = ? AND kind = ?');
$st->execute([$familyId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** 只给「选人」用的精简列表(下拉框、关系选择器),带「我叫他什么」 */
public static function options(int $familyId, int $exceptId = 0, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder, n.nickname AS my_nickname
FROM persons p
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
ORDER BY p.id ASC'
);
$st->execute([$viewerId, $familyId, $exceptId]);
return $st->fetchAll();
}
/**
* 最近要过生日的人(首页用)。
*
* ⚠️ 这里返回的是「**一个生日事件**」而不是「一个人」:
* 一个人可能同时有身份证生日与农历生日两条(不重合时),
* 也可能它们正好落在同一天(那就是 1 条,kinds 里有两个)。
* 早先按"人"来算,于是农历生日完全不会被提醒 —— 而家里老人过的正是农历。
*/
public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$out = [];
foreach (self::map($familyId) as $p) {
foreach (Birthday::nextOccurrences($p, $today, $withinDays) as $occ) {
$out[] = [
'id' => (int)$p['id'],
'name' => (string)$p['name'],
'avatar' => $p['avatar'],
'date' => $occ['date'],
'days_left' => $occ['days'],
'kinds' => $occ['kinds'],
'kind_words' => array_map(fn($k) => Birthday::KINDS[$k] ?? $k, $occ['kinds']),
'lunar_label' => $occ['lunar_label'],
'age' => $occ['age'],
];
}
}
usort($out, function ($a, $b) {
return $a['days_left'] <=> $b['days_left'] ?: strcmp($a['date'], $b['date']);
});
return $out;
}
/** 校验表单,返回错误数组(空数组表示通过) */
public static function validate(array $d): array
{
$err = [];
if (trim((string)($d['name'] ?? '')) === '') {
$err['name'] = '姓名不能为空';
}
$phone = trim((string)($d['phone'] ?? ''));
if ($phone !== '' && !preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$err['phone'] = '手机号格式看起来不对';
}
$email = trim((string)($d['email'] ?? ''));
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$err['email'] = '邮箱格式看起来不对';
}
// 生日现在是「日期 + 时分秒」,但要宽松:只填日期也必须收下
foreach (['birthday_solar' => '阳历生日', 'birthday_lunar' => '农历生日'] as $key => $word) {
$v = trim((string)($d[$key] ?? ''));
if ($v === '') {
continue;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}([ ]\d{2}:\d{2}(:\d{2})?)?$/', $v)) {
$err[$key] = $word . '格式不对';
continue;
}
[$yy, $mm, $dd] = array_map('intval', explode('-', substr($v, 0, 10)));
if (!checkdate($mm, $dd, $yy) || $yy < Lunar::MIN_YEAR || $yy > Lunar::MAX_YEAR) {
$err[$key] = $word . '看起来不是一个真实的日子';
}
}
// 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思
$idc = trim((string)($d['id_card'] ?? ''));
if ($idc !== '' && !preg_match('/^\d{17}[\dXx]$|^\d{15}$/', $idc)) {
$err['id_card'] = '身份证号应为 18 位(或 15 位老证)';
}
$rank = $d['birth_rank'] ?? '';
if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) {
$err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)';
}
$slogan = trim((string)($d['slogan'] ?? ''));
if (mb_strlen($slogan, 'UTF-8') > 200) {
$err['slogan'] = '一句话请控制在 200 字以内';
}
return $err;
}
/** 角色与人物的中文名 */
public static function genderWord(string $g): string
{
return ['m' => '男', 'f' => '女'][$g] ?? '';
}
}