Files
FamilyBook/templates/people/show.php
T
laoyang 1c460a0e83 图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单
① 上传图片一律可裁剪,比例由图位决定
   新增 app/ImageSlot.php 统一配置(头像 1:1/512×512;题图 16:9 只留注释,
   将来启用只加一行)。前端只挑区域、写回归一化矩形 "x,y,w,h",真正裁剪在服务端
   Upload::fitRegion():夹回图内 → 在框内居中收缩到目标比例(不外扩)→ 钉住 sx+sw ≤ w。
   比例解析单独写 parseRatioAttr():parseFloat('16:9') 得 16,会把题图裁成细长条且不报错。

② 名册按关系分组,顺序每人可自定
   分类全家共用(person_groups,另一个人可以自己加分类);「自己的」只体现在顺序上
   (user_settings.group_order,每人一份)。persons.group_id 故意不加外键(老库只能
   ADD COLUMN),完整性由 PersonGroup 保证:删分类把人置回未分类,人不会从名册消失。

③ 设置分两级:系统设置 / 个人设置
   个人设置拆成 templates/settings/me.php + _profile.php + _nicknames.php,
   取代 templates/people/me.php(旧路由与旧表单仍兼容)。

④ 右上角「姓名/头像 + 下拉菜单」
   原来是两个裸链接,窄屏会被 CSS 藏掉导致改密码/退出不可达;改成 <details> 零 JS 菜单
   (app.js 只补「点外面 / Esc」)。菜单项:个人设置 / 系统设置(管理员)/ 修改密码 / 退出登录。

Schema 版本 3 → 4;安装与老库升级共用 PersonGroup::backfillMissing() 一条回填路径。

验证:php -l 全量;e2e 202/0(新增「关系分组」与「图片裁剪」两节);老库升级 49/0;
桌面几何 108/0;移动端几何 192/0(360/375/390/414);交互 76/0;
反向验证 31/31(逐条把修复改回旧写法,指定的目标断言必须变红);
部署后本地↔线上 72 个文件 sha256 逐字节一致。
2026-09-30 11:06:22 +08:00

449 lines
20 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
/**
* 成员详情 —— 同时是「以我为中心」的呈现页与亲属关系的录入入口。
*
* 页面上每个人名旁边都带着「我该怎么称呼他」,同一个页面
* 爸爸看到「我的儿子」,爱人看到「我的丈夫」,全靠 Kinship 现算。
*/
$age = age_from(\App\Birthday::solarOf($person));
$admin = \App\Auth::isAdmin();
$canW = \App\Auth::canWrite();
$pid = (int)$person['id'];
$p = $person;
$isSelf = (int)$p['id'] === (int)\App\Auth::personId();
$role = (string)($p['account_role'] ?? '');
// ⚠️ 显示名一律走「我怎么称呼他」,不要直接 echo 姓名:
// 同一个「小明」,妈妈看到的是"明明"、姐姐看到的是"小弟"。
// 这里一次性把「我设过的称呼」取出来做本地查表,避免每个节点一次 SQL。
$nickMap = $isSelf ? [] : \App\Nickname::mapFor((int)$meId);
$nameOf = function (int $id) use ($people, $pid, $nickMap): string {
$real = (string)($people[$id]['name'] ?? '(已移除)');
if ($id === $pid) {
return $real; // 我自己看到自己,永远用本名
}
$nk = (string)($nickMap[$id] ?? '');
return $nk !== '' ? $nk : $real;
};
// 当前这个人在我眼里叫什么(页面上所有"给他看的人名"都用它)
$pShown = $isSelf ? (string)$p['name'] : (string)($shown !== '' ? $shown : $p['name']);
$isMyNick = !$isSelf && $pShown !== (string)$p['name'];
// 当前这个人的父母/配偶/子女(用于在录入表单里显示已填的内容)
$theirParents = \App\Kinship::parents($graph, $pid);
$theirSpouses = \App\Kinship::spouseIds($graph, $pid);
$theirKids = \App\Kinship::childIds($graph, $pid);
$theirSibs = \App\Kinship::siblingIds($graph, $pid);
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
?>
<div class="page-head">
<h1><?= e($pShown) ?></h1>
<span class="sub">
<?php if ($isSelf): ?>
这是你自己
<?php else: ?>
<span class="rel-label"><?= e($labelToMe) ?></span>
<?php if ($isMyNick): ?>
· 本名 <?= e((string)$p['name']) ?>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
</span>
<div class="actions">
<?php if ($isSelf): ?>
<a class="btn btn-sm" href="<?= e(url('settings/me')) ?>#me-profile">改我自己的资料</a>
<?php endif; ?>
<?php if ($canW): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a>
<?php endif; ?>
<?php if ($admin): ?>
<form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline"
onsubmit="return confirm('确定要从名册里移除 <?= e($p['name']) ?> 吗?相关的账号与亲属关系也会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $pid ?>">
<button class="btn btn-danger btn-sm" type="submit">移除</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if ($isSelf && !empty($p['slogan'])): ?>
<p class="motto-line">“<?= e((string)$p['slogan']) ?>”</p>
<?php endif; ?>
<div class="detail">
<aside class="detail-photo">
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
<div class="name"><?= e($pShown) ?></div>
<?php if ($isMyNick): ?><div class="sub">本名 <?= e((string)$p['name']) ?></div><?php endif; ?>
<div class="sub">
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
家庭管理员
<?php elseif ($role === 'guest'): ?>
访客(只读)
<?php elseif (!empty($p['account_id'])): ?>
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
<?php else: ?>
还没有开通登录
<?php endif; ?>
</div>
</aside>
<div>
<div class="card card-pad">
<dl class="kv">
<div class="kv-row"><dt>姓名</dt><dd><?= e((string)$p['name']) ?></dd></div>
<div class="kv-row">
<dt>性别</dt>
<dd><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '未填写') ?></dd>
</div>
<?php if (!empty($p['birth_rank'])): ?>
<div class="kv-row"><dt>家中排行</dt><dd>第 <?= (int)$p['birth_rank'] ?> 个孩子</dd></div>
<?php endif; ?>
<!-- ============ 三个生日 ============ -->
<div class="kv-row">
<dt>生日</dt>
<dd>
<?php if (!$birthdays): ?>
<span class="muted">未填写</span>
<?php else: ?>
<ul class="bd-list">
<?php foreach ($birthdays as $b): ?>
<li>
<span class="bd-kind bd-kind-<?= e((string)$b['kind']) ?>">
<?= e($shortKind[$b['kind']] ?? (string)$b['kind']) ?>
</span>
<span class="bd-val"><?= e((string)$b['text']) ?></span>
<?php if ((string)$b['note'] !== ''): ?>
<span class="muted small">(<?= e((string)$b['note']) ?>)</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($age !== null): ?>
<div class="muted small" style="margin-top:4px">今年 <?= (int)$age ?> 岁</div>
<?php endif; ?>
<?php endif; ?>
</dd>
</div>
<?php if ($birthdayNext): ?>
<div class="kv-row">
<dt>接下来</dt>
<dd>
<?php foreach ($birthdayNext as $n): ?>
<?php
$words = [];
foreach ((array)$n['kinds'] as $k) {
$words[] = $shortKind[$k] ?? (string)$k;
}
?>
<div>
<?= e((string)$n['date']) ?> ·
<?php if ((int)$n['days'] === 0): ?>
<b style="color:var(--brand)">今天</b>
<?php else: ?>
还有 <b><?= (int)$n['days'] ?></b> 天
<?php endif; ?>
<span class="muted small">
(<?= e(implode('、', $words)) ?><?= (string)$n['lunar_label'] !== '' ? ',农历' . e((string)$n['lunar_label']) : '' ?>)
</span>
</div>
<?php endforeach; ?>
</dd>
</div>
<?php endif; ?>
<div class="kv-row">
<dt>手机号</dt>
<dd class="mono"><?= e(\App\Crypto::maskPhone((string)($p['phone_encrypted'] ?? ''))) ?></dd>
</div>
<div class="kv-row">
<dt>身份证号</dt>
<dd class="mono"><?= e(\App\Crypto::maskIdCard((string)($p['id_card_encrypted'] ?? ''))) ?></dd>
</div>
<?php if (!empty($p['email'])): ?>
<div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div>
<?php endif; ?>
<?php if (!empty($p['address'])): ?>
<div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div>
<?php endif; ?>
<?php if (!empty($p['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($p['note'])) ?></dd></div>
<?php endif; ?>
<div class="kv-row">
<dt>最近登录</dt>
<dd><?= !empty($p['last_login_at']) ? e($p['last_login_at']) : '从未登录' ?></dd>
</div>
</dl>
</div>
<!-- ============ 我叫他/她什么(视角属性,只有我自己能改) ============ -->
<?php if (!$isSelf): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">
我怎么称呼 <?= e((string)$p['name']) ?>
<span class="hint">只影响我自己看到的</span>
</h2>
<form method="post" action="<?= e(url('nickname/save')) ?>" class="nick-inline">
<?= csrf_field() ?>
<input type="hidden" name="target_id" value="<?= $pid ?>">
<input type="hidden" name="back" value="people/show">
<input class="input" name="nickname" maxlength="50"
value="<?= e($myNickname) ?>" placeholder="留空 = 恢复成用姓名显示">
<button class="btn btn-sm" type="submit">记下</button>
</form>
<p class="small muted" style="margin:10px 0 0">
同一个「小明」,妈妈叫"明明"、姐姐叫"小弟" —— 称呼是<b>每个人各自定的</b>,
不是 TA 自带的属性。所以这里改的只影响你自己的页面,别人看到的还是他们自己的叫法。
</p>
</div>
<?php endif; ?>
<!-- ============ 亲属关系 ============ -->
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">
<?= e($pShown) ?> 的亲属
<span class="hint">关系是双向的,填一次两边都生效</span>
</h2>
<?php
// 已有关系一览(从「当前这个人」的视角)
$blocks = [
['父母', $theirParents['father'] ? [[$theirParents['father'], '父亲']] : [],
$theirParents['mother'] ? [[$theirParents['mother'], '母亲']] : []],
];
?>
<div class="rel-summary">
<div class="rel-sum-item">
<span class="rel-sum-k">父亲</span>
<span class="rel-sum-v">
<?php if ($theirParents['father']): ?>
<a href="<?= e(url('people/show', ['id' => $theirParents['father']])) ?>"><?= e($nameOf($theirParents['father'])) ?></a>
<?php else: ?><span class="muted">未填写</span><?php endif; ?>
</span>
</div>
<div class="rel-sum-item">
<span class="rel-sum-k">母亲</span>
<span class="rel-sum-v">
<?php if ($theirParents['mother']): ?>
<a href="<?= e(url('people/show', ['id' => $theirParents['mother']])) ?>"><?= e($nameOf($theirParents['mother'])) ?></a>
<?php else: ?><span class="muted">未填写</span><?php endif; ?>
</span>
</div>
<div class="rel-sum-item">
<span class="rel-sum-k">配偶</span>
<span class="rel-sum-v">
<?php if ($theirSpouses): ?>
<?php foreach ($theirSpouses as $s): ?>
<a href="<?= e(url('people/show', ['id' => $s])) ?>"><?= e($nameOf($s)) ?></a>
<?php endforeach; ?>
<?php else: ?><span class="muted">未填写</span><?php endif; ?>
</span>
</div>
<div class="rel-sum-item">
<span class="rel-sum-k">子女</span>
<span class="rel-sum-v">
<?php if ($theirKids): ?>
<?php foreach ($theirKids as $c): ?>
<a href="<?= e(url('people/show', ['id' => $c])) ?>"><?= e($nameOf($c)) ?></a>
<?php $rk = $people[$c]['birth_rank'] ?? null; ?>
<?php if ($rk): ?><span class="muted small">(第<?= (int)$rk ?>)</span><?php endif; ?>
<?php endforeach; ?>
<?php else: ?><span class="muted">未填写</span><?php endif; ?>
</span>
</div>
<div class="rel-sum-item">
<span class="rel-sum-k">兄弟姐妹</span>
<span class="rel-sum-v">
<?php if ($theirSibs): ?>
<?php foreach ($theirSibs as $s): ?>
<a href="<?= e(url('people/show', ['id' => $s])) ?>"><?= e($nameOf($s)) ?></a>
<?php endforeach; ?>
<span class="muted small">(由共同父母自动认出)</span>
<?php else: ?><span class="muted">暂无</span><?php endif; ?>
</span>
</div>
</div>
<?php if ($canW): ?>
<div class="rel-add">
<h3 class="rel-add-title">添加/修改关系</h3>
<form method="post" action="<?= e(url('kin/relate')) ?>" class="rel-form">
<?= csrf_field() ?>
<input type="hidden" name="from_id" value="<?= $pid ?>">
<div class="field">
<label>关系</label>
<select class="select" name="kind" id="relKind">
<option value="parent">是我的父母</option>
<option value="child">是我的子女</option>
<option value="spouse">是我的配偶</option>
</select>
</div>
<div class="field" id="relRoleWrap">
<label id="relRoleLabel">是哪一位</label>
<select class="select" name="role">
<option value="father">父亲</option>
<option value="mother">母亲</option>
</select>
</div>
<div class="field">
<label>对方</label>
<select class="select" name="to_id" required>
<option value="">— 选择一个人 —</option>
<?php foreach ($options as $o): ?>
<option value="<?= (int)$o['id'] ?>">
<?= e((string)$o['name']) ?><?= !empty($o['my_nickname']) ? '(' . e((string)$o['my_nickname']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field" style="justify-content:flex-end">
<button class="btn btn-sm" type="submit">记下这条关系</button>
</div>
</form>
<p class="small muted" style="margin:12px 0 0">
找不到人?先到「添加成员」把人建出来 —— 不确定真实姓名时,
起个「我的父亲」这样的名字也可以,之后再改。
</p>
</div>
<?php if ($theirRels): ?>
<details class="rel-list">
<summary>已有关系明细(<?= count($theirRels) ?> 条)</summary>
<ul>
<?php foreach ($theirRels as $r): ?>
<li>
<?php if ($r['kind'] === 'parent'): ?>
<?= e($nameOf((int)$r['from_id'])) ?>
(<?= $r['from_role'] === 'mother' ? '母亲' : '父亲' ?>)
→
<?= e($nameOf((int)$r['to_id'])) ?>
<?php else: ?>
<?= e($nameOf((int)$r['from_id'])) ?> 与 <?= e($nameOf((int)$r['to_id'])) ?> 是配偶
<?php endif; ?>
<form method="post" action="<?= e(url('kin/unrelate')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-danger btn-sm" type="submit">解除</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<?php else: ?>
<p class="small muted" style="margin:12px 0 0">你的身份是访客,只能查看亲属关系,不能修改。</p>
<?php endif; ?>
</div>
<!-- ============ 以这个人看出去的家谱 ============ -->
<?php if (!empty($theirTree['nodes']) && count($theirTree['nodes']) > 1): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">从 <?= e($pShown) ?> 的角度看 <span class="hint">上下各两代</span></h2>
<div class="mini-tree">
<?php foreach ($theirTree['generations'] as $level => $ids): ?>
<div class="mini-gen">
<?php foreach ($ids as $id): $n = $theirTree['nodes'][$id]; ?>
<a class="mini-node <?= (int)$id === $pid ? 'is-me' : '' ?>"
href="<?= e(url('people/show', ['id' => (int)$id])) ?>">
<img src="<?= e(avatar_url($n['avatar'])) ?>" alt="">
<span class="mn-name"><?= e($nameOf((int)$id)) ?></span>
<span class="mn-label"><?= e($n['label']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($admin): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">登录账号</h2>
<?php if (empty($p['account_id'])): ?>
<p class="small muted" style="margin:0 0 14px">
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录家书了。
</p>
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<div class="field">
<label>登录密码</label>
<input class="input" name="password" type="password" placeholder="至少 8 位,含字母和数字" required>
</div>
<div class="field">
<label>角色</label>
<select class="select" name="role">
<option value="member">家庭成员(可创建与编辑)</option>
<option value="admin">管理员(可改系统设置)</option>
<option value="guest">访客(只能查看)</option>
</select>
</div>
<div class="field full">
<button class="btn btn-sm" type="submit">开通账号</button>
</div>
</form>
<?php else: ?>
<p class="small muted" style="margin:0 0 14px">
账号已开通,登录名是 <?= e($p['name']) ?> 的手机号。
当前角色:<b><?= e(\App\Account::roleWord($role)) ?></b>
<?= $role === 'guest' ? '—— 只能查看,不能新增或修改任何内容。' : '' ?>
</p>
<div class="btn-row" style="margin-top:0;flex-wrap:wrap">
<?php if (!$isSelf): ?>
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<select class="select" name="role" style="width:auto;display:inline-block">
<option value="member" <?= $role === 'member' ? 'selected' : '' ?>>家庭成员</option>
<option value="admin" <?= $role === 'admin' ? 'selected' : '' ?>>管理员</option>
<option value="guest" <?= $role === 'guest' ? 'selected' : '' ?>>访客</option>
</select>
<button class="btn btn-ghost btn-sm" type="submit">改角色</button>
</form>
<form method="post" action="<?= e(url('accounts/status')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<input type="hidden" name="status" value="<?= $p['account_status'] === 'active' ? 'disabled' : 'active' ?>">
<button class="btn btn-ghost btn-sm" type="submit">
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
</button>
</form>
<?php else: ?>
<span class="small muted">这是你自己 —— 要改自己的权限,请让另一位管理员来操作。</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
// 关系表单的「是哪一位」下拉有**两种含义**:
// kind=parent → 对方是父亲/母亲(填的是对方)
// kind=child → 我是孩子的父亲/母亲(填的是自己)
// 所以标签必须跟着换,否则用户会以为在填对方的身份,把关系记反。
(function () {
var kind = document.getElementById('relKind');
var wrap = document.getElementById('relRoleWrap');
var lab = document.getElementById('relRoleLabel');
if (!kind || !wrap || !lab) return;
function sync() {
if (kind.value === 'spouse') {
wrap.style.display = 'none';
return;
}
wrap.style.display = '';
lab.textContent = kind.value === 'child' ? '我是孩子的' : '对方是我的';
}
kind.addEventListener('change', sync);
sync();
})();
</script>