五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式

1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」:
   新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。
2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」;
   同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。
3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键,
   云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。
4. 三种角色:admin / member / guest,访客只读、写操作一律 403;
   accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。
5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 ——
   兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹),
   认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。

顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、
birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、
layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。

验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 ·
反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
This commit is contained in:
2026-09-30 08:13:07 +08:00
parent 8eb19267bc
commit 1669ab48d0
30 changed files with 3034 additions and 161 deletions
+70 -25
View File
@@ -1,19 +1,37 @@
<?php
$memberCount = count($people);
$openCount = 0;
foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
/**
* 首页 —— 以「我」为中心。
*
* 同一份数据,爸爸登进来看到的是「我的父亲/我的儿子」,
* 爱人登进来看到的是同一批人,称呼却变成「公公/婆婆」。
* 称呼全部由 Kinship 现算,库里不存任何一次性称谓。
*/
$kinCount = count($kin);
$hasRel = $relCount > 0;
// 有亲属关系 → 只显示家人;还没建 → 退回显示全名册,别让首页空着
$list = $hasRel ? $kin : [];
if (!$list) {
foreach (\App\Person::all((int)\App\Auth::familyId()) as $p) {
$list[(int)$p['id']] = $p + ['label' => ''];
}
}
?>
<section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<div>
<h1><?= e($family['name']) ?></h1>
<p class="motto"><?= e($family['motto'] ?: '一本用岁月写成的家谱') ?></p>
<h1><?= e(\App\Auth::homeTitle()) ?></h1>
<p class="motto">
<?php if ($me): ?>
<?= e($family['name']) ?> · 这里的称呼都是按你的视角算的
<?php else: ?>
<?= e($family['motto'] ?: '一本用岁月写成的家谱') ?>
<?php endif; ?>
</p>
</div>
<div class="hero-stats">
<div class="stat"><b><?= (int)$memberCount ?></b><span>位家庭成员</span></div>
<div class="stat"><b><?= (int)$openCount ?></b><span>已开通登录</span></div>
<div class="stat"><b><?= count($birthdays) ?></b><span>近期生日</span></div>
<div class="stat"><b><?= (int)$kinCount ?></b><span>与我有关</span></div>
<div class="stat"><b><?= (int)$allCount ?></b><span>名册总数</span></div>
<div class="stat"><b><?= (int)$relCount ?></b><span>亲属连线</span></div>
</div>
</section>
@@ -23,9 +41,12 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
<div class="bdays">
<?php foreach ($birthdays as $b): ?>
<div class="bday">
<img src="<?= e(\App\Upload::avatarUrl($b['avatar'])) ?>" alt="">
<img src="<?= e(avatar_url($b['avatar'])) ?>" alt="">
<div>
<b><?= e($b['name']) ?></b>
<?php if (!empty($b['label']) && $b['label'] !== '我'): ?>
<span class="badge"><?= e($b['label']) ?></span>
<?php endif; ?>
<div class="d">
<?php if ($b['days_left'] === 0): ?>
就是今天,生日快乐
@@ -42,36 +63,60 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">家庭成员</h1>
<h1 style="font-size:15px"><?= $hasRel ? '我的家人' : '家庭成员' ?></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">管理名册</a>
<?php if (\App\Auth::isAdmin()): ?>
<a class="btn btn-sm" href="<?= e(url('people/new')) ?>">添加成员</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('kin')) ?>">我的家谱</a>
<?php if ($me): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/show', ['id' => (int)$me['id']])) ?>">补充亲属关系</a>
<?php endif; ?>
</div>
</div>
<?php if (!$people): ?>
<?php if (!$hasRel): ?>
<div class="empty">
<b>先把家人连起来</b>
目前还没有任何亲属关系,所以下面显示的是整个名册。<br>
到自己的资料页填上「谁是我的父母、配偶、子女」,之后这一页就只显示跟你有关的人,
每个人的称呼也会按你的视角来算。
<?php if ($me): ?>
<div class="btn-row" style="justify-content:center">
<a class="btn" href="<?= e(url('people/show', ['id' => (int)$me['id']])) ?>">开始连关系</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$list): ?>
<div class="empty">
<b>名册还是空的</b>
先把家里人加进来,之后他们就能出现在每一条记录里。
<div class="btn-row" style="justify-content:center">
<a class="btn" href="<?= e(url('people/new')) ?>">添加成员</a>
</div>
</div>
<?php else: ?>
<div class="people-grid">
<?php foreach ($people as $p): ?>
<?php foreach ($list as $p): ?>
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
<div class="ph"><img src="<?= e(\App\Upload::avatarUrl($p['avatar'])) ?>" alt=""></div>
<div class="ph"><img src="<?= e(avatar_url($p['avatar'] ?? '')) ?>" alt=""></div>
<div class="pb">
<div class="pname"><?= e($p['name']) ?></div>
<div class="pname">
<?= e($p['name']) ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div>
<div class="pmeta">
<?php if ($p['account_role'] === 'admin' && $p['account_status'] === 'active'): ?>
<span class="badge badge-admin">管理员</span>
<?php elseif (!empty($p['account_id'])): ?>
<span class="badge badge-open">已开通</span>
<?php else: ?>
<span class="badge">未开通</span>
<?php if (!empty($p['label'])): ?>
<span class="rel-label"><?= e($p['label']) ?></span>
<?php endif; ?>
<?php
$role = $p['account_role'] ?? '';
if ($role === 'admin' && ($p['account_status'] ?? '') === 'active') {
echo '<span class="badge badge-admin">管理员</span>';
} elseif ($role === 'guest') {
echo '<span class="badge">访客</span>';
} elseif (!empty($p['account_id'])) {
echo '<span class="badge badge-open">已开通</span>';
}
$age = age_from($p['birthday'] ?? '');
if ($age !== null) { echo ' · ' . (int)$age . ' 岁'; }
?>
@@ -81,4 +126,4 @@ foreach ($people as $p) { if (!empty($p['account_id'])) { $openCount++; } }
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</section>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="auth-wrap">
<div class="auth-card">
<img class="logo" src="/assets/icons/icon-192.png" alt="">
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<h1>安装「家书」</h1>
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* 我的家谱:以我为中心,向上向下各三代。
*
* 只做「呈现 + 说清楚推导规则」两件事 —— 关系的录入在成员详情页,
* 因为那里有具体的人,比在一张图上点来点去更好理解。
*/
$gens = $tree['generations'];
$nodes = $tree['nodes'];
$me = $nodes[$meId] ?? null;
// 代数 → 人类说法
$genWord = function (int $level): string {
if ($level === 0) {
return '我这一辈'; // ⚠️ 必须先判 0:它既不 <0 也不在 $down 里,
// 漏判就会显示成「下 0 代」这种没人看得懂的东西
}
if ($level < 0) {
$up = [-3 => '曾祖辈', -2 => '祖辈', -1 => '父母辈'];
return $up[$level] ?? '上 ' . abs($level) . ' 代';
}
$down = [1 => '子女辈', 2 => '孙辈', 3 => '曾孙辈'];
return $down[$level] ?? '下 ' . $level . ' 代';
};
?>
<div class="page-head">
<h1>我的家谱</h1>
<span class="sub">以 <?= e(\App\Auth::name()) ?> 为中心 · 上下各三代</span>
</div>
<?php if (!$nodes || count($nodes) <= 1): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<div class="tree">
<?php foreach ($gens as $level => $ids): ?>
<div class="tree-gen">
<div class="tree-gen-label">
<span class="gname"><?= e($genWord((int)$level)) ?></span>
<span class="gtag"><?= (int)$level === 0 ? '中心' : ((int)$level < 0 ? '上 ' . abs((int)$level) . ' 代' : '下 ' . (int)$level . ' 代') ?></span>
</div>
<div class="tree-row">
<?php foreach ($ids as $id): $n = $nodes[$id]; ?>
<a class="tnode <?= (int)$id === $meId ? 'is-me' : '' ?> <?= $n['ph'] ? 'is-placeholder' : '' ?>"
href="<?= e(url('people/show', ['id' => $n['id']])) ?>"
title="<?= e($n['name'] . ' · ' . $n['label']) ?>">
<span class="tph"><img src="<?= e(avatar_url($n['avatar'])) ?>" alt=""></span>
<span class="tname"><?= e($n['name']) ?></span>
<span class="tlabel"><?= e($n['label']) ?></span>
<?php if ($n['ph']): ?><span class="tph-mark">待确认</span><?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php if ((int)$level !== (int)array_key_last($gens)): ?>
<div class="tree-link"></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="card card-pad" style="margin-top:24px">
<h2 class="section-title">这张图是怎么来的 <span class="hint">只存两代,其余推导</span></h2>
<p class="small muted" style="margin:0 0 10px">
系统里只记录**相邻两代之间**的关系:谁是谁的父母、谁和谁是配偶。
至于「奶奶是谁」,是顺着「我的父亲 → 我父亲的母亲」两级边推出来的。
这样每条关系只需维护一次,同一个人在每个人眼里还会自动变成不同的称呼。
</p>
<p class="small muted" style="margin:0">
兄弟姐妹也不单独存档 —— 只要你们有同一对父母,系统就会把你们认成一家人,
并按排行显示成哥哥/姐姐/弟弟/妹妹、长子/次女。
</p>
</div>
<div class="btn-row">
<a class="btn btn-ghost" href="<?= e(url('people/show', ['id' => $meId])) ?>">补充我的亲属关系</a>
<a class="btn btn-ghost" href="<?= e(url('people')) ?>">看整个名册</a>
</div>
<?php endif; ?>
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* 独立页骨架(登录 / 安装 / 错误提示)。
*
* ⚠️ 这个文件存在的唯一理由:renderBare() 早先只 require 内容模板,
* 输出里没有 <head> 与 CSS 链接 —— 浏览器只能拿默认样式渲染,
* 于是登录页看起来「完全没样式」。裸页也必须是一份完整的 HTML 文档。
*
* 变量:$pageTitle / $tplFile / $extraHead(均由 View::renderBare 提供)
*/
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · 家书</title>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#A84A28">
<link rel="stylesheet" href="<?= e(asset('/assets/css/app.css')) ?>">
<?= $extraHead ?? '' ?>
</head>
<body>
<?php require $tplFile; ?>
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
</body>
</html>
+10 -3
View File
@@ -1,12 +1,18 @@
<?php
$navItems = [
'home' => ['首页', 'home'],
'kin' => ['家谱', 'kin'],
'people' => ['名册', 'people'],
];
if (\App\Auth::isAdmin()) {
$navItems['settings'] = ['设置', 'settings'];
}
$me = \App\Auth::check();
// ⚠️ 变量名不要叫 $me:layout 是在 extract($data) **之后**被 require 的,
// 这里再赋值会把页面传进来的 $me(当前登录者的完整记录)覆盖掉。
// home.php 用 $me 画「补充亲属关系」链接,被覆盖成 true 之后
// 链接就变成 people/show&id=0,还顺带在页首打出一条 Warning。
// 改名前请先确认所有模板都没有依赖这个布尔值。
$isLoggedIn = \App\Auth::check();
?>
<!DOCTYPE html>
<html lang="zh-CN">
@@ -22,7 +28,7 @@ $me = \App\Auth::check();
</head>
<body>
<?php if ($me): ?>
<?php if ($isLoggedIn): ?>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="<?= e(url('home')) ?>">
@@ -36,8 +42,9 @@ $me = \App\Auth::check();
</nav>
<div class="topbar-right">
<span class="who">
<img src="<?= e(\App\Upload::avatarUrl($_SESSION['avatar'] ?? null)) ?>" alt="">
<img src="<?= e(avatar_url($_SESSION['avatar'] ?? null)) ?>" alt="">
<span class="who-name"><?= e(\App\Auth::name()) ?></span>
<?php if (\App\Auth::isGuest()): ?><span class="badge">访客</span><?php endif; ?>
</span>
<a class="link-quiet who-pwd" href="<?= e(url('auth/password')) ?>">改密码</a>
<a class="link-quiet" href="<?= e(url('auth/logout', ['_csrf' => csrf_token()])) ?>">退出</a>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="auth-wrap">
<div class="auth-card">
<img class="logo" src="/assets/icons/icon-192.png" alt="">
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<h1><?= e($familyName) ?></h1>
<p class="lead">用手机号登录你的家书。</p>
+23 -1
View File
@@ -1,6 +1,6 @@
<?php
$isEdit = !empty($person['id']);
$avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
$avatarUrl = avatar_url($person['avatar'] ?? null);
?>
<div class="page-head">
@@ -48,6 +48,14 @@ $avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
<?php if (!empty($errors['birthday'])): ?><span class="err"><?= e($errors['birthday']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="birth_rank">家中排行</label>
<input class="input" id="birth_rank" name="birth_rank" type="number" min="1" max="99"
value="<?= e(old('birth_rank', $person['birth_rank'] ?? '')) ?>" placeholder="老大填 1">
<span class="tip">用来认兄弟姐妹的长幼:长子、次女、三弟…</span>
<?php if (!empty($errors['birth_rank'])): ?><span class="err"><?= e($errors['birth_rank']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="phone">手机号</label>
<input class="input" id="phone" name="phone" inputmode="numeric"
@@ -103,6 +111,20 @@ $avatarUrl = \App\Upload::avatarUrl($person['avatar'] ?? null);
<span class="tip">小的排前面。</span>
</div>
<div class="field full">
<label class="check-item">
<input type="checkbox" name="is_placeholder" value="1"
<?= old('is_placeholder', $person['is_placeholder'] ?? 0) ? 'checked' : '' ?>>
<span>
<b>这是「虚拟人物」</b><br>
<span class="muted small">
还没弄清真实身份时先占个位,比如起名「我的父亲」「外婆」。
他能参与亲属关系,等弄清真名再改过来即可;家谱上会标「待确认」。
</span>
</span>
</label>
</div>
</div>
<div class="btn-row">
+17 -8
View File
@@ -26,17 +26,26 @@
<div class="people-grid">
<?php foreach ($people as $p): ?>
<a class="person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>">
<div class="ph"><img src="<?= e(\App\Upload::avatarUrl($p['avatar'])) ?>" alt=""></div>
<div class="ph"><img src="<?= e(avatar_url($p['avatar'])) ?>" alt=""></div>
<div class="pb">
<div class="pname"><?= e($p['name']) ?></div>
<div class="pname">
<?= e($p['name']) ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">待确认</span><?php endif; ?>
</div>
<div class="pmeta">
<?php if ($p['account_role'] === 'admin' && $p['account_status'] === 'active'): ?>
<span class="badge badge-admin">管理员</span>
<?php elseif (!empty($p['account_id'])): ?>
<span class="badge badge-open">已开通</span>
<?php else: ?>
<span class="badge">未开通</span>
<?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>';
}
?>
<?php if (!empty($p['nickname'])): ?> · <?= e($p['nickname']) ?><?php endif; ?>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
<div class="page-head">
<h1>邀请访客</h1>
<span class="sub">朋友只读 —— 能看,不能改</span>
</div>
<div class="card card-pad">
<h2 class="section-title">三种身份 <span class="hint">决定能看到多少、能不能改</span></h2>
<div class="role-cards">
<div class="role-card">
<div class="rc-name">管理员</div>
<p class="rc-desc">一个家庭通常只有一位。改家庭名称、附件存哪儿、谁能登录,都在他手里。</p>
</div>
<div class="role-card">
<div class="rc-name">家庭成员</div>
<p class="rc-desc">正常使用的人。可以建自己的通讯录、日历本,维护家里的名册与亲属关系。</p>
</div>
<div class="role-card">
<div class="rc-name">访客</div>
<p class="rc-desc">一起旅行、一起看过电影的朋友。能登录进来看,但改不了任何东西,也不出现在家谱里。</p>
</div>
</div>
</div>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">开通一个访客账号</h2>
<?php if (!empty($errors['_'] ?? null)): ?>
<div class="flash"><div class="flash-item err"><?= e($errors['_']) ?></div></div>
<?php endif; ?>
<form method="post" action="<?= e(url('accounts/invite_create')) ?>">
<?= csrf_field() ?>
<div class="form-grid">
<div class="field">
<label for="v_name">对方的名字</label>
<input class="input" id="v_name" name="name" value="<?= e(old('name')) ?>" required>
<?php if (!empty($errors['name'])): ?><span class="err"><?= e($errors['name']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="v_phone">手机号(登录名)</label>
<input class="input" id="v_phone" name="phone" value="<?= e(old('phone')) ?>"
inputmode="numeric" required>
<?php if (!empty($errors['phone'])): ?><span class="err"><?= e($errors['phone']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="v_password">初始密码</label>
<input class="input" id="v_password" name="password" type="password" required>
<span class="tip">至少 8 位,含字母和数字。把手机号和密码告诉他即可登录。</span>
<?php if (!empty($errors['password'])): ?><span class="err"><?= e($errors['password']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="v_note">备注</label>
<input class="input" id="v_note" name="note" value="<?= e(old('note')) ?>"
placeholder="比如:2026 年一起去青海的朋友">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">开通访客账号</button>
</div>
</form>
</div>
<?php
// 已有访客
$guests = [];
foreach ($people as $pp) {
if (($pp['account_role'] ?? '') === 'guest') {
$guests[] = $pp;
}
}
?>
<?php if ($guests): ?>
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">已有访客 <span class="hint"><?= count($guests) ?> 位</span></h2>
<ul class="plain-list">
<?php foreach ($guests as $g): ?>
<li>
<a href="<?= e(url('people/show', ['id' => (int)$g['id']])) ?>"><?= e($g['name']) ?></a>
<span class="muted small">
<?= e(\App\Crypto::maskPhone((string)($g['phone_encrypted'] ?? ''))) ?>
· <?= $g['account_status'] === 'active' ? '正常' : '已停用' ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<p class="small muted" style="margin:14px 0 0">
要停用或删除,点进对方的名字,在「登录账号」里操作。
</p>
</div>
<?php endif; ?>
+264 -46
View File
@@ -1,21 +1,48 @@
<?php
$age = age_from($person['birthday'] ?? '');
$isMe = (int)$person['id'] === (int)\App\Auth::personId();
$admin = \App\Auth::isAdmin();
/**
* 成员详情 —— 同时是「以我为中心」的呈现页与亲属关系的录入入口。
*
* 页面上每个人名旁边都带着「我该怎么称呼他」,同一个页面
* 爸爸看到「我的儿子」,爱人看到「我的丈夫」,全靠 Kinship 现算。
*/
$age = age_from($person['birthday'] ?? '');
$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'] ?? '');
$nameOf = function (int $id) use ($people): string {
return (string)($people[$id]['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);
?>
<div class="page-head">
<h1><?= e($person['name']) ?></h1>
<h1><?= e($p['name']) ?></h1>
<span class="sub">
<?php if (!empty($person['nickname'])): ?>小名 <?= e($person['nickname']) ?><?php endif; ?>
<?php if ($labelToMe !== '我'): ?>
<span class="rel-label"><?= e($labelToMe) ?></span>
<?php else: ?>
这是你自己
<?php endif; ?>
<?php if (!empty($p['nickname'])): ?> · 小名 <?= e($p['nickname']) ?><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
</span>
<div class="actions">
<?php if ($canW): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => $pid])) ?>">编辑资料</a>
<?php endif; ?>
<?php if ($admin): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/edit', ['id' => (int)$person['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('people/destroy')) ?>" style="display:inline"
onsubmit="return confirm('确定要从名册里移除 <?= e($person['name']) ?> 吗?相关的账号也会一起删掉。')">
onsubmit="return confirm('确定要从名册里移除 <?= e($p['name']) ?> 吗?相关的账号与亲属关系也会一起删掉。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$person['id'] ?>">
<input type="hidden" name="id" value="<?= $pid ?>">
<button class="btn btn-danger btn-sm" type="submit">移除</button>
</form>
<?php endif; ?>
@@ -24,13 +51,15 @@ $admin = \App\Auth::isAdmin();
<div class="detail">
<aside class="detail-photo">
<img src="<?= e(\App\Upload::avatarUrl($person['avatar'])) ?>" alt="">
<div class="name"><?= e($person['name']) ?></div>
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
<div class="name"><?= e($p['name']) ?></div>
<div class="sub">
<?php if ($person['account_role'] === 'admin' && $person['account_status'] === 'active'): ?>
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
家庭管理员
<?php elseif (!empty($person['account_id'])): ?>
已开通登录<?= $person['account_status'] === 'disabled' ? '(已停用)' : '' ?>
<?php elseif ($role === 'guest'): ?>
访客(只读)
<?php elseif (!empty($p['account_id'])): ?>
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
<?php else: ?>
还没有开通登录
<?php endif; ?>
@@ -40,23 +69,24 @@ $admin = \App\Auth::isAdmin();
<div>
<div class="card card-pad">
<dl class="kv">
<div class="kv-row">
<dt>姓名</dt><dd><?= e($person['name']) ?></dd>
</div>
<?php if (!empty($person['nickname'])): ?>
<div class="kv-row"><dt>小名</dt><dd><?= e($person['nickname']) ?></dd></div>
<div class="kv-row"><dt>姓名</dt><dd><?= e($p['name']) ?></dd></div>
<?php if (!empty($p['nickname'])): ?>
<div class="kv-row"><dt>小名</dt><dd><?= e($p['nickname']) ?></dd></div>
<?php endif; ?>
<div class="kv-row">
<dt>性别</dt>
<dd><?= $person['gender'] === 'm' ? '男' : ($person['gender'] === 'f' ? '女' : '未填写') ?></dd>
<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 (!empty($person['birthday'])): ?>
<?= e($person['birthday']) ?>
<?php if (!empty($p['birthday'])): ?>
<?= e($p['birthday']) ?>
<?php if ($age !== null): ?> · <?= (int)$age ?> 岁<?php endif; ?>
<?php $d = days_until_birthday($person['birthday']); ?>
<?php $d = days_until_birthday($p['birthday']); ?>
<?php if ($d === 0): ?> · <span style="color:var(--brand)">今天生日</span>
<?php elseif ($d !== null): ?> · 还有 <?= (int)$d ?> 天<?php endif; ?>
<?php else: ?>
@@ -66,39 +96,199 @@ $admin = \App\Auth::isAdmin();
</div>
<div class="kv-row">
<dt>手机号</dt>
<dd class="mono"><?= e(\App\Crypto::maskPhone($person['phone_encrypted'] ?? '')) ?></dd>
<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($person['id_card_encrypted'] ?? '')) ?></dd>
<dd class="mono"><?= e(\App\Crypto::maskIdCard((string)($p['id_card_encrypted'] ?? ''))) ?></dd>
</div>
<?php if (!empty($person['email'])): ?>
<div class="kv-row"><dt>邮箱</dt><dd><?= e($person['email']) ?></dd></div>
<?php if (!empty($p['email'])): ?>
<div class="kv-row"><dt>邮箱</dt><dd><?= e($p['email']) ?></dd></div>
<?php endif; ?>
<?php if (!empty($person['address'])): ?>
<div class="kv-row"><dt>住址</dt><dd><?= e($person['address']) ?></dd></div>
<?php if (!empty($p['address'])): ?>
<div class="kv-row"><dt>住址</dt><dd><?= e($p['address']) ?></dd></div>
<?php endif; ?>
<?php if (!empty($person['note'])): ?>
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e($person['note'])) ?></dd></div>
<?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($person['last_login_at']) ? e($person['last_login_at']) : '从未登录' ?></dd>
<dd><?= !empty($p['last_login_at']) ? e($p['last_login_at']) : '从未登录' ?></dd>
</div>
</dl>
</div>
<!-- ============ 亲属关系 ============ -->
<div class="card card-pad" style="margin-top:16px">
<h2 class="section-title">
<?= e($p['name']) ?> 的亲属
<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($o['name']) ?><?= !empty($o['nickname']) ? '(' . e($o['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($p['name']) ?> 的角度看 <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($n['name']) ?></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($person['account_id'])): ?>
<?php if (empty($p['account_id'])): ?>
<p class="small muted" style="margin:0 0 14px">
开通后,<?= e($person['name']) ?> 就能用手机号和密码登录家书了。
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录家书了。
</p>
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= (int)$person['id'] ?>">
<input type="hidden" name="person_id" value="<?= $pid ?>">
<div class="field">
<label>登录密码</label>
<input class="input" name="password" type="password" placeholder="至少 8 位,含字母和数字" required>
@@ -106,8 +296,9 @@ $admin = \App\Auth::isAdmin();
<div class="field">
<label>角色</label>
<select class="select" name="role">
<option value="member">普通成员</option>
<option value="admin">管理员</option>
<option value="member">家庭成员(可创建与编辑)</option>
<option value="admin">管理员(可改系统设置)</option>
<option value="guest">访客(只能查看)</option>
</select>
</div>
<div class="field full">
@@ -116,24 +307,28 @@ $admin = \App\Auth::isAdmin();
</form>
<?php else: ?>
<p class="small muted" style="margin:0 0 14px">
账号已开通,登录名是 <?= e($person['name']) ?> 的手机号。
账号已开通,登录名是 <?= 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 (!$isMe): ?>
<?php if (!$isSelf): ?>
<form method="post" action="<?= e(url('accounts/role')) ?>" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= (int)$person['id'] ?>">
<input type="hidden" name="role" value="<?= $person['account_role'] === 'admin' ? 'member' : 'admin' ?>">
<button class="btn btn-ghost btn-sm" type="submit">
<?= $person['account_role'] === 'admin' ? '取消管理员' : '设为管理员' ?>
</button>
<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="<?= (int)$person['id'] ?>">
<input type="hidden" name="status" value="<?= $person['account_status'] === 'active' ? 'disabled' : 'active' ?>">
<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">
<?= $person['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
<?= $p['account_status'] === 'active' ? '停用账号' : '启用账号' ?>
</button>
</form>
<?php else: ?>
@@ -145,3 +340,26 @@ $admin = \App\Auth::isAdmin();
<?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>
+139 -1
View File
@@ -1,5 +1,6 @@
<form method="post" action="<?= e(url('settings/update')) ?>" class="card card-pad">
<?= csrf_field() ?>
<h2 class="section-title">家庭</h2>
<div class="form-grid">
<div class="field full">
<label for="name">家庭名称</label>
@@ -16,15 +17,152 @@
</div>
</form>
<!-- ==================== 附件存储 ==================== -->
<form method="post" action="<?= e(url('settings/storage')) ?>" class="card card-pad" style="margin-top:20px">
<?= csrf_field() ?>
<h2 class="section-title">
附件存储
<span class="hint">头像等文件存哪儿</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
数据库里只存「相对路径」,所以随时可以在本地与云端之间切换 ——
已上传的文件会按「本地优先」的顺序去找,切换后老文件照样能打开,出问题也能随时切回来。
</p>
<div class="field" style="margin-bottom:16px">
<label>存在哪里</label>
<div class="radio-row">
<label class="radio-item">
<input type="radio" name="driver" value="local" <?= $storageDriver === 'local' ? 'checked' : '' ?>>
<span><b>服务器本地</b><br><span class="muted small">最简单,文件和服务在一起。适合家里自己用。</span></span>
</label>
<label class="radio-item">
<input type="radio" name="driver" value="oss" <?= $storageDriver === 'oss' ? 'checked' : '' ?>>
<span><b>阿里云 OSS</b><br><span class="muted small">文件独立存放,换服务器不丢;也可以挂 CDN 加速。</span></span>
</label>
</div>
</div>
<?php if ($storageDriver === 'oss' && !$storageOk): ?>
<div class="flash"><div class="flash-item err">
云端配置还不完整,系统现在**临时用本地存储**在跑 —— 填全下面的信息再保存。
</div></div>
<?php elseif ($storageDriver === 'oss'): ?>
<div class="flash"><div class="flash-item">
当前生效:阿里云 OSS<?= $storageEffective === 'local' ? '(配置不完整,实际走的本地)' : '' ?>
</div></div>
<?php endif; ?>
<details class="oss-box" <?= $storageDriver === 'oss' ? 'open' : '' ?>>
<summary>阿里云 OSS 参数</summary>
<div class="form-grid" style="margin-top:14px">
<div class="field">
<label>凭据方式</label>
<select class="select" name="oss[auth_mode]">
<option value="ak" <?= $storageOss['auth_mode'] === 'ak' ? 'selected' : '' ?>>AccessKey(手动填)</option>
<option value="ecs_role" <?= $storageOss['auth_mode'] === 'ecs_role' ? 'selected' : '' ?>>服务器绑定角色(更安全)</option>
</select>
<span class="tip">若这台服务器是阿里云 ECS 且绑了 RAM 角色,优先用角色 —— 服务器上不落长期密钥。</span>
</div>
<div class="field">
<label>Bucket</label>
<input class="input" name="oss[bucket]" value="<?= e($storageOss['bucket']) ?>" placeholder="例如 my-family-bucket">
</div>
<div class="field">
<label>Endpoint</label>
<input class="input" name="oss[endpoint]" value="<?= e($storageOss['endpoint']) ?>"
placeholder="oss-cn-beijing.aliyuncs.com">
<span class="tip">不要带 https://,且要和桶的地域一致。</span>
</div>
<div class="field">
<label>桶内前缀</label>
<input class="input" name="oss[prefix]" value="<?= e($storageOss['prefix']) ?>" placeholder="可留空,如 familybook">
<span class="tip">一个桶放多个站点时用它隔开。</span>
</div>
<div class="field">
<label>AccessKey ID</label>
<input class="input" name="oss[access_key_id]" value="<?= e($storageOss['access_key_id']) ?>"
autocomplete="off" placeholder="用角色方式时可留空">
</div>
<div class="field">
<label>AccessKey Secret</label>
<input class="input" name="oss[access_key_secret]" type="password"
value="<?= $storageOss['access_key_secret'] !== '' ? '••••••••••••' : '' ?>"
autocomplete="new-password" placeholder="留空表示不改动已保存的">
</div>
<div class="field">
<label>RAM 角色名</label>
<input class="input" name="oss[ram_role_name]" value="<?= e($storageOss['ram_role_name']) ?>"
placeholder="留空 = 自动取实例绑定的角色">
</div>
<div class="field">
<label>自定义域名 / CDN</label>
<input class="input" name="oss[custom_domain]" value="<?= e($storageOss['custom_domain']) ?>"
placeholder="可留空">
<span class="tip">填了会替代 bucket.endpoint 生成访问地址;该域名需已绑定到这个桶。</span>
</div>
<div class="field full">
<label class="check-item">
<input type="checkbox" name="oss[internal_endpoint]" value="1"
<?= $storageOss['internal_endpoint'] === '1' ? 'checked' : '' ?>>
<span>服务端读取优先走内网 endpoint(服务器与桶在同地域时更快,还免流量费)</span>
</label>
<label class="check-item">
<input type="checkbox" name="oss[https]" value="1"
<?= $storageOss['https'] === '1' ? 'checked' : '' ?>>
<span>用 HTTPS 访问对象</span>
</label>
</div>
</div>
</details>
<div class="btn-row">
<button class="btn" type="submit">保存存储设置</button>
</div>
</form>
<form method="post" action="<?= e(url('settings/test_storage')) ?>" style="margin-top:12px">
<?= csrf_field() ?>
<button class="btn btn-ghost" type="submit">测试存储是否可用</button>
<span class="small muted" style="margin-left:10px">
会真的写一个临时文件、读回来、再删掉 —— 比只连一下可靠。
</span>
</form>
<!-- ==================== 运行环境 ==================== -->
<div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">运行环境 <span class="hint">排错时看这一块</span></h2>
<dl class="kv">
<div class="kv-row"><dt>PHP</dt><dd><?= e(PHP_VERSION) ?></dd></div>
<div class="kv-row"><dt>数据库</dt><dd><?= e($dbVersion) ?></dd></div>
<div class="kv-row"><dt>加密密钥</dt><dd><?= $keyOk ? '已配置' : '<span style="color:var(--red)">未配置</span>' ?></dd></div>
<div class="kv-row">
<dt>附件存储</dt>
<dd><?= $storageEffective === 'oss' ? '阿里云 OSS' : '服务器本地' ?>
<?php if ($storageDriver !== $storageEffective): ?>
<span class="muted small">(设置的是 OSS,但配置不全,实际用本地)</span>
<?php endif; ?>
</dd>
</div>
<div class="kv-row">
<dt>亲属连线</dt>
<dd><?= (int)$relCount ?> 条</dd>
</div>
<div class="kv-row">
<dt>账号</dt>
<dd>
管理员 <?= (int)$roleCounts['admin'] ?> ·
家庭成员 <?= (int)$roleCounts['member'] ?> ·
访客 <?= (int)$roleCounts['guest'] ?>
</dd>
</div>
</dl>
<p class="small muted" style="margin:14px 0 0">
手机号与身份证号都是加密后入库的。这个密钥一旦换掉,已有数据就再也解不开 ——
请把它和数据库分开、各备份一份。
</p>
</div>
<p class="small muted" style="margin:10px 0 0">
访客账号在「<a href="<?= e(url('accounts/invite')) ?>">邀请访客</a>」里开通。
</p>
</div>